diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index de58c7cf6..571a968ad 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -144,6 +144,28 @@ export default ({ config }: ConfigContext): ExpoConfig => { ], category: ['BROWSABLE', 'DEFAULT'], }, + { + action: 'VIEW', + data: [ + { + scheme: 'content', + mimeType: 'application/json', + }, + { + scheme: 'file', + mimeType: 'application/json', + }, + { + scheme: 'content', + mimeType: 'text/json', + }, + { + scheme: 'file', + mimeType: 'text/json', + }, + ], + category: ['DEFAULT', 'BROWSABLE'], + }, ], }, plugins: [ @@ -279,7 +301,11 @@ export default ({ config }: ConfigContext): ExpoConfig => { }, android: { enabled: true, - singleShareMimeTypes: ['text/*'], + singleShareMimeTypes: [ + 'text/*', + 'application/json', + 'application/octet-stream', + ], multipleShareMimeTypes: ['text/*'], }, }), diff --git a/apps/mobile/drizzle/0023_external_import_jobs.sql b/apps/mobile/drizzle/0023_external_import_jobs.sql new file mode 100644 index 000000000..bb028520f --- /dev/null +++ b/apps/mobile/drizzle/0023_external_import_jobs.sql @@ -0,0 +1,71 @@ +CREATE TABLE IF NOT EXISTS `external_import_jobs` ( + `job_id` text PRIMARY KEY NOT NULL, + `source` text NOT NULL, + `source_playlist_id` text NOT NULL, + `playlist_metadata` text NOT NULL, + `total_count` integer DEFAULT 0 NOT NULL, + `processed_count` integer DEFAULT 0 NOT NULL, + `matched_count` integer DEFAULT 0 NOT NULL, + `unmatched_count` integer DEFAULT 0 NOT NULL, + `error_count` integer DEFAULT 0 NOT NULL, + `status` text DEFAULT 'pending' NOT NULL, + `saved_playlist_id` integer, + `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + `last_processed_at` integer +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `external_import_jobs_source_playlist_unq` ON `external_import_jobs` (`source`,`source_playlist_id`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `external_import_jobs_status_idx` ON `external_import_jobs` (`status`);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `external_import_items` ( + `item_id` text PRIMARY KEY NOT NULL, + `job_id` text NOT NULL, + `fingerprint` text NOT NULL, + `original_index` integer NOT NULL, + `original_track` text NOT NULL, + `status` text DEFAULT 'pending' NOT NULL, + `matched_video` text, + `error_type` text, + `error_message` text, + `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + FOREIGN KEY (`job_id`) REFERENCES `external_import_jobs`(`job_id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `external_import_items_job_idx` ON `external_import_items` (`job_id`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `external_import_items_job_index_idx` ON `external_import_items` (`job_id`,`original_index`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `external_import_items_job_fingerprint_idx` ON `external_import_items` (`job_id`,`fingerprint`);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `external_track_mappings` ( + `track_id` integer PRIMARY KEY NOT NULL, + `playlist_id` integer NOT NULL, + `job_id` text, + `item_id` text, + `source` text NOT NULL, + `source_playlist_id` text NOT NULL, + `source_track_id` text, + `original_index` integer DEFAULT 0 NOT NULL, + `track_fingerprint` text NOT NULL, + `original_title` text NOT NULL, + `original_translated_title` text, + `original_artists_json` text NOT NULL, + `original_album` text NOT NULL, + `original_duration` integer DEFAULT 0 NOT NULL, + `original_cover_url` text, + `match_status` text DEFAULT 'pending' NOT NULL, + `matched_bvid` text, + `matched_cid` integer, + `matched_title` text, + `matched_author` text, + `matched_mid` integer, + `matched_pic` text, + `matched_duration` text, + `matched_video_json` text, + `error_type` text, + `error_message` text, + `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + FOREIGN KEY (`track_id`) REFERENCES `tracks`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`playlist_id`) REFERENCES `playlists`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `external_track_mappings_playlist_idx` ON `external_track_mappings` (`playlist_id`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `external_track_mappings_status_idx` ON `external_track_mappings` (`playlist_id`,`match_status`); diff --git a/apps/mobile/drizzle/meta/0023_snapshot.json b/apps/mobile/drizzle/meta/0023_snapshot.json new file mode 100644 index 000000000..8898e4450 --- /dev/null +++ b/apps/mobile/drizzle/meta/0023_snapshot.json @@ -0,0 +1,1262 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b81def43-4af9-49db-b9df-261dde14bc85", + "prevId": "a4ab76a5-4aa6-448c-961d-2d35f076a4fd", + "tables": { + "artists": { + "name": "artists", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_id": { + "name": "remote_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "source_remote_id_unq": { + "name": "source_remote_id_unq", + "columns": ["source", "remote_id"], + "isUnique": true, + "where": "source != 'local'" + }, + "local_artist_unq": { + "name": "local_artist_unq", + "columns": ["name"], + "isUnique": true, + "where": "source = 'local'" + }, + "artists_name_idx": { + "name": "artists_name_idx", + "columns": ["name"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "source_integrity_check": { + "name": "source_integrity_check", + "value": "\n (source = 'local' AND remote_id IS NULL) \n OR \n (source != 'local' AND remote_id IS NOT NULL)\n " + } + } + }, + "bilibili_metadata": { + "name": "bilibili_metadata", + "columns": { + "track_id": { + "name": "track_id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bvid": { + "name": "bvid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cid": { + "name": "cid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_multi_page": { + "name": "is_multi_page", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "main_track_title": { + "name": "main_track_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "video_is_valid": { + "name": "video_is_valid", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "bilibili_metadata_bvid_cid_idx": { + "name": "bilibili_metadata_bvid_cid_idx", + "columns": ["bvid", "cid"], + "isUnique": false + } + }, + "foreignKeys": { + "bilibili_metadata_track_id_tracks_id_fk": { + "name": "bilibili_metadata_track_id_tracks_id_fk", + "tableFrom": "bilibili_metadata", + "tableTo": "tracks", + "columnsFrom": ["track_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dynamic_playlist_sources": { + "name": "dynamic_playlist_sources", + "columns": { + "playlist_id": { + "name": "playlist_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_playlist_id": { + "name": "source_playlist_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "dynamic_playlist_sources_playlist_idx": { + "name": "dynamic_playlist_sources_playlist_idx", + "columns": ["playlist_id"], + "isUnique": false + }, + "dynamic_playlist_sources_playlist_position_idx": { + "name": "dynamic_playlist_sources_playlist_position_idx", + "columns": ["playlist_id", "position"], + "isUnique": false + }, + "dynamic_playlist_sources_source_idx": { + "name": "dynamic_playlist_sources_source_idx", + "columns": ["source_playlist_id"], + "isUnique": false + } + }, + "foreignKeys": { + "dynamic_playlist_sources_playlist_id_playlists_id_fk": { + "name": "dynamic_playlist_sources_playlist_id_playlists_id_fk", + "tableFrom": "dynamic_playlist_sources", + "tableTo": "playlists", + "columnsFrom": ["playlist_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dynamic_playlist_sources_source_playlist_id_playlists_id_fk": { + "name": "dynamic_playlist_sources_source_playlist_id_playlists_id_fk", + "tableFrom": "dynamic_playlist_sources", + "tableTo": "playlists", + "columnsFrom": ["source_playlist_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dynamic_playlist_sources_playlist_id_source_playlist_id_pk": { + "columns": ["playlist_id", "source_playlist_id"], + "name": "dynamic_playlist_sources_playlist_id_source_playlist_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_import_items": { + "name": "external_import_items", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_index": { + "name": "original_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_track": { + "name": "original_track", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "matched_video": { + "name": "matched_video", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_type": { + "name": "error_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "external_import_items_job_idx": { + "name": "external_import_items_job_idx", + "columns": ["job_id"], + "isUnique": false + }, + "external_import_items_job_index_idx": { + "name": "external_import_items_job_index_idx", + "columns": ["job_id", "original_index"], + "isUnique": false + }, + "external_import_items_job_fingerprint_idx": { + "name": "external_import_items_job_fingerprint_idx", + "columns": ["job_id", "fingerprint"], + "isUnique": false + } + }, + "foreignKeys": { + "external_import_items_job_id_external_import_jobs_job_id_fk": { + "name": "external_import_items_job_id_external_import_jobs_job_id_fk", + "tableFrom": "external_import_items", + "tableTo": "external_import_jobs", + "columnsFrom": ["job_id"], + "columnsTo": ["job_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_import_jobs": { + "name": "external_import_jobs", + "columns": { + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_playlist_id": { + "name": "source_playlist_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "playlist_metadata": { + "name": "playlist_metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_count": { + "name": "total_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "matched_count": { + "name": "matched_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "unmatched_count": { + "name": "unmatched_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "error_count": { + "name": "error_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "saved_playlist_id": { + "name": "saved_playlist_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "last_processed_at": { + "name": "last_processed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "external_import_jobs_source_playlist_unq": { + "name": "external_import_jobs_source_playlist_unq", + "columns": ["source", "source_playlist_id"], + "isUnique": true + }, + "external_import_jobs_status_idx": { + "name": "external_import_jobs_status_idx", + "columns": ["status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_track_mappings": { + "name": "external_track_mappings", + "columns": { + "track_id": { + "name": "track_id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "playlist_id": { + "name": "playlist_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_playlist_id": { + "name": "source_playlist_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_track_id": { + "name": "source_track_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_index": { + "name": "original_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "track_fingerprint": { + "name": "track_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_title": { + "name": "original_title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_translated_title": { + "name": "original_translated_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_artists_json": { + "name": "original_artists_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_album": { + "name": "original_album", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_duration": { + "name": "original_duration", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "original_cover_url": { + "name": "original_cover_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "match_status": { + "name": "match_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "matched_bvid": { + "name": "matched_bvid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "matched_cid": { + "name": "matched_cid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "matched_title": { + "name": "matched_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "matched_author": { + "name": "matched_author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "matched_mid": { + "name": "matched_mid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "matched_pic": { + "name": "matched_pic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "matched_duration": { + "name": "matched_duration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "matched_video_json": { + "name": "matched_video_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_type": { + "name": "error_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "external_track_mappings_playlist_idx": { + "name": "external_track_mappings_playlist_idx", + "columns": ["playlist_id"], + "isUnique": false + }, + "external_track_mappings_status_idx": { + "name": "external_track_mappings_status_idx", + "columns": ["playlist_id", "match_status"], + "isUnique": false + } + }, + "foreignKeys": { + "external_track_mappings_track_id_tracks_id_fk": { + "name": "external_track_mappings_track_id_tracks_id_fk", + "tableFrom": "external_track_mappings", + "tableTo": "tracks", + "columnsFrom": ["track_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_track_mappings_playlist_id_playlists_id_fk": { + "name": "external_track_mappings_playlist_id_playlists_id_fk", + "tableFrom": "external_track_mappings", + "tableTo": "playlists", + "columnsFrom": ["playlist_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "local_metadata": { + "name": "local_metadata", + "columns": { + "track_id": { + "name": "track_id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "local_path": { + "name": "local_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "local_metadata_track_id_tracks_id_fk": { + "name": "local_metadata_track_id_tracks_id_fk", + "tableFrom": "local_metadata", + "tableTo": "tracks", + "columnsFrom": ["track_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "play_history": { + "name": "play_history", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "track_id": { + "name": "track_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_played": { + "name": "duration_played", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed": { + "name": "completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "play_history_track_idx": { + "name": "play_history_track_idx", + "columns": ["track_id"], + "isUnique": false + }, + "play_history_start_time_idx": { + "name": "play_history_start_time_idx", + "columns": ["start_time"], + "isUnique": false + } + }, + "foreignKeys": { + "play_history_track_id_tracks_id_fk": { + "name": "play_history_track_id_tracks_id_fk", + "tableFrom": "play_history", + "tableTo": "tracks", + "columnsFrom": ["track_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "playlist_sync_queue": { + "name": "playlist_sync_queue", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "playlist_id": { + "name": "playlist_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "operation_at": { + "name": "operation_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "playlist_sync_queue_status_idx": { + "name": "playlist_sync_queue_status_idx", + "columns": ["status"], + "isUnique": false + }, + "playlist_sync_queue_playlist_id_idx": { + "name": "playlist_sync_queue_playlist_id_idx", + "columns": ["playlist_id"], + "isUnique": false + } + }, + "foreignKeys": { + "playlist_sync_queue_playlist_id_playlists_id_fk": { + "name": "playlist_sync_queue_playlist_id_playlists_id_fk", + "tableFrom": "playlist_sync_queue", + "tableTo": "playlists", + "columnsFrom": ["playlist_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "playlist_tracks": { + "name": "playlist_tracks", + "columns": { + "playlist_id": { + "name": "playlist_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "track_id": { + "name": "track_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_key": { + "name": "sort_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "playlist_tracks_track_idx": { + "name": "playlist_tracks_track_idx", + "columns": ["track_id"], + "isUnique": false + }, + "playlist_tracks_sort_key_idx": { + "name": "playlist_tracks_sort_key_idx", + "columns": ["playlist_id", "sort_key"], + "isUnique": false + } + }, + "foreignKeys": { + "playlist_tracks_playlist_id_playlists_id_fk": { + "name": "playlist_tracks_playlist_id_playlists_id_fk", + "tableFrom": "playlist_tracks", + "tableTo": "playlists", + "columnsFrom": ["playlist_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "playlist_tracks_track_id_tracks_id_fk": { + "name": "playlist_tracks_track_id_tracks_id_fk", + "tableFrom": "playlist_tracks", + "tableTo": "tracks", + "columnsFrom": ["track_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "playlist_tracks_playlist_id_track_id_pk": { + "columns": ["playlist_id", "track_id"], + "name": "playlist_tracks_playlist_id_track_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "playlists": { + "name": "playlists", + "columns": { + "player_preference": { + "name": "player_preference", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'inherit'" + }, + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_count": { + "name": "item_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_sync_id": { + "name": "remote_sync_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "share_id": { + "name": "share_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "share_role": { + "name": "share_role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_share_sync_at": { + "name": "last_share_sync_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "playlists_title_idx": { + "name": "playlists_title_idx", + "columns": ["title"], + "isUnique": false + }, + "playlists_type_idx": { + "name": "playlists_type_idx", + "columns": ["type"], + "isUnique": false + }, + "playlists_author_idx": { + "name": "playlists_author_idx", + "columns": ["author_id"], + "isUnique": false + }, + "playlists_share_id_idx": { + "name": "playlists_share_id_idx", + "columns": ["share_id"], + "isUnique": false + } + }, + "foreignKeys": { + "playlists_author_id_artists_id_fk": { + "name": "playlists_author_id_artists_id_fk", + "tableFrom": "playlists", + "tableTo": "artists", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tracks": { + "name": "tracks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "unique_key": { + "name": "unique_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "artist_id": { + "name": "artist_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "tracks_unique_key_unique": { + "name": "tracks_unique_key_unique", + "columns": ["unique_key"], + "isUnique": true + }, + "tracks_artist_idx": { + "name": "tracks_artist_idx", + "columns": ["artist_id"], + "isUnique": false + }, + "tracks_title_idx": { + "name": "tracks_title_idx", + "columns": ["title"], + "isUnique": false + }, + "tracks_source_idx": { + "name": "tracks_source_idx", + "columns": ["source"], + "isUnique": false + } + }, + "foreignKeys": { + "tracks_artist_id_artists_id_fk": { + "name": "tracks_artist_id_artists_id_fk", + "tableFrom": "tracks", + "tableTo": "artists", + "columnsFrom": ["artist_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/mobile/drizzle/meta/_journal.json b/apps/mobile/drizzle/meta/_journal.json index 69ed1c66f..c6b049b1b 100644 --- a/apps/mobile/drizzle/meta/_journal.json +++ b/apps/mobile/drizzle/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1788924679697, "tag": "0022_player_preference", "breakpoints": true + }, + { + "idx": 23, + "version": "6", + "when": 1791562928843, + "tag": "0023_external_import_jobs", + "breakpoints": true } ] } diff --git a/apps/mobile/drizzle/migrations.js b/apps/mobile/drizzle/migrations.js index 611c3a674..8ca8ae6aa 100644 --- a/apps/mobile/drizzle/migrations.js +++ b/apps/mobile/drizzle/migrations.js @@ -23,6 +23,7 @@ import m0019 from './0019_icy_mandarin.sql' import m0020 from './0020_ambitious_sheva_callister.sql' import m0021 from './0021_heavy_next_avengers.sql' import m0022 from './0022_player_preference.sql' +import m0023 from './0023_external_import_jobs.sql' import journal from './meta/_journal.json' export default { @@ -51,5 +52,6 @@ export default { m0020, m0021, m0022, + m0023, }, } diff --git a/apps/mobile/expo-plugins/withAbiFilters.js b/apps/mobile/expo-plugins/withAbiFilters.js index b586f1aac..11788b089 100644 --- a/apps/mobile/expo-plugins/withAbiFilters.js +++ b/apps/mobile/expo-plugins/withAbiFilters.js @@ -30,9 +30,9 @@ const withAbiFilters = (config, { abiFilters = ['arm64-v8a'] } = {}) => { // Add ndk abiFilters to defaultConfig if (config.modResults.contents.includes('defaultConfig {')) { config.modResults.contents = config.modResults.contents.replace( - /(defaultConfig\s*\{[^}]*versionName\s+[^}]*)/, + /(versionName\s+["'][^"']+["'])/, `$1 - + ndk { abiFilters ${abiFiltersString} }`, diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index ff36df767..5fe93aa43 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -1,3 +1,4 @@ +import { File } from 'expo-file-system' import { useObserve } from 'expo-observe' import { useFocusEffect, useRouter } from 'expo-router' import { useIncomingShare } from 'expo-sharing' @@ -10,6 +11,7 @@ import { useState, } from 'react' import { + AppState, BackHandler, Keyboard, KeyboardAvoidingView, @@ -31,14 +33,19 @@ import SearchHistory, { } from '@/features/search/SearchHistory' import SearchSuggestions from '@/features/search/SearchSuggestions' import useCurrentTrack from '@/hooks/player/useCurrentTrack' +import { playlistKeys } from '@/hooks/queries/db/playlist' import useSkinForegroundColor from '@/hooks/theme/useSkinForegroundColor' import { useNowPlayingBar } from '@/hooks/ui/useNowPlayingBar' +import { queryClient } from '@/lib/config/queryClient' +import { syncExternalPlaylistFacade } from '@/lib/facades/syncExternalPlaylist' import { markPerfInteractive } from '@/lib/performance' +import { playlistOutService } from '@/lib/services/playlistOutService' import { toastAndLogError } from '@/utils/error-handling' import { matchSearchStrategies, navigateWithSearchStrategy, } from '@/utils/search' +import toast from '@/utils/toast' const SEARCH_HISTORY_KEY = 'bilibili_search_history' const MAX_SEARCH_HISTORY = 10 @@ -90,8 +97,12 @@ export default function SearchPage() { useFocusEffect(useCallback(() => () => dismissPanel(), [dismissPanel])) const [contentHeight, setContentHeight] = useState(0) const contentBottomPadding = haveTrack ? 86 : 24 - const { resolvedSharedPayloads, isResolving, clearSharedPayloads } = - useIncomingShare() + const { + resolvedSharedPayloads, + isResolving, + clearSharedPayloads, + refreshSharePayloads, + } = useIncomingShare() const { markInteractive } = useObserve() useEffect(() => { markInteractive() @@ -213,6 +224,104 @@ export default function SearchPage() { [searchHistory, setSearchHistory], ) + const handleIncomingJsonContent = useCallback( + async (content: string, fallbackTitle = '本地导入歌单') => { + const jsonRes = playlistOutService.parseJsonPlaylist( + content, + fallbackTitle, + ) + if (jsonRes.isErr()) { + toast.error(`解析歌单文件失败: ${jsonRes.error.message}`) + return + } + + const parsedData = jsonRes.value + const allTracksHaveBvid = + parsedData.tracks.length > 0 && + parsedData.tracks.every((t) => Boolean(t.bilibili?.bvid)) + + if ( + allTracksHaveBvid && + playlistOutService.isDirectBilibiliPlaylist(parsedData) + ) { + const loadingToast = toast.loading('正在导入哔哩哔哩歌单...') + try { + const saveRes = + await syncExternalPlaylistFacade.saveDirectBilibiliPlaylist( + parsedData.playlist, + parsedData.tracks, + ) + toast.dismiss(loadingToast) + if (saveRes.isErr()) { + toast.error(`直接导入失败: ${saveRes.error.message}`) + return + } + await queryClient.invalidateQueries({ + queryKey: playlistKeys.playlistLists(), + }) + toast.success('哔哩哔哩歌单已直接导入到本地') + router.navigate(`/playlist/local/${saveRes.value}`) + } catch (e) { + toast.dismiss(loadingToast) + toast.error( + `直接导入失败: ${e instanceof Error ? e.message : String(e)}`, + ) + } + return + } + + const cacheId = parsedData.playlist.id + playlistOutService.setCachedPlaylist(cacheId, parsedData) + router.navigate({ + pathname: '/playlist/external-sync', + params: { id: cacheId, source: 'local_json' }, + }) + }, + [router], + ) + + const handleIncomingFileUri = useCallback( + async (uri: string, originalName?: string | null) => { + try { + const file = new File(uri) + const content = await file.text() + const fallbackTitle = originalName + ? originalName.replace(/\.json$/i, '') + : '本地导入歌单' + await handleIncomingJsonContent(content, fallbackTitle) + } catch (e) { + toast.error( + `读取歌单文件失败: ${e instanceof Error ? e.message : String(e)}`, + ) + } + }, + [handleIncomingJsonContent], + ) + + const checkPendingFile = useCallback(() => { + const pendingUri = playlistOutService.consumePendingIncomingFileUri() + if (pendingUri) { + void handleIncomingFileUri(pendingUri) + } + }, [handleIncomingFileUri]) + + useFocusEffect( + useCallback(() => { + checkPendingFile() + refreshSharePayloads() + }, [checkPendingFile, refreshSharePayloads]), + ) + + useEffect(() => { + checkPendingFile() + const sub = AppState.addEventListener('change', (state) => { + if (state === 'active') { + checkPendingFile() + } + }) + return () => sub.remove() + }, [checkPendingFile]) + useEffect(() => { if (resolvedSharedPayloads.length === 0) return if (resolvedSharedPayloads.length > 1) { @@ -227,18 +336,68 @@ export default function SearchPage() { } } const data = resolvedSharedPayloads[0] + const isLocalFile = + (data.shareType === 'file' || + ('contentType' in data && data.contentType === 'file')) && + (('contentUri' in data && Boolean(data.contentUri)) || + (typeof data.value === 'string' && + (data.value.startsWith('file:') || + data.value.startsWith('content:')))) + + if (isLocalFile) { + const fileUri = + ('contentUri' in data && data.contentUri) || data.value + const fileName = data.originalName + clearSharedPayloads() + void handleIncomingFileUri(fileUri, fileName) + return + } + let query: string | undefined if (data.shareType === 'text') { query = data.value + } else if (data.shareType === 'url') { + const contentUri = + 'contentUri' in data ? data.contentUri ?? undefined : undefined + const isBilibiliContentUri = + typeof contentUri === 'string' && + /(^|:\/\/)(?:[\w-]+\.)?(?:bilibili\.com|b23\.tv)(?:\/|$)/i.test( + contentUri, + ) + query = + (isBilibiliContentUri ? contentUri : undefined) || + data.value || + contentUri + } else if ('contentType' in data && data.contentType === 'website') { + query = + ('contentUri' in data ? data.contentUri ?? undefined : undefined) || + data.value + } else if ( + typeof data.value === 'string' && + data.value.trim().length > 0 + ) { + query = data.value } + if (!query) { clearSharedPayloads() return } clearSharedPayloads() + const trimmed = query.trim() + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + void handleIncomingJsonContent(trimmed) + return + } void handleEnter(query) - }, [resolvedSharedPayloads, clearSharedPayloads, handleEnter]) + }, [ + resolvedSharedPayloads, + clearSharedPayloads, + handleEnter, + handleIncomingFileUri, + handleIncomingJsonContent, + ]) return ( { @@ -212,6 +216,14 @@ function RootLayout() { playlistSyncWorker.recoverStuckRows().catch((error) => { logger.error('恢复同步任务失败:', error) }) + try { + const recoveredCount = externalPlaylistImportWorker.recoverStuckJobs() + if (recoveredCount > 0) { + cancelImportProgressNotification() + } + } catch (error) { + logger.error('恢复外部歌单导入任务失败:', error) + } const firstOpen = storage.getBoolean('first_open') ?? true if (firstOpen) { @@ -220,6 +232,27 @@ function RootLayout() { } }, [isReady, migrationsSuccess]) + useEffect(() => { + const stopNotifications = externalImportNotificationService.startListening() + const unsubscribeWorker = externalPlaylistImportWorker.subscribe( + (event) => { + if (event.type === 'completed') { + toast.success('外部歌单自动匹配完成') + } else if (event.type === 'rate_limited') { + toast.error('触发 Bilibili 请求限制,匹配已自动暂停,进度已保存', { + id: 'bilibili-rate-limit', + }) + } else if (event.type === 'failed') { + toast.error('外部歌单自动匹配中断,进度已保存') + } + }, + ) + return () => { + stopNotifications() + unsubscribeWorker() + } + }, []) + useEffect(() => { if (migrationsError) { void hideBootSplash({ fade: true }) @@ -338,6 +371,10 @@ function RootLayout() { name='playlist/local/[id]' options={{ headerShown: false }} /> + @@ -52,7 +65,20 @@ const SyncTrackItem = memo( onPress: () => void }) => { const theme = useTheme() - const result = useExternalPlaylistSyncStore((state) => state.results[index]) + const rawResult = useExternalPlaylistSyncStore( + (state) => state.results[index], + ) + const result = isMatchResultForTrack(rawResult, track) + ? rawResult + : undefined + const status = getMatchResultStatus(result) + const artistNames = track.artists.filter(Boolean).join(' / ') + const trackDurationSec = + track.duration > 0 ? Math.round(track.duration / 1000) : 0 + const matchedVideo = status === 'matched' ? result?.matchedVideo : null + const matchedDurationSec = matchedVideo?.duration + ? parseDurationString(matchedVideo.duration) + : trackDurationSec return ( @@ -65,86 +91,112 @@ const SyncTrackItem = memo( borderRadius={LIST_ITEM_BORDER_RADIUS} /> + {/* 第一行:歌曲名称 - 歌曲作者 */} {track.title} {track.translatedTitle && ` (${track.translatedTitle})`} + {artistNames ? ` - ${artistNames}` : ''} + + {/* 第二行:音源名称 / 状态提示 */} + {matchedVideo ? ( + + {matchedVideo.title.replace(/]*>|<\/em>/g, '')} + + ) : status === 'unmatched' ? ( + + 待匹配音源 + + ) : status === 'error' ? ( + + 匹配出错: {result?.errorMessage ?? '网络或接口异常,可稍后重试'} + + ) : status === 'rate_limited' ? ( + + 请求暂时受限,进度已保存 + + ) : ( + + {track.album || '待匹配音源'} + + )} + + {/* 第三行:UP主 · 时长 */} - {track.artists.join(', ')} - {track.album} + {matchedVideo + ? `${matchedVideo.author || '未知 UP 主'}${matchedDurationSec > 0 ? ` · ${formatDurationToHHMMSS(matchedDurationSec)}` : ''}` + : `待匹配 UP 主${trackDurationSec > 0 ? ` · ${formatDurationToHHMMSS(trackDurationSec)}` : ''}`} - {result?.matchedVideo && ( - - - 已匹配:{' '} - {result.matchedVideo.title - .replace(//g, '') - .replace(/<\/em>/g, '')} - - - UP主: {result.matchedVideo.author} - - - )} - {!result ? ( - - ) : !result.matchedVideo ? ( - - - - - ) : ( - - - - - )} + + @@ -161,7 +213,6 @@ const renderItem = ({ GenericTrack, { openManualMatch: (track: GenericTrack, index: number) => void - syncing: boolean } >) => { if (!extraData) return null @@ -178,7 +229,7 @@ const ExternalPlaylistSyncPageInner = () => { const isListReady = useScreenTransitionReady() const { id, source } = useLocalSearchParams<{ id: string - source: 'netease' | 'qq' + source: import('@/lib/services/externalPlaylistService').ExternalPlaylistSource }>() const theme = useTheme() const insets = useSafeAreaInsets() @@ -193,82 +244,117 @@ const ExternalPlaylistSyncPageInner = () => { source ?? 'netease', ) + const coverRef = useImage( + resolveBilibiliImageUrl(data?.playlist.coverUrl) ?? '', + { + onError: () => void 0, + }, + ) const { - setSyncing, - setProgress, - setResult, - reset, - setSessionKey, - clearSession, - syncing, - progress, - results, - } = useExternalPlaylistSyncStore((state) => state) - const tracks = data?.tracks ?? [] + backgroundColor, + primaryButtonColor, + primaryButtonTextColor, + secondaryButtonContainerColor, + secondaryButtonIconColor, + } = usePlaylistBackgroundColor( + coverRef, + theme.dark, + theme.colors.background, + data?.playlist.coverUrl, + ) + + const syncStore = useExternalPlaylistSyncStoreApi() + const currentJobId = useExternalPlaylistSyncStore((s) => s.currentJobId) + const setSessionKey = useExternalPlaylistSyncStore((s) => s.setSessionKey) + const setResult = useExternalPlaylistSyncStore((s) => s.setResult) + const startOrResumeMatching = useExternalPlaylistSyncStore( + (s) => s.startOrResumeMatching, + ) + const pauseMatching = useExternalPlaylistSyncStore((s) => s.pauseMatching) + const syncing = useExternalPlaylistSyncStore((s) => s.syncing) + const isRateLimited = useExternalPlaylistSyncStore((s) => s.isRateLimited) + const progress = useExternalPlaylistSyncStore((s) => s.progress) + const etaSeconds = useExternalPlaylistSyncStore((s) => s.etaSeconds) + const matchedCount = useExternalPlaylistSyncStore((s) => s.matchedCount) + const unmatchedCount = useExternalPlaylistSyncStore((s) => s.unmatchedCount) + const errorCount = useExternalPlaylistSyncStore((s) => s.errorCount) + const processedCount = useExternalPlaylistSyncStore((s) => s.processedCount) + + const [savedPlaylistId, setSavedPlaylistId] = useState(null) + const isSavingRef = useRef(false) + + const tracks = useMemo(() => data?.tracks ?? [], [data?.tracks]) const sessionKey = useMemo(() => { if (!id || !source) return null return `${source}:${id}` }, [id, source]) - const abortControllerRef = useRef(null) - const sessionStartTimeRef = useRef(0) + + const lastInitializedSessionKeyRef = useRef(null) useEffect(() => { if (!sessionKey || tracks.length === 0) return - setSessionKey(sessionKey, tracks.length) - }, [sessionKey, setSessionKey, tracks.length]) + if (lastInitializedSessionKeyRef.current === sessionKey) return + lastInitializedSessionKeyRef.current = sessionKey + setSessionKey(sessionKey, tracks, data?.playlist) + }, [data?.playlist, sessionKey, setSessionKey, tracks]) useEffect(() => { return () => { - abortControllerRef.current?.abort() - setSyncing(false) + if (externalPlaylistImportWorker.getActiveJobId() !== null) { + toast.info('匹配正在后台继续进行,可在歌单列表查看实时进度') + } } - }, [setSyncing]) + }, []) - const [etaSeconds, setEtaSeconds] = useState(null) + const hasProcessedAny = processedCount > 0 + const pendingCount = Math.max(0, tracks.length - processedCount) - const [isExiting, setIsExiting] = useState(false) + const proceedSave = useCallback( + async (options?: { redirectToPlaylist?: boolean; silent?: boolean }) => { + if (!data?.playlist || !data?.tracks || data.tracks.length === 0) { + return null + } + if (isSavingRef.current) return savedPlaylistId + isSavingRef.current = true - const hasResults = Object.keys(results).length > 0 && !isExiting - usePreventRemove(hasResults, () => { - openModal('Alert', { - title: '确定要退出吗?', - message: - '当前匹配结果已临时保存,下次进入这个歌单可以继续匹配。匹配完成后仍需要手动保存到本地歌单。', - buttons: [ - { - text: '取消', - }, - { - text: '退出', - onPress: () => { - setIsExiting(true) - useModalStore.getState().doAfterModalHostClosed(() => router.back()) - }, - }, - ], - }) - }) - - const handleSave = async () => { - if (!data?.playlist || !data?.tracks || !results) return - const matchResults = data.tracks - .map((_, index) => results[index]) - .filter((result) => result !== undefined) - if (matchResults.length === 0) { - toast.error('没有可保存的内容') - return - } + const redirectToPlaylist = options?.redirectToPlaylist ?? true + const silent = options?.silent ?? false + + if (savedPlaylistId) { + isSavingRef.current = false + if (redirectToPlaylist) { + router.replace(`/playlist/local/${savedPlaylistId}`) + } + return savedPlaylistId + } - const unmatchedCount = matchResults.filter( - (r) => r.matchedVideo === null, - ).length + if (syncing) { + await syncStore.getState().pauseMatchingAndWait() + } - const unprocessedCount = data.tracks.length - matchResults.length + const currentResults = syncStore.getState().results + // 构建完整歌单的匹配结果(未处理歌曲作为 pending/unmatched 一并保留,不丢弃任何歌曲) + const fullMatchResults: MatchResult[] = data.tracks.map( + (track, index) => { + const existing = currentResults[index] + if (existing && isMatchResultForTrack(existing, track)) { + return existing + } + return { + track, + matchedVideo: null, + status: 'pending', + trackFingerprint: getTrackFingerprint(track), + } + }, + ) - const proceedSave = async () => { - const loadingToast = toast.loading('正在保存到本地...') + const loadingToast = silent + ? null + : toast.loading('正在保存为本地歌单...') const coverUrl = data.playlist.coverUrl ?? '' const description = data.playlist.description ?? '' + try { const saveResult = await syncExternalPlaylistFacade.saveMatchedPlaylist( { @@ -276,139 +362,111 @@ const ExternalPlaylistSyncPageInner = () => { coverUrl, description, }, - matchResults, + fullMatchResults, + { + jobId: currentJobId ?? undefined, + source: source ?? 'netease', + sourcePlaylistId: id ?? data.playlist.id, + }, ) + if (loadingToast) toast.dismiss(loadingToast) + if (saveResult.isErr()) { - toast.error(`保存失败: ${saveResult.error.message}`) - } else { - toast.success('歌单已保存到本地') - await queryClient.invalidateQueries({ - queryKey: playlistKeys.playlistLists(), - }) - clearSession() - const playlistId = saveResult.value + if (!silent) toast.error(`保存失败: ${saveResult.error.message}`) + isSavingRef.current = false + return null + } + + const playlistId = saveResult.value + setSavedPlaylistId(playlistId) + await queryClient.invalidateQueries({ + queryKey: playlistKeys.playlistLists(), + }) + + if (!silent) { + toast.success(`已保存为本地歌单《${data.playlist.title}》`) + } + + if (redirectToPlaylist) { useModalStore .getState() .doAfterModalHostClosed(() => router.replace(`/playlist/local/${playlistId}`), ) } + isSavingRef.current = false + return playlistId } catch { - toast.error('保存失败') + if (loadingToast) toast.dismiss(loadingToast) + if (!silent) toast.error('保存失败') + isSavingRef.current = false + return null } - toast.dismiss(loadingToast) - } + }, + [ + currentJobId, + data, + id, + queryClient, + router, + savedPlaylistId, + source, + syncStore, + syncing, + ], + ) - if (unmatchedCount > 0 || unprocessedCount > 0) { - const messages = [] - if (unprocessedCount > 0) { - messages.push(`还有 ${unprocessedCount} 首歌曲未进行匹配`) - } - if (unmatchedCount > 0) { - messages.push(`还有 ${unmatchedCount} 首歌曲未匹配到视频`) - } + const handleBack = useCallback(() => { + router.back() + }, [router]) - openModal('Alert', { - title: '存在未完成的项目', - message: `${messages.join(',')}。如果继续,这些已匹配的歌曲将被保存,未匹配的将被忽略。建议您完成匹配或手动匹配剩余歌曲。`, - buttons: [ - { - text: '去手动匹配', - }, - { - text: '仍要保存', - onPress: proceedSave, - }, - ], - }) - } else { - await proceedSave() + const handleDiscardDraft = useCallback(() => { + if (!currentJobId) { + router.back() + return } - } + alert( + '放弃导入草稿', + `确定要放弃并删除《${data?.playlist.title || id}》的导入草稿吗?已匹配的临时进度将被清除。`, + [ + { text: '取消' }, + { + text: '放弃并删除', + onPress: () => { + externalPlaylistImportWorker.cancel(currentJobId) + externalImportJobService.deleteJob(currentJobId) + toast.success('已放弃导入草稿') + router.back() + }, + }, + ], + { cancelable: true }, + ) + }, [currentJobId, data?.playlist.title, id, router]) - const processedIndexes = Object.keys(results).map(Number) - const hasProcessedAny = processedIndexes.length > 0 - const failedIndexes = processedIndexes.filter( - (index) => results[index]?.matchedVideo === null, - ) - const unprocessedIndexes = tracks - .map((_, index) => index) - .filter((index) => !Object.hasOwn(results, index)) const syncButtonText = syncing ? '暂停' : !hasProcessedAny ? '开始匹配' - : unprocessedIndexes.length > 0 + : pendingCount > 0 ? '继续匹配' - : failedIndexes.length > 0 + : errorCount > 0 ? '继续匹配失败项' - : '重新匹配全部' + : unmatchedCount > 0 + ? '重新匹配未匹配项' + : '重新匹配全部' - const handleSync = async () => { + const handleSync = () => { if (!data?.tracks) return if (syncing) { - abortControllerRef.current?.abort() - setSyncing(false) - setEtaSeconds(null) + pauseMatching() toast.info('已暂停匹配') return } - let indexesToProcess = unprocessedIndexes - - if (indexesToProcess.length === 0 && failedIndexes.length > 0) { - indexesToProcess = failedIndexes - } - - if (indexesToProcess.length === 0) { - reset() - indexesToProcess = data.tracks.map((_, index) => index) - } - - setSyncing(true) - setProgress(0, indexesToProcess.length) - - abortControllerRef.current = new AbortController() - sessionStartTimeRef.current = Date.now() - - // Initial rough estimate - setEtaSeconds(indexesToProcess.length * 1.2) - - const result = await externalPlaylistService.matchExternalPlaylist( - data.tracks, - (current, total, matchResult, trackIndex) => { - setResult(trackIndex, matchResult) - setProgress(current, total) - - // ETA Calculation - const now = Date.now() - const elapsed = now - sessionStartTimeRef.current - const processedInSession = current - - if (processedInSession > 0) { - const avgTimePerItem = elapsed / processedInSession - const remainingItems = total - current - const eta = (avgTimePerItem * remainingItems) / 1000 - setEtaSeconds(eta) - } - }, - { - trackIndexes: indexesToProcess, - signal: abortControllerRef.current.signal, - }, - ) - - setSyncing(false) - setEtaSeconds(null) - if (result.isErr()) { - if (result.error.message !== 'Aborted') { - toast.error(`匹配出错: ${result.error.message}`) - } - } else { - toast.success('匹配完成') - } + void startOrResumeMatching() } const handleOpenManualMatch = useCallback( @@ -416,14 +474,28 @@ const ExternalPlaylistSyncPageInner = () => { openModal('ManualMatchExternalSync', { track, initialQuery: `${track.title} - ${track.artists.join(' ')}`, - onMatch: (result) => setResult(index, result), + onMatch: (result) => + setResult(index, { + ...result, + track, + status: result.matchedVideo ? 'matched' : 'unmatched', + trackFingerprint: getTrackFingerprint(track), + }), }) }, [openModal, setResult], ) + const extraData = useMemo( + () => ({ + openManualMatch: handleOpenManualMatch, + }), + [handleOpenManualMatch], + ) + const keyExtractor = useCallback( - (item: GenericTrack, index: number) => `${index}-${item.title}`, + (item: GenericTrack, index: number) => + `${index}-${getTrackFingerprint(item)}`, [], ) @@ -441,59 +513,99 @@ const ExternalPlaylistSyncPageInner = () => { ) } + const summaryText = `共 ${tracks.length} 首 · 已匹配 ${matchedCount} 首 · 未匹配 ${unmatchedCount} 首${errorCount > 0 ? ` · 待重试 ${errorCount} 首` : ''}` + return ( - - - + + + + - - 匹配进度已临时保存。完成后请点击右上角或下方的保存按钮写入本地歌单。 - + + 0} + actions={ + savedPlaylistId + ? [ + { + label: '前往本地歌单', + onPress: () => + router.replace(`/playlist/local/${savedPlaylistId}`), + }, + ] + : [ + { + label: '保存为本地歌单', + onPress: () => + void proceedSave({ redirectToPlaylist: true }), + }, + ] + } + icon={ + savedPlaylistId || (pendingCount === 0 && hasProcessedAny) + ? 'check-circle-outline' + : 'information' + } + > + {savedPlaylistId + ? `${summaryText}\n已保存为本地歌单,所有歌曲(含待匹配歌曲)均已完整收录在播放列表中。` + : pendingCount === 0 && hasProcessedAny + ? `${summaryText}\n全单匹配已完成!点击【保存为本地歌单】即可保存入库。` + : `${summaryText}\n受哔哩哔哩接口限制匹配较慢,支持后台自动匹配。直接返回可在歌单列表顶部继续查看进度。`} + + 0 + ? `标签:${data.playlist.tags.map((t) => `#${t}`).join(' ')}` + : '', + data.playlist.description?.trim() + ? `简介:${data.playlist.description.trim()}` + : '简介:暂无简介', + ] + .filter(Boolean) + .join('\n')} + cover={coverRef ?? data.playlist.coverUrl ?? ''} + primaryButtonColor={primaryButtonColor} + primaryButtonTextColor={primaryButtonTextColor} + secondaryButtonContainerColor={secondaryButtonContainerColor} + secondaryButtonIconColor={secondaryButtonIconColor} + subtitles={[ + `创建者:${data.playlist.author.name}`, + ...(data.playlist.createTime + ? [`创建于:${data.playlist.createTime}`] + : data.playlist.updateTime + ? [`更新于:${data.playlist.updateTime}`] + : []), + `${data.playlist.trackCount} 首歌曲`, + ]} + /> + } role='list' /> @@ -501,9 +613,12 @@ const ExternalPlaylistSyncPageInner = () => { ) @@ -512,25 +627,31 @@ const ExternalPlaylistSyncPageInner = () => { const ExternalPlaylistSyncFooter = ({ onSync, syncing, + isRateLimited, progress, etaSeconds, buttonText, + primaryButtonColor, + primaryButtonTextColor, }: { onSync: () => void syncing: boolean + isRateLimited: boolean progress: number etaSeconds: number | null buttonText: string + primaryButtonColor?: string + primaryButtonTextColor?: string }) => { const theme = useTheme() const insets = useSafeAreaInsets() const etaText = etaSeconds !== null - ? etaSeconds > 60 - ? `${(etaSeconds / 60).toFixed(1)} 分 (ETA)` - : `${etaSeconds.toFixed(0)} 秒 (ETA)` - : '计算中...' + ? etaSeconds >= 60 + ? `预计还需要约 ${(etaSeconds / 60).toFixed(1)} 分钟` + : `预计还需要约 ${Math.max(1, Math.round(etaSeconds))} 秒` + : '正在预估时间...' return ( + {isRateLimited && ( + + + + 触发哔哩哔哩接口风控,已自动暂停,可稍后重试 + + + )} {syncing ? ( - 剩余 {etaText} + {etaText} @@ -577,10 +723,18 @@ const ExternalPlaylistSyncFooter = ({ onPress={onSync} style={[ styles.button, - { backgroundColor: theme.colors.primaryContainer }, + { + backgroundColor: + primaryButtonColor ?? theme.colors.primaryContainer, + }, ]} > - + {buttonText} @@ -613,7 +767,7 @@ const styles = StyleSheet.create({ }, itemInner: { flexDirection: 'row', - alignItems: 'flex-start', + alignItems: 'center', }, itemContent: { flex: 1, @@ -621,9 +775,10 @@ const styles = StyleSheet.create({ marginLeft: 12, }, statusContainer: { - marginLeft: 8, - minWidth: 60, - alignItems: 'flex-end', + marginLeft: 4, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', }, footer: { padding: 16, diff --git a/apps/mobile/src/app/playlist/local/[id].tsx b/apps/mobile/src/app/playlist/local/[id].tsx index 3d1f12420..ff88fbfc1 100644 --- a/apps/mobile/src/app/playlist/local/[id].tsx +++ b/apps/mobile/src/app/playlist/local/[id].tsx @@ -5,7 +5,8 @@ import { and, eq } from 'drizzle-orm' import { useLiveQuery } from 'drizzle-orm/expo-sqlite' import { useImage } from 'expo-image' import { useObserve } from 'expo-observe' -import { useLocalSearchParams, useRouter } from 'expo-router' +import type { RelativePathString } from 'expo-router' +import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router' import { useCallback, useDeferredValue, @@ -15,7 +16,16 @@ import { useState, } from 'react' import { StyleSheet, View } from 'react-native' -import { Appbar, MD3Theme, Text, useTheme } from 'react-native-paper' +import { + Appbar, + Dialog, + MD3Theme, + Portal, + RadioButton, + Surface, + Text, + useTheme, +} from 'react-native-paper' import { Searchbar as SearchBar } from 'react-native-paper' import Animated, { useAnimatedStyle, @@ -24,6 +34,7 @@ import Animated, { } from 'react-native-reanimated' import ActivityIndicator from '@/components/common/ActivityIndicator' +import Button from '@/components/common/Button' import { MenuView } from '@/components/common/FunctionalMenu' import IconButton from '@/components/common/IconButton' import { alert } from '@/components/modals/AlertModal' @@ -48,6 +59,7 @@ import { } from '@/hooks/mutations/db/playlist' import useCurrentTrack from '@/hooks/player/useCurrentTrack' import { + playlistKeys, useAllPlaylistsContainingTrack, usePlaylistContentsInfinite, usePlaylistMetadata, @@ -59,15 +71,26 @@ import usePreventRemove from '@/hooks/router/usePreventRemove' import { useScreenTransitionReady } from '@/hooks/router/useScreenTransitionReady' import useAppStore from '@/hooks/stores/useAppStore' import { useModalStore } from '@/hooks/stores/useModalStore' +import { usePlayerStore } from '@/hooks/stores/usePlayerStore' import { useDoubleTapScrollToTop } from '@/hooks/ui/useDoubleTapScrollToTop' import { type MenuBuilder, useMenuActions } from '@/hooks/ui/useMenuActions' import { usePlaylistBackgroundColor } from '@/hooks/ui/usePlaylistBackgroundColor' import { useIsActuallyOffline } from '@/hooks/utils/useIsActuallyOffline' +import { queryClient } from '@/lib/config/queryClient' import db from '@/lib/db/db' import * as schema from '@/lib/db/schema' import { CustomError } from '@/lib/errors' +import { + applyPlaylistViewModeToTrack, + type ExternalPlaylistViewMode, + type ExternalTrackMappingRecord, + externalImportJobService, +} from '@/lib/services/externalImportJobService' +import { playlistOutService } from '@/lib/services/playlistOutService' import { playlistService } from '@/lib/services/playlistService' +import { externalPlaylistImportWorker } from '@/lib/workers/ExternalPlaylistImportWorker' import type { Track } from '@/types/core/media' +import type { GenericTrack } from '@/types/external_playlist' import { toastAndLogError } from '@/utils/error-handling' import * as Haptics from '@/utils/haptics' import { resolveBilibiliImageUrl } from '@/utils/imageUrl' @@ -89,11 +112,21 @@ const SYNC_ICON = Icon.select({ android: import('@expo/material-symbols/sync.xml'), }) +const LIST_ICON = Icon.select({ + ios: 'list.bullet', + android: import('@expo/material-symbols/format_list_bulleted.xml'), +}) + const SHARE_ICON = Icon.select({ ios: 'square.and.arrow.up', android: import('@expo/material-symbols/share.xml'), }) +const EXPORT_JSON_ICON = Icon.select({ + ios: 'arrow.down.doc', + android: import('@expo/material-symbols/download.xml'), +}) + const LINK_ICON = Icon.select({ ios: 'link', android: import('@expo/material-symbols/link.xml'), @@ -231,6 +264,18 @@ export default function LocalPlaylistPage() { ) } +type PlaylistExportFormat = 'json' + +const PLAYLIST_EXPORT_FORMATS: { + value: PlaylistExportFormat + label: string +}[] = [ + { + value: 'json', + label: 'JSON (.json)', + }, +] + function LocalPlaylistContent({ id, playlistData, @@ -251,6 +296,9 @@ function LocalPlaylistContent({ const theme = useTheme() const { colors } = theme const [playerPreferenceVisible, setPlayerPreferenceVisible] = useState(false) + const [exportDialogVisible, setExportDialogVisible] = useState(false) + const [exportFormat, setExportFormat] = useState('json') + const [isExporting, setIsExporting] = useState(false) const [isResolvingSelection, setIsResolvingSelection] = useState(false) const router = useRouter() const bbplayerToken = useAppStore((state) => state.bbplayerToken) @@ -282,6 +330,12 @@ function LocalPlaylistContent({ syncFailuresSheetRef.current = sheet if (sheet) void sheet.present() }, []) + const openUnmatchedTracksPage = useCallback(() => { + router.push({ + pathname: '/playlist/local/unmatched' as RelativePathString, + params: { id }, + }) + }, [id, router]) const selection = { active: selectMode, @@ -456,7 +510,12 @@ function LocalPlaylistContent({ primaryButtonTextColor, secondaryButtonContainerColor, secondaryButtonIconColor, - } = usePlaylistBackgroundColor(coverRef, theme.dark, colors.background) + } = usePlaylistBackgroundColor( + coverRef, + theme.dark, + colors.background, + playlistMetadata?.coverUrl, + ) const { mutate: syncPlaylist } = usePlaylistSync() const { mutate: deletePlaylist } = useDeletePlaylist() @@ -554,6 +613,295 @@ function LocalPlaylistContent({ isOffline, playableOfflineKeys, ) + + const numericPlaylistId = Number(id) + const [externalTrackMappings, setExternalTrackMappings] = useState< + ExternalTrackMappingRecord[] + >(() => { + try { + return externalImportJobService.getPlaylistTrackMappings( + numericPlaylistId, + ) + } catch { + return [] + } + }) + const [externalViewMode, setExternalViewMode] = + useState(() => + externalImportJobService.getPlaylistViewMode(numericPlaylistId), + ) + const [isRematchRunning, setIsRematchRunning] = useState(() => + externalPlaylistImportWorker.isPlaylistRematchRunning(numericPlaylistId), + ) + const [rematchProgress, setRematchProgress] = useState<{ + completed: number + total: number + } | null>(null) + + const refreshExternalMappings = useCallback(() => { + try { + setExternalTrackMappings( + externalImportJobService.getPlaylistTrackMappings(numericPlaylistId), + ) + setExternalViewMode( + externalImportJobService.getPlaylistViewMode(numericPlaylistId), + ) + setIsRematchRunning( + externalPlaylistImportWorker.isPlaylistRematchRunning( + numericPlaylistId, + ), + ) + } catch { + // Ignore if DB not ready + } + }, [numericPlaylistId]) + + useEffect(() => { + refreshExternalMappings() + const unsubService = externalImportJobService.subscribeChanges(() => { + refreshExternalMappings() + }) + const unsubWorker = externalPlaylistImportWorker.subscribe(() => { + refreshExternalMappings() + }) + return () => { + unsubService() + unsubWorker() + } + }, [refreshExternalMappings]) + + useFocusEffect( + useCallback(() => { + refreshExternalMappings() + void queryClient.invalidateQueries({ + queryKey: playlistKeys.playlistContents(numericPlaylistId), + }) + }, [numericPlaylistId, refreshExternalMappings]), + ) + + const mappingsByTrackId = useMemo(() => { + const map = new Map() + for (const m of externalTrackMappings) { + map.set(m.trackId, m) + } + return map + }, [externalTrackMappings]) + + const isExternalConvertedPlaylist = + externalTrackMappings.length > 0 || + allLoadedTracks.some( + (track) => + track.uniqueKey.startsWith('external::') || + (track.source === 'bilibili' && + (track.bilibiliMetadata.mainTrackTitle?.startsWith('音源:') || + track.bilibiliMetadata.mainTrackTitle?.startsWith('[未匹配音源]'))), + ) + + const displayedPlaylistData = + externalViewMode !== 'bilibili' + ? finalPlaylistData + : finalPlaylistData.map((track) => + applyPlaylistViewModeToTrack( + track, + externalViewMode, + mappingsByTrackId.get(track.id), + ), + ) + + const handleToggleExternalViewMode = useCallback(() => { + const nextMode = + externalImportJobService.togglePlaylistViewMode(numericPlaylistId) + setExternalViewMode(nextMode) + void usePlayerStore.getState().sync() + toast.success( + nextMode === 'bilibili' ? '已切换为 B 站原版视图' : '已切换为原歌单视图', + ) + }, [numericPlaylistId]) + + const externalMatchStats = useMemo(() => { + const loadedTracks = + ( + playlistData?.pages as Array<{ + tracks: Track[] + }> + )?.flatMap((page) => page.tracks) ?? [] + const mappedTrackIds = new Set() + let unmatchedCount = 0 + let errorCount = 0 + let rateLimitedCount = 0 + let matchedCount = 0 + + for (const m of externalTrackMappings) { + mappedTrackIds.add(m.trackId) + if (m.matchStatus === 'matched' && m.matchedBvid) { + matchedCount++ + } else if (m.matchStatus === 'rate_limited') { + rateLimitedCount++ + } else if (m.matchStatus === 'error') { + errorCount++ + } else { + unmatchedCount++ + } + } + + for (const track of loadedTracks) { + if ( + track.source === 'bilibili' && + !track.bilibiliMetadata.bvid && + !mappedTrackIds.has(track.id) + ) { + unmatchedCount++ + } + } + + const failedCount = errorCount + rateLimitedCount + const unfinishedCount = unmatchedCount + failedCount + return { + unmatchedCount, + errorCount, + rateLimitedCount, + matchedCount, + failedCount, + unfinishedCount, + totalExternalCount: externalTrackMappings.length, + } + }, [externalTrackMappings, playlistData?.pages]) + + const handleManualMatchLocalTrack = useCallback( + (track: Track) => { + if (isSharedSubscriber) { + toast.info('订阅的共享歌单不支持修改音源') + return + } + let mapping: ExternalTrackMappingRecord | null = null + try { + mapping = externalImportJobService.getTrackMapping(track.id) + } catch { + mapping = null + } + const originalTrack: GenericTrack = mapping?.originalTrack ?? { + title: track.title, + artists: track.artist?.name ? [track.artist.name] : [], + album: '', + duration: Math.max(0, (track.duration ?? 0) * 1000), + coverUrl: track.coverUrl ?? undefined, + } + const initialQuery = + `${originalTrack.title} ${originalTrack.artists.join(' ')}`.trim() + const hadExistingBvid = + track.source === 'bilibili' && Boolean(track.bilibiliMetadata.bvid) + + openModal('ManualMatchExternalSync', { + track: originalTrack, + initialQuery, + onMatch: (matchResult) => { + externalImportJobService.updateTrackMatchInPlace( + track.id, + { + ...matchResult, + track: originalTrack, + status: 'matched', + }, + { + playlistId: numericPlaylistId, + originalTrack, + }, + ) + refreshExternalMappings() + void Promise.all([ + queryClient.invalidateQueries({ + queryKey: playlistKeys.playlistContents(numericPlaylistId), + }), + queryClient.invalidateQueries({ + queryKey: playlistKeys.playlistMetadata(numericPlaylistId), + }), + ]) + toast.success(hadExistingBvid ? '已更新匹配音源' : '手动匹配成功') + }, + }) + }, + [isSharedSubscriber, numericPlaylistId, openModal, refreshExternalMappings], + ) + + const handleLocalTrackPress = useCallback( + (track: Track) => { + if (track.source === 'bilibili' && !track.bilibiliMetadata.bvid) { + if (isSharedSubscriber) { + toast.info('该歌曲尚未匹配 B 站音源') + return + } + handleManualMatchLocalTrack(track) + return + } + handleTrackPress(track) + }, + [handleManualMatchLocalTrack, handleTrackPress, isSharedSubscriber], + ) + + const handleBatchRematch = useCallback( + async (mode: 'unfinished' | 'failed') => { + if ( + externalPlaylistImportWorker.isPlaylistRematchRunning(numericPlaylistId) + ) { + return + } + setIsRematchRunning(true) + try { + const result = await externalPlaylistImportWorker.rematchPlaylistTracks( + numericPlaylistId, + { + mode, + onProgress: (completed, total) => { + setRematchProgress({ completed, total }) + void queryClient.invalidateQueries({ + queryKey: playlistKeys.playlistContents(numericPlaylistId), + }) + }, + }, + ) + setRematchProgress(null) + setIsRematchRunning(false) + refreshExternalMappings() + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: playlistKeys.playlistContents(numericPlaylistId), + }), + queryClient.invalidateQueries({ + queryKey: playlistKeys.playlistMetadata(numericPlaylistId), + }), + ]) + + if (result.status === 'rate_limited') { + toast.error('遇到 Bilibili 请求受限,已自动暂停匹配,请稍后重试', { + id: 'bilibili-rate-limit', + }) + } else if (result.total === 0) { + toast.info('没有需要继续匹配的歌曲') + } else if (result.matched === 0) { + toast.info( + `未找到新的匹配音源 (0 / ${result.total}),可点击「查看清单」手动搜索`, + ) + } else { + toast.success( + `继续匹配完成:成功匹配 ${result.matched} / ${result.total} 首`, + ) + } + } catch (e) { + setRematchProgress(null) + setIsRematchRunning(false) + toastAndLogError('继续匹配歌单音源失败', e, SCOPE) + } + }, + [numericPlaylistId, refreshExternalMappings], + ) + + const handlePauseRematch = useCallback(() => { + externalPlaylistImportWorker.pausePlaylistRematch(numericPlaylistId) + setIsRematchRunning(false) + setRematchProgress(null) + toast.info('已暂停继续匹配') + }, [numericPlaylistId]) + const pullingIcon = useMemo( () => () => ( openModal('UpdateTrackLocalPlaylists', { track }), openEditTrackModal: (track) => openModal('EditTrackMetadata', { track }), + onRematchTrack: handleManualMatchLocalTrack, playlist: playlistMetadata, isReadOnly: isSharedSubscriber, }) @@ -929,7 +1278,40 @@ function LocalPlaylistContent({ })) const draggedTrack = - dragging !== null ? finalPlaylistData[dragging.trackIndex] : null + dragging !== null ? displayedPlaylistData[dragging.trackIndex] : null + + const handleConfirmExport = async () => { + if (isExporting) return + setIsExporting(true) + setExportDialogVisible(false) + const loadingToast = toast.loading('正在生成导出文件...') + try { + const tracksResult = await playlistService.getPlaylistTracks(Number(id)) + if (tracksResult.isErr()) { + toast.dismiss(loadingToast) + toastAndLogError('获取歌单曲目失败', tracksResult.error, SCOPE) + return + } + toast.dismiss(loadingToast) + switch (exportFormat) { + case 'json': { + const exportRes = await playlistOutService.exportPlaylistToJsonFile( + playlistMetadata, + tracksResult.value, + ) + if (exportRes.isErr()) { + toast.error(exportRes.error.message) + } + break + } + } + } catch (e) { + toast.dismiss(loadingToast) + toastAndLogError('导出歌单失败', e, SCOPE) + } finally { + setIsExporting(false) + } + } const menuActions = useMenuActions(addPlaylistMenuItems) @@ -950,6 +1332,25 @@ function LocalPlaylistContent({ }) } + if ( + isLocal && + !isSharedSubscriber && + externalMatchStats.unfinishedCount > 0 + ) { + menu.add({ + title: `未完成匹配清单 (${externalMatchStats.unfinishedCount})`, + image: LIST_ICON, + onPress: openUnmatchedTracksPage, + }) + menu.add({ + title: `继续匹配未完成歌曲 (${externalMatchStats.unfinishedCount})`, + image: SYNC_ICON, + onPress: () => { + void handleBatchRematch('unfinished') + }, + }) + } + if (!isSharedSubscriber) { menu.add({ title: '编辑播放列表信息', @@ -1027,6 +1428,14 @@ function LocalPlaylistContent({ }) } + menu.add({ + title: '导出歌单', + image: EXPORT_JSON_ICON, + onPress: () => { + setExportDialogVisible(true) + }, + }) + menu.add({ title: playlistMetadata.isPinned ? '取消置顶' : '置顶', image: playlistMetadata.isPinned ? UNPIN_ICON : PIN_ICON, @@ -1062,6 +1471,49 @@ function LocalPlaylistContent({ visible={playerPreferenceVisible} onDismiss={() => setPlayerPreferenceVisible(false)} /> + + { + if (!isExporting) setExportDialogVisible(false) + }} + > + 导出歌单 + + 请选择导出文件格式: + { + setExportFormat(value as PlaylistExportFormat) + }} + > + {PLAYLIST_EXPORT_FORMATS.map((item) => ( + + ))} + + + + + + + + + {playlistMetadata.type === 'local' && + !isSharedSubscriber && + (externalMatchStats.unfinishedCount > 0 || + isRematchRunning) && ( + + + + + {isRematchRunning + ? `正在继续匹配 B 站音源 (${rematchProgress?.completed ?? 0}/${rematchProgress?.total ?? externalMatchStats.unfinishedCount})...` + : `尚有 ${externalMatchStats.unfinishedCount} 首歌曲待匹配音源`} + + + {externalMatchStats.rateLimitedCount > 0 + ? `其中 ${externalMatchStats.rateLimitedCount} 首因 Bilibili 请求受限暂停,可稍后继续匹配或点击单曲手动匹配` + : externalMatchStats.errorCount > 0 + ? `待匹配 ${externalMatchStats.unmatchedCount} 首 · 网络异常 ${externalMatchStats.errorCount} 首 · 点击单曲可手动匹配` + : '点击下方按钮继续自动匹配,或点击列表中任意歌曲手动匹配 / 更换音源'} + + + + + {isRematchRunning ? ( + + ) : ( + <> + + + {externalMatchStats.failedCount > 0 && + externalMatchStats.unmatchedCount > 0 && ( + + )} + + )} + + + )} {isSharedLoggedOut && ( void + }) => { + const { colors } = useTheme() + const { track, mapping, status, isMatched, matchedTitle } = item + + const isError = status === 'error' || status === 'rate_limited' + const statusLabel = isMatched + ? '已匹配' + : status === 'rate_limited' + ? '请求受限' + : status === 'error' + ? '网络异常' + : '待匹配' + + const statusBgColor = isMatched + ? colors.primaryContainer + : isError + ? colors.errorContainer + : colors.surfaceVariant + const statusTextColor = isMatched + ? colors.onPrimaryContainer + : isError + ? colors.onErrorContainer + : colors.onSurfaceVariant + + const artistText = + track.artist?.name || mapping?.originalArtists.join(', ') || '未知歌手' + + return ( + onManualMatch(track)} + style={styles.itemRipple} + > + + + + + + {track.title} + + + + + {artistText} + + + + {statusLabel} + + + + + {isMatched && matchedTitle ? ( + + {`音源: ${matchedTitle}`} + + ) : status === 'error' && + item.errorMessage && + item.errorMessage !== 'OK' && + item.errorMessage !== '0' ? ( + + {item.errorMessage} + + ) : null} + + + + + + ) + }, +) + +export default function UnmatchedTracksPage() { + const { id } = useLocalSearchParams<{ id: string }>() + const router = useRouter() + const { colors } = useTheme() + const insets = useSafeAreaInsets() + const queryClient = useQueryClient() + const openModal = useModalStore((state) => state.open) + + const numericPlaylistId = Number(id) + const { data: playlistMetadata } = usePlaylistMetadata(numericPlaylistId) + const { data: allTracks = [], isLoading: isTracksLoading } = + usePlaylistContents(numericPlaylistId) + + const [mappings, setMappings] = useState(() => { + try { + return externalImportJobService.getPlaylistTrackMappings( + numericPlaylistId, + ) + } catch { + return [] + } + }) + + const [isRematchRunning, setIsRematchRunning] = useState(() => + externalPlaylistImportWorker.isPlaylistRematchRunning(numericPlaylistId), + ) + const [rematchProgress, setRematchProgress] = useState<{ + completed: number + total: number + } | null>(null) + const [hideMatched, setHideMatched] = useState(false) + + const refreshMappings = useCallback(() => { + try { + setMappings( + externalImportJobService.getPlaylistTrackMappings(numericPlaylistId), + ) + setIsRematchRunning( + externalPlaylistImportWorker.isPlaylistRematchRunning( + numericPlaylistId, + ), + ) + } catch { + // Ignore + } + }, [numericPlaylistId]) + + useEffect(() => { + refreshMappings() + const unsubService = externalImportJobService.subscribeChanges(() => { + refreshMappings() + }) + const unsubWorker = externalPlaylistImportWorker.subscribe(() => { + refreshMappings() + }) + return () => { + unsubService() + unsubWorker() + } + }, [refreshMappings]) + + const mappingByTrackId = useMemo(() => { + const map = new Map() + for (const m of mappings) { + map.set(m.trackId, m) + } + return map + }, [mappings]) + + // 记录初始进入该页面时未完成匹配的 trackId 集合,避免用户手动匹配成功后项目突然消失跳动 + const initialUnfinishedIdsRef = useRef | null>(null) + if (!initialUnfinishedIdsRef.current && allTracks.length > 0) { + const set = new Set() + for (const track of allTracks) { + const m = mappingByTrackId.get(track.id) + const hasBvid = + track.source === 'bilibili' && Boolean(track.bilibiliMetadata.bvid) + + if (m) { + if (m.matchStatus !== 'matched' || !m.matchedBvid) { + set.add(track.id) + } + } else if (!hasBvid && track.source === 'bilibili') { + set.add(track.id) + } + } + initialUnfinishedIdsRef.current = set + } + const initialUnfinishedIds = useMemo( + () => initialUnfinishedIdsRef.current ?? new Set(), + // eslint-disable-next-line react-hooks/exhaustive-deps + [initialUnfinishedIdsRef.current], + ) + + // 格式化要展示的未完成/处理中曲目列表 + const displayItems = useMemo(() => { + const list: UnmatchedItemData[] = [] + + for (const track of allTracks) { + const m = mappingByTrackId.get(track.id) + const hasBvid = + track.source === 'bilibili' && Boolean(track.bilibiliMetadata.bvid) + const isCurrentlyMatched = + (m?.matchStatus === 'matched' && Boolean(m?.matchedBvid)) || hasBvid + + // 只展示属于该列表(初始未完成,或当前仍未完成)的曲目 + const isRelevant = + initialUnfinishedIds.has(track.id) || !isCurrentlyMatched + + if (!isRelevant) continue + if (hideMatched && isCurrentlyMatched) continue + + const status: ExternalTrackMatchStatus = isCurrentlyMatched + ? 'matched' + : (m?.matchStatus ?? 'unmatched') + + const matchedTitle = + m?.matchedVideo?.title?.replace(/]*>|<\/em>/g, '') || + (hasBvid + ? (track.bilibiliMetadata.mainTrackTitle ?? undefined) + : undefined) + + list.push({ + track, + mapping: m, + status, + errorMessage: m?.errorMessage ?? undefined, + isMatched: isCurrentlyMatched, + matchedTitle, + }) + } + + return list + }, [allTracks, hideMatched, initialUnfinishedIds, mappingByTrackId]) + + const remainingUnmatchedCount = useMemo(() => { + return displayItems.filter((i) => !i.isMatched).length + }, [displayItems]) + + const handleManualMatch = useCallback( + (track: Track) => { + let mapping: ExternalTrackMappingRecord | null = null + try { + mapping = externalImportJobService.getTrackMapping(track.id) + } catch { + mapping = null + } + const originalTrack: GenericTrack = mapping?.originalTrack ?? { + title: track.title, + artists: track.artist?.name ? [track.artist.name] : [], + album: '', + duration: Math.max(0, (track.duration ?? 0) * 1000), + coverUrl: track.coverUrl ?? undefined, + } + const initialQuery = + `${originalTrack.title} ${originalTrack.artists.join(' ')}`.trim() + const hadExistingBvid = + track.source === 'bilibili' && Boolean(track.bilibiliMetadata.bvid) + + openModal('ManualMatchExternalSync', { + track: originalTrack, + initialQuery, + onMatch: (matchResult) => { + externalImportJobService.updateTrackMatchInPlace( + track.id, + { + ...matchResult, + track: originalTrack, + status: 'matched', + }, + { + playlistId: numericPlaylistId, + originalTrack, + }, + ) + refreshMappings() + void Promise.all([ + queryClient.invalidateQueries({ + queryKey: playlistKeys.playlistContents(numericPlaylistId), + }), + queryClient.invalidateQueries({ + queryKey: playlistKeys.playlistMetadata(numericPlaylistId), + }), + ]) + toast.success(hadExistingBvid ? '已更新匹配音源' : '手动匹配成功') + }, + }) + }, + [numericPlaylistId, openModal, queryClient, refreshMappings], + ) + + const handleBatchRematch = useCallback( + async (mode: 'unfinished' | 'failed') => { + if ( + externalPlaylistImportWorker.isPlaylistRematchRunning(numericPlaylistId) + ) { + return + } + setIsRematchRunning(true) + try { + const result = await externalPlaylistImportWorker.rematchPlaylistTracks( + numericPlaylistId, + { + mode, + onProgress: (completed, total) => { + setRematchProgress({ completed, total }) + void queryClient.invalidateQueries({ + queryKey: playlistKeys.playlistContents(numericPlaylistId), + }) + }, + }, + ) + setRematchProgress(null) + setIsRematchRunning(false) + refreshMappings() + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: playlistKeys.playlistContents(numericPlaylistId), + }), + queryClient.invalidateQueries({ + queryKey: playlistKeys.playlistMetadata(numericPlaylistId), + }), + ]) + + if (result.status === 'rate_limited') { + toast.error('遇到 Bilibili 请求受限,已自动暂停匹配,请稍后重试', { + id: 'bilibili-rate-limit', + }) + } else if (result.total === 0) { + toast.info('没有需要继续匹配的歌曲') + } else if (result.matched === 0) { + toast.info( + `未找到新的匹配音源 (0 / ${result.total}),可点击「手动匹配」搜索绑定`, + ) + } else { + toast.success( + `继续匹配完成:成功匹配 ${result.matched} / ${result.total} 首`, + ) + } + } catch (e) { + setRematchProgress(null) + setIsRematchRunning(false) + toastAndLogError('继续匹配歌单音源失败', e, SCOPE) + } + }, + [numericPlaylistId, queryClient, refreshMappings], + ) + + const handlePauseRematch = useCallback(() => { + externalPlaylistImportWorker.pausePlaylistRematch(numericPlaylistId) + setIsRematchRunning(false) + setRematchProgress(null) + toast.info('已暂停自动匹配') + }, [numericPlaylistId]) + + return ( + + + router.back()} /> + + {isRematchRunning ? ( + + ) : ( + void handleBatchRematch('unfinished')} + accessibilityLabel='重新自动匹配' + /> + )} + + + {isRematchRunning && ( + + {rematchProgress + ? `正在后台自动匹配音源 (${rematchProgress.completed} / ${rematchProgress.total})...` + : '正在后台自动匹配音源...'} + + )} + + + setHideMatched(false)} + style={styles.chip} + > + {`全部待办 (${displayItems.length})`} + + setHideMatched(true)} + style={styles.chip} + > + {`仅看未匹配 (${remainingUnmatchedCount})`} + + + + + + {isTracksLoading ? ( + + + + 加载待匹配歌曲中... + + + ) : displayItems.length === 0 ? ( + + + + 所有歌曲均已成功匹配音源 + + + 当前歌单中所有歌曲均已找到并绑定 B 站对应视频,可以直接播放 + + + + ) : ( + item.track.uniqueKey || String(item.track.id)} + renderItem={({ item }) => ( + + )} + ItemSeparatorComponent={Divider} + contentContainerStyle={{ + paddingBottom: insets.bottom + 90, + }} + /> + )} + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + filterBar: { + flexDirection: 'row', + paddingHorizontal: 16, + paddingVertical: 10, + gap: 8, + alignItems: 'center', + }, + chip: { + borderRadius: 8, + }, + centerContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 24, + }, + itemRipple: { + paddingHorizontal: 16, + paddingVertical: 10, + }, + itemRow: { + flexDirection: 'row', + alignItems: 'center', + }, + itemInfo: { + flex: 1, + marginLeft: 12, + marginRight: 8, + gap: 3, + }, + secondaryRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + statusBadge: { + paddingHorizontal: 6, + paddingVertical: 1, + borderRadius: 4, + }, + statusBadgeText: { + fontSize: 10, + fontWeight: '600', + }, + actionButton: { + marginLeft: 'auto', + }, +}) diff --git a/apps/mobile/src/app/playlist/remote/collection/[id].tsx b/apps/mobile/src/app/playlist/remote/collection/[id].tsx index ac570c9ed..402ec2155 100644 --- a/apps/mobile/src/app/playlist/remote/collection/[id].tsx +++ b/apps/mobile/src/app/playlist/remote/collection/[id].tsx @@ -99,7 +99,12 @@ export default function CollectionPage() { primaryButtonTextColor, secondaryButtonContainerColor, secondaryButtonIconColor, - } = usePlaylistBackgroundColor(coverRef, theme.dark, colors.background) + } = usePlaylistBackgroundColor( + coverRef, + theme.dark, + colors.background, + collectionData?.info?.cover, + ) const { playTrack } = useRemotePlaylist() const openModal = useModalStore((state) => state.open) diff --git a/apps/mobile/src/app/playlist/remote/favorite/[id].tsx b/apps/mobile/src/app/playlist/remote/favorite/[id].tsx index 18e385bf5..ad7fc8c75 100644 --- a/apps/mobile/src/app/playlist/remote/favorite/[id].tsx +++ b/apps/mobile/src/app/playlist/remote/favorite/[id].tsx @@ -106,7 +106,12 @@ export default function FavoritePage() { primaryButtonTextColor, secondaryButtonContainerColor, secondaryButtonIconColor, - } = usePlaylistBackgroundColor(coverRef, theme.dark, colors.background) + } = usePlaylistBackgroundColor( + coverRef, + theme.dark, + colors.background, + favoriteData?.pages[0]?.info?.cover, + ) const { playTrack } = useRemotePlaylist() diff --git a/apps/mobile/src/app/playlist/remote/multipage/[bvid].tsx b/apps/mobile/src/app/playlist/remote/multipage/[bvid].tsx index 9ebc01c95..75455653b 100644 --- a/apps/mobile/src/app/playlist/remote/multipage/[bvid].tsx +++ b/apps/mobile/src/app/playlist/remote/multipage/[bvid].tsx @@ -118,7 +118,12 @@ export default function MultipagePage() { primaryButtonTextColor, secondaryButtonContainerColor, secondaryButtonIconColor, - } = usePlaylistBackgroundColor(coverRef, theme.dark, colors.background) + } = usePlaylistBackgroundColor( + coverRef, + theme.dark, + colors.background, + videoData?.pic, + ) const { mutate: syncMultipage } = usePlaylistSync() diff --git a/apps/mobile/src/app/playlist/remote/series/[id].tsx b/apps/mobile/src/app/playlist/remote/series/[id].tsx index 5a6960b4c..20a51c128 100644 --- a/apps/mobile/src/app/playlist/remote/series/[id].tsx +++ b/apps/mobile/src/app/playlist/remote/series/[id].tsx @@ -121,7 +121,12 @@ export default function SeriesPage() { primaryButtonTextColor, secondaryButtonContainerColor, secondaryButtonIconColor, - } = usePlaylistBackgroundColor(coverRef, theme.dark, colors.background) + } = usePlaylistBackgroundColor( + coverRef, + theme.dark, + colors.background, + archivesData?.pages[0]?.archives?.[0]?.pic, + ) const { playTrack } = useRemotePlaylist() const openModal = useModalStore((state) => state.open) diff --git a/apps/mobile/src/app/playlist/remote/uploader/[mid].tsx b/apps/mobile/src/app/playlist/remote/uploader/[mid].tsx index d934a2a67..500f74678 100644 --- a/apps/mobile/src/app/playlist/remote/uploader/[mid].tsx +++ b/apps/mobile/src/app/playlist/remote/uploader/[mid].tsx @@ -173,7 +173,12 @@ export default function UploaderPage() { primaryButtonTextColor, secondaryButtonContainerColor, secondaryButtonIconColor, - } = usePlaylistBackgroundColor(coverRef, theme.dark, colors.background) + } = usePlaylistBackgroundColor( + coverRef, + theme.dark, + colors.background, + uploaderUserInfo?.face, + ) const { playTrack } = useRemotePlaylist() diff --git a/apps/mobile/src/app/share/playlist.tsx b/apps/mobile/src/app/share/playlist.tsx index 465c79550..e63e3c9f4 100644 --- a/apps/mobile/src/app/share/playlist.tsx +++ b/apps/mobile/src/app/share/playlist.tsx @@ -143,7 +143,12 @@ export default function SharedPlaylistPreviewPage() { primaryButtonColor, primaryButtonTextColor, secondaryButtonIconColor, - } = usePlaylistBackgroundColor(coverRef, theme.dark, colors.background) + } = usePlaylistBackgroundColor( + coverRef, + theme.dark, + colors.background, + data?.playlist.coverUrl, + ) const nowForTracks = new Date() const previewTracks = data diff --git a/apps/mobile/src/components/NowPlayingBar.tsx b/apps/mobile/src/components/NowPlayingBar.tsx index f8da46faf..26db2ac64 100644 --- a/apps/mobile/src/components/NowPlayingBar.tsx +++ b/apps/mobile/src/components/NowPlayingBar.tsx @@ -307,7 +307,7 @@ const NowPlayingBarContent = memo(function NowPlayingBarContent() { borderRadius: nowPlayingBarStyle === 'bottom' ? 12 : 24, }, ]} - recyclingKey={currentTrack.uniqueKey} + recyclingKey={`${currentTrack.uniqueKey}:${currentTrack.coverUrl ?? ''}`} cachePolicy={'disk'} /> diff --git a/apps/mobile/src/components/common/BilibiliIcon.tsx b/apps/mobile/src/components/common/BilibiliIcon.tsx new file mode 100644 index 000000000..327799b40 --- /dev/null +++ b/apps/mobile/src/components/common/BilibiliIcon.tsx @@ -0,0 +1,22 @@ +import Svg, { Path } from 'react-native-svg' + +export interface BilibiliIconProps { + size?: number + color?: string +} + +export default function BilibiliIcon({ + size = 20, + color = 'currentColor', +}: BilibiliIconProps) { + return ( + + + + ) +} diff --git a/apps/mobile/src/components/modals/edit-metadata/editPlaylistMetadataModal.tsx b/apps/mobile/src/components/modals/edit-metadata/editPlaylistMetadataModal.tsx index ab1b319da..aa5ed57ab 100644 --- a/apps/mobile/src/components/modals/edit-metadata/editPlaylistMetadataModal.tsx +++ b/apps/mobile/src/components/modals/edit-metadata/editPlaylistMetadataModal.tsx @@ -87,13 +87,16 @@ export default function EditPlaylistMetadataModal({ if (!coverDir.exists) { coverDir.create({ intermediates: true }) } - const coverFile = new FileSystem.File(coverDir, assetFile.name) + const coverFile = new FileSystem.File( + coverDir, + `${playlist.id}_${Date.now()}_${assetFile.name}`, + ) if (coverFile.exists) { coverFile.delete() } await assetFile.copy(coverFile) setCoverUrl(coverFile.uri) - }, []) + }, [playlist.id]) const handleDismiss = useCallback(() => { close() diff --git a/apps/mobile/src/components/modals/playlist/CreatePlaylistModal.tsx b/apps/mobile/src/components/modals/playlist/CreatePlaylistModal.tsx index 11256aae7..eff7a22cd 100644 --- a/apps/mobile/src/components/modals/playlist/CreatePlaylistModal.tsx +++ b/apps/mobile/src/components/modals/playlist/CreatePlaylistModal.tsx @@ -77,7 +77,10 @@ export default function CreatePlaylistModal({ if (!coverDir.exists) { coverDir.create({ intermediates: true, idempotent: true }) } - const coverFile = new FileSystem.File(coverDir, assetFile.name) + const coverFile = new FileSystem.File( + coverDir, + `${Date.now()}_${assetFile.name}`, + ) if (coverFile.exists) { coverFile.delete() } diff --git a/apps/mobile/src/components/modals/playlist/InputExternalPlaylistInfo.tsx b/apps/mobile/src/components/modals/playlist/InputExternalPlaylistInfo.tsx index 1b0cb9d7f..9ac81dcee 100644 --- a/apps/mobile/src/components/modals/playlist/InputExternalPlaylistInfo.tsx +++ b/apps/mobile/src/components/modals/playlist/InputExternalPlaylistInfo.tsx @@ -1,24 +1,369 @@ import { SegmentedControl } from '@expo/ui/community/segmented-control' +import { useQueryClient } from '@tanstack/react-query' +import * as Clipboard from 'expo-clipboard' +import * as DocumentPicker from 'expo-document-picker' +import { File } from 'expo-file-system' import { useRouter } from 'expo-router' import { useState } from 'react' -import { StyleSheet, View } from 'react-native' -import { Dialog, Text, TextInput } from 'react-native-paper' +import { Linking, StyleSheet, View } from 'react-native' +import { + Dialog, + Text, + TextInput, + TouchableRipple, + useTheme, +} from 'react-native-paper' import Button from '@/components/common/Button' +import IconButton from '@/components/common/IconButton' +import { playlistKeys } from '@/hooks/queries/db/playlist' import { useModalStore } from '@/hooks/stores/useModalStore' +import { syncExternalPlaylistFacade } from '@/lib/facades/syncExternalPlaylist' +import { externalImportJobService } from '@/lib/services/externalImportJobService' +import { playlistOutService } from '@/lib/services/playlistOutService' +import { + parseKugouCredentialsInput, + playlistOutStorage, +} from '@/lib/storage/playlistOutStorage' import { parseExternalPlaylistInfo } from '@/lib/utils/playlistUrlParser' +import type { GenericPlaylist, GenericTrack } from '@/types/external_playlist' +import toast from '@/utils/toast' + +const PLATFORMS = [ + { + key: 'qq', + label: 'QQ音乐', + bg: '#2BB66A', + text: '#FFFFFF', + }, + { + key: 'netease', + label: '网易云音乐', + bg: '#D43C33', + text: '#FFFFFF', + }, + { + key: 'kugou', + label: '酷狗音乐', + bg: '#0C8ED9', + text: '#FFFFFF', + }, + { + key: 'qishui', + label: '汽水音乐', + bg: '#00C2B8', + text: '#FFFFFF', + }, +] as const + +const PLAYLISTOUT_WEB_URL = 'https://playlistout.lengxiqwq.com' +const PLAYLISTOUT_KUGOU_LOGIN_URL = + 'https://playlistout.lengxiqwq.com/?login=kugou' const InputExternalPlaylistInfoModal = () => { - const [input, setInput] = useState('') - const [source, setSource] = useState<'netease' | 'qq'>('netease') + const theme = useTheme() const router = useRouter() + const queryClient = useQueryClient() const close = useModalStore((state) => state.close) - const handleConfirm = () => { - if (!input.trim()) return - const parsed = parseExternalPlaylistInfo(input) - const finalId = parsed?.id ?? input.trim() - const finalSource = parsed?.source ?? source + // Mode state: 'smart' (default) vs 'legacy' + const [mode, setMode] = useState<'smart' | 'legacy'>(() => + playlistOutStorage.getImportMode(), + ) + const [showKugouSettings, setShowKugouSettings] = useState(false) + const [kugouTokenInput, setKugouTokenInput] = useState(() => + playlistOutStorage.getKugouToken(), + ) + const [kugouUseridInput, setKugouUseridInput] = useState(() => + playlistOutStorage.getKugouUserid(), + ) + const [isValidatingKugou, setIsValidatingKugou] = useState(false) + const [kugouValidationResult, setKugouValidationResult] = useState<{ + valid: boolean + message: string + } | null>(null) + + // Smart mode inputs + const [smartInput, setSmartInput] = useState('') + const [isResolving, setIsResolving] = useState(false) + + // Legacy mode inputs + const [legacyInput, setLegacyInput] = useState('') + const [legacySource, setLegacySource] = useState<'netease' | 'qq'>('netease') + + const detectedPlatform = playlistOutService.detectPlatform(smartInput) + const kugouStatus = playlistOutStorage.isKugouConfigured() + + const switchMode = (newMode: 'smart' | 'legacy') => { + setMode(newMode) + playlistOutStorage.setImportMode(newMode) + } + + const handlePaste = async () => { + try { + const text = await Clipboard.getStringAsync() + if (text) { + setSmartInput(text.trim()) + toast.info('已粘贴剪贴板内容') + } else { + toast.info('剪贴板为空') + } + } catch { + toast.error('无法读取剪贴板') + } + } + + const handleOpenWebsite = async () => { + try { + await Linking.openURL(PLAYLISTOUT_WEB_URL) + } catch { + toast.error('无法打开外部浏览器') + } + } + + const handleOpenKugouLoginWebsite = async () => { + try { + await Linking.openURL(PLAYLISTOUT_KUGOU_LOGIN_URL) + } catch { + toast.error('无法打开外部浏览器') + } + } + + const handleDirectBilibiliImport = async (data: { + playlist: GenericPlaylist + tracks: GenericTrack[] + }): Promise => { + if (!playlistOutService.isDirectBilibiliPlaylist(data)) { + return false + } + const loadingToast = toast.loading('检测到哔哩哔哩歌单,正在直接导入...') + try { + const saveRes = + await syncExternalPlaylistFacade.saveDirectBilibiliPlaylist( + { + title: data.playlist.title, + coverUrl: data.playlist.coverUrl ?? '', + description: data.playlist.description ?? '', + }, + data.tracks, + ) + toast.dismiss(loadingToast) + if (saveRes.isErr()) { + toast.error(`直接导入失败: ${saveRes.error.message}`) + return true + } + await queryClient.invalidateQueries({ + queryKey: playlistKeys.playlistLists(), + }) + toast.success('哔哩哔哩歌单已直接导入到本地') + const playlistId = saveRes.value + close('InputExternalPlaylistInfo') + useModalStore.getState().doAfterModalHostClosed(() => { + router.navigate(`/playlist/local/${playlistId}`) + }) + return true + } catch (e) { + toast.dismiss(loadingToast) + toast.error(`直接导入失败: ${e instanceof Error ? e.message : String(e)}`) + return true + } + } + + const handleSmartResolve = async () => { + const trimmed = smartInput.trim() + if (!trimmed) { + toast.error('请输入歌单链接') + return + } + + setIsResolving(true) + // If input is raw JSON text, parse it directly + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + const jsonRes = playlistOutService.parseJsonPlaylist(trimmed) + if (jsonRes.isErr()) { + setIsResolving(false) + toast.error(jsonRes.error.message) + return + } + if (await handleDirectBilibiliImport(jsonRes.value)) { + setIsResolving(false) + return + } + setIsResolving(false) + const cacheId = jsonRes.value.playlist.id + playlistOutService.setCachedPlaylist(cacheId, jsonRes.value) + externalImportJobService.createOrResumeJob({ + source: 'local_json', + sourcePlaylistId: cacheId, + tracks: jsonRes.value.tracks, + playlistMetadata: jsonRes.value.playlist, + }) + close('InputExternalPlaylistInfo') + useModalStore.getState().doAfterModalHostClosed(() => { + router.navigate({ + pathname: '/playlist/external-sync', + params: { id: cacheId, source: 'local_json' }, + }) + }) + return + } + + // If input is an online .json URL, resolve it so Bilibili JSON can be imported directly + if (/^https?:\/\/[^\s]+\.json(?:\?[^\s]*)?$/i.test(trimmed)) { + const resolveRes = await playlistOutService.resolvePlaylist(trimmed) + if (resolveRes.isErr()) { + setIsResolving(false) + toast.error(resolveRes.error.message) + return + } + if (await handleDirectBilibiliImport(resolveRes.value)) { + setIsResolving(false) + return + } + setIsResolving(false) + const cacheId = resolveRes.value.playlist.id + playlistOutService.setCachedPlaylist(cacheId, resolveRes.value) + externalImportJobService.createOrResumeJob({ + source: 'local_json', + sourcePlaylistId: cacheId, + tracks: resolveRes.value.tracks, + playlistMetadata: resolveRes.value.playlist, + }) + close('InputExternalPlaylistInfo') + useModalStore.getState().doAfterModalHostClosed(() => { + router.navigate({ + pathname: '/playlist/external-sync', + params: { id: cacheId, source: 'local_json' }, + }) + }) + return + } + + // Navigate to external-sync with playlistout source for API resolution + setIsResolving(false) + close('InputExternalPlaylistInfo') + useModalStore.getState().doAfterModalHostClosed(() => { + router.navigate({ + pathname: '/playlist/external-sync', + params: { id: trimmed, source: 'playlistout' }, + }) + }) + } + + const handlePickLocalJson = async () => { + try { + const res = await DocumentPicker.getDocumentAsync({ + type: ['application/json', 'text/json', '*/*'], + copyToCacheDirectory: true, + }) + if (res.canceled || !res.assets?.[0]?.uri) return + + const asset = res.assets[0] + const file = new File(asset.uri) + const content = await file.text() + const jsonRes = playlistOutService.parseJsonPlaylist( + content, + asset.name ? asset.name.replace(/\.json$/i, '') : '本地导入歌单', + ) + + if (jsonRes.isErr()) { + toast.error(jsonRes.error.message) + return + } + + if (await handleDirectBilibiliImport(jsonRes.value)) { + return + } + + const cacheId = jsonRes.value.playlist.id + playlistOutService.setCachedPlaylist(cacheId, jsonRes.value) + externalImportJobService.createOrResumeJob({ + source: 'local_json', + sourcePlaylistId: cacheId, + tracks: jsonRes.value.tracks, + playlistMetadata: jsonRes.value.playlist, + }) + close('InputExternalPlaylistInfo') + useModalStore.getState().doAfterModalHostClosed(() => { + router.navigate({ + pathname: '/playlist/external-sync', + params: { id: cacheId, source: 'local_json' }, + }) + }) + } catch (e) { + toast.error( + `读取本地文件失败: ${e instanceof Error ? e.message : String(e)}`, + ) + } + } + + const handleTokenChange = (text: string) => { + const parsed = parseKugouCredentialsInput(text) + if (parsed) { + setKugouTokenInput(parsed.token) + if (parsed.userid) { + setKugouUseridInput(parsed.userid) + toast.info('已自动识别并拆分填入 Token 与 UID') + } + } else { + setKugouTokenInput(text) + } + setKugouValidationResult(null) + } + + const handleUseridChange = (text: string) => { + const parsed = parseKugouCredentialsInput(text) + if (parsed) { + setKugouTokenInput(parsed.token) + if (parsed.userid) { + setKugouUseridInput(parsed.userid) + toast.info('已自动识别并拆分填入 Token 与 UID') + } + } else { + setKugouUseridInput(text) + } + setKugouValidationResult(null) + } + + const handleValidateKugou = async () => { + if (!kugouTokenInput.trim() && !kugouUseridInput.trim()) { + toast.info('请先填入 Token 与 UID') + return + } + setIsValidatingKugou(true) + const res = await playlistOutService.verifyKugouCredentials( + kugouTokenInput, + kugouUseridInput, + ) + setIsValidatingKugou(false) + setKugouValidationResult(res) + if (res.valid) { + toast.success(res.message) + } else { + toast.error(res.message) + } + } + + const handleSaveKugouToken = () => { + playlistOutStorage.setKugouCredentials(kugouTokenInput, kugouUseridInput) + toast.success('酷狗凭据已保存') + setShowKugouSettings(false) + } + + const handleClearKugouToken = () => { + playlistOutStorage.clearKugouCredentials() + setKugouTokenInput('') + setKugouUseridInput('') + setKugouValidationResult(null) + toast.success('酷狗凭据已清空') + setShowKugouSettings(false) + } + + const handleLegacyConfirm = () => { + if (!legacyInput.trim()) return + const parsed = parseExternalPlaylistInfo(legacyInput) + const finalId = parsed?.id ?? legacyInput.trim() + const finalSource = parsed?.source ?? legacySource close('InputExternalPlaylistInfo') useModalStore.getState().doAfterModalHostClosed(() => { @@ -29,52 +374,470 @@ const InputExternalPlaylistInfoModal = () => { }) } + // Sub-view: Kugou Token & UserID Settings + if (showKugouSettings) { + const isConfigured = Boolean( + playlistOutStorage.getKugouToken() || playlistOutStorage.getKugouUserid(), + ) + return ( + <> + ⚙️ 酷狗音乐解析设置 + + + 因酷狗官方限制,解析完整歌单必须同时提供 Token 与用户 ID + (UID)。未配置或缺少 UID 时仅能解析前 10 首。 + + + + + + + + + + + + {kugouValidationResult && ( + + {kugouValidationResult.valid ? '✅ ' : '❌ '} + {kugouValidationResult.message} + + )} + + + + 💡 获取方式:{'\n'} + 1. 点击上方「网页端获取凭据」在网页中扫码登录酷狗{'\n'} + 2. 分别点击「复制 Token」和「复制 User ID」粘贴到此处{'\n'} + 3. 点击「测试连接」验证通过后点击保存即可!{'\n'}📌 + 说明:酷狗接口仅允许解锁本人创建/收藏的歌单;他人歌单受限制仅能预览 + 10 首,他人歌单建议在网页端导出 JSON 导入。 + + + + + {isConfigured ? ( + + ) : null} + + + + + ) + } + + // View: Legacy Mode + if (mode === 'legacy') { + return ( + <> + + 输入外部歌单信息 + + + + { + setLegacyInput(text) + const result = parseExternalPlaylistInfo(text) + if (result) { + setLegacySource(result.source) + } + }} + mode='outlined' + style={styles.input} + /> + + 来源: + { + const selectedIndex = event.nativeEvent.selectedSegmentIndex + setLegacySource(selectedIndex === 0 ? 'netease' : 'qq') + }} + values={['网易云音乐', 'QQ音乐']} + style={styles.segmentedButtons} + /> + + + + + + + + ) + } + + // View: Smart PlaylistOut Mode (Default) return ( <> - 输入外部歌单信息 - + + 导入外部歌单 + + { + setKugouTokenInput(playlistOutStorage.getKugouToken()) + setKugouUseridInput(playlistOutStorage.getKugouUserid()) + setKugouValidationResult(null) + setShowKugouSettings(true) + }} + /> + + + + + + {/* 智能输入框 */} { - setInput(text) - const result = parseExternalPlaylistInfo(text) - if (result) { - setSource(result.source) - } - }} + label='歌单链接' + placeholder='粘贴歌单链接' + value={smartInput} + onChangeText={setSmartInput} mode='outlined' style={styles.input} + right={ + + } /> - - 来源: - { - const selectedIndex = event.nativeEvent.selectedSegmentIndex - setSource(selectedIndex === 0 ? 'netease' : 'qq') - }} - values={['网易云音乐', 'QQ音乐']} - style={styles.segmentedButtons} - /> + + {/* 平台指示标签:整块圆角矩形背景点亮各平台专属品牌色 */} + + {PLATFORMS.map((p) => { + const isMatched = detectedPlatform === p.key + return ( + + + {p.label} + + + ) + })} - - - + + {/* 提示信息卡片:通用解析方式 + 酷狗音乐特殊说明 */} + + + + • + + + 可直接粘贴以上支持的链接,或在网页端解析后导出 JSON 进行本地导入 + + + + + • + + + {kugouStatus.configured + ? '已配置酷狗凭据,支持完整解析本人创建歌单;他人歌单受限制仅能预览 10 首' + : kugouStatus.hasToken && !kugouStatus.hasUserid + ? '⚠️ 酷狗已填 Token 但缺少 UID,请点击右上角设置补全 UID 解锁完整歌单' + : '酷狗音乐未登录受限仅 10 首,需要在右上角设置中填入 Token 与 UID 解锁'} + + + + + {/* 核心大操作按钮(置于面板内部核心区) */} - + + {/* 辅助操作栏:左侧选择本地 JSON,右侧网页端解析 */} + + + + + + + {/* 底部操作区:署名水平居中,取消按钮靠右 */} + + + + Powered by Playlist Out + + + close('InputExternalPlaylistInfo')} + style={styles.cancelRipple} + borderless + > + + 取消 + + + ) } const styles = StyleSheet.create({ + headerRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingRight: 8, + }, + headerRightActions: { + flexDirection: 'row', + alignItems: 'center', + }, + titleText: { + flex: 1, + }, + smartContent: { + paddingTop: 4, + paddingBottom: 0, + }, input: { - marginBottom: 16, + marginBottom: 10, + }, + platformRow: { + flexDirection: 'row', + marginBottom: 12, + gap: 6, + }, + platformChip: { + flex: 1, + paddingVertical: 6, + paddingHorizontal: 4, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + }, + platformLabel: { + fontSize: 11, + }, + infoNoticeCard: { + borderRadius: 12, + paddingVertical: 10, + paddingHorizontal: 12, + marginBottom: 14, + }, + noticeItemRow: { + flexDirection: 'row', + alignItems: 'flex-start', + }, + bulletDot: { + fontSize: 14, + lineHeight: 18, + marginRight: 6, + fontWeight: 'bold', + }, + noticeTextLine: { + flex: 1, + fontSize: 12, + lineHeight: 18, + }, + openWebButton: { + marginTop: 4, + marginBottom: 10, + borderRadius: 12, + }, + settingsActionRow: { + flexDirection: 'row', + marginTop: 4, + marginBottom: 6, + }, + hintBox: { + borderRadius: 8, + padding: 10, + }, + primaryResolveButton: { + borderRadius: 24, + marginBottom: 8, + }, + primaryResolveButtonContent: { + height: 48, + }, + secondaryActionsRow: { + flexDirection: 'row', + justifyContent: 'space-around', + alignItems: 'center', + marginBottom: 4, + }, + auxButton: { + borderRadius: 16, + }, + footerRow: { + position: 'relative', + flexDirection: 'row', + justifyContent: 'flex-end', + alignItems: 'center', + paddingHorizontal: 24, + paddingBottom: 16, + paddingTop: 6, + minHeight: 40, + }, + centerAttributionContainer: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'center', + }, + footerText: { + fontSize: 11, + letterSpacing: 0.5, + }, + cancelRipple: { + paddingVertical: 6, + paddingHorizontal: 8, + marginRight: -8, + borderRadius: 8, + }, + cancelText: { + fontSize: 14, + fontWeight: '600', }, segmentedContainer: { marginTop: 8, diff --git a/apps/mobile/src/components/modals/playlist/ManualMatchExternalSync.tsx b/apps/mobile/src/components/modals/playlist/ManualMatchExternalSync.tsx index f5aab6489..aee870e8c 100644 --- a/apps/mobile/src/components/modals/playlist/ManualMatchExternalSync.tsx +++ b/apps/mobile/src/components/modals/playlist/ManualMatchExternalSync.tsx @@ -1,83 +1,156 @@ +import { + Orpheus, + PlaybackState, + useIsPlaying, + usePlaybackState, +} from '@bbplayer/orpheus' import { LegendList } from '@legendapp/list/react-native' import { decode } from 'he' -import { memo, useCallback, useMemo, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { StyleSheet, View } from 'react-native' -import { Dialog, Text, TouchableRipple } from 'react-native-paper' +import { Dialog, Text, TouchableRipple, useTheme } from 'react-native-paper' import { Searchbar as SearchBar } from 'react-native-paper' import ActivityIndicator from '@/components/common/ActivityIndicator' import Button from '@/components/common/Button' import CoverWithPlaceHolder from '@/components/common/CoverWithPlaceHolder' +import IconButton from '@/components/common/IconButton' import { useSearchResults } from '@/hooks/queries/bilibili/search' import { useModalStore } from '@/hooks/stores/useModalStore' +import { usePlayerStore } from '@/hooks/stores/usePlayerStore' +import { isBilibiliRateLimitError } from '@/lib/errors/thirdparty/bilibili' +import { syncFacade } from '@/lib/facades/syncBilibiliPlaylist' import type { MatchResult } from '@/lib/services/externalPlaylistService' +import { getTrackFingerprint } from '@/lib/services/externalPlaylistService' import type { BilibiliSearchVideo } from '@/types/apis/bilibili' +import type { BilibiliTrack } from '@/types/core/media' import type { GenericTrack } from '@/types/external_playlist' import type { ListRenderItemInfoWithExtraData } from '@/types/legendlist' -import { formatDurationToHHMMSS } from '@/utils/time' +import { toastAndLogError } from '@/utils/error-handling' +import { addToQueue } from '@/utils/player' +import { formatDurationToHHMMSS, parseDurationString } from '@/utils/time' + +function mapSearchVideoToBilibiliTrack( + item: BilibiliSearchVideo, +): BilibiliTrack { + const coverUrl = item.pic + ? item.pic.startsWith('//') + ? `https:${item.pic}` + : item.pic + : null + const cleanTitle = item.title.replace(/]*>|<\/em>/g, '') + const timestamp = item.senddate ? item.senddate * 1000 : Date.now() + return { + id: item.aid || 0, + uniqueKey: `bilibili::${item.bvid}`, + source: 'bilibili', + title: cleanTitle, + artist: { + id: item.mid || 0, + name: item.author || '未知 UP 主', + remoteId: String(item.mid || `bili_up_${item.author || 'unknown'}`), + source: 'bilibili', + createdAt: new Date(timestamp), + updatedAt: new Date(timestamp), + }, + coverUrl, + duration: item.duration ? parseDurationString(item.duration) : 0, + createdAt: new Date(timestamp), + updatedAt: new Date(timestamp), + titleHtml: item.title, + bilibiliMetadata: { + bvid: item.bvid, + cid: null, + isMultiPage: false, + videoIsValid: true, + }, + } +} + +interface SearchItemExtraData { + handlePressItem: (item: BilibiliSearchVideo) => void + handleToggleAudition: (item: BilibiliSearchVideo) => void + activeBvid: string | null +} const renderItem = ({ item, extraData, }: ListRenderItemInfoWithExtraData< BilibiliSearchVideo, - { - handlePressItem: (item: BilibiliSearchVideo) => void - } + SearchItemExtraData >) => { if (!extraData) throw new Error('Extradata 不存在') return ( ) } const SearchItem = memo(function SearchItem({ item, + isAuditioning, onPress, + onToggleAudition, }: { item: BilibiliSearchVideo + isAuditioning: boolean onPress: (item: BilibiliSearchVideo) => void + onToggleAudition: (item: BilibiliSearchVideo) => void }) { + const theme = useTheme() const coverUrl = item.pic.startsWith('//') ? `https:${item.pic}` : item.pic + const cleanTitle = item.title.replace(/]*>|<\/em>/g, '') + const durationSec = item.duration ? parseDurationString(item.duration) : 0 + return ( - onPress(item)} - > - - - - - {item.title - .replace(//g, '') - .replace(/<\/em>/g, '')} - - - {item.author} -{' '} - {formatDurationToHHMMSS( - Math.round( - parseInt(item.duration.split(':')[0]) * 60 + - parseInt(item.duration.split(':')[1]), - ), - )} - + + onPress(item)} + > + + + + + {cleanTitle} + + + {item.author} · {formatDurationToHHMMSS(durationSec)} + + - - + + onToggleAudition(item)} + style={styles.auditionButton} + /> + ) }) @@ -92,10 +165,47 @@ export default function ManualMatchExternalSync({ }) { const [query, setQuery] = useState(initialQuery) const [finalQuery, setFinalQuery] = useState(initialQuery) + const [loadingBvid, setLoadingBvid] = useState(null) + const loadingBvidRef = useRef(null) + const auditionedKeyRef = useRef(null) const close = useModalStore((state) => state.close) - const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = - useSearchResults(finalQuery) + const isPlaying = useIsPlaying() + const playbackState = usePlaybackState() + const currentOrpheusTrackId = usePlayerStore( + (state) => state.orpheusTrack?.id, + ) + + const activeBvid = useMemo(() => { + if (loadingBvid) return loadingBvid + if ( + currentOrpheusTrackId?.startsWith('bilibili::') && + (isPlaying || playbackState === PlaybackState.BUFFERING) + ) { + return currentOrpheusTrackId.slice('bilibili::'.length) + } + return null + }, [currentOrpheusTrackId, isPlaying, loadingBvid, playbackState]) + + useEffect(() => { + return () => { + loadingBvidRef.current = null + if (auditionedKeyRef.current) { + auditionedKeyRef.current = null + void Orpheus.pause() + } + } + }, []) + + const { + data, + isLoading, + isError, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useSearchResults(finalQuery) const allVideos = useMemo(() => { if (!data?.pages) { @@ -115,18 +225,91 @@ export default function ManualMatchExternalSync({ return [...uniqueMap.values()] }, [data]) + const handleToggleAudition = useCallback( + async (video: BilibiliSearchVideo) => { + const targetKey = `bilibili::${video.bvid}` + const isCurrent = usePlayerStore.getState().orpheusTrack?.id === targetKey + const currentlyPlaying = await Orpheus.getIsPlaying() + + if ( + loadingBvidRef.current === video.bvid || + (isCurrent && (currentlyPlaying || isPlaying)) + ) { + loadingBvidRef.current = null + setLoadingBvid(null) + auditionedKeyRef.current = null + await Orpheus.pause() + return + } + + if (isCurrent && !currentlyPlaying) { + auditionedKeyRef.current = targetKey + await Orpheus.play() + return + } + + loadingBvidRef.current = video.bvid + setLoadingBvid(video.bvid) + try { + const biliTrack = mapSearchVideoToBilibiliTrack(video) + const createResult = await syncFacade.addTrackToLocal(biliTrack) + if (createResult.isErr()) { + toastAndLogError( + '加载试听音源失败', + createResult.error, + 'UI.Modal.ManualMatchExternalSync', + ) + return + } + if (loadingBvidRef.current !== video.bvid) { + return + } + auditionedKeyRef.current = biliTrack.uniqueKey + await addToQueue({ + tracks: [biliTrack], + playNow: true, + clearQueue: false, + playNext: false, + startFromKey: biliTrack.uniqueKey, + }) + } finally { + if (loadingBvidRef.current === video.bvid) { + loadingBvidRef.current = null + setLoadingBvid(null) + } + } + }, + [isPlaying], + ) + const handlePressItem = useCallback( (video: BilibiliSearchVideo) => { + loadingBvidRef.current = null + if (auditionedKeyRef.current) { + auditionedKeyRef.current = null + void Orpheus.pause() + } onMatch({ track, matchedVideo: video, + status: 'matched', + trackFingerprint: getTrackFingerprint(track), }) close('ManualMatchExternalSync') }, [close, onMatch, track], ) - const extraData = useMemo(() => ({ handlePressItem }), [handlePressItem]) + const extraData = useMemo( + () => ({ + handlePressItem, + handleToggleAudition: (video: BilibiliSearchVideo) => { + void handleToggleAudition(video) + }, + activeBvid, + }), + [activeBvid, handlePressItem, handleToggleAudition], + ) const keyExtractor = useCallback((item: BilibiliSearchVideo) => item.bvid, []) @@ -138,6 +321,17 @@ export default function ManualMatchExternalSync({ ) } + if (isError && allVideos.length === 0) { + return ( + + + {isBilibiliRateLimitError(error) + ? '请求暂时受限,进度已保存,请稍后再试' + : `搜索失败: ${error instanceof Error ? error.message : '请稍后重试'}`} + + + ) + } if (allVideos.length > 0) { return ( - + ) } const styles = StyleSheet.create({ + itemRow: { + flexDirection: 'row', + alignItems: 'center', + paddingRight: 8, + }, searchItem: { + flex: 1, paddingVertical: 8, - paddingHorizontal: 16, + paddingLeft: 16, + paddingRight: 8, }, itemContainer: { flexDirection: 'row', @@ -204,6 +416,9 @@ const styles = StyleSheet.create({ marginLeft: 12, flex: 1, }, + auditionButton: { + margin: 0, + }, centerContainer: { flex: 1, justifyContent: 'center', diff --git a/apps/mobile/src/features/library/local/DraftImportPlaylistItem.tsx b/apps/mobile/src/features/library/local/DraftImportPlaylistItem.tsx new file mode 100644 index 000000000..ac0b0c499 --- /dev/null +++ b/apps/mobile/src/features/library/local/DraftImportPlaylistItem.tsx @@ -0,0 +1,346 @@ +import { useRouter } from 'expo-router' +import { memo, useCallback } from 'react' +import { StyleSheet, View } from 'react-native' +import { Touchable } from 'react-native-gesture-handler' +import { Divider, Icon, ProgressBar, Text, useTheme } from 'react-native-paper' + +import Button from '@/components/common/Button' +import CoverWithPlaceHolder from '@/components/common/CoverWithPlaceHolder' +import IconButton from '@/components/common/IconButton' +import { alert } from '@/components/modals/AlertModal' +import { + externalImportJobService, + type ImportJob, + isJobWaitingConfirmation, +} from '@/lib/services/externalImportJobService' +import { externalPlaylistImportWorker } from '@/lib/workers/ExternalPlaylistImportWorker' +import { LIST_ITEM_COVER_SIZE } from '@/theme/dimensions' +import toast from '@/utils/toast' + +export interface DraftPlatformBadgeStyle { + label: string + backgroundColor: string + textColor: string +} + +export function resolveDraftImportPlatformBadge( + job: Pick, + fallbackColors: { backgroundColor: string; textColor: string } = { + backgroundColor: '#5C6BC0', + textColor: '#FFFFFF', + }, +): DraftPlatformBadgeStyle { + const platform = job.playlistMetadata?.platform + if (platform === 'qq' || platform === 'qqmusic' || job.source === 'qq') { + return { + label: 'QQ音乐', + backgroundColor: '#2BB66A', + textColor: '#FFFFFF', + } + } + if (platform === 'netease' || job.source === 'netease') { + return { + label: '网易云音乐', + backgroundColor: '#D43C33', + textColor: '#FFFFFF', + } + } + if (platform === 'kugou') { + return { + label: '酷狗音乐', + backgroundColor: '#0C8ED9', + textColor: '#FFFFFF', + } + } + if (platform === 'qishui') { + return { + label: '汽水音乐', + backgroundColor: '#00C2B8', + textColor: '#FFFFFF', + } + } + if (job.source === 'local_json') { + return { + label: 'JSON导入', + backgroundColor: fallbackColors.backgroundColor, + textColor: fallbackColors.textColor, + } + } + return { + label: '外部导入', + backgroundColor: fallbackColors.backgroundColor, + textColor: fallbackColors.textColor, + } +} + +interface DraftImportPlaylistItemProps { + job: ImportJob + currentTrackTitle?: string | null +} + +const DraftImportPlaylistItem = memo( + ({ job, currentTrackTitle }: DraftImportPlaylistItemProps) => { + const router = useRouter() + const theme = useTheme() + const { colors } = theme + + const totalCount = Math.max(job.totalCount, 0) + const processedCount = Math.min(job.processedCount, totalCount) + const progress = totalCount > 0 ? processedCount / totalCount : 0 + const percentage = Math.round(progress * 100) + const isRunning = + job.status === 'running' || + externalPlaylistImportWorker.isJobRunning(job.jobId) + const isWaitingConfirm = isJobWaitingConfirmation(job) + const isRateLimited = job.status === 'rate_limited' + const needManualCount = job.unmatchedCount + job.errorCount + const platformBadge = resolveDraftImportPlatformBadge(job, { + backgroundColor: colors.secondaryContainer, + textColor: colors.onSecondaryContainer, + }) + + const navigateToSyncPage = useCallback(() => { + router.navigate({ + pathname: '/playlist/external-sync', + params: { + id: job.sourcePlaylistId, + source: job.source, + }, + }) + }, [job.source, job.sourcePlaylistId, router]) + + const handlePrimaryAction = useCallback(() => { + if (isRunning) { + externalPlaylistImportWorker.pause(job.jobId) + toast.info('已暂停匹配,进度已保存') + return + } + if (isWaitingConfirm) { + navigateToSyncPage() + return + } + void externalPlaylistImportWorker.start(job.jobId) + }, [isRunning, isWaitingConfirm, job.jobId, navigateToSyncPage]) + + const handleDiscardDraft = useCallback(() => { + alert( + '放弃导入草稿', + `确定要放弃并删除《${job.playlistMetadata.title || job.sourcePlaylistId}》的导入草稿吗?已匹配的临时进度将被清除。`, + [ + { text: '取消' }, + { + text: '删除草稿', + onPress: () => { + externalPlaylistImportWorker.cancel(job.jobId) + externalImportJobService.deleteJob(job.jobId) + toast.success('已删除导入草稿') + }, + }, + ], + { cancelable: true }, + ) + }, [job.jobId, job.playlistMetadata.title, job.sourcePlaylistId]) + + let statusText: string + let statusColor = colors.onSurfaceVariant + + if (isRunning) { + statusText = `正在匹配 ${processedCount} / ${totalCount} · ${percentage}%` + statusColor = colors.primary + } else if (isRateLimited) { + statusText = `风控暂停 · 已完成 ${processedCount} / ${totalCount}(${job.matchedCount} 首成功)` + statusColor = colors.error + } else if (isWaitingConfirm) { + statusText = `已完成待确认 · ${job.matchedCount} 首成功,${needManualCount} 首待处理` + statusColor = colors.primary + } else if (job.status === 'paused') { + statusText = `已暂停 · 已完成 ${processedCount} / ${totalCount} · ${percentage}%` + } else if (job.status === 'failed') { + statusText = `匹配中断 · 已完成 ${processedCount} / ${totalCount}` + statusColor = colors.error + } else { + statusText = `待开始匹配 · 共 ${totalCount} 首歌曲` + } + + const primaryButtonLabel = isRunning + ? '暂停' + : isWaitingConfirm + ? '去确认' + : processedCount > 0 || job.errorCount > 0 + ? '继续' + : '开始' + + return ( + + + + + + + + + + {platformBadge.label} + + + + {job.playlistMetadata.title || job.sourcePlaylistId} + + + + + + + {statusText} + + + + {isRunning && currentTrackTitle ? ( + + 当前:{currentTrackTitle} + + ) : null} + + + + + + + + + {totalCount > 0 && ( + + )} + + + + + ) + }, +) + +const styles = StyleSheet.create({ + rectButton: { + borderRadius: 12, + marginBottom: 6, + overflow: 'hidden', + }, + cardInner: { + paddingHorizontal: 8, + paddingTop: 8, + paddingBottom: 6, + }, + itemContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + textContainer: { + marginLeft: 12, + flex: 1, + marginRight: 8, + }, + titleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + marginBottom: 2, + }, + draftBadge: { + paddingHorizontal: 6, + paddingVertical: 1, + borderRadius: 4, + }, + titleText: { + flex: 1, + }, + subtitleContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + marginTop: 2, + }, + actionsContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 2, + }, + progressBar: { + height: 3, + borderRadius: 2, + marginTop: 8, + }, +}) + +DraftImportPlaylistItem.displayName = 'DraftImportPlaylistItem' + +export default DraftImportPlaylistItem diff --git a/apps/mobile/src/features/library/local/LocalPlaylistList.tsx b/apps/mobile/src/features/library/local/LocalPlaylistList.tsx index b78619019..30a6b1b9d 100644 --- a/apps/mobile/src/features/library/local/LocalPlaylistList.tsx +++ b/apps/mobile/src/features/library/local/LocalPlaylistList.tsx @@ -1,6 +1,14 @@ import { Icon } from '@expo/ui' import { LegendList } from '@legendapp/list/react-native' -import { memo, useCallback, useDeferredValue, useMemo, useState } from 'react' +import { useFocusEffect } from 'expo-router' +import { + memo, + useCallback, + useDeferredValue, + useEffect, + useMemo, + useState, +} from 'react' import { RefreshControl, StyleSheet, View } from 'react-native' import { Searchbar, Text, useTheme } from 'react-native-paper' @@ -16,8 +24,14 @@ import { import useAppStore from '@/hooks/stores/useAppStore' import { useModalStore } from '@/hooks/stores/useModalStore' import { useMenuActions } from '@/hooks/ui/useMenuActions' +import { + externalImportJobService, + type ImportJob, +} from '@/lib/services/externalImportJobService' +import { externalPlaylistImportWorker } from '@/lib/workers/ExternalPlaylistImportWorker' import type { Playlist } from '@/types/core/media' +import DraftImportPlaylistItem from './DraftImportPlaylistItem' import LocalPlaylistItem from './LocalPlaylistItem' const CREATE_PLAYLIST_ICON = Icon.select({ @@ -55,6 +69,41 @@ const LocalPlaylistListComponent = memo(() => { const openModal = useModalStore((state) => state.open) const hasBilibiliCookie = useAppStore((state) => state.hasBilibiliCookie) + const [draftJobs, setDraftJobs] = useState(() => { + try { + return externalImportJobService.listActiveDraftJobs() + } catch { + return [] + } + }) + const [currentTrackByJobId, setCurrentTrackByJobId] = useState< + Record + >({}) + + useEffect(() => { + const refreshDrafts = () => { + try { + setDraftJobs(externalImportJobService.listActiveDraftJobs()) + } catch { + // Ignore + } + } + refreshDrafts() + const unsubChanges = + externalImportJobService.subscribeChanges(refreshDrafts) + const unsubWorker = externalPlaylistImportWorker.subscribe((event) => { + refreshDrafts() + setCurrentTrackByJobId((prev) => ({ + ...prev, + [event.jobId]: event.currentTrackTitle, + })) + }) + return () => { + unsubChanges() + unsubWorker() + } + }, []) + const { data: playlists, isPending: playlistsIsPending, @@ -63,8 +112,29 @@ const LocalPlaylistListComponent = memo(() => { isError: playlistsIsError, } = usePlaylistLists() + useFocusEffect( + useCallback(() => { + void refetch() + try { + setDraftJobs(externalImportJobService.listActiveDraftJobs()) + } catch { + // Ignore + } + }, [refetch]), + ) + const { data: searchResults } = useSearchPlaylists(deferredSearchQuery, true) + const filteredDraftJobs = useMemo(() => { + const q = deferredSearchQuery.trim().toLowerCase() + if (!q) return draftJobs + return draftJobs.filter((job) => + (job.playlistMetadata.title || job.sourcePlaylistId) + .toLowerCase() + .includes(q), + ) + }, [deferredSearchQuery, draftJobs]) + const finalPlaylists = useMemo(() => { if (deferredSearchQuery.trim()) { return searchResults ?? [] @@ -96,6 +166,11 @@ const LocalPlaylistListComponent = memo(() => { const onRefresh = async () => { setRefreshing(true) + try { + setDraftJobs(externalImportJobService.listActiveDraftJobs()) + } catch { + // Ignore + } await refetch() setRefreshing(false) } @@ -148,7 +223,7 @@ const LocalPlaylistListComponent = memo(() => { - {playlists.length ?? 0} 个播放列表 + {(playlists.length ?? 0) + draftJobs.length} 个播放列表 { data={finalPlaylists ?? []} renderItem={renderPlaylistItem} recycleItems + ListHeaderComponent={ + filteredDraftJobs.length > 0 ? ( + + {filteredDraftJobs.map((job) => ( + + ))} + + ) : null + } refreshControl={ { } keyExtractor={keyExtractor} ListEmptyComponent={ - 没有播放列表 + filteredDraftJobs.length === 0 ? ( + 没有播放列表 + ) : null } /> @@ -225,7 +320,9 @@ const styles = StyleSheet.create({ marginBottom: 20, marginTop: 0, }, - + draftsSection: { + marginBottom: 8, + }, emptyList: { textAlign: 'center', }, diff --git a/apps/mobile/src/features/player/components/main/PlayerTrackInfo.tsx b/apps/mobile/src/features/player/components/main/PlayerTrackInfo.tsx index 4bc1618a8..40b4b894e 100644 --- a/apps/mobile/src/features/player/components/main/PlayerTrackInfo.tsx +++ b/apps/mobile/src/features/player/components/main/PlayerTrackInfo.tsx @@ -170,7 +170,7 @@ export function TrackInfo({ height: coverSize, borderRadius: coverBorderRadius, }} - recyclingKey={currentTrack.uniqueKey} + recyclingKey={`${currentTrack.uniqueKey}:${currentTrack.coverUrl ?? ''}`} cachePolicy={'disk'} transition={300} /> diff --git a/apps/mobile/src/features/playlist/local/components/LocalPlaylistHeader.tsx b/apps/mobile/src/features/playlist/local/components/LocalPlaylistHeader.tsx index f17663c89..5c328ba81 100644 --- a/apps/mobile/src/features/playlist/local/components/LocalPlaylistHeader.tsx +++ b/apps/mobile/src/features/playlist/local/components/LocalPlaylistHeader.tsx @@ -12,12 +12,17 @@ import { useTheme, } from 'react-native-paper' +import BilibiliIcon from '@/components/common/BilibiliIcon' import Button from '@/components/common/Button' import CoverWithPlaceHolder from '@/components/common/CoverWithPlaceHolder' import IconButton from '@/components/common/IconButton' import { alert } from '@/components/modals/AlertModal' import type { SharedPlaylistMember } from '@/hooks/queries/sharedPlaylistMembers' import { useModalStore } from '@/hooks/stores/useModalStore' +import { + externalImportJobService, + type ExternalPlaylistViewMode, +} from '@/lib/services/externalImportJobService' import { playlistService } from '@/lib/services/playlistService' import type { Playlist } from '@/types/core/media' import { toastAndLogError } from '@/utils/error-handling' @@ -41,6 +46,8 @@ interface PlaylistHeaderProps { primaryButtonTextColor?: string secondaryButtonContainerColor?: string secondaryButtonIconColor?: string + externalViewMode?: ExternalPlaylistViewMode + onToggleExternalViewMode?: () => void } interface SubtitlePieces { @@ -102,6 +109,8 @@ export const PlaylistHeader = memo(function PlaylistHeader({ primaryButtonTextColor, secondaryButtonContainerColor, secondaryButtonIconColor, + externalViewMode, + onToggleExternalViewMode, }: PlaylistHeaderProps) { const [showFullTitle, setShowFullTitle] = useState(false) const router = useRouter() @@ -133,10 +142,15 @@ export const PlaylistHeader = memo(function PlaylistHeader({ tracksResult.value .filter((item) => item.source === 'bilibili' - ? item.bilibiliMetadata.videoIsValid + ? Boolean(item.bilibiliMetadata.bvid) && + item.bilibiliMetadata.videoIsValid : true, ) - .map((t) => { + .map((rawTrack) => { + const t = externalImportJobService.resolveTrackForPlayback( + rawTrack, + playlist.id, + ) const url = getInternalPlayUri(t) if (!url) return return { @@ -408,6 +422,26 @@ export const PlaylistHeader = memo(function PlaylistHeader({ containerColor={secondaryButtonContainerColor} iconColor={secondaryButtonIconColor} /> + {onToggleExternalViewMode && ( + ( + + ) + : 'music-note' + } + size={20} + onPress={onToggleExternalViewMode} + testID='playlist-toggle-external-view-mode' + containerColor={secondaryButtonContainerColor} + iconColor={secondaryButtonIconColor} + /> + )} diff --git a/apps/mobile/src/features/playlist/local/components/LocalPlaylistItem.tsx b/apps/mobile/src/features/playlist/local/components/LocalPlaylistItem.tsx index f84338bed..b522f1592 100644 --- a/apps/mobile/src/features/playlist/local/components/LocalPlaylistItem.tsx +++ b/apps/mobile/src/features/playlist/local/components/LocalPlaylistItem.tsx @@ -1,14 +1,13 @@ import { DownloadState } from '@bbplayer/orpheus' import { Icon as ExpoIcon } from '@expo/ui' -import { memo, useCallback } from 'react' -import { Easing, StyleSheet, useColorScheme, View } from 'react-native' +import { memo, useCallback, useMemo } from 'react' +import { StyleSheet, useColorScheme, View } from 'react-native' import { usePanGesture, GestureDetector, Touchable, } from 'react-native-gesture-handler' import { Icon, Surface, useTheme } from 'react-native-paper' -import TextTicker from 'react-native-text-ticker' import CoverWithPlaceHolder from '@/components/common/CoverWithPlaceHolder' import UniversalCheckbox from '@/components/common/UniversalCheckbox' @@ -57,6 +56,79 @@ interface TrackListItemProps { downloadState?: DownloadState } +export function resolveTrackDisplayLines( + data: Track, + playlistType: Playlist['type'], +) { + const rawMainTitle = + data.source === 'bilibili' + ? (data.bilibiliMetadata.mainTrackTitle?.trim() ?? '') + : '' + const isUnmatched = data.source === 'bilibili' && !data.bilibiliMetadata.bvid + const isExternalOriginalModeTrack = + isUnmatched || + rawMainTitle.startsWith('音源:') || + rawMainTitle.startsWith('[未匹配音源]') + + if (isExternalOriginalModeTrack) { + const artistName = data.artist?.name?.trim() + const primaryLine = artistName + ? `${data.title} - ${artistName}` + : data.title + + if (isUnmatched) { + return { + primaryLine, + secondaryLine: '待匹配音源', + isSourceError: false, + upName: null, + hideDetailsRow: true, + } + } + + let secondaryLine: string + let upName: string + + if (rawMainTitle.startsWith('音源:')) { + const withoutPrefix = rawMainTitle.replace(/^音源:\s*/, '') + const upMarker = ' · UP: ' + const upIdx = withoutPrefix.lastIndexOf(upMarker) + secondaryLine = + (upIdx !== -1 ? withoutPrefix.slice(0, upIdx) : withoutPrefix).trim() || + data.title + upName = + (upIdx !== -1 + ? withoutPrefix.slice(upIdx + upMarker.length) + : '' + ).trim() || '未知 UP 主' + } else { + secondaryLine = '未匹配音源' + upName = artistName ?? '未知 UP 主' + } + + return { + primaryLine, + secondaryLine, + isSourceError: false, + upName, + hideDetailsRow: false, + } + } + + const hasSeparateSourceTitle = + data.source === 'bilibili' && + Boolean(rawMainTitle && rawMainTitle !== data.title) && + playlistType !== 'multi_page' + + return { + primaryLine: data.title, + secondaryLine: hasSeparateSourceTitle ? rawMainTitle : null, + isSourceError: false, + upName: data.artist?.name ?? null, + hideDetailsRow: false, + } +} + /** * 可复用的播放列表项目组件。 */ @@ -85,6 +157,11 @@ export const TrackListItem = memo(function TrackListItem({ const highlighted = (isCurrentTrack && !selectMode) || isSelected + const displayLines = useMemo( + () => resolveTrackDisplayLines(data, playlist.type), + [data, playlist.type], + ) + const dragPan = usePanGesture({ activateAfterLongPress: 200, runOnJS: true, @@ -190,7 +267,7 @@ export const TrackListItem = memo(function TrackListItem({ {/* Cover Image */} {showCoverImage ? ( + {/* 第一行:歌曲名称 - 歌曲作者 */} - {data.title} + {displayLines.primaryLine} - - {/* Display Artist if available */} - {data.artist && ( - <> - - {data.artist.name ?? '未知'} - - - • - - - )} - {/* Display Duration */} - - {data.duration ? formatDurationToHHMMSS(data.duration) : ''} + + {/* 第二行:音源名称(单行省略,不使用跑马灯;selectMode 下隐藏以固定高度) */} + {!selectMode && displayLines.secondaryLine ? ( + + {displayLines.secondaryLine} - {/* 显示下载状态 */} - {renderDownloadStatus()} - - {/* 显示主视频标题(如果是分 p) — selectMode 下隐藏以固定高度 */} - {!selectMode && - data.source === 'bilibili' && - data.bilibiliMetadata.mainTrackTitle && - data.bilibiliMetadata.mainTrackTitle !== data.title && - playlist.type !== 'multi_page' && ( - + {displayLines.upName ? ( + <> + + {displayLines.upName} + + {data.duration > 0 ? ( + + · + + ) : null} + + ) : null} + {/* Display Duration */} + - {data.bilibiliMetadata.mainTrackTitle} - - )} + {data.duration ? formatDurationToHHMMSS(data.duration) : ''} + + {/* 显示下载状态 */} + {renderDownloadStatus()} + + ) : null} {/* Context Menu / Drag Handle */} @@ -318,11 +413,17 @@ const styles = StyleSheet.create({ flex: 1, marginRight: 4, }, + sourceLineText: { + marginTop: 2, + }, detailsContainer: { flexDirection: 'row', alignItems: 'center', marginTop: 2, - flexWrap: 'wrap', + flexWrap: 'nowrap', + }, + upNameText: { + flexShrink: 1, }, dotSeparator: { marginHorizontal: 4, diff --git a/apps/mobile/src/features/playlist/local/hooks/useLocalPlaylistMenu.ts b/apps/mobile/src/features/playlist/local/hooks/useLocalPlaylistMenu.ts index 096d36cd3..e9d1bd205 100644 --- a/apps/mobile/src/features/playlist/local/hooks/useLocalPlaylistMenu.ts +++ b/apps/mobile/src/features/playlist/local/hooks/useLocalPlaylistMenu.ts @@ -57,12 +57,18 @@ const DELETE_ICON = Icon.select({ android: import('@expo/material-symbols/delete.xml'), }) +const REMATCH_ICON = Icon.select({ + ios: 'arrow.triangle.2.circlepath', + android: import('@expo/material-symbols/sync.xml'), +}) + const SCOPE = 'UI.Playlist.Local.Menu' interface LocalPlaylistMenuProps { deleteTrack: (trackId: number) => void openAddToPlaylistModal: (track: Track) => void openEditTrackModal: (track: Track) => void + onRematchTrack?: (track: Track) => void playlist: Playlist | null | undefined isReadOnly: boolean } @@ -71,6 +77,7 @@ export function useLocalPlaylistMenu({ deleteTrack, openAddToPlaylistModal, openEditTrackModal, + onRematchTrack, playlist, isReadOnly, }: LocalPlaylistMenuProps) { @@ -97,21 +104,43 @@ export function useLocalPlaylistMenu({ downloadState?: DownloadState, ): TrackMenuItem[] => { if (!playlist) return [] - const menuItems: TrackMenuItem[] = [ - { + const hasPlayableSource = + item.source !== 'bilibili' || Boolean(item.bilibiliMetadata.bvid) + const menuItems: TrackMenuItem[] = [] + + if (hasPlayableSource) { + menuItems.push({ title: '下一首播放', leadingIcon: PLAY_NEXT_ICON, onPress: () => playNext(item), isHighFreq: true, - }, - { - title: '添加到本地歌单', - leadingIcon: ADD_TO_PLAYLIST_ICON, - onPress: () => openAddToPlaylistModal(item), - isHighFreq: true, - }, - ] - if (item.source === 'bilibili') { + }) + } + + if ( + playlist.type === 'local' && + !isReadOnly && + item.source === 'bilibili' && + onRematchTrack + ) { + menuItems.push({ + title: item.bilibiliMetadata.bvid + ? '重新匹配 B 站音源' + : '手动匹配 B 站音源', + leadingIcon: REMATCH_ICON, + onPress: () => onRematchTrack(item), + isHighFreq: !item.bilibiliMetadata.bvid, + }) + } + + menuItems.push({ + title: '添加到本地歌单', + leadingIcon: ADD_TO_PLAYLIST_ICON, + onPress: () => openAddToPlaylistModal(item), + isHighFreq: true, + }) + + if (item.source === 'bilibili' && Boolean(item.bilibiliMetadata.bvid)) { menuItems.push( { title: '查看详细信息', @@ -126,7 +155,10 @@ export function useLocalPlaylistMenu({ title: '查看 up 主作品', leadingIcon: ARTIST_ICON, onPress: () => { - if (!item.artist?.remoteId) { + if ( + !item.artist?.remoteId || + item.artist.remoteId.startsWith('ext_artist_') + ) { return } router.navigate({ diff --git a/apps/mobile/src/features/playlist/local/hooks/useLocalPlaylistPlayer.ts b/apps/mobile/src/features/playlist/local/hooks/useLocalPlaylistPlayer.ts index 0a5dc81d2..03c683a26 100644 --- a/apps/mobile/src/features/playlist/local/hooks/useLocalPlaylistPlayer.ts +++ b/apps/mobile/src/features/playlist/local/hooks/useLocalPlaylistPlayer.ts @@ -30,9 +30,17 @@ export function useLocalPlaylistPlayer( return } let tracks = tracksResult.value.filter((item) => - item.source === 'bilibili' ? item.bilibiliMetadata.videoIsValid : true, + item.source === 'bilibili' + ? Boolean(item.bilibiliMetadata.bvid) && + item.bilibiliMetadata.videoIsValid + : true, ) + if (tracks.length === 0) { + toast.info('歌单中暂无已匹配音源的歌曲,请先匹配 B 站音源') + return + } + if (isOffline) { const originalLength = tracks.length const urisToCheck: { uniqueKey: string; uri: string }[] = [] diff --git a/apps/mobile/src/features/playlist/remote/components/PlaylistHeader.tsx b/apps/mobile/src/features/playlist/remote/components/PlaylistHeader.tsx index 2d58e8a0b..50ed14db0 100644 --- a/apps/mobile/src/features/playlist/remote/components/PlaylistHeader.tsx +++ b/apps/mobile/src/features/playlist/remote/components/PlaylistHeader.tsx @@ -16,7 +16,7 @@ interface PlaylistHeaderProps { subtitles: string | string[] | undefined // 通常格式: "Author • n Tracks" description: string | undefined onClickMainButton?: () => void - mainButtonIcon: IconSource + mainButtonIcon?: IconSource linkedPlaylistId?: number id: string | number mainButtonText?: string @@ -54,6 +54,12 @@ export const PlaylistHeader = memo(function PlaylistHeader({ const [showFullTitle, setShowFullTitle] = useState(false) if (!title) return null + const hasActions = Boolean( + linkedPlaylistId || + onClickMainButton || + (props.secondaryButtonText && props.onClickSecondaryButton), + ) + return ( {/* 收藏夹信息 */} @@ -94,51 +100,53 @@ export const PlaylistHeader = memo(function PlaylistHeader({ {/* 操作按钮 */} - - {linkedPlaylistId ? ( - - ) : ( - onClickMainButton && ( + {hasActions && ( + + {linkedPlaylistId ? ( - ) - )} - {props.secondaryButtonText && props.onClickSecondaryButton && ( - - )} - + ) : ( + onClickMainButton && ( + + ) + )} + {props.secondaryButtonText && props.onClickSecondaryButton && ( + + )} + + )} state.open) useEffect(() => { - if (__DEV__) { + if (__DEV__ || !enabled) { + return + } + const firstOpen = storage.getBoolean('first_open') ?? true + if (firstOpen) { return } let isMounted = true @@ -29,5 +33,5 @@ export default function useCheckUpdate() { return () => { isMounted = false } - }, [open]) + }, [enabled, open]) } diff --git a/apps/mobile/src/hooks/mutations/db/playlist.ts b/apps/mobile/src/hooks/mutations/db/playlist.ts index ecef882e8..a7263d5d0 100644 --- a/apps/mobile/src/hooks/mutations/db/playlist.ts +++ b/apps/mobile/src/hooks/mutations/db/playlist.ts @@ -9,6 +9,7 @@ import { playlistFacade } from '@/lib/facades/playlist' import { sharedPlaylistFacade } from '@/lib/facades/sharedPlaylist' import type { PlaylistSyncProgress } from '@/lib/facades/syncBilibiliPlaylist' import { syncFacade } from '@/lib/facades/syncBilibiliPlaylist' +import { externalImportJobService } from '@/lib/services/externalImportJobService' import { playlistService } from '@/lib/services/playlistService' import type { Playlist } from '@/types/core/media' import type { CreateArtistPayload } from '@/types/services/artist' @@ -224,6 +225,11 @@ export const useDeletePlaylist = () => { }, onSuccess: (_, { playlistId }) => { toast.success('删除成功') + try { + externalImportJobService.deleteJobsForPlaylist(playlistId) + } catch { + // Ignore cleanup errors + } // 取消被删歌单的在途查询:删除成功时详情页可能仍处于挂载状态, // 若不处理,重连/重取会在歌单已不存在时再次查询(BBPLAYER-76 / BBPLAYER-AG) void queryClient.cancelQueries({ diff --git a/apps/mobile/src/hooks/queries/bilibili/search.ts b/apps/mobile/src/hooks/queries/bilibili/search.ts index 14f407897..376ea75f7 100644 --- a/apps/mobile/src/hooks/queries/bilibili/search.ts +++ b/apps/mobile/src/hooks/queries/bilibili/search.ts @@ -1,11 +1,24 @@ import { useInfiniteQuery, useQuery } from '@tanstack/react-query' import { bilibiliApi } from '@/lib/api/bilibili/api' +import { isBilibiliRateLimitError } from '@/lib/errors/thirdparty/bilibili' import log from '@/utils/log' import { returnOrThrowAsync } from '@/utils/neverthrow-utils' const logger = log.extend('Queries.SearchQueries') +const MAX_SEARCH_RETRIES = 2 + +export const shouldRetryBilibiliSearch = ( + failureCount: number, + error: unknown, +): boolean => { + if (isBilibiliRateLimitError(error)) { + return false + } + return failureCount < MAX_SEARCH_RETRIES +} + export const searchQueryKeys = { all: ['bilibili', 'search'] as const, results: (query: string) => @@ -30,6 +43,7 @@ export const useSearchResults = (query: string) => { }), ), enabled, + retry: shouldRetryBilibiliSearch, staleTime: 5 * 60 * 1000, initialPageParam: 1, getNextPageParam: (lastPage, allPages) => { @@ -54,6 +68,7 @@ export const useUserSearchResults = (query: string) => { bilibiliApi.searchUsers({ keyword: query, page: 1, signal }), ), enabled, + retry: shouldRetryBilibiliSearch, staleTime: 5 * 60 * 1000, }) } @@ -64,6 +79,7 @@ export const useHotSearches = () => { queryKey: searchQueryKeys.hotSearches(), queryFn: ({ signal }) => returnOrThrowAsync(bilibiliApi.getHotSearches({ signal })), + retry: shouldRetryBilibiliSearch, staleTime: 15 * 60 * 1000, }) } diff --git a/apps/mobile/src/hooks/queries/external-playlist/useExternalPlaylist.ts b/apps/mobile/src/hooks/queries/external-playlist/useExternalPlaylist.ts index dddef258c..4c44b1d30 100644 --- a/apps/mobile/src/hooks/queries/external-playlist/useExternalPlaylist.ts +++ b/apps/mobile/src/hooks/queries/external-playlist/useExternalPlaylist.ts @@ -1,10 +1,11 @@ import { useQuery } from '@tanstack/react-query' +import type { ExternalPlaylistSource } from '@/lib/services/externalPlaylistService' import { externalPlaylistService } from '@/lib/services/externalPlaylistService' export const useExternalPlaylist = ( playlistId: string, - source: 'netease' | 'qq', + source: ExternalPlaylistSource, ) => { return useQuery({ queryKey: ['external-playlist', source, playlistId], @@ -19,6 +20,15 @@ export const useExternalPlaylist = ( } return result.value }, + initialData: () => + playlistId + ? (externalPlaylistService.getPersistedPlaylistSync( + playlistId, + source, + ) ?? undefined) + : undefined, enabled: !!playlistId, + staleTime: Infinity, + networkMode: 'always', }) } diff --git a/apps/mobile/src/hooks/stores/useExternalPlaylistSyncStore.tsx b/apps/mobile/src/hooks/stores/useExternalPlaylistSyncStore.tsx index d292b8606..935c8f697 100644 --- a/apps/mobile/src/hooks/stores/useExternalPlaylistSyncStore.tsx +++ b/apps/mobile/src/hooks/stores/useExternalPlaylistSyncStore.tsx @@ -1,115 +1,524 @@ -import { createContext, use, useState } from 'react' +import { createContext, use, useEffect, useState } from 'react' import { createStore, useStore } from 'zustand' -import { createJSONStorage, persist } from 'zustand/middleware' +import type { StateStorage } from 'zustand/middleware' +import { + computeImportJobStats, + type ExternalImportJobService, + externalImportJobService, + type ImportItem, + type ImportJob, + type ImportJobSnapshot, + type ImportJobStatus, + itemsToResultsRecord, + parseSessionKeyToJobIdentity, + reconcileSessionResults, +} from '@/lib/services/externalImportJobService' import type { MatchResult } from '@/lib/services/externalPlaylistService' +import { + getMatchResultFingerprint, + getMatchResultStatus, +} from '@/lib/services/externalPlaylistService' +import { + type ExternalPlaylistImportWorker, + externalPlaylistImportWorker, + type ImportWorkerRunOptions, + type ImportWorkerRunOutcome, +} from '@/lib/workers/ExternalPlaylistImportWorker' +import type { GenericPlaylist, GenericTrack } from '@/types/external_playlist' import { zustandStorage } from '@/utils/mmkv' -type SyncSession = { +export { reconcileSessionResults } + +export type SyncSession = { results: Record + isRateLimited?: boolean updatedAt: number } -interface SyncState { +export interface SyncState { + currentJobId: string | null currentSessionKey: string | null - sessions: Record + job: ImportJob | null + items: ImportItem[] + jobStatus: ImportJobStatus results: Record + totalTracks: number + processedCount: number + matchedCount: number + unmatchedCount: number + errorCount: number + rateLimitedCount: number progress: number + etaSeconds: number | null syncing: boolean - setSessionKey: (sessionKey: string, total: number) => void + isRateLimited: boolean + setSessionKey: ( + sessionKey: string, + tracksOrTotal: GenericTrack[] | number, + playlistMetadata?: GenericPlaylist, + ) => void + setJobStatus: (status: ImportJobStatus) => void setSyncing: (syncing: boolean) => void + setRateLimited: (isRateLimited: boolean) => void setResult: (index: number, result: MatchResult) => void setProgress: (current: number, total: number) => void + startOrResumeMatching: ( + options?: ImportWorkerRunOptions, + ) => Promise + pauseMatching: () => void + pauseMatchingAndWait: () => Promise + bindWorkerEvents: () => () => void + unbindWorkerEvents: () => void reset: () => void clearSession: () => void + cancelJob: () => void } type SyncStore = ReturnType -const createExternalPlaylistSyncStore = () => { - return createStore()( - persist( - (set, get) => ({ - currentSessionKey: null, - sessions: {}, - results: {}, - progress: 0, - syncing: false, - setSessionKey: (sessionKey, total) => { - const results = get().sessions[sessionKey]?.results ?? {} +export const getProgressFromResults = ( + results: Record, + total: number, +) => { + if (total <= 0) return 0 + const completedCount = Object.values(results).filter((res) => { + const status = getMatchResultStatus(res) + return status === 'matched' || status === 'unmatched' + }).length + return Math.min(completedCount / total, 1) +} + +function mapSnapshotToStorePatch( + snapshot: ImportJobSnapshot, + options?: { + syncingOverride?: boolean + etaSeconds?: number | null + }, +) { + const results = itemsToResultsRecord(snapshot.items) + const stats = computeImportJobStats(snapshot.items, snapshot.job.totalCount) + const isRateLimited = + snapshot.job.status === 'rate_limited' || stats.rateLimitedCount > 0 + const syncing = options?.syncingOverride ?? snapshot.job.status === 'running' + + return { + currentJobId: snapshot.job.jobId, + job: snapshot.job, + items: snapshot.items, + jobStatus: snapshot.job.status, + results, + totalTracks: stats.totalCount, + processedCount: stats.processedCount, + matchedCount: stats.matchedCount, + unmatchedCount: stats.unmatchedCount, + errorCount: stats.errorCount, + rateLimitedCount: stats.rateLimitedCount, + progress: getProgressFromResults(results, stats.totalCount), + etaSeconds: syncing ? (options?.etaSeconds ?? null) : null, + syncing, + isRateLimited, + } +} + +export const createExternalPlaylistSyncStore = ( + storageImpl: StateStorage = zustandStorage, + jobService: ExternalImportJobService = externalImportJobService, + worker: ExternalPlaylistImportWorker = externalPlaylistImportWorker, +) => { + let workerUnsubscribe: (() => void) | null = null + + const ensureWorkerSubscription = ( + set: (partial: Partial) => void, + get: () => SyncState, + ) => { + if (workerUnsubscribe) return workerUnsubscribe + workerUnsubscribe = worker.subscribe((event) => { + const currentJobId = get().currentJobId + if (!currentJobId || event.jobId !== currentJobId) return + if (!event.snapshot) return + + const isRunning = + event.type === 'started' || event.type === 'progress' + ? true + : event.type === 'paused' || + event.type === 'rate_limited' || + event.type === 'completed' || + event.type === 'cancelled' || + event.type === 'failed' + ? false + : worker.isJobRunning(currentJobId) + set( + mapSnapshotToStorePatch(event.snapshot, { + syncingOverride: isRunning, + etaSeconds: event.etaSeconds, + }), + ) + }) + return workerUnsubscribe + } + + return createStore()((set, get) => ({ + currentJobId: null, + currentSessionKey: null, + job: null, + items: [], + jobStatus: 'pending', + results: {}, + totalTracks: 0, + processedCount: 0, + matchedCount: 0, + unmatchedCount: 0, + errorCount: 0, + rateLimitedCount: 0, + progress: 0, + etaSeconds: null, + syncing: false, + isRateLimited: false, + bindWorkerEvents: () => { + ensureWorkerSubscription(set, get) + return () => { + if (workerUnsubscribe) { + workerUnsubscribe() + workerUnsubscribe = null + } + } + }, + unbindWorkerEvents: () => { + if (workerUnsubscribe) { + workerUnsubscribe() + workerUnsubscribe = null + } + }, + setSessionKey: (sessionKey, tracksOrTotal, playlistMetadata) => { + ensureWorkerSubscription(set, get) + const { jobId, source, sourcePlaylistId } = + parseSessionKeyToJobIdentity(sessionKey) + const isActivelyRunning = worker.isJobRunning(jobId) + const currentEta = worker.getEtaSeconds(jobId) + + if (Array.isArray(tracksOrTotal)) { + const snapshot = jobService.createOrResumeJob({ + source, + sourcePlaylistId, + tracks: tracksOrTotal, + playlistMetadata, + legacyStorage: storageImpl, + }) + set({ + currentSessionKey: sessionKey, + ...mapSnapshotToStorePatch(snapshot, { + syncingOverride: isActivelyRunning, + etaSeconds: currentEta, + }), + }) + return + } + + const existingSnapshot = jobService.getJobSnapshot(jobId) + if (existingSnapshot) { + if (existingSnapshot.job.status === 'running' && !isActivelyRunning) { + const recovered = + jobService.updateJobStatus( + jobId, + existingSnapshot.job.rateLimitedCount > 0 + ? 'rate_limited' + : 'paused', + ) ?? existingSnapshot set({ currentSessionKey: sessionKey, - results, - progress: getProgressFromResults(results, total), - syncing: false, + ...mapSnapshotToStorePatch(recovered, { + syncingOverride: false, + etaSeconds: null, + }), }) - }, - setSyncing: (syncing) => set({ syncing }), - setResult: (index, result) => - set((state) => { - const results = { ...state.results, [index]: result } - return { - results, - sessions: state.currentSessionKey - ? { - ...state.sessions, - [state.currentSessionKey]: { - results, - updatedAt: Date.now(), - }, - } - : state.sessions, - } + return + } + set({ + currentSessionKey: sessionKey, + ...mapSnapshotToStorePatch(existingSnapshot, { + syncingOverride: isActivelyRunning, + etaSeconds: currentEta, }), - setProgress: (current, total) => - set({ progress: total > 0 ? current / total : 0 }), - reset: () => - set((state) => ({ - results: {}, - progress: 0, - syncing: false, - sessions: state.currentSessionKey - ? { - ...state.sessions, - [state.currentSessionKey]: { - results: {}, - updatedAt: Date.now(), - }, - } - : state.sessions, - })), - clearSession: () => - set((state) => { - if (!state.currentSessionKey) { - return { results: {}, progress: 0, syncing: false } - } - const sessions = { ...state.sessions } - delete sessions[state.currentSessionKey] - return { - sessions, - results: {}, - progress: 0, - syncing: false, - } + }) + return + } + + set({ + currentJobId: jobId, + currentSessionKey: sessionKey, + job: null, + items: [], + jobStatus: 'pending', + results: {}, + totalTracks: tracksOrTotal, + processedCount: 0, + matchedCount: 0, + unmatchedCount: 0, + errorCount: 0, + rateLimitedCount: 0, + progress: 0, + etaSeconds: null, + syncing: false, + isRateLimited: false, + }) + }, + startOrResumeMatching: async (options) => { + ensureWorkerSubscription(set, get) + const { currentJobId } = get() + if (!currentJobId) return null + set({ syncing: true, jobStatus: 'running' }) + return await worker.start(currentJobId, options) + }, + pauseMatching: () => { + const { currentJobId } = get() + if (!currentJobId) return + const snapshot = worker.pause(currentJobId) + if (snapshot) { + set( + mapSnapshotToStorePatch(snapshot, { + syncingOverride: false, + etaSeconds: null, }), - }), - { - name: 'external-playlist-sync-storage', - storage: createJSONStorage(() => zustandStorage), - partialize: (state) => ({ - sessions: state.sessions, - }), - }, - ), - ) -} + ) + } else { + set({ syncing: false, jobStatus: 'paused', etaSeconds: null }) + } + }, + pauseMatchingAndWait: async () => { + const { currentJobId } = get() + if (!currentJobId) return + set({ syncing: false, jobStatus: 'paused', etaSeconds: null }) + await worker.pauseAndWait(currentJobId) + }, + setJobStatus: (status) => { + const { currentJobId } = get() + if (!currentJobId) { + set({ jobStatus: status, syncing: status === 'running' }) + return + } + const snapshot = jobService.updateJobStatus(currentJobId, status) + if (snapshot) { + set( + mapSnapshotToStorePatch(snapshot, { + syncingOverride: status === 'running', + etaSeconds: worker.getEtaSeconds(currentJobId), + }), + ) + } else { + set({ jobStatus: status, syncing: status === 'running' }) + } + }, + setSyncing: (syncing) => { + const state = get() + const { currentJobId } = state + if (syncing) { + if (currentJobId) { + const snapshot = jobService.updateJobStatus(currentJobId, 'running') + if (snapshot) { + set( + mapSnapshotToStorePatch(snapshot, { + syncingOverride: true, + etaSeconds: worker.getEtaSeconds(currentJobId), + }), + ) + return + } + } + set({ + syncing: true, + jobStatus: 'running', + isRateLimited: false, + }) + return + } -const getProgressFromResults = ( - results: Record, - total: number, -) => { - if (total <= 0) return 0 - return Math.min(Object.keys(results).length / total, 1) + if (currentJobId) { + const latest = jobService.getJobSnapshot(currentJobId) + if (latest) { + let nextStatus: ImportJobStatus = latest.job.status + if (latest.job.status === 'running') { + if (latest.job.rateLimitedCount > 0 || state.isRateLimited) { + nextStatus = 'rate_limited' + } else if ( + latest.job.totalCount > 0 && + latest.job.processedCount === latest.job.totalCount + ) { + nextStatus = 'completed' + } else if ( + latest.job.processedCount > 0 || + latest.job.errorCount > 0 + ) { + nextStatus = 'paused' + } else { + nextStatus = 'pending' + } + const updated = + jobService.updateJobStatus(currentJobId, nextStatus) ?? latest + set( + mapSnapshotToStorePatch(updated, { + syncingOverride: false, + etaSeconds: null, + }), + ) + return + } + set( + mapSnapshotToStorePatch(latest, { + syncingOverride: false, + etaSeconds: null, + }), + ) + return + } + } + + set({ syncing: false, etaSeconds: null }) + }, + setRateLimited: (isRateLimited) => { + const { currentJobId } = get() + if (currentJobId && isRateLimited) { + const snapshot = jobService.updateJobStatus( + currentJobId, + 'rate_limited', + ) + if (snapshot) { + set({ + ...mapSnapshotToStorePatch(snapshot, { + syncingOverride: false, + etaSeconds: null, + }), + isRateLimited: true, + }) + return + } + } + set({ + isRateLimited, + ...(isRateLimited + ? { jobStatus: 'rate_limited', syncing: false, etaSeconds: null } + : {}), + }) + }, + setResult: (index, result) => { + const state = get() + const normalizedResult: MatchResult = { + ...result, + status: getMatchResultStatus(result), + trackFingerprint: getMatchResultFingerprint(result), + } + + if (state.currentJobId) { + const snapshot = jobService.updateItemResult( + state.currentJobId, + index, + normalizedResult, + ) + if (snapshot) { + set( + mapSnapshotToStorePatch(snapshot, { + syncingOverride: state.syncing, + etaSeconds: state.etaSeconds, + }), + ) + worker.notifyUpdated(state.currentJobId) + return + } + } + + const results = { ...state.results, [index]: normalizedResult } + const isRateLimited = + normalizedResult.status === 'rate_limited' || state.isRateLimited + const progress = + state.totalTracks > 0 + ? getProgressFromResults(results, state.totalTracks) + : state.progress + set({ + results, + isRateLimited, + progress, + }) + }, + setProgress: (current, total) => + set({ progress: total > 0 ? Math.min(current / total, 1) : 0 }), + reset: () => { + const { currentJobId } = get() + if (currentJobId) { + if (worker.isJobRunning(currentJobId)) { + worker.pause(currentJobId) + } + const snapshot = jobService.resetJob(currentJobId) + if (snapshot) { + set( + mapSnapshotToStorePatch(snapshot, { + syncingOverride: false, + etaSeconds: null, + }), + ) + worker.notifyUpdated(currentJobId) + return + } + } + set({ + jobStatus: 'pending', + results: {}, + processedCount: 0, + matchedCount: 0, + unmatchedCount: 0, + errorCount: 0, + rateLimitedCount: 0, + progress: 0, + etaSeconds: null, + syncing: false, + isRateLimited: false, + }) + }, + clearSession: () => { + const { currentJobId } = get() + if (currentJobId) { + if (worker.isJobRunning(currentJobId)) { + worker.cancel(currentJobId) + } + jobService.deleteJob(currentJobId, storageImpl) + } + set({ + currentJobId: null, + job: null, + items: [], + jobStatus: 'pending', + results: {}, + processedCount: 0, + matchedCount: 0, + unmatchedCount: 0, + errorCount: 0, + rateLimitedCount: 0, + progress: 0, + etaSeconds: null, + syncing: false, + isRateLimited: false, + }) + }, + cancelJob: () => { + const { currentJobId } = get() + if (currentJobId) { + const snapshot = worker.cancel(currentJobId) + if (snapshot) { + set( + mapSnapshotToStorePatch(snapshot, { + syncingOverride: false, + etaSeconds: null, + }), + ) + return + } + } + set({ + jobStatus: 'cancelled', + syncing: false, + etaSeconds: null, + }) + }, + })) } const ExternalPlaylistSyncStoreContext = createContext(null) @@ -119,7 +528,12 @@ export const ExternalPlaylistSyncStoreProvider = ({ }: { children: React.ReactNode }) => { - const [store] = useState(createExternalPlaylistSyncStore) + const [store] = useState(() => createExternalPlaylistSyncStore()) + + useEffect(() => { + return store.getState().bindWorkerEvents() + }, [store]) + return ( {children} diff --git a/apps/mobile/src/hooks/stores/usePlayerStore.ts b/apps/mobile/src/hooks/stores/usePlayerStore.ts index b5e798bab..bd6ab7fdc 100644 --- a/apps/mobile/src/hooks/stores/usePlayerStore.ts +++ b/apps/mobile/src/hooks/stores/usePlayerStore.ts @@ -1,6 +1,7 @@ import { Orpheus, type Track as OrpheusTrack } from '@bbplayer/orpheus' import { create } from 'zustand' +import { externalImportJobService } from '@/lib/services/externalImportJobService' import { trackService } from '@/lib/services/trackService' import type { Track } from '@/types/core/media' import { toastAndLogError } from '@/utils/error-handling' @@ -9,6 +10,7 @@ import log from '@/utils/log' const logger = log.extend('Store.Player') let syncRevision = 0 let initialized = false +let cachedRawTrack: Track | null = null interface PlayerState { orpheusTrack: OrpheusTrack | null @@ -35,6 +37,14 @@ export const usePlayerStore = create((set, get) => ({ Orpheus.addListener('onQueueChanged', async () => { await get().sync() }) + externalImportJobService.subscribeChanges(() => { + if (cachedRawTrack) { + set({ + internalTrack: + externalImportJobService.resolveTrackForPlayback(cachedRawTrack), + }) + } + }) }, sync: async () => { @@ -46,29 +56,36 @@ export const usePlayerStore = create((set, get) => ({ ]) if (revision !== syncRevision) return - const currentInternalTrackId = get().internalTrack?.uniqueKey + const currentInternalTrackId = cachedRawTrack?.uniqueKey const newTrackId = currentTrack?.id set({ orpheusTrack: currentTrack, currentIndex }) if (!currentTrack) { + cachedRawTrack = null set({ internalTrack: null }) return } - if (newTrackId !== currentInternalTrackId) { + if (newTrackId !== currentInternalTrackId || !cachedRawTrack) { const result = await trackService.getTrackByUniqueKey(currentTrack.id) if (revision !== syncRevision || get().orpheusTrack?.id !== newTrackId) return if (result.isErr()) { + cachedRawTrack = null set({ internalTrack: null }) toastAndLogError('读取当前曲目信息失败', result.error, 'Store.Player') return } - set({ internalTrack: result.value }) + cachedRawTrack = result.value } + + set({ + internalTrack: + externalImportJobService.resolveTrackForPlayback(cachedRawTrack), + }) } catch (e) { logger.warning('Failed to sync player state', { error: e }) } diff --git a/apps/mobile/src/hooks/ui/usePlaylistBackgroundColor.ts b/apps/mobile/src/hooks/ui/usePlaylistBackgroundColor.ts index 1fd077e83..e9d748dd9 100644 --- a/apps/mobile/src/hooks/ui/usePlaylistBackgroundColor.ts +++ b/apps/mobile/src/hooks/ui/usePlaylistBackgroundColor.ts @@ -1,14 +1,77 @@ import type { ExtractedPalette } from '@bbplayer/image-theme-colors' import ImageThemeColors from '@bbplayer/image-theme-colors' import type { ImageRef } from 'expo-image' +import md5 from 'md5' import { useEffect, useMemo, useState } from 'react' import { AppState } from 'react-native' +import { createMMKV } from 'react-native-mmkv' import useAppStore from '@/hooks/stores/useAppStore' import { useNowPlayingBar } from '@/hooks/ui/useNowPlayingBar' import { clampHslLightness, hexToHsl, hslToString } from '@/utils/color' +import { resolveBilibiliImageUrl } from '@/utils/imageUrl' import { reportErrorToSentry } from '@/utils/log' +let paletteStorageInstance: ReturnType | null = null + +function getPaletteStorage() { + if (!paletteStorageInstance) { + paletteStorageInstance = createMMKV({ id: 'palette-cache' }) + } + return paletteStorageInstance +} + +const memoryPaletteCache = new Map() + +function normalizePaletteCacheKey( + cacheKey: string | null | undefined, +): string | undefined { + if (!cacheKey || !cacheKey.trim()) return undefined + const trimmed = cacheKey.trim() + return resolveBilibiliImageUrl(trimmed) ?? trimmed +} + +export function getCachedPalette( + cacheKey: string | null | undefined, +): ExtractedPalette | undefined { + const normalizedKey = normalizePaletteCacheKey(cacheKey) + if (!normalizedKey) return undefined + + const memoryCached = memoryPaletteCache.get(normalizedKey) + if (memoryCached) return memoryCached + + try { + const storage = getPaletteStorage() + const key = `pal_${md5(normalizedKey)}` + const raw = storage.getString(key) + if (raw) { + const parsed = JSON.parse(raw) as ExtractedPalette + memoryPaletteCache.set(normalizedKey, parsed) + return parsed + } + } catch { + // ignore MMKV or JSON errors + } + return undefined +} + +export function setCachedPalette( + cacheKey: string | null | undefined, + palette: ExtractedPalette, +): void { + const normalizedKey = normalizePaletteCacheKey(cacheKey) + if (!normalizedKey) return + + memoryPaletteCache.set(normalizedKey, palette) + try { + const storage = getPaletteStorage() + const key = `pal_${md5(normalizedKey)}` + storage.set(key, JSON.stringify(palette)) + } catch { + // ignore MMKV errors + } +} + const DARK_BACKGROUND_MIN_LIGHTNESS = 10 const DARK_BACKGROUND_MAX_LIGHTNESS = 18 const LIGHT_BACKGROUND_MIN_LIGHTNESS = 90 @@ -94,15 +157,33 @@ export function usePlaylistBackgroundColor( imageRef: ImageRef | null | undefined, isDarkMode: boolean, fallbackColor: string, + cacheKey?: string | null, ): PlaylistBackgroundColorResult { const pureWhiteBackground = useAppStore( (state) => state.settings.pureWhiteBackground, ) - const [palette, setPalette] = useState( - undefined, + + const normalizedKey = useMemo( + () => normalizePaletteCacheKey(cacheKey), + [cacheKey], + ) + + const cachedPalette = useMemo( + () => getCachedPalette(normalizedKey), + [normalizedKey], ) + + const [extracted, setExtracted] = useState<{ + key?: string + palette?: ExtractedPalette + } | null>(null) + const [appState, setAppState] = useState(AppState.currentState) + const palette = + cachedPalette ?? + (extracted?.key === normalizedKey ? extracted?.palette : undefined) + useEffect(() => { const subscription = AppState.addEventListener('change', (nextAppState) => { setAppState(nextAppState) @@ -113,8 +194,18 @@ export function usePlaylistBackgroundColor( }, []) useEffect(() => { - if (!imageRef || pureWhiteBackground) { - setPalette(undefined) + if (pureWhiteBackground) { + setExtracted(null) + return + } + + // 如果已有有效缓存,无需重复进行高开销的原生图片调色板计算 + if (cachedPalette) { + return + } + + if (!imageRef) { + setExtracted(null) return } @@ -123,15 +214,19 @@ export function usePlaylistBackgroundColor( } let isCancelled = false + const targetCacheKey = normalizedKey const extract = async () => { try { const result = await ImageThemeColors.extractThemeColorAsync(imageRef) if (!isCancelled) { if (result) { - setPalette(result) + if (targetCacheKey) { + setCachedPalette(targetCacheKey, result) + } + setExtracted({ key: targetCacheKey, palette: result }) } else { - setPalette(undefined) + setExtracted(null) } } } catch (e) { @@ -146,7 +241,7 @@ export function usePlaylistBackgroundColor( return () => { isCancelled = true } - }, [imageRef, appState, pureWhiteBackground]) + }, [imageRef, appState, normalizedKey, cachedPalette, pureWhiteBackground]) const result = useMemo(() => { if (pureWhiteBackground) { diff --git a/apps/mobile/src/lib/api/bilibili/client.ts b/apps/mobile/src/lib/api/bilibili/client.ts index 60077ca81..cc84211a6 100644 --- a/apps/mobile/src/lib/api/bilibili/client.ts +++ b/apps/mobile/src/lib/api/bilibili/client.ts @@ -104,11 +104,19 @@ class ApiClient { if (endpoint === '/x/web-interface/nav') { return okAsync(data.data) } - if (data.code !== 0) { + if ( + data.code !== 0 || + (data.data && + typeof data.data === 'object' && + 'v_voucher' in data.data) + ) { return errAsync( new BilibiliApiError({ - message: data.message, - msgCode: data.code, + message: + data.message && data.message !== 'OK' && data.message !== '0' + ? data.message + : '请求被风控拦截', + msgCode: data.code !== 0 ? data.code : -412, rawData: data.data, type: 'ResponseFailed', }), diff --git a/apps/mobile/src/lib/config/queryClient.ts b/apps/mobile/src/lib/config/queryClient.ts index 54e484bc6..3fcd8aa7c 100644 --- a/apps/mobile/src/lib/config/queryClient.ts +++ b/apps/mobile/src/lib/config/queryClient.ts @@ -5,7 +5,10 @@ import { router } from 'expo-router' import { type WebDavBackupConfig } from '@/hooks/queries/backup' import { WebDavError } from '@/lib/backup/webdav-client' import { ThirdPartyError } from '@/lib/errors' -import { BilibiliApiError } from '@/lib/errors/thirdparty/bilibili' +import { + BilibiliApiError, + isBilibiliRateLimitError, +} from '@/lib/errors/thirdparty/bilibili' import { toastAndLogError } from '@/utils/error-handling' import toast from '@/utils/toast' @@ -42,6 +45,12 @@ export const queryClient = new QueryClient({ if (query.meta?.silent === true) return const handleOfflineError = async () => { try { + if (isBilibiliRateLimitError(error)) { + toast.error('频繁操作触发了 B 站风控拦截,请稍后再试!', { + id: 'bilibili-rate-limit', + }) + return + } if ( error instanceof BilibiliApiError && error.data.msgCode === -101 diff --git a/apps/mobile/src/lib/db/schema.ts b/apps/mobile/src/lib/db/schema.ts index d5182a112..ab3bf6777 100644 --- a/apps/mobile/src/lib/db/schema.ts +++ b/apps/mobile/src/lib/db/schema.ts @@ -252,6 +252,150 @@ export const playlistSyncQueue = sqliteTable( ], ) +export const externalImportJobs = sqliteTable( + 'external_import_jobs', + { + jobId: text('job_id').primaryKey().notNull(), + source: text('source', { + enum: ['netease', 'qq', 'playlistout', 'local_json'], + }).notNull(), + sourcePlaylistId: text('source_playlist_id').notNull(), + playlistMetadata: text('playlist_metadata', { mode: 'json' }).notNull(), + totalCount: integer('total_count').notNull().default(0), + processedCount: integer('processed_count').notNull().default(0), + matchedCount: integer('matched_count').notNull().default(0), + unmatchedCount: integer('unmatched_count').notNull().default(0), + errorCount: integer('error_count').notNull().default(0), + status: text('status', { + enum: [ + 'pending', + 'running', + 'paused', + 'rate_limited', + 'completed_waiting_confirmation', + 'completed', + 'cancelled', + 'failed', + ], + }) + .notNull() + .default('pending'), + savedPlaylistId: integer('saved_playlist_id'), + createdAt: integer('created_at', { mode: 'timestamp_ms' }) + .notNull() + .default(sql`(unixepoch() * 1000)`), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }) + .notNull() + .default(sql`(unixepoch() * 1000)`) + .$onUpdate(() => new Date()), + lastProcessedAt: integer('last_processed_at', { mode: 'timestamp_ms' }), + }, + (table) => [ + uniqueIndex('external_import_jobs_source_playlist_unq').on( + table.source, + table.sourcePlaylistId, + ), + index('external_import_jobs_status_idx').on(table.status), + ], +) + +export const externalImportItems = sqliteTable( + 'external_import_items', + { + itemId: text('item_id').primaryKey().notNull(), + jobId: text('job_id') + .notNull() + .references(() => externalImportJobs.jobId, { onDelete: 'cascade' }), + fingerprint: text('fingerprint').notNull(), + originalIndex: integer('original_index').notNull(), + originalTrack: text('original_track', { mode: 'json' }).notNull(), + status: text('status', { + enum: ['pending', 'matched', 'unmatched', 'error', 'rate_limited'], + }) + .notNull() + .default('pending'), + matchedVideo: text('matched_video', { mode: 'json' }), + errorType: text('error_type', { + enum: ['network', 'timeout', 'api', 'rate_limited', 'unknown'], + }), + errorMessage: text('error_message'), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }) + .notNull() + .default(sql`(unixepoch() * 1000)`) + .$onUpdate(() => new Date()), + }, + (table) => [ + index('external_import_items_job_idx').on(table.jobId), + index('external_import_items_job_index_idx').on( + table.jobId, + table.originalIndex, + ), + index('external_import_items_job_fingerprint_idx').on( + table.jobId, + table.fingerprint, + ), + ], +) + +export const externalTrackMappings = sqliteTable( + 'external_track_mappings', + { + trackId: integer('track_id') + .primaryKey() + .notNull() + .references(() => tracks.id, { onDelete: 'cascade' }), + playlistId: integer('playlist_id') + .notNull() + .references(() => playlists.id, { onDelete: 'cascade' }), + jobId: text('job_id'), + itemId: text('item_id'), + source: text('source', { + enum: ['netease', 'qq', 'playlistout', 'local_json'], + }).notNull(), + sourcePlaylistId: text('source_playlist_id').notNull(), + sourceTrackId: text('source_track_id'), + originalIndex: integer('original_index').notNull().default(0), + trackFingerprint: text('track_fingerprint').notNull(), + originalTitle: text('original_title').notNull(), + originalTranslatedTitle: text('original_translated_title'), + originalArtistsJson: text('original_artists_json').notNull(), + originalAlbum: text('original_album').notNull(), + originalDuration: integer('original_duration').notNull().default(0), + originalCoverUrl: text('original_cover_url'), + matchStatus: text('match_status', { + enum: ['pending', 'matched', 'unmatched', 'error', 'rate_limited'], + }) + .notNull() + .default('pending'), + matchedBvid: text('matched_bvid'), + matchedCid: integer('matched_cid'), + matchedTitle: text('matched_title'), + matchedAuthor: text('matched_author'), + matchedMid: integer('matched_mid'), + matchedPic: text('matched_pic'), + matchedDuration: text('matched_duration'), + matchedVideoJson: text('matched_video_json'), + errorType: text('error_type', { + enum: ['network', 'timeout', 'api', 'rate_limited', 'unknown'], + }), + errorMessage: text('error_message'), + createdAt: integer('created_at', { mode: 'timestamp_ms' }) + .notNull() + .default(sql`(unixepoch() * 1000)`), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }) + .notNull() + .default(sql`(unixepoch() * 1000)`) + .$onUpdate(() => new Date()), + }, + (table) => [ + index('external_track_mappings_playlist_idx').on(table.playlistId), + index('external_track_mappings_status_idx').on( + table.playlistId, + table.matchStatus, + ), + ], +) + // ################################## // RELATIONS // ################################## @@ -274,6 +418,10 @@ export const trackRelations = relations(tracks, ({ one, many }) => ({ fields: [tracks.id], references: [localMetadata.trackId], }), + externalTrackMapping: one(externalTrackMappings, { + fields: [tracks.id], + references: [externalTrackMappings.trackId], + }), playHistory: many(playHistory), })) @@ -290,6 +438,7 @@ export const playlistRelations = relations(playlists, ({ one, many }) => ({ references: [artists.id], }), trackLinks: many(playlistTracks), + externalTrackMappings: many(externalTrackMappings), dynamicSources: many(dynamicPlaylistSources, { relationName: 'dynamicPlaylist', }), @@ -341,3 +490,34 @@ export const localMetadataRelations = relations(localMetadata, ({ one }) => ({ references: [tracks.id], }), })) + +export const externalImportJobRelations = relations( + externalImportJobs, + ({ many }) => ({ + items: many(externalImportItems), + }), +) + +export const externalImportItemRelations = relations( + externalImportItems, + ({ one }) => ({ + job: one(externalImportJobs, { + fields: [externalImportItems.jobId], + references: [externalImportJobs.jobId], + }), + }), +) + +export const externalTrackMappingRelations = relations( + externalTrackMappings, + ({ one }) => ({ + track: one(tracks, { + fields: [externalTrackMappings.trackId], + references: [tracks.id], + }), + playlist: one(playlists, { + fields: [externalTrackMappings.playlistId], + references: [playlists.id], + }), + }), +) diff --git a/apps/mobile/src/lib/errors/thirdparty/bilibili.ts b/apps/mobile/src/lib/errors/thirdparty/bilibili.ts index acde62e8a..e81772241 100644 --- a/apps/mobile/src/lib/errors/thirdparty/bilibili.ts +++ b/apps/mobile/src/lib/errors/thirdparty/bilibili.ts @@ -3,12 +3,65 @@ import { ThirdPartyError } from '@/lib/errors' export type BilibiliApiErrorType = | 'RequestFailed' | 'ResponseFailed' + | 'RateLimited' | 'NoCookie' | 'CsrfError' | 'AudioStreamError' | 'RequestAborted' | 'InvalidArgument' +export const BILIBILI_RATE_LIMIT_CODES = new Set([412, -412, 429, -429, -509]) + +const RATE_LIMIT_MESSAGE_PATTERN = + /\b(412|429)\b|请求过于频繁|请求被拦截|触发风控|too many requests|precondition failed|rate[\s_-]*limit/i + +export function isBilibiliRateLimitError(error: unknown): boolean { + if (!error) return false + + if (error instanceof BilibiliApiError) { + if (error.type === 'RateLimited') return true + if (BILIBILI_RATE_LIMIT_CODES.has(error.data.msgCode)) return true + if (RATE_LIMIT_MESSAGE_PATTERN.test(error.message)) return true + return false + } + + if (typeof error === 'object') { + const record = error as Record + if (record.type === 'RateLimited') return true + + const codeCandidates = [ + record.msgCode, + record.status, + record.statusCode, + record.code, + typeof record.data === 'object' && record.data !== null + ? (record.data as Record).msgCode + : undefined, + ] + for (const code of codeCandidates) { + if (typeof code === 'number' && BILIBILI_RATE_LIMIT_CODES.has(code)) { + return true + } + } + + if ( + typeof record.message === 'string' && + RATE_LIMIT_MESSAGE_PATTERN.test(record.message) + ) { + return true + } + } + + if ( + error instanceof Error && + RATE_LIMIT_MESSAGE_PATTERN.test(error.message) + ) { + return true + } + + return false +} + interface BilibiliApiErrorDetails { message: string msgCode?: number diff --git a/apps/mobile/src/lib/facades/playlist.ts b/apps/mobile/src/lib/facades/playlist.ts index 064342bda..4f2b4112b 100644 --- a/apps/mobile/src/lib/facades/playlist.ts +++ b/apps/mobile/src/lib/facades/playlist.ts @@ -6,12 +6,19 @@ import defaultDb from '@/lib/db/db' import * as schema from '@/lib/db/schema' import { createFacadeError } from '@/lib/errors/facade' import { createValidationError } from '@/lib/errors/service' -import { artistService as artistServiceInstance, type ArtistService } from '@/lib/services/artistService' +import { + artistService as artistServiceInstance, + type ArtistService, +} from '@/lib/services/artistService' +import { externalImportJobService } from '@/lib/services/externalImportJobService' import { playlistService as playlistServiceInstance, type PlaylistService, } from '@/lib/services/playlistService' -import { trackService as trackServiceInstance, type TrackService } from '@/lib/services/trackService' +import { + trackService as trackServiceInstance, + type TrackService, +} from '@/lib/services/trackService' import { playlistSyncWorker } from '@/lib/workers/PlaylistSyncWorker' import type { CreateArtistPayload } from '@/types/services/artist' import type { @@ -679,7 +686,14 @@ export class PlaylistFacade { if (!shareId) { return this.playlistService .deletePlaylist(playlistId) - .map(() => undefined) + .map(() => { + try { + externalImportJobService.deleteJobsForPlaylist(playlistId) + } catch { + // Ignore + } + return undefined + }) .mapErr((e) => createFacadeError('PlaylistDeleteFailed', '删除播放列表失败', { cause: e, @@ -711,6 +725,11 @@ export class PlaylistFacade { const deleteRes = await this.playlistService.deletePlaylist(playlistId) if (deleteRes.isErr()) throw deleteRes.error + try { + externalImportJobService.deleteJobsForPlaylist(playlistId) + } catch { + // Ignore + } } else if (shareRole === 'subscriber' || shareRole === 'editor') { // subscriber/editor:服务端解除成员关系 const resp = await this.bbplayerApi.playlists[ @@ -742,6 +761,11 @@ export class PlaylistFacade { // subscriber:删除本地副本 const r = await txPlaylist.deletePlaylist(playlistId) if (r.isErr()) throw r.error + try { + externalImportJobService.deleteJobsForPlaylist(playlistId) + } catch { + // Ignore + } } else { // editor:保留本地数据,仅断开共享连接 const r = await txPlaylist.updatePlaylistMetadata(playlistId, { diff --git a/apps/mobile/src/lib/facades/syncExternalPlaylist.ts b/apps/mobile/src/lib/facades/syncExternalPlaylist.ts index 5afb293b8..c7e634370 100644 --- a/apps/mobile/src/lib/facades/syncExternalPlaylist.ts +++ b/apps/mobile/src/lib/facades/syncExternalPlaylist.ts @@ -2,19 +2,31 @@ import type { ExpoSQLiteDatabase } from 'drizzle-orm/expo-sqlite' import { ResultAsync } from 'neverthrow' import defaultDb from '@/lib/db/db' -import type * as schema from '@/lib/db/schema' +import * as schema from '@/lib/db/schema' import type { DatabaseError, ServiceError } from '@/lib/errors' import type { FacadeError } from '@/lib/errors/facade' import { createFacadeError } from '@/lib/errors/facade' import { analyticsService } from '@/lib/services/analyticsService' import type { ArtistService } from '@/lib/services/artistService' import { artistService as artistServiceInstance } from '@/lib/services/artistService' +import { + createImportItemId, + createImportJobId, + externalImportJobService, + type UpsertExternalTrackMappingInput, +} from '@/lib/services/externalImportJobService' import type { MatchResult } from '@/lib/services/externalPlaylistService' +import { + type ExternalPlaylistSource, + getMatchResultStatus, + getTrackFingerprint, +} from '@/lib/services/externalPlaylistService' import generateUniqueTrackKey from '@/lib/services/genKey' import type { PlaylistService } from '@/lib/services/playlistService' import { playlistService as playlistServiceInstance } from '@/lib/services/playlistService' import type { TrackService } from '@/lib/services/trackService' import { trackService as trackServiceInstance } from '@/lib/services/trackService' +import type { GenericTrack } from '@/types/external_playlist' import log from '@/utils/log' import { parseDurationString } from '@/utils/time' @@ -29,17 +41,17 @@ export class SyncExternalPlaylistFacade { ) {} /** - * 保存匹配后的外部歌单到本地 + * 直接导入已包含 B 站 BV 元数据的 JSON 歌单到本地(免搜索匹配) * @param playlistInfo 歌单信息 - * @param matchResults 匹配结果 + * @param tracks 包含 bilibili 扩展元数据的歌曲列表 */ - public saveMatchedPlaylist( + public saveDirectBilibiliPlaylist( playlistInfo: { title: string coverUrl: string description: string }, - matchResults: MatchResult[], + tracks: GenericTrack[], ): ResultAsync { return ResultAsync.fromPromise( this.db.transaction(async (tx) => { @@ -47,27 +59,35 @@ export class SyncExternalPlaylistFacade { const trackSvc = this.trackService.withDB(tx) const artistSvc = this.artistService.withDB(tx) - // 1. 提取所有需要创建/查找的 Artist - const uniqueArtistsMap = new Map< - string, - { name: string; remoteId: string; face?: string } - >() - - const validMatches = matchResults.filter((r) => r.matchedVideo !== null) - if (validMatches.length === 0) { + const validTracks = tracks.filter((t) => Boolean(t.bilibili?.bvid)) + if (validTracks.length === 0) { throw createFacadeError( 'SavePlaylistFailed', - '没有匹配到任何歌曲,无法保存', + '未找到有效的哔哩哔哩视频信息,无法直接导入', ) } - for (const match of validMatches) { - const video = match.matchedVideo! - const remoteId = String(video.mid) + // 1. 提取所有需要创建/查找的 B 站 UP 主 (Artist) + const uniqueArtistsMap = new Map< + string, + { + name: string + remoteId: string + avatarUrl?: string + signature?: string + } + >() + + for (const track of validTracks) { + const bili = track.bilibili! + const artistName = track.artists[0]?.trim() || '未知UP主' + const remoteId = bili.upMid?.trim() || `bili_up_${artistName}` if (!uniqueArtistsMap.has(remoteId)) { uniqueArtistsMap.set(remoteId, { - name: video.author, - remoteId: remoteId, + name: artistName, + remoteId, + avatarUrl: bili.upAvatarUrl?.trim() || undefined, + signature: bili.upSignature?.trim() || undefined, }) } } @@ -77,7 +97,8 @@ export class SyncExternalPlaylistFacade { name: artist.name, source: 'bilibili' as const, remoteId: artist.remoteId, - avatarUrl: undefined, + avatarUrl: artist.avatarUrl, + signature: artist.signature, }), ) @@ -86,25 +107,38 @@ export class SyncExternalPlaylistFacade { if (artistsMapResult.isErr()) throw artistsMapResult.error const artistsMap = artistsMapResult.value - // 2. 创建 Tracks - const trackPayloads = validMatches.map((match) => { - const video = match.matchedVideo! - const artistId = artistsMap.get(String(video.mid))?.id + // 2. 创建 Bilibili Tracks + const trackPayloads = validTracks.map((track) => { + const bili = track.bilibili! + const artistName = track.artists[0]?.trim() || '未知UP主' + const remoteId = bili.upMid?.trim() || `bili_up_${artistName}` + const artistId = artistsMap.get(remoteId)?.id + const hasCid = + typeof bili.cid === 'number' && + Number.isFinite(bili.cid) && + bili.cid > 0 + const isMultiPage = Boolean(bili.isMultiPage && hasCid) + const mainTrackTitle = isMultiPage + ? bili.mainTrackTitle?.trim() || track.album?.trim() || undefined + : undefined return { - title: video.title.replace(/]*>|<\/em>/g, ''), // 去除高亮标签 + title: track.title, source: 'bilibili' as const, bilibiliMetadata: { - bvid: video.bvid, - isMultiPage: false, - cid: undefined, - videoIsValid: true, + bvid: bili.bvid, + isMultiPage, + cid: hasCid ? bili.cid : undefined, + mainTrackTitle, + videoIsValid: track.isAvailable !== false, }, - coverUrl: video.pic.startsWith('//') - ? `https:${video.pic}` - : video.pic, - duration: parseDurationString(video.duration), - artistId: artistId, + coverUrl: track.coverUrl + ? track.coverUrl.startsWith('//') + ? `https:${track.coverUrl}` + : track.coverUrl + : undefined, + duration: Math.max(0, Math.round((track.duration || 0) / 1000)), + artistId, } }) @@ -115,37 +149,42 @@ export class SyncExternalPlaylistFacade { if (tracksResult.isErr()) throw tracksResult.error const trackIdsMap = tracksResult.value - // 3. 按照原始 matchResults 的顺序(保持用户看到的顺序)收集 ID + // 3. 按原始顺序收集去重后的 Track ID const orderedTrackIds: number[] = [] + const seenTrackIds = new Set() for (const payload of trackPayloads) { const keyResult = generateUniqueTrackKey(payload) if (keyResult.isOk()) { const id = trackIdsMap.get(keyResult.value) - if (id) { + if (id && !seenTrackIds.has(id)) { + seenTrackIds.add(id) orderedTrackIds.push(id) } } } - // 4. 创建 Playlist + // 4. 创建本地 Playlist const playlistResult = await playlistSvc.createPlaylist({ title: playlistInfo.title, description: playlistInfo.description, coverUrl: playlistInfo.coverUrl, - type: 'local', // 另存为本地歌单 - authorId: undefined, // 本地歌单没有 strict author + type: 'local', + authorId: undefined, }) if (playlistResult.isErr()) throw playlistResult.error const playlistId = playlistResult.value.id - // 5. 添加 Tracks 到 Playlist + // 5. 添加 Tracks 到 Playlist(逆序追加以保证 DESC sort_key 下第 1 首排在最前) const addTracksResult = await playlistSvc.addManyTracksToLocalPlaylist( playlistId, - orderedTrackIds, + orderedTrackIds.toReversed(), ) if (addTracksResult.isErr()) throw addTracksResult.error - logger.info('Save matched playlist success', { playlistId }) + logger.info('Direct import bilibili JSON playlist success', { + playlistId, + trackCount: orderedTrackIds.length, + }) void analyticsService.logPlaylistSync( 'sync_external', 'external', @@ -159,6 +198,269 @@ export class SyncExternalPlaylistFacade { : createFacadeError('SavePlaylistFailed', String(e)), ) } + + /** + * 保存匹配后的外部歌单到本地 + * @param playlistInfo 歌单信息 + * @param matchResults 匹配结果 + */ + public saveMatchedPlaylist( + playlistInfo: { + title: string + coverUrl: string + description: string + }, + matchResults: MatchResult[], + context?: { + jobId?: string + source?: ExternalPlaylistSource + sourcePlaylistId?: string + }, + ): ResultAsync { + return ResultAsync.fromPromise( + (async () => { + if (matchResults.length === 0) { + throw createFacadeError( + 'SavePlaylistFailed', + '歌单中没有可保存的歌曲', + ) + } + + const externalSource: ExternalPlaylistSource = + context?.source ?? 'netease' + const externalPlaylistId = + context?.sourcePlaylistId?.trim() || `saved_${Date.now()}` + const resolvedJobId = + context?.jobId ?? + createImportJobId(externalSource, externalPlaylistId) + + const mappingInputs: UpsertExternalTrackMappingInput[] = [] + + const playlistId = await this.db.transaction(async (tx) => { + const playlistSvc = this.playlistService.withDB(tx) + const artistSvc = this.artistService.withDB(tx) + + // 1. 提取所有需要创建/查找的原始歌手 + const uniqueArtistsMap = new Map< + string, + { name: string; remoteId: string } + >() + + for (const match of matchResults) { + const originalArtistName = + match.track.artists.filter(Boolean).join(' / ').trim() || + match.matchedVideo?.author?.trim() || + '未知歌手' + const remoteId = `ext_artist_${originalArtistName}` + if (!uniqueArtistsMap.has(remoteId)) { + uniqueArtistsMap.set(remoteId, { + name: originalArtistName, + remoteId, + }) + } + } + + const artistPayloads = Array.from(uniqueArtistsMap.values()).map( + (artist) => ({ + name: artist.name, + source: 'bilibili' as const, + remoteId: artist.remoteId, + avatarUrl: undefined, + }), + ) + + const artistsMapResult = + await artistSvc.findOrCreateManyRemoteArtists(artistPayloads) + if (artistsMapResult.isErr()) throw artistsMapResult.error + const artistsMap = artistsMapResult.value + + // 2. 创建本地 Playlist + const playlistResult = await playlistSvc.createPlaylist({ + title: playlistInfo.title, + description: playlistInfo.description, + coverUrl: playlistInfo.coverUrl, + type: 'local', + authorId: undefined, + }) + if (playlistResult.isErr()) throw playlistResult.error + const createdPlaylistId = playlistResult.value.id + + // 3. 准备所有数据进行批量插入 + const orderedTrackIds: number[] = [] + const occurrenceCounter = new Map() + + const trackInserts = [] + const metaDataInserts = [] + + for (let i = 0; i < matchResults.length; i++) { + const match = matchResults[i] + const track = match.track + const video = match.matchedVideo + const fingerprint = getTrackFingerprint(track) + const occurrence = occurrenceCounter.get(fingerprint) ?? 0 + occurrenceCounter.set(fingerprint, occurrence + 1) + + const originalArtistName = + track.artists.filter(Boolean).join(' / ').trim() || + video?.author?.trim() || + '未知歌手' + const artistRemoteId = `ext_artist_${originalArtistName}` + const artistId = artistsMap.get(artistRemoteId)?.id + + const rawCover = track.coverUrl || video?.pic || undefined + const normalizedCover = rawCover + ? rawCover.startsWith('//') + ? `https:${rawCover}` + : rawCover + : undefined + + const durationSec = + track.duration > 0 + ? Math.max(0, Math.round(track.duration / 1000)) + : video + ? parseDurationString(video.duration) + : 0 + + const uniqueKey = `external::${createdPlaylistId}::${fingerprint}#${occurrence}` + + trackInserts.push({ + title: track.title, + source: 'bilibili' as const, + artistId, + coverUrl: normalizedCover, + duration: durationSec, + uniqueKey, + }) + } + + let insertedTracks: { id: number; uniqueKey: string }[] = [] + if (trackInserts.length > 0) { + insertedTracks = await tx + .insert(schema.tracks) + .values(trackInserts) + .returning({ + id: schema.tracks.id, + uniqueKey: schema.tracks.uniqueKey, + }) + } + + const trackIdByKey = new Map() + for (const item of insertedTracks) { + if (item.uniqueKey) { + trackIdByKey.set(item.uniqueKey, item.id) + } + } + + for (let i = 0; i < matchResults.length; i++) { + const currentUniqueKey = trackInserts[i]?.uniqueKey + const trackId = currentUniqueKey + ? trackIdByKey.get(currentUniqueKey) + : undefined + if (!trackId) continue + orderedTrackIds.push(trackId) + const match = matchResults[i] + const track = match.track + const video = match.matchedVideo + const status = getMatchResultStatus(match) + const fingerprint = getTrackFingerprint(track) + + const uniqueKeyParts = trackInserts[i].uniqueKey.split('#') + const occurrence = parseInt( + uniqueKeyParts[uniqueKeyParts.length - 1], + 10, + ) + + const cleanVideoTitle = video + ? video.title.replace(/]*>|<\/em>/g, '') + : '' + const mainTrackTitle = video + ? `音源: ${cleanVideoTitle}${video.author ? ` · UP: ${video.author}` : ''}` + : status === 'rate_limited' + ? '[未匹配音源] Bilibili 请求受限,可继续匹配' + : status === 'error' + ? `[未匹配音源] ${match.errorMessage ?? '网络或超时异常,可重试'}` + : '[未匹配音源] 点击手动匹配或继续自动匹配' + + const rawBvid = video?.bvid ?? track.bilibili?.bvid ?? '' + const rawCid = track.bilibili?.cid ?? null + const isMultiPage = track.bilibili?.isMultiPage ?? false + + metaDataInserts.push({ + trackId, + bvid: rawBvid, + cid: rawCid, + isMultiPage, + mainTrackTitle, + videoIsValid: true, + }) + + const isMatched = Boolean(video || rawBvid) + mappingInputs.push({ + trackId, + playlistId: createdPlaylistId, + jobId: resolvedJobId, + itemId: createImportItemId( + resolvedJobId, + fingerprint, + occurrence, + ), + externalSource, + externalPlaylistId, + originalIndex: i, + originalTrack: track, + matchStatus: isMatched + ? 'matched' + : status === 'pending' + ? 'unmatched' + : status, + matchedVideo: video, + errorType: match.errorType ?? null, + errorMessage: match.errorMessage ?? null, + }) + } + + if (metaDataInserts.length > 0) { + await tx.insert(schema.bilibiliMetadata).values(metaDataInserts) + } + + // 4. 添加 Tracks 到 Playlist(逆序追加以保证 DESC sort_key 下第 1 首排在最前) + if (orderedTrackIds.length > 0) { + const addTracksResult = + await playlistSvc.addManyTracksToLocalPlaylist( + createdPlaylistId, + orderedTrackIds.toReversed(), + ) + if (addTracksResult.isErr()) throw addTracksResult.error + } + + return createdPlaylistId + }) + + // 5. 写入 external_track_mappings 并将对应的 Draft Import Job 标记为已确认 + try { + externalImportJobService.upsertTrackMappings(mappingInputs) + if (context?.jobId || context?.sourcePlaylistId) { + externalImportJobService.markJobConfirmed(resolvedJobId, playlistId) + } + } catch (error) { + logger.warning( + '保存歌单后同步 external_track_mappings 或确认任务失败,但本地歌单已保存', + { + playlistId, + resolvedJobId, + error, + }, + ) + } + + return playlistId + })(), + (e) => + e instanceof Error + ? createFacadeError('SavePlaylistFailed', e.message, { cause: e }) + : createFacadeError('SavePlaylistFailed', String(e)), + ) + } } export const syncExternalPlaylistFacade = new SyncExternalPlaylistFacade( diff --git a/apps/mobile/src/lib/services/externalImportJobService.ts b/apps/mobile/src/lib/services/externalImportJobService.ts new file mode 100644 index 000000000..7fe68585f --- /dev/null +++ b/apps/mobile/src/lib/services/externalImportJobService.ts @@ -0,0 +1,2047 @@ +import type { StateStorage } from 'zustand/middleware' + +import { + type ExternalPlaylistSource, + getMatchResultFingerprint, + getMatchResultStatus, + getTrackFingerprint, + isMatchResultForTrack, + type MatchErrorType, + type MatchResult, + type MatchResultStatus, +} from '@/lib/services/externalPlaylistService' +import type { BilibiliSearchVideo } from '@/types/apis/bilibili' +import type { Track } from '@/types/core/media' +import type { GenericPlaylist, GenericTrack } from '@/types/external_playlist' +import log from '@/utils/log' +import { zustandStorage } from '@/utils/mmkv' + +const logger = log.extend('Services.ExternalImportJob') + +export const LEGACY_SYNC_STORAGE_KEY = 'external-playlist-sync-storage' +export const PLAYLIST_VIEW_MODES_STORAGE_KEY = 'external-playlist-view-modes' + +export type ExternalPlaylistViewMode = 'original' | 'bilibili' + +export type ImportJobStatus = + | 'pending' + | 'running' + | 'paused' + | 'rate_limited' + | 'completed_waiting_confirmation' + | 'completed' + | 'cancelled' + | 'failed' + +export interface ImportJob { + jobId: string + source: ExternalPlaylistSource + sourcePlaylistId: string + playlistMetadata: GenericPlaylist + totalCount: number + processedCount: number + matchedCount: number + unmatchedCount: number + errorCount: number + rateLimitedCount: number + status: ImportJobStatus + savedPlaylistId: number | null + createdAt: number + updatedAt: number + lastProcessedAt: number | null +} + +export interface ImportItem { + itemId: string + jobId: string + fingerprint: string + originalIndex: number + originalTrack: GenericTrack + status: MatchResultStatus + matchedVideo: BilibiliSearchVideo | null + errorType?: MatchErrorType + errorMessage?: string + updatedAt: number +} + +export interface ImportJobSnapshot { + job: ImportJob + items: ImportItem[] +} + +export interface ImportJobStats { + totalCount: number + processedCount: number + matchedCount: number + unmatchedCount: number + errorCount: number + rateLimitedCount: number + pendingCount: number +} + +export type ExternalTrackMatchStatus = + | 'matched' + | 'unmatched' + | 'pending' + | 'error' + | 'rate_limited' + +export interface ExternalTrackMappingRecord { + trackId: number + playlistId: number + jobId: string | null + itemId: string | null + externalSource: ExternalPlaylistSource + externalPlaylistId: string + originalIndex: number + fingerprint: string + originalTrackTitle: string + originalTranslatedTitle: string | null + originalArtists: string[] + originalAlbum: string + originalDuration: number + originalCoverUrl: string | null + originalTrack: GenericTrack + matchStatus: ExternalTrackMatchStatus + matchedBvid: string | null + matchedVideo: BilibiliSearchVideo | null + errorType: MatchErrorType | null + errorMessage: string | null + createdAt: number + updatedAt: number +} + +export interface UpsertExternalTrackMappingInput { + trackId: number + playlistId: number + jobId?: string | null + itemId?: string | null + externalSource: ExternalPlaylistSource + externalPlaylistId: string + originalIndex?: number + originalTrack: GenericTrack + matchStatus: ExternalTrackMatchStatus + matchedVideo?: BilibiliSearchVideo | null + errorType?: MatchErrorType | null + errorMessage?: string | null +} + +export function isJobWaitingConfirmation(job: ImportJob): boolean { + return ( + (job.status === 'completed' || + job.status === 'completed_waiting_confirmation') && + job.savedPlaylistId === null + ) +} + +export function mappingToGenericTrack( + mapping: Pick< + ExternalTrackMappingRecord, + | 'originalTrackTitle' + | 'originalTranslatedTitle' + | 'originalArtists' + | 'originalAlbum' + | 'originalDuration' + | 'originalCoverUrl' + >, +): GenericTrack { + return { + title: mapping.originalTrackTitle, + ...(mapping.originalTranslatedTitle + ? { translatedTitle: mapping.originalTranslatedTitle } + : {}), + artists: mapping.originalArtists, + album: mapping.originalAlbum, + duration: mapping.originalDuration, + coverUrl: mapping.originalCoverUrl ?? undefined, + } +} + +export function parseMatchedVideoDurationSeconds( + duration: string | number | null | undefined, +): number { + if (typeof duration === 'number' && Number.isFinite(duration)) { + return Math.max(0, Math.floor(duration)) + } + if (typeof duration !== 'string' || !duration.trim()) { + return 0 + } + const trimmed = duration.trim() + if (/^\d+$/.test(trimmed)) { + return Math.max(0, Number(trimmed)) + } + const parts = trimmed.split(':').map((part) => Number(part)) + if (parts.some((n) => !Number.isFinite(n) || n < 0)) { + return 0 + } + if (parts.length === 2) { + return parts[0] * 60 + parts[1] + } + if (parts.length === 3) { + return parts[0] * 3600 + parts[1] * 60 + parts[2] + } + return 0 +} + +export function parseExternalPlaylistIdFromUniqueKey( + uniqueKey: string | null | undefined, +): number | null { + if (!uniqueKey || !uniqueKey.startsWith('external::')) { + return null + } + const parts = uniqueKey.split('::') + if (parts.length < 2) { + return null + } + const parsed = Number(parts[1]) + return Number.isInteger(parsed) && parsed > 0 ? parsed : null +} + +export function applyPlaylistViewModeToTrack( + track: Track, + mode: ExternalPlaylistViewMode, + mapping?: ExternalTrackMappingRecord | null, +): Track { + if ( + mode !== 'bilibili' || + track.source !== 'bilibili' || + !track.bilibiliMetadata.bvid + ) { + return track + } + + const matchedVideo = mapping?.matchedVideo ?? null + const rawMainTitle = track.bilibiliMetadata.mainTrackTitle?.trim() ?? '' + + let biliTitle = '' + let biliAuthor = '' + let biliMid: string | null = null + let biliPic: string | null = null + let biliDurationSec = 0 + + if (matchedVideo) { + biliTitle = matchedVideo.title.replace(/]*>|<\/em>/g, '').trim() + biliAuthor = matchedVideo.author?.trim() ?? '' + biliMid = matchedVideo.mid ? String(matchedVideo.mid) : null + if (matchedVideo.pic) { + biliPic = matchedVideo.pic.startsWith('//') + ? `https:${matchedVideo.pic}` + : matchedVideo.pic + } + biliDurationSec = parseMatchedVideoDurationSeconds(matchedVideo.duration) + } + + if ((!biliTitle || !biliAuthor) && rawMainTitle.startsWith('音源:')) { + const withoutPrefix = rawMainTitle.replace(/^音源:\s*/, '') + const upMarker = ' · UP: ' + const upIdx = withoutPrefix.lastIndexOf(upMarker) + if (!biliTitle) { + biliTitle = ( + upIdx !== -1 ? withoutPrefix.slice(0, upIdx) : withoutPrefix + ).trim() + } + if (!biliAuthor && upIdx !== -1) { + biliAuthor = withoutPrefix.slice(upIdx + upMarker.length).trim() + } + } + + return { + ...track, + title: biliTitle || track.title, + artist: biliAuthor + ? { + id: track.artist?.id ?? 0, + name: biliAuthor, + remoteId: biliMid ?? track.artist?.remoteId ?? null, + source: 'bilibili', + createdAt: track.artist?.createdAt ?? track.createdAt, + updatedAt: track.artist?.updatedAt ?? track.updatedAt, + } + : track.artist, + coverUrl: biliPic || track.coverUrl, + duration: biliDurationSec > 0 ? biliDurationSec : track.duration, + bilibiliMetadata: { + ...track.bilibiliMetadata, + mainTrackTitle: null, + }, + } +} + +export interface SqliteSyncExecutor { + execSync(source: string): void + // oxlint-disable-next-line typescript/no-unnecessary-type-parameters + getFirstSync(source: string, params?: (string | number | null)[]): T | null + getAllSync(source: string, params?: (string | number | null)[]): T[] + runSync(source: string, params?: (string | number | null)[]): unknown + withTransactionSync(task: () => void): void +} + +const VALID_SOURCES = new Set([ + 'netease', + 'qq', + 'playlistout', + 'local_json', +]) + +export function createImportJobId( + source: ExternalPlaylistSource, + sourcePlaylistId: string, +): string { + return `import:${source}:${sourcePlaylistId.trim()}` +} + +export function parseSessionKeyToJobIdentity(sessionKey: string): { + jobId: string + source: ExternalPlaylistSource + sourcePlaylistId: string + legacySessionKey: string +} { + const trimmed = sessionKey.trim() + const withoutPrefix = trimmed.startsWith('import:') + ? trimmed.slice('import:'.length) + : trimmed + const colonIndex = withoutPrefix.indexOf(':') + if (colonIndex <= 0) { + const fallbackId = withoutPrefix || 'unknown' + return { + jobId: createImportJobId('netease', fallbackId), + source: 'netease', + sourcePlaylistId: fallbackId, + legacySessionKey: `netease:${fallbackId}`, + } + } + + const rawSource = withoutPrefix.slice(0, colonIndex) as ExternalPlaylistSource + const source: ExternalPlaylistSource = VALID_SOURCES.has(rawSource) + ? rawSource + : 'netease' + const sourcePlaylistId = + withoutPrefix.slice(colonIndex + 1).trim() || 'unknown' + return { + jobId: createImportJobId(source, sourcePlaylistId), + source, + sourcePlaylistId, + legacySessionKey: `${source}:${sourcePlaylistId}`, + } +} + +export function createImportItemId( + jobId: string, + fingerprint: string, + occurrenceIndex = 0, +): string { + return `${jobId}::${fingerprint}#${occurrenceIndex}` +} + +export function computeImportJobStats( + items: Pick[], + explicitTotalCount?: number, +): ImportJobStats { + let matchedCount = 0 + let unmatchedCount = 0 + let errorCount = 0 + let rateLimitedCount = 0 + let pendingCount = 0 + + for (const item of items) { + switch (item.status) { + case 'matched': + matchedCount++ + break + case 'unmatched': + unmatchedCount++ + break + case 'error': + errorCount++ + break + case 'rate_limited': + rateLimitedCount++ + break + default: + pendingCount++ + break + } + } + + const totalCount = explicitTotalCount ?? items.length + const processedCount = matchedCount + unmatchedCount + + return { + totalCount, + processedCount, + matchedCount, + unmatchedCount, + errorCount, + rateLimitedCount, + pendingCount, + } +} + +export function isVerifiableLegacyMatchResult( + candidate: unknown, +): candidate is MatchResult { + if (!candidate || typeof candidate !== 'object') return false + const record = candidate as Record + if (!record.track || typeof record.track !== 'object') return false + const track = record.track as Record + if (typeof track.title !== 'string' || !track.title.trim()) return false + if (!Array.isArray(track.artists)) return false + if (typeof track.duration !== 'number' || !Number.isFinite(track.duration)) { + return false + } + return true +} + +export function reconcileSessionResults( + savedResults: Record, + tracks: GenericTrack[], +): Record { + const reconciled: Record = {} + const consumedIndices = new Set() + + // 1. First pass: keep results at the same index if track fingerprint still matches + for (let i = 0; i < tracks.length; i++) { + const track = tracks[i] + const existing = savedResults[i] + if ( + track && + isVerifiableLegacyMatchResult(existing) && + isMatchResultForTrack(existing, track) + ) { + reconciled[i] = { + ...existing, + track, + trackFingerprint: getTrackFingerprint(track), + status: getMatchResultStatus(existing), + } + consumedIndices.add(i) + } + } + + // 2. Second pass: collect remaining unconsumed verifiable saved results by fingerprint + const unconsumedByFingerprint = new Map() + for (const [key, res] of Object.entries(savedResults)) { + const idx = Number(key) + if (consumedIndices.has(idx) || !isVerifiableLegacyMatchResult(res)) { + continue + } + const fp = getMatchResultFingerprint(res) + const list = unconsumedByFingerprint.get(fp) + if (list) { + list.push(res) + } else { + unconsumedByFingerprint.set(fp, [res]) + } + } + + // 3. Re-associate tracks whose order changed if fingerprint matches + for (let i = 0; i < tracks.length; i++) { + if (reconciled[i]) continue + const track = tracks[i] + if (!track) continue + const fp = getTrackFingerprint(track) + const candidates = unconsumedByFingerprint.get(fp) + const matched = candidates?.shift() + if (matched) { + reconciled[i] = { + ...matched, + track, + trackFingerprint: fp, + status: getMatchResultStatus(matched), + } + } + } + + return reconciled +} + +export function buildDefaultPlaylistMetadata( + source: ExternalPlaylistSource, + sourcePlaylistId: string, + trackCount: number, +): GenericPlaylist { + return { + id: sourcePlaylistId, + title: `外部歌单 ${sourcePlaylistId}`, + coverUrl: '', + description: '', + trackCount, + author: { + name: '', + }, + platform: source === 'qq' ? 'qqmusic' : source, + } +} + +export function itemToMatchResult(item: ImportItem): MatchResult { + return { + track: item.originalTrack, + matchedVideo: item.matchedVideo, + status: item.status, + trackFingerprint: item.fingerprint, + ...(item.errorType ? { errorType: item.errorType } : {}), + ...(item.errorMessage ? { errorMessage: item.errorMessage } : {}), + } +} + +export function itemsToResultsRecord( + items: ImportItem[], +): Record { + const results: Record = {} + for (const item of items) { + if (item.status !== 'pending') { + results[item.originalIndex] = itemToMatchResult(item) + } + } + return results +} + +type DbJobRow = { + job_id: string + source: ExternalPlaylistSource + source_playlist_id: string + playlist_metadata: string + total_count: number + processed_count: number + matched_count: number + unmatched_count: number + error_count: number + status: ImportJobStatus + saved_playlist_id: number | null + created_at: number + updated_at: number + last_processed_at: number | null +} + +type DbItemRow = { + item_id: string + job_id: string + fingerprint: string + original_index: number + original_track: string + status: MatchResultStatus + matched_video: string | null + error_type: MatchErrorType | null + error_message: string | null + updated_at: number +} + +type DbTrackMappingRow = { + track_id: number + playlist_id: number + job_id: string | null + item_id: string | null + source: ExternalPlaylistSource + source_playlist_id: string + source_track_id: string | null + original_index: number + track_fingerprint: string + original_title: string + original_translated_title: string | null + original_artists_json: string + original_album: string + original_duration: number + original_cover_url: string | null + match_status: ExternalTrackMatchStatus + matched_bvid: string | null + matched_cid: number | null + matched_title: string | null + matched_author: string | null + matched_mid: number | null + matched_pic: string | null + matched_duration: string | null + matched_video_json: string | null + error_type: MatchErrorType | null + error_message: string | null + created_at: number + updated_at: number +} + +const ENSURE_IMPORT_JOB_TABLES_SQL = ` +CREATE TABLE IF NOT EXISTS \`external_import_jobs\` ( + \`job_id\` text PRIMARY KEY NOT NULL, + \`source\` text NOT NULL, + \`source_playlist_id\` text NOT NULL, + \`playlist_metadata\` text NOT NULL, + \`total_count\` integer DEFAULT 0 NOT NULL, + \`processed_count\` integer DEFAULT 0 NOT NULL, + \`matched_count\` integer DEFAULT 0 NOT NULL, + \`unmatched_count\` integer DEFAULT 0 NOT NULL, + \`error_count\` integer DEFAULT 0 NOT NULL, + \`status\` text DEFAULT 'pending' NOT NULL, + \`saved_playlist_id\` integer, + \`created_at\` integer DEFAULT (unixepoch() * 1000) NOT NULL, + \`updated_at\` integer DEFAULT (unixepoch() * 1000) NOT NULL, + \`last_processed_at\` integer +); +CREATE UNIQUE INDEX IF NOT EXISTS \`external_import_jobs_source_playlist_unq\` ON \`external_import_jobs\` (\`source\`,\`source_playlist_id\`); +CREATE INDEX IF NOT EXISTS \`external_import_jobs_status_idx\` ON \`external_import_jobs\` (\`status\`); +CREATE TABLE IF NOT EXISTS \`external_import_items\` ( + \`item_id\` text PRIMARY KEY NOT NULL, + \`job_id\` text NOT NULL, + \`fingerprint\` text NOT NULL, + \`original_index\` integer NOT NULL, + \`original_track\` text NOT NULL, + \`status\` text DEFAULT 'pending' NOT NULL, + \`matched_video\` text, + \`error_type\` text, + \`error_message\` text, + \`updated_at\` integer DEFAULT (unixepoch() * 1000) NOT NULL, + FOREIGN KEY (\`job_id\`) REFERENCES \`external_import_jobs\`(\`job_id\`) ON UPDATE no action ON DELETE cascade +); +CREATE INDEX IF NOT EXISTS \`external_import_items_job_idx\` ON \`external_import_items\` (\`job_id\`); +CREATE INDEX IF NOT EXISTS \`external_import_items_job_index_idx\` ON \`external_import_items\` (\`job_id\`,\`original_index\`); +CREATE INDEX IF NOT EXISTS \`external_import_items_job_fingerprint_idx\` ON \`external_import_items\` (\`job_id\`,\`fingerprint\`); +CREATE TABLE IF NOT EXISTS \`external_track_mappings\` ( + \`track_id\` integer PRIMARY KEY NOT NULL, + \`playlist_id\` integer NOT NULL, + \`job_id\` text, + \`item_id\` text, + \`source\` text NOT NULL, + \`source_playlist_id\` text NOT NULL, + \`source_track_id\` text, + \`original_index\` integer DEFAULT 0 NOT NULL, + \`track_fingerprint\` text NOT NULL, + \`original_title\` text NOT NULL, + \`original_translated_title\` text, + \`original_artists_json\` text NOT NULL, + \`original_album\` text NOT NULL, + \`original_duration\` integer DEFAULT 0 NOT NULL, + \`original_cover_url\` text, + \`match_status\` text DEFAULT 'pending' NOT NULL, + \`matched_bvid\` text, + \`matched_cid\` integer, + \`matched_title\` text, + \`matched_author\` text, + \`matched_mid\` integer, + \`matched_pic\` text, + \`matched_duration\` text, + \`matched_video_json\` text, + \`error_type\` text, + \`error_message\` text, + \`created_at\` integer DEFAULT (unixepoch() * 1000) NOT NULL, + \`updated_at\` integer DEFAULT (unixepoch() * 1000) NOT NULL, + FOREIGN KEY (\`track_id\`) REFERENCES \`tracks\`(\`id\`) ON DELETE cascade, + FOREIGN KEY (\`playlist_id\`) REFERENCES \`playlists\`(\`id\`) ON DELETE cascade +); +CREATE INDEX IF NOT EXISTS \`external_track_mappings_playlist_idx\` ON \`external_track_mappings\` (\`playlist_id\`); +CREATE INDEX IF NOT EXISTS \`external_track_mappings_status_idx\` ON \`external_track_mappings\` (\`playlist_id\`,\`match_status\`); +` + +function getDefaultSqliteExecutor(): SqliteSyncExecutor { + // Lazy require so unit tests can inject or mock @/lib/db/db cleanly + // oxlint-disable-next-line typescript/no-require-imports + const dbModule = require('@/lib/db/db') as { expoDb: SqliteSyncExecutor } + return dbModule.expoDb +} + +export class ExternalImportJobService { + private tablesEnsured = false + private activeJobChecker: (jobId: string) => boolean = () => false + private readonly changeListeners = new Set<() => void>() + + constructor( + private readonly getDb: () => SqliteSyncExecutor = getDefaultSqliteExecutor, + ) {} + + public subscribeChanges(listener: () => void): () => void { + this.changeListeners.add(listener) + return () => { + this.changeListeners.delete(listener) + } + } + + private notifyChanges(): void { + for (const listener of this.changeListeners) { + try { + listener() + } catch (e) { + logger.warning('ExternalImportJobService 变更监听回调异常:', e) + } + } + } + + private onCancelJob?: (jobId: string) => void + private onCancelPlaylist?: (playlistId: number) => void + + public setWorkerCancellationHooks(hooks: { + cancelJob: (jobId: string) => void + cancelPlaylist: (playlistId: number) => void + }): void { + this.onCancelJob = hooks.cancelJob + this.onCancelPlaylist = hooks.cancelPlaylist + } + + public setActiveJobChecker(checker: (jobId: string) => boolean): void { + this.activeJobChecker = checker + } + + public isJobActivelyRunning(jobId: string): boolean { + return this.activeJobChecker(jobId) + } + + /** + * 检查并清理所有指向不存在或已删除本地歌单的孤立外部导入任务。 + * 防止歌单被删除后遗留僵尸任务或导致再次导入时复用旧数据。 + */ + public cleanupOrphanedJobs(): number { + const executor = this.getDb() + let cleanedCount = 0 + try { + executor.withTransactionSync(() => { + const rows = executor.getAllSync( + `SELECT * FROM external_import_jobs WHERE saved_playlist_id IS NOT NULL`, + ) + for (const row of rows) { + let playlistExists = false + try { + const pl = executor.getFirstSync<{ id: number }>( + `SELECT id FROM playlists WHERE id = ?`, + [row.saved_playlist_id], + ) + playlistExists = Boolean(pl) + } catch { + // 单元测试中可能尚未创建 playlists 表,容错处理 + playlistExists = true + } + if (!playlistExists) { + try { + this.onCancelJob?.(row.job_id) + } catch { + // Ignore + } + executor.runSync( + `DELETE FROM external_import_items WHERE job_id = ?`, + [row.job_id], + ) + executor.runSync( + `DELETE FROM external_import_jobs WHERE job_id = ?`, + [row.job_id], + ) + this.consumeLegacySession( + `${row.source}:${row.source_playlist_id}`, + [], + ) + cleanedCount++ + } + } + }) + } catch (e) { + logger.warning('清理孤立外部导入任务时出错:', e) + } + + if (cleanedCount > 0) { + logger.info(`已清理 ${cleanedCount} 个指向已删除歌单的孤立外部导入任务`) + this.notifyChanges() + } + return cleanedCount + } + + private db(): SqliteSyncExecutor { + const executor = this.getDb() + if (!this.tablesEnsured) { + executor.execSync(ENSURE_IMPORT_JOB_TABLES_SQL) + try { + executor.execSync( + 'ALTER TABLE external_import_jobs ADD COLUMN saved_playlist_id integer;', + ) + } catch { + // Column already exists + } + try { + executor.execSync( + 'ALTER TABLE external_track_mappings ADD COLUMN matched_video_json text;', + ) + } catch { + // Column already exists + } + this.tablesEnsured = true + this.cleanupOrphanedJobs() + } + return executor + } + + private mapJobRow(row: DbJobRow, rateLimitedCount = 0): ImportJob { + let playlistMetadata: GenericPlaylist + try { + playlistMetadata = JSON.parse(row.playlist_metadata) as GenericPlaylist + } catch { + playlistMetadata = buildDefaultPlaylistMetadata( + row.source, + row.source_playlist_id, + row.total_count, + ) + } + + return { + jobId: row.job_id, + source: row.source, + sourcePlaylistId: row.source_playlist_id, + playlistMetadata, + totalCount: row.total_count, + processedCount: row.processed_count, + matchedCount: row.matched_count, + unmatchedCount: row.unmatched_count, + errorCount: row.error_count, + rateLimitedCount, + status: row.status, + savedPlaylistId: row.saved_playlist_id ?? null, + createdAt: row.created_at, + updatedAt: row.updated_at, + lastProcessedAt: row.last_processed_at, + } + } + + private mapTrackMappingRow( + row: DbTrackMappingRow, + ): ExternalTrackMappingRecord { + let originalArtists: string[] = [] + try { + const parsed = JSON.parse(row.original_artists_json) as unknown + if (Array.isArray(parsed)) { + originalArtists = parsed.filter( + (item): item is string => typeof item === 'string', + ) + } + } catch { + originalArtists = [] + } + + let matchedVideo: BilibiliSearchVideo | null = null + if (row.matched_video_json) { + try { + matchedVideo = JSON.parse(row.matched_video_json) as BilibiliSearchVideo + } catch { + matchedVideo = null + } + } else if (row.matched_bvid) { + matchedVideo = { + type: 'video', + aid: 0, + bvid: row.matched_bvid, + title: row.matched_title ?? row.original_title, + pic: row.matched_pic ?? row.original_cover_url ?? '', + author: row.matched_author ?? '', + duration: row.matched_duration ?? '0:00', + senddate: 0, + mid: row.matched_mid ?? 0, + typeid: 130, + } + } + + const originalTrack: GenericTrack = { + title: row.original_title, + ...(row.original_translated_title + ? { translatedTitle: row.original_translated_title } + : {}), + artists: originalArtists, + album: row.original_album, + duration: row.original_duration, + coverUrl: row.original_cover_url ?? undefined, + } + + return { + trackId: row.track_id, + playlistId: row.playlist_id, + jobId: row.job_id ?? null, + itemId: row.item_id ?? null, + externalSource: row.source, + externalPlaylistId: row.source_playlist_id, + originalIndex: row.original_index, + fingerprint: row.track_fingerprint, + originalTrackTitle: row.original_title, + originalTranslatedTitle: row.original_translated_title ?? null, + originalArtists, + originalAlbum: row.original_album, + originalDuration: row.original_duration, + originalCoverUrl: row.original_cover_url ?? null, + originalTrack, + matchStatus: row.match_status, + matchedBvid: row.matched_bvid ?? null, + matchedVideo, + errorType: row.error_type ?? null, + errorMessage: row.error_message ?? null, + createdAt: row.created_at, + updatedAt: row.updated_at, + } + } + + private mapItemRow(row: DbItemRow): ImportItem { + const originalTrack = JSON.parse(row.original_track) as GenericTrack + const matchedVideo = row.matched_video + ? (JSON.parse(row.matched_video) as BilibiliSearchVideo) + : null + + return { + itemId: row.item_id, + jobId: row.job_id, + fingerprint: row.fingerprint, + originalIndex: row.original_index, + originalTrack, + status: row.status, + matchedVideo, + ...(row.error_type ? { errorType: row.error_type } : {}), + ...(row.error_message ? { errorMessage: row.error_message } : {}), + updatedAt: row.updated_at, + } + } + + public getJobSnapshot(jobId: string): ImportJobSnapshot | null { + const executor = this.db() + const jobRow = executor.getFirstSync( + `SELECT * FROM external_import_jobs WHERE job_id = ?`, + [jobId], + ) + if (!jobRow) return null + + const itemRows = executor.getAllSync( + `SELECT * FROM external_import_items WHERE job_id = ? ORDER BY original_index ASC`, + [jobId], + ) + const items = itemRows.map((r) => this.mapItemRow(r)) + const stats = computeImportJobStats(items, jobRow.total_count) + + return { + job: this.mapJobRow(jobRow, stats.rateLimitedCount), + items, + } + } + + /** + * 应用启动或页面重新挂载时调用: + * 将上次因崩溃 / 强杀遗留的 running 状态恢复为 paused 或 rate_limited,防止假 running。 + */ + public recoverStuckJobs( + activeJobIds: ReadonlySet = new Set(), + ): number { + const executor = this.db() + let recovered = 0 + executor.withTransactionSync(() => { + const runningRows = executor.getAllSync( + `SELECT * FROM external_import_jobs WHERE status = 'running'`, + ) + const now = Date.now() + for (const row of runningRows) { + if ( + activeJobIds.has(row.job_id) || + this.isJobActivelyRunning(row.job_id) + ) { + continue + } + const itemRows = executor.getAllSync( + `SELECT * FROM external_import_items WHERE job_id = ? ORDER BY original_index ASC`, + [row.job_id], + ) + const items = itemRows.map((r) => this.mapItemRow(r)) + const stats = computeImportJobStats(items, row.total_count) + const nextStatus: ImportJobStatus = + stats.rateLimitedCount > 0 ? 'rate_limited' : 'paused' + executor.runSync( + `UPDATE external_import_jobs + SET status = ?, processed_count = ?, matched_count = ?, unmatched_count = ?, error_count = ?, updated_at = ? + WHERE job_id = ?`, + [ + nextStatus, + stats.processedCount, + stats.matchedCount, + stats.unmatchedCount, + stats.errorCount, + now, + row.job_id, + ], + ) + recovered++ + } + }) + if (recovered > 0) { + logger.info( + `已恢复 ${recovered} 个中断的外部歌单导入任务(running -> paused/rate_limited)`, + ) + } + return recovered + } + + /** + * 读取并一次性迁移旧版 MMKV external-playlist-sync-storage 中的会话数据。 + * - 仅迁移包含可校验歌曲信息且 fingerprint 匹配的条目; + * - 安全忽略损坏或无法校验身份的数据,绝不按纯数组下标套到别的歌曲; + * - 迁移完成后清理对应的旧 session 条目。 + */ + public consumeLegacySession( + legacySessionKey: string, + tracks: GenericTrack[], + storageImpl: StateStorage = zustandStorage, + ): { + reconciledResults: Record + isRateLimited: boolean + updatedAt: number + didMigrate: boolean + } { + const rawJson = storageImpl.getItem(LEGACY_SYNC_STORAGE_KEY) + if (typeof rawJson !== 'string' || !rawJson.trim()) { + return { + reconciledResults: {}, + isRateLimited: false, + updatedAt: Date.now(), + didMigrate: false, + } + } + + try { + const parsed = JSON.parse(rawJson) as { + state?: { + sessions?: Record< + string, + { + results?: Record + isRateLimited?: boolean + updatedAt?: number + } + > + } + version?: number + } + const sessions = parsed?.state?.sessions + if (!sessions || typeof sessions !== 'object') { + return { + reconciledResults: {}, + isRateLimited: false, + updatedAt: Date.now(), + didMigrate: false, + } + } + + const legacySession = sessions[legacySessionKey] + if (!legacySession || typeof legacySession !== 'object') { + return { + reconciledResults: {}, + isRateLimited: false, + updatedAt: Date.now(), + didMigrate: false, + } + } + + const verifiableResults: Record = {} + if (legacySession.results && typeof legacySession.results === 'object') { + for (const [k, candidate] of Object.entries(legacySession.results)) { + const idx = Number(k) + if ( + Number.isInteger(idx) && + idx >= 0 && + isVerifiableLegacyMatchResult(candidate) + ) { + verifiableResults[idx] = { + ...candidate, + status: getMatchResultStatus(candidate), + trackFingerprint: getMatchResultFingerprint(candidate), + } + } + } + } + + const reconciledResults = reconcileSessionResults( + verifiableResults, + tracks, + ) + const hasRateLimited = Object.values(reconciledResults).some( + (r) => getMatchResultStatus(r) === 'rate_limited', + ) + const isRateLimited = + Boolean(legacySession.isRateLimited) || hasRateLimited + const updatedAt = + typeof legacySession.updatedAt === 'number' && + Number.isFinite(legacySession.updatedAt) + ? legacySession.updatedAt + : Date.now() + + // One-time cleanup of migrated session key from legacy MMKV storage + const remainingSessions = { ...sessions } + delete remainingSessions[legacySessionKey] + if (Object.keys(remainingSessions).length === 0) { + storageImpl.removeItem(LEGACY_SYNC_STORAGE_KEY) + } else { + storageImpl.setItem( + LEGACY_SYNC_STORAGE_KEY, + JSON.stringify({ + ...parsed, + state: { + ...parsed.state, + sessions: remainingSessions, + }, + }), + ) + } + + return { + reconciledResults, + isRateLimited, + updatedAt, + didMigrate: true, + } + } catch (e) { + logger.warning( + '解析旧版 external-playlist-sync-storage 失败,已安全忽略:', + e, + ) + return { + reconciledResults: {}, + isRateLimited: false, + updatedAt: Date.now(), + didMigrate: false, + } + } + } + + /** + * 创建或恢复持久化的 Import Job,并按歌曲稳定身份 (fingerprint + occurrence) 对齐所有条目。 + */ + public createOrResumeJob(params: { + source: ExternalPlaylistSource + sourcePlaylistId: string + tracks: GenericTrack[] + playlistMetadata?: Partial> & { + coverUrl?: string | null + } + legacyStorage?: StateStorage + }): ImportJobSnapshot { + const { + source, + sourcePlaylistId, + tracks, + playlistMetadata, + legacyStorage = zustandStorage, + } = params + const jobId = createImportJobId(source, sourcePlaylistId) + if (this.isJobActivelyRunning(jobId)) { + const runningSnapshot = this.getJobSnapshot(jobId) + if (runningSnapshot) { + return runningSnapshot + } + } + + const legacySessionKey = `${source}:${sourcePlaylistId}` + const executor = this.db() + const now = Date.now() + + let snapshot!: ImportJobSnapshot + + executor.withTransactionSync(() => { + let existingJobRow = executor.getFirstSync( + `SELECT * FROM external_import_jobs WHERE job_id = ?`, + [jobId], + ) + + // 如果该任务之前已被正式保存过(saved_playlist_id IS NOT NULL): + // 说明之前的那次导入早已归档,或者其对应的本地歌单可能已被删除。 + // 此时用户进入 createOrResumeJob 是发起了【全新的导入/重新解析】, + // 绝不能复用旧的已匹配结果,更不能复用指向旧歌单的 saved_playlist_id, + // 必须彻底清除历史旧条目与旧结果,作为全新的导入重新开始。 + if ( + existingJobRow?.saved_playlist_id !== null && + existingJobRow?.saved_playlist_id !== undefined + ) { + try { + this.onCancelJob?.(jobId) + } catch { + // Ignore + } + executor.runSync(`DELETE FROM external_import_items WHERE job_id = ?`, [ + jobId, + ]) + executor.runSync(`DELETE FROM external_import_jobs WHERE job_id = ?`, [ + jobId, + ]) + this.consumeLegacySession(legacySessionKey, [], legacyStorage) + existingJobRow = null + } + + const existingItemRows = existingJobRow + ? executor.getAllSync( + `SELECT * FROM external_import_items WHERE job_id = ? ORDER BY original_index ASC`, + [jobId], + ) + : [] + const existingItems = existingItemRows.map((r) => this.mapItemRow(r)) + + // Consume legacy MMKV session if present + const legacyMigration = this.consumeLegacySession( + legacySessionKey, + tracks, + legacyStorage, + ) + + // Index existing persistent items by their stable itemId (jobId::fingerprint#occurrence) + const existingByItemId = new Map() + for (const item of existingItems) { + existingByItemId.set(item.itemId, item) + } + + const occurrenceCounter = new Map() + const reconciledItems: ImportItem[] = [] + const validItemIds = new Set() + + for (let i = 0; i < tracks.length; i++) { + const track = tracks[i] + const fingerprint = getTrackFingerprint(track) + const occurrence = occurrenceCounter.get(fingerprint) ?? 0 + occurrenceCounter.set(fingerprint, occurrence + 1) + + const itemId = createImportItemId(jobId, fingerprint, occurrence) + validItemIds.add(itemId) + + const existingItem = existingByItemId.get(itemId) + const legacyResult = legacyMigration.reconciledResults[i] + + if ( + existingItem && + existingItem.status !== 'pending' && + existingItem.fingerprint === fingerprint + ) { + reconciledItems.push({ + itemId, + jobId, + fingerprint, + originalIndex: i, + originalTrack: track, + status: existingItem.status, + matchedVideo: existingItem.matchedVideo, + ...(existingItem.errorType + ? { errorType: existingItem.errorType } + : {}), + ...(existingItem.errorMessage + ? { errorMessage: existingItem.errorMessage } + : {}), + updatedAt: existingItem.updatedAt, + }) + } else if (legacyResult && isMatchResultForTrack(legacyResult, track)) { + const legacyStatus = getMatchResultStatus(legacyResult) + reconciledItems.push({ + itemId, + jobId, + fingerprint, + originalIndex: i, + originalTrack: track, + status: legacyStatus, + matchedVideo: legacyResult.matchedVideo ?? null, + ...(legacyResult.errorType + ? { errorType: legacyResult.errorType } + : {}), + ...(legacyResult.errorMessage + ? { errorMessage: legacyResult.errorMessage } + : {}), + updatedAt: legacyMigration.updatedAt, + }) + } else if (track.bilibili?.bvid?.trim()) { + // 如果导入的歌曲本身已包含有效 Bilibili BVID,直接作为已匹配保留,仅匹配缺少音源的歌曲 + const bvid = track.bilibili.bvid.trim() + const midNum = track.bilibili.upMid + ? Number(track.bilibili.upMid) + : 0 + const matchedVideo: BilibiliSearchVideo = { + type: 'video', + aid: 0, + bvid, + title: track.bilibili.mainTrackTitle || track.title, + author: + track.artists.filter(Boolean).join(' / ') || '未知歌手', + mid: isNaN(midNum) ? 0 : midNum, + pic: track.coverUrl ?? '', + duration: String( + track.duration > 0 + ? Math.round(track.duration / 1000) + : 0, + ), + senddate: 0, + typeid: 0, + } + reconciledItems.push({ + itemId, + jobId, + fingerprint, + originalIndex: i, + originalTrack: track, + status: 'matched', + matchedVideo, + updatedAt: existingItem?.updatedAt ?? now, + }) + } else { + reconciledItems.push({ + itemId, + jobId, + fingerprint, + originalIndex: i, + originalTrack: track, + status: 'pending', + matchedVideo: null, + updatedAt: existingItem?.updatedAt ?? now, + }) + } + } + + const stats = computeImportJobStats(reconciledItems, tracks.length) + const baseMetadata = existingJobRow + ? this.mapJobRow(existingJobRow).playlistMetadata + : buildDefaultPlaylistMetadata(source, sourcePlaylistId, tracks.length) + const resolvedMetadata: GenericPlaylist = playlistMetadata + ? { + ...baseMetadata, + ...playlistMetadata, + coverUrl: playlistMetadata.coverUrl ?? baseMetadata.coverUrl, + trackCount: playlistMetadata.trackCount ?? tracks.length, + author: playlistMetadata.author ?? baseMetadata.author, + } + : baseMetadata + + let nextStatus: ImportJobStatus + const hasAnyAttempted = + stats.processedCount > 0 || + stats.errorCount > 0 || + stats.rateLimitedCount > 0 + + if ( + stats.rateLimitedCount > 0 || + (legacyMigration.didMigrate && legacyMigration.isRateLimited) || + (existingJobRow?.status === 'rate_limited' && stats.pendingCount > 0) + ) { + nextStatus = 'rate_limited' + } else if ( + stats.totalCount > 0 && + stats.processedCount === stats.totalCount + ) { + nextStatus = 'completed' + } else if (existingJobRow?.status === 'running') { + // Prevent fake running state on recovery + nextStatus = 'paused' + } else if ( + existingJobRow?.status === 'paused' || + existingJobRow?.status === 'failed' || + existingJobRow?.status === 'cancelled' + ) { + nextStatus = hasAnyAttempted ? existingJobRow.status : 'pending' + } else if (hasAnyAttempted) { + nextStatus = 'paused' + } else { + nextStatus = 'pending' + } + + const createdAt = existingJobRow?.created_at ?? now + const savedPlaylistId = existingJobRow?.saved_playlist_id ?? null + const lastProcessedAt = + existingJobRow?.last_processed_at ?? + (hasAnyAttempted ? legacyMigration.updatedAt : null) + + executor.runSync( + `INSERT INTO external_import_jobs ( + job_id, source, source_playlist_id, playlist_metadata, + total_count, processed_count, matched_count, unmatched_count, error_count, + status, saved_playlist_id, created_at, updated_at, last_processed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(job_id) DO UPDATE SET + playlist_metadata = excluded.playlist_metadata, + total_count = excluded.total_count, + processed_count = excluded.processed_count, + matched_count = excluded.matched_count, + unmatched_count = excluded.unmatched_count, + error_count = excluded.error_count, + status = excluded.status, + saved_playlist_id = excluded.saved_playlist_id, + updated_at = excluded.updated_at, + last_processed_at = excluded.last_processed_at`, + [ + jobId, + source, + sourcePlaylistId, + JSON.stringify(resolvedMetadata), + stats.totalCount, + stats.processedCount, + stats.matchedCount, + stats.unmatchedCount, + stats.errorCount, + nextStatus, + savedPlaylistId, + createdAt, + now, + lastProcessedAt, + ], + ) + + // Delete stale items that no longer belong to the current playlist tracks + for (const oldItem of existingItems) { + if (!validItemIds.has(oldItem.itemId)) { + executor.runSync( + `DELETE FROM external_import_items WHERE item_id = ?`, + [oldItem.itemId], + ) + } + } + + // Upsert all current items + for (const item of reconciledItems) { + executor.runSync( + `INSERT INTO external_import_items ( + item_id, job_id, fingerprint, original_index, original_track, + status, matched_video, error_type, error_message, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(item_id) DO UPDATE SET + original_index = excluded.original_index, + original_track = excluded.original_track, + status = excluded.status, + matched_video = excluded.matched_video, + error_type = excluded.error_type, + error_message = excluded.error_message, + updated_at = excluded.updated_at`, + [ + item.itemId, + item.jobId, + item.fingerprint, + item.originalIndex, + JSON.stringify(item.originalTrack), + item.status, + item.matchedVideo ? JSON.stringify(item.matchedVideo) : null, + item.errorType ?? null, + item.errorMessage ?? null, + item.updatedAt, + ], + ) + } + + snapshot = { + job: { + jobId, + source, + sourcePlaylistId, + playlistMetadata: resolvedMetadata, + totalCount: stats.totalCount, + processedCount: stats.processedCount, + matchedCount: stats.matchedCount, + unmatchedCount: stats.unmatchedCount, + errorCount: stats.errorCount, + rateLimitedCount: stats.rateLimitedCount, + status: nextStatus, + savedPlaylistId, + createdAt, + updatedAt: now, + lastProcessedAt, + }, + items: reconciledItems, + } + }) + + this.notifyChanges() + return snapshot + } + + public updateJobStatus( + jobId: string, + status: ImportJobStatus, + ): ImportJobSnapshot | null { + const executor = this.db() + const now = Date.now() + executor.withTransactionSync(() => { + if (status === 'running') { + // Clear previous rate_limited marker on items when user resumes matching + executor.runSync( + `UPDATE external_import_items + SET status = 'pending', error_type = NULL, error_message = NULL, updated_at = ? + WHERE job_id = ? AND status = 'rate_limited'`, + [now, jobId], + ) + } + executor.runSync( + `UPDATE external_import_jobs SET status = ?, updated_at = ? WHERE job_id = ?`, + [status, now, jobId], + ) + }) + this.notifyChanges() + return this.getJobSnapshot(jobId) + } + + public updateItemResult( + jobId: string, + originalIndex: number, + result: MatchResult, + ): ImportJobSnapshot | null { + const executor = this.db() + const now = Date.now() + + executor.withTransactionSync(() => { + const itemRow = executor.getFirstSync( + `SELECT * FROM external_import_items WHERE job_id = ? AND original_index = ?`, + [jobId, originalIndex], + ) + if (!itemRow) return + + const currentItem = this.mapItemRow(itemRow) + if (!isMatchResultForTrack(result, currentItem.originalTrack)) { + logger.warning( + `拒绝写入不匹配的歌曲结果: jobId=${jobId}, index=${originalIndex}`, + ) + return + } + + const nextItemStatus = getMatchResultStatus(result) + executor.runSync( + `UPDATE external_import_items + SET status = ?, matched_video = ?, error_type = ?, error_message = ?, updated_at = ? + WHERE item_id = ?`, + [ + nextItemStatus, + result.matchedVideo ? JSON.stringify(result.matchedVideo) : null, + result.errorType ?? null, + result.errorMessage ?? null, + now, + currentItem.itemId, + ], + ) + + const allItemRows = executor.getAllSync( + `SELECT * FROM external_import_items WHERE job_id = ? ORDER BY original_index ASC`, + [jobId], + ) + const allItems = allItemRows.map((r) => this.mapItemRow(r)) + const jobRow = executor.getFirstSync( + `SELECT * FROM external_import_jobs WHERE job_id = ?`, + [jobId], + ) + if (!jobRow) return + + const stats = computeImportJobStats(allItems, jobRow.total_count) + let nextJobStatus: ImportJobStatus = jobRow.status + if (nextItemStatus === 'rate_limited') { + nextJobStatus = 'rate_limited' + } else if ( + jobRow.status !== 'running' && + stats.totalCount > 0 && + stats.processedCount === stats.totalCount + ) { + nextJobStatus = 'completed' + } + + executor.runSync( + `UPDATE external_import_jobs + SET processed_count = ?, matched_count = ?, unmatched_count = ?, error_count = ?, + status = ?, updated_at = ?, last_processed_at = ? + WHERE job_id = ?`, + [ + stats.processedCount, + stats.matchedCount, + stats.unmatchedCount, + stats.errorCount, + nextJobStatus, + now, + now, + jobId, + ], + ) + + // 如果该任务已经正式保存为本地歌单,同步更新本地歌单中的对应歌曲音源映射与元数据 + if (jobRow.saved_playlist_id) { + const mappingRow = executor.getFirstSync( + `SELECT track_id FROM external_track_mappings WHERE playlist_id = ? AND original_index = ?`, + [jobRow.saved_playlist_id, originalIndex], + ) + if (mappingRow) { + this.updateTrackMatchInPlace(mappingRow.track_id, result) + } + } + }) + + this.notifyChanges() + return this.getJobSnapshot(jobId) + } + + public resetJob(jobId: string): ImportJobSnapshot | null { + const executor = this.db() + const now = Date.now() + executor.withTransactionSync(() => { + executor.runSync( + `UPDATE external_import_items + SET status = 'pending', matched_video = NULL, error_type = NULL, error_message = NULL, updated_at = ? + WHERE job_id = ?`, + [now, jobId], + ) + executor.runSync( + `UPDATE external_import_jobs + SET processed_count = 0, matched_count = 0, unmatched_count = 0, error_count = 0, + status = 'pending', saved_playlist_id = NULL, updated_at = ?, last_processed_at = NULL + WHERE job_id = ?`, + [now, jobId], + ) + }) + this.notifyChanges() + return this.getJobSnapshot(jobId) + } + + /** + * 阶段 5:列出所有尚未正式转换为本地歌单的导入中草稿任务(Draft Importing Playlists)。 + * 已正式保存(saved_playlist_id IS NOT NULL)或已取消(status = 'cancelled')的任务不会作为草稿重复出现在歌单列表。 + */ + public listActiveDraftJobs(): ImportJob[] { + this.cleanupOrphanedJobs() + const executor = this.db() + const rows = executor.getAllSync( + `SELECT * FROM external_import_jobs + WHERE saved_playlist_id IS NULL AND status != 'cancelled' + ORDER BY updated_at DESC`, + ) + return rows.map((row) => { + const rateLimitedRow = executor.getFirstSync<{ cnt: number }>( + `SELECT COUNT(*) AS cnt FROM external_import_items WHERE job_id = ? AND status = 'rate_limited'`, + [row.job_id], + ) + return this.mapJobRow(row, rateLimitedRow?.cnt ?? 0) + }) + } + + /** + * 阶段 5:当用户确认并将导入任务正式保存为本地歌单后,记录 saved_playlist_id 并标记为 completed, + * 使其从“导入中的草稿歌单”转为正式本地歌单,不再与正式歌单重复显示。 + */ + public markJobConfirmed( + jobId: string, + savedPlaylistId: number, + ): ImportJobSnapshot | null { + const executor = this.db() + const now = Date.now() + executor.runSync( + `UPDATE external_import_jobs + SET status = 'completed', saved_playlist_id = ?, updated_at = ? + WHERE job_id = ?`, + [savedPlaylistId, now, jobId], + ) + this.notifyChanges() + return this.getJobSnapshot(jobId) + } + + public deleteJob( + jobId: string, + legacyStorage: StateStorage = zustandStorage, + ): void { + const executor = this.db() + const jobRow = executor.getFirstSync( + `SELECT * FROM external_import_jobs WHERE job_id = ?`, + [jobId], + ) + executor.withTransactionSync(() => { + executor.runSync(`DELETE FROM external_import_items WHERE job_id = ?`, [ + jobId, + ]) + executor.runSync(`DELETE FROM external_import_jobs WHERE job_id = ?`, [ + jobId, + ]) + }) + if (jobRow) { + this.consumeLegacySession( + `${jobRow.source}:${jobRow.source_playlist_id}`, + [], + legacyStorage, + ) + } + this.notifyChanges() + } + + /** + * 阶段 6:写入或更新正式歌单中外部歌曲的映射记录。 + */ + public upsertTrackMapping( + input: UpsertExternalTrackMappingInput, + ): ExternalTrackMappingRecord { + const executor = this.db() + const now = Date.now() + const fingerprint = getTrackFingerprint(input.originalTrack) + const matchedVideo = input.matchedVideo ?? null + + executor.runSync( + `INSERT INTO external_track_mappings ( + track_id, playlist_id, job_id, item_id, source, source_playlist_id, + original_index, track_fingerprint, original_title, original_translated_title, + original_artists_json, original_album, original_duration, original_cover_url, + match_status, matched_bvid, matched_title, matched_author, matched_mid, + matched_pic, matched_duration, matched_video_json, + error_type, error_message, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(track_id) DO UPDATE SET + playlist_id = excluded.playlist_id, + job_id = COALESCE(excluded.job_id, external_track_mappings.job_id), + item_id = COALESCE(excluded.item_id, external_track_mappings.item_id), + source = excluded.source, + source_playlist_id = excluded.source_playlist_id, + original_index = excluded.original_index, + track_fingerprint = excluded.track_fingerprint, + original_title = excluded.original_title, + original_translated_title = excluded.original_translated_title, + original_artists_json = excluded.original_artists_json, + original_album = excluded.original_album, + original_duration = excluded.original_duration, + original_cover_url = excluded.original_cover_url, + match_status = excluded.match_status, + matched_bvid = excluded.matched_bvid, + matched_title = excluded.matched_title, + matched_author = excluded.matched_author, + matched_mid = excluded.matched_mid, + matched_pic = excluded.matched_pic, + matched_duration = excluded.matched_duration, + matched_video_json = excluded.matched_video_json, + error_type = excluded.error_type, + error_message = excluded.error_message, + updated_at = excluded.updated_at`, + [ + input.trackId, + input.playlistId, + input.jobId ?? null, + input.itemId ?? null, + input.externalSource, + input.externalPlaylistId, + input.originalIndex ?? 0, + fingerprint, + input.originalTrack.title, + input.originalTrack.translatedTitle ?? null, + JSON.stringify(input.originalTrack.artists ?? []), + input.originalTrack.album ?? '', + input.originalTrack.duration ?? 0, + input.originalTrack.coverUrl ?? null, + input.matchStatus, + matchedVideo?.bvid ?? null, + matchedVideo?.title ?? null, + matchedVideo?.author ?? null, + matchedVideo?.mid ?? null, + matchedVideo?.pic ?? null, + matchedVideo?.duration ?? null, + matchedVideo ? JSON.stringify(matchedVideo) : null, + input.errorType ?? null, + input.errorMessage ?? null, + now, + now, + ], + ) + + this.notifyChanges() + const saved = this.getTrackMapping(input.trackId) + if (!saved) { + throw new Error(`写入外部歌曲映射失败: trackId=${input.trackId}`) + } + return saved + } + + public upsertTrackMappings(inputs: UpsertExternalTrackMappingInput[]): void { + if (inputs.length === 0) return + for (const input of inputs) { + this.upsertTrackMapping(input) + } + } + + public getTrackMapping(trackId: number): ExternalTrackMappingRecord | null { + const executor = this.db() + const row = executor.getFirstSync( + `SELECT * FROM external_track_mappings WHERE track_id = ?`, + [trackId], + ) + return row ? this.mapTrackMappingRow(row) : null + } + + public getPlaylistTrackMappings( + playlistId: number, + ): ExternalTrackMappingRecord[] { + const executor = this.db() + const rows = executor.getAllSync( + `SELECT * FROM external_track_mappings WHERE playlist_id = ? ORDER BY original_index ASC, track_id ASC`, + [playlistId], + ) + return rows.map((row) => this.mapTrackMappingRow(row)) + } + + /** + * 阶段 6:在正式歌单中原地更新某首歌曲的 B 站视频匹配结果: + * - 只更新 external_track_mappings 与 bilibili_metadata(bvid / video_is_valid / main_track_title); + * - 绝不修改 tracks 表的 title / artist_id / duration / cover_url; + * - 绝不修改 playlist_tracks 表的 sort_key(保持歌单原有顺序)。 + */ + public updateTrackMatchInPlace( + trackId: number, + result: MatchResult, + fallbackContext?: { + playlistId: number + externalSource?: ExternalPlaylistSource + externalPlaylistId?: string + originalIndex?: number + originalTrack?: GenericTrack + }, + ): ExternalTrackMappingRecord | null { + const executor = this.db() + const now = Date.now() + let updatedMapping: ExternalTrackMappingRecord | null = null + + executor.withTransactionSync(() => { + const existingRow = executor.getFirstSync( + `SELECT * FROM external_track_mappings WHERE track_id = ?`, + [trackId], + ) + + const nextStatus = getMatchResultStatus(result) + const matchedVideo = result.matchedVideo ?? null + + if (existingRow) { + const existing = this.mapTrackMappingRow(existingRow) + const keepPreviousVideo = + !matchedVideo && + Boolean(existing.matchedBvid && existing.matchedVideo) + const effectiveVideo = keepPreviousVideo + ? existing.matchedVideo + : matchedVideo + const effectiveBvid = keepPreviousVideo + ? existing.matchedBvid + : (matchedVideo?.bvid ?? null) + + executor.runSync( + `UPDATE external_track_mappings + SET match_status = ?, + matched_bvid = ?, + matched_title = ?, + matched_author = ?, + matched_mid = ?, + matched_pic = ?, + matched_duration = ?, + matched_video_json = ?, + error_type = ?, + error_message = ?, + updated_at = ? + WHERE track_id = ?`, + [ + nextStatus, + effectiveBvid, + effectiveVideo?.title ?? null, + effectiveVideo?.author ?? null, + effectiveVideo?.mid ?? null, + effectiveVideo?.pic ?? null, + effectiveVideo?.duration ?? null, + effectiveVideo ? JSON.stringify(effectiveVideo) : null, + result.errorType ?? null, + result.errorMessage ?? null, + now, + trackId, + ], + ) + } else if (fallbackContext) { + const origTrack = fallbackContext.originalTrack ?? result.track + this.upsertTrackMapping({ + trackId, + playlistId: fallbackContext.playlistId, + externalSource: fallbackContext.externalSource ?? 'netease', + externalPlaylistId: + fallbackContext.externalPlaylistId ?? + String(fallbackContext.playlistId), + originalIndex: fallbackContext.originalIndex ?? 0, + originalTrack: origTrack, + matchStatus: nextStatus, + matchedVideo, + errorType: result.errorType ?? null, + errorMessage: result.errorMessage ?? null, + }) + } + + // Update bilibili_metadata in-place if present + try { + if (matchedVideo) { + const cleanVideoTitle = matchedVideo.title.replace( + /]*>|<\/em>/g, + '', + ) + const mainTrackTitle = `音源: ${cleanVideoTitle}${matchedVideo.author ? ` · UP: ${matchedVideo.author}` : ''}` + executor.runSync( + `UPDATE bilibili_metadata + SET bvid = ?, cid = NULL, is_multi_page = 0, video_is_valid = 1, main_track_title = ? + WHERE track_id = ?`, + [matchedVideo.bvid, mainTrackTitle, trackId], + ) + } else { + const statusHint = + nextStatus === 'rate_limited' + ? '[未匹配音源] Bilibili 请求受限,请稍后重试' + : nextStatus === 'error' + ? `[未匹配音源] ${result.errorMessage ?? '网络或超时异常,可重试'}` + : '[未匹配音源] 未找到匹配视频,点击手动匹配' + executor.runSync( + `UPDATE bilibili_metadata + SET video_is_valid = 1, main_track_title = ? + WHERE track_id = ? AND (bvid IS NULL OR bvid = '')`, + [statusHint, trackId], + ) + } + } catch { + // Ignore if bilibili_metadata table does not exist in isolated unit test DB + } + + const refreshed = executor.getFirstSync( + `SELECT * FROM external_track_mappings WHERE track_id = ?`, + [trackId], + ) + updatedMapping = refreshed ? this.mapTrackMappingRow(refreshed) : null + }) + + this.notifyChanges() + return updatedMapping + } + + private playlistViewModes = new Map() + private playlistViewModesHydrated = false + + private ensurePlaylistViewModesHydrated(): void { + if (this.playlistViewModesHydrated) return + this.playlistViewModesHydrated = true + try { + const raw = zustandStorage.getItem(PLAYLIST_VIEW_MODES_STORAGE_KEY) + if (!raw || typeof raw !== 'string') return + const parsed = JSON.parse(raw) as Record + for (const [key, val] of Object.entries(parsed)) { + const id = Number(key) + if ( + Number.isInteger(id) && + id > 0 && + (val === 'original' || val === 'bilibili') + ) { + this.playlistViewModes.set(id, val) + } + } + } catch { + // Ignore malformed storage + } + } + + private persistPlaylistViewModes(): void { + try { + const record: Record = {} + for (const [id, mode] of this.playlistViewModes.entries()) { + if (mode === 'bilibili') { + record[String(id)] = mode + } + } + zustandStorage.setItem( + PLAYLIST_VIEW_MODES_STORAGE_KEY, + JSON.stringify(record), + ) + } catch { + // Ignore storage errors + } + } + + public getPlaylistViewMode(playlistId: number): ExternalPlaylistViewMode { + this.ensurePlaylistViewModesHydrated() + return this.playlistViewModes.get(playlistId) ?? 'original' + } + + public setPlaylistViewMode( + playlistId: number, + mode: ExternalPlaylistViewMode, + ): ExternalPlaylistViewMode { + this.ensurePlaylistViewModesHydrated() + if (mode === 'original') { + this.playlistViewModes.delete(playlistId) + } else { + this.playlistViewModes.set(playlistId, mode) + } + this.persistPlaylistViewModes() + this.notifyChanges() + return mode + } + + public togglePlaylistViewMode(playlistId: number): ExternalPlaylistViewMode { + const current = this.getPlaylistViewMode(playlistId) + const next: ExternalPlaylistViewMode = + current === 'original' ? 'bilibili' : 'original' + return this.setPlaylistViewMode(playlistId, next) + } + + public resolveTrackForPlayback( + track: Track, + explicitPlaylistId?: number, + ): Track { + const playlistId = + explicitPlaylistId ?? + parseExternalPlaylistIdFromUniqueKey(track.uniqueKey) + if (!playlistId) { + return track + } + const mode = this.getPlaylistViewMode(playlistId) + if (mode !== 'bilibili') { + return track + } + let mapping: ExternalTrackMappingRecord | null = null + try { + mapping = this.getTrackMapping(track.id) + } catch { + mapping = null + } + return applyPlaylistViewModeToTrack(track, mode, mapping) + } + + /** + * 当本地歌单被删除时调用:彻底清理该歌单关联的所有外部导入任务、草稿条目、映射记录与旧缓存。 + * 确保再次导入该外部歌单时不会遗留旧数据或导致状态混乱。 + */ + public deleteJobsForPlaylist( + playlistId: number, + legacyStorage: StateStorage = zustandStorage, + ): void { + const executor = this.db() + try { + this.onCancelPlaylist?.(playlistId) + } catch { + // Ignore + } + + executor.withTransactionSync(() => { + const jobRows = executor.getAllSync( + `SELECT * FROM external_import_jobs WHERE saved_playlist_id = ?`, + [playlistId], + ) + for (const job of jobRows) { + try { + this.onCancelJob?.(job.job_id) + } catch { + // Ignore + } + executor.runSync(`DELETE FROM external_import_items WHERE job_id = ?`, [ + job.job_id, + ]) + executor.runSync(`DELETE FROM external_import_jobs WHERE job_id = ?`, [ + job.job_id, + ]) + this.consumeLegacySession( + `${job.source}:${job.source_playlist_id}`, + [], + legacyStorage, + ) + } + executor.runSync( + `DELETE FROM external_track_mappings WHERE playlist_id = ?`, + [playlistId], + ) + this.playlistViewModes.delete(playlistId) + }) + this.persistPlaylistViewModes() + this.notifyChanges() + } + + public deletePlaylistTrackMappings(playlistId: number): void { + this.deleteJobsForPlaylist(playlistId) + } + + public clearAllJobsForTesting(): void { + const executor = this.db() + executor.withTransactionSync(() => { + executor.runSync(`DELETE FROM external_track_mappings`) + executor.runSync(`DELETE FROM external_import_items`) + executor.runSync(`DELETE FROM external_import_jobs`) + }) + this.playlistViewModes.clear() + this.playlistViewModesHydrated = true + try { + zustandStorage.removeItem(PLAYLIST_VIEW_MODES_STORAGE_KEY) + } catch { + // Ignore + } + this.notifyChanges() + } +} + +export const externalImportJobService = new ExternalImportJobService() diff --git a/apps/mobile/src/lib/services/externalImportNotificationService.ts b/apps/mobile/src/lib/services/externalImportNotificationService.ts new file mode 100644 index 000000000..9f33ee6a3 --- /dev/null +++ b/apps/mobile/src/lib/services/externalImportNotificationService.ts @@ -0,0 +1,292 @@ +import { + cancelImportProgressNotification, + type ImportProgressNotificationOptions, + updateImportProgressNotification, +} from '@bbplayer/native' + +import type { ImportJobSnapshot } from '@/lib/services/externalImportJobService' +import { + type ExternalPlaylistImportWorker, + externalPlaylistImportWorker, + type ImportWorkerEvent, +} from '@/lib/workers/ExternalPlaylistImportWorker' +import log from '@/utils/log' + +const logger = log.extend('Services.ExternalImportNotification') + +export interface ImportLiveActivityPayload { + jobId: string + playlistTitle: string + source: string + processedCount: number + totalCount: number + matchedCount: number + unmatchedCount: number + errorCount: number + percentage: number + progressText: string + currentTrackTitle: string | null + compactLeadingText: string + compactTrailingText: string + status: 'running' | 'paused' | 'rate_limited' | 'completed' | 'failed' + statusMessage: string + deepLinkUri: string +} + +export interface ImportNotificationAdapter { + updateAndroidNotification(options: ImportProgressNotificationOptions): boolean + cancelAndroidNotification(): boolean + updateIosLiveActivity(payload: ImportLiveActivityPayload | null): void +} + +export function buildImportDeepLinkUri( + source: string, + sourcePlaylistId: string, +): string { + return `bbplayer:///playlist/external-sync?source=${encodeURIComponent(source)}&id=${encodeURIComponent(sourcePlaylistId)}` +} + +export function buildAndroidNotificationPayload( + event: ImportWorkerEvent, +): ImportProgressNotificationOptions | null { + const snapshot = event.snapshot + if (!snapshot) return null + + const { job } = snapshot + const playlistTitle = job.playlistMetadata.title || job.sourcePlaylistId + const totalCount = Math.max(job.totalCount, 0) + const processedCount = Math.min(job.processedCount, totalCount) + const percentage = + totalCount > 0 ? Math.round((processedCount / totalCount) * 100) : 0 + const deepLinkUri = buildImportDeepLinkUri(job.source, job.sourcePlaylistId) + + if ( + (event.type === 'started' || + event.type === 'progress' || + event.type === 'updated') && + job.status === 'running' + ) { + return { + jobId: job.jobId, + title: `正在匹配《${playlistTitle}》`, + body: `${processedCount} / ${totalCount} · ${percentage}%`, + subText: event.currentTrackTitle + ? `当前:${event.currentTrackTitle}` + : undefined, + progress: processedCount, + maxProgress: totalCount, + ongoing: true, + deepLinkUri, + status: 'running', + } + } + + if (event.type === 'rate_limited' || job.status === 'rate_limited') { + return { + jobId: job.jobId, + title: '歌单匹配已暂停', + body: 'Bilibili 请求暂时受限,进度已保存', + subText: `已完成 ${processedCount} / ${totalCount} 首,点击返回查看`, + progress: processedCount, + maxProgress: totalCount, + ongoing: false, + deepLinkUri, + status: 'rate_limited', + } + } + + if (event.type === 'completed' || job.status === 'completed') { + const needActionCount = job.unmatchedCount + job.errorCount + return { + jobId: job.jobId, + title: `《${playlistTitle}》匹配完成`, + body: `${job.matchedCount} 首成功,${needActionCount} 首需要处理`, + progress: totalCount, + maxProgress: totalCount, + ongoing: false, + deepLinkUri, + status: 'completed', + } + } + + if (event.type === 'paused' || job.status === 'paused') { + return { + jobId: job.jobId, + title: `《${playlistTitle}》匹配已暂停`, + body: `已完成 ${processedCount} / ${totalCount} · ${percentage}%,点击继续`, + progress: processedCount, + maxProgress: totalCount, + ongoing: false, + deepLinkUri, + status: 'paused', + } + } + + if (event.type === 'failed' || job.status === 'failed') { + return { + jobId: job.jobId, + title: `《${playlistTitle}》匹配中断`, + body: `已完成 ${processedCount} / ${totalCount} 首,进度已保存`, + progress: processedCount, + maxProgress: totalCount, + ongoing: false, + deepLinkUri, + status: 'failed', + } + } + + return null +} + +export function buildIosLiveActivityPayload( + snapshot: ImportJobSnapshot | null, + currentTrackTitle: string | null, +): ImportLiveActivityPayload | null { + if (!snapshot) return null + const { job } = snapshot + if (job.status === 'cancelled' || job.status === 'pending') { + return null + } + + const playlistTitle = job.playlistMetadata.title || job.sourcePlaylistId + const totalCount = Math.max(job.totalCount, 0) + const processedCount = Math.min(job.processedCount, totalCount) + const percentage = + totalCount > 0 ? Math.round((processedCount / totalCount) * 100) : 0 + const needActionCount = job.unmatchedCount + job.errorCount + const deepLinkUri = buildImportDeepLinkUri(job.source, job.sourcePlaylistId) + + let status: ImportLiveActivityPayload['status'] + let statusMessage: string + + switch (job.status) { + case 'running': + status = 'running' + statusMessage = currentTrackTitle + ? `正在匹配:${currentTrackTitle}` + : '正在自动匹配 Bilibili 音源...' + break + case 'rate_limited': + status = 'rate_limited' + statusMessage = '请求受限,已暂停保护进度' + break + case 'completed': + case 'completed_waiting_confirmation': + status = 'completed' + statusMessage = `匹配完成 · ${job.matchedCount} 首成功,${needActionCount} 首待处理` + break + case 'failed': + status = 'failed' + statusMessage = '匹配中断,回到 App 继续' + break + default: + status = 'paused' + statusMessage = '已暂停,回到 App 继续' + break + } + + return { + jobId: job.jobId, + playlistTitle, + source: job.source, + processedCount, + totalCount, + matchedCount: job.matchedCount, + unmatchedCount: job.unmatchedCount, + errorCount: job.errorCount, + percentage, + progressText: `${processedCount}/${totalCount}`, + currentTrackTitle: status === 'running' ? currentTrackTitle : null, + compactLeadingText: 'BB', + compactTrailingText: `${processedCount}/${totalCount}`, + status, + statusMessage, + deepLinkUri, + } +} + +const defaultAdapter: ImportNotificationAdapter = { + updateAndroidNotification: (options) => + updateImportProgressNotification(options), + cancelAndroidNotification: () => cancelImportProgressNotification(), + updateIosLiveActivity: () => { + // iOS ActivityKit bridge hook (no-op when ActivityKit extension is not active) + }, +} + +export class ExternalImportNotificationService { + private unsubscribeWorker: (() => void) | null = null + private lastAndroidPayload: ImportProgressNotificationOptions | null = null + private lastIosPayload: ImportLiveActivityPayload | null = null + + constructor( + private readonly worker: ExternalPlaylistImportWorker = externalPlaylistImportWorker, + private readonly adapter: ImportNotificationAdapter = defaultAdapter, + ) {} + + public startListening(): () => void { + if (this.unsubscribeWorker) { + return () => this.stopListening() + } + this.unsubscribeWorker = this.worker.subscribe((event) => { + this.handleWorkerEvent(event) + }) + return () => this.stopListening() + } + + public stopListening(): void { + this.unsubscribeWorker?.() + this.unsubscribeWorker = null + } + + public getLastAndroidPayload(): ImportProgressNotificationOptions | null { + return this.lastAndroidPayload + } + + public getLastIosPayload(): ImportLiveActivityPayload | null { + return this.lastIosPayload + } + + public handleWorkerEvent(event: ImportWorkerEvent): void { + try { + if (event.type === 'cancelled' || !event.snapshot) { + this.lastAndroidPayload = null + this.lastIosPayload = null + this.adapter.cancelAndroidNotification() + this.adapter.updateIosLiveActivity(null) + return + } + + const androidPayload = buildAndroidNotificationPayload(event) + if (androidPayload) { + this.lastAndroidPayload = androidPayload + this.adapter.updateAndroidNotification(androidPayload) + } + + const iosPayload = buildIosLiveActivityPayload( + event.snapshot, + event.currentTrackTitle, + ) + this.lastIosPayload = iosPayload + this.adapter.updateIosLiveActivity(iosPayload) + } catch (e) { + logger.warning('更新外部歌单导入系统通知失败:', e) + } + } + + /** + * 当 iOS 后台执行即将被系统挂起时调用: + * 诚实将正在运行的任务标记为已暂停并更新 Live Activity,绝不伪造后台进度。 + */ + public handleIosBackgroundExpiration(): void { + const activeJobId = this.worker.getActiveJobId() + if (!activeJobId) return + const pausedSnapshot = this.worker.pause(activeJobId) + const iosPayload = buildIosLiveActivityPayload(pausedSnapshot, null) + this.lastIosPayload = iosPayload + this.adapter.updateIosLiveActivity(iosPayload) + } +} + +export const externalImportNotificationService = + new ExternalImportNotificationService() diff --git a/apps/mobile/src/lib/services/externalPlaylistService.ts b/apps/mobile/src/lib/services/externalPlaylistService.ts index c1df2994f..976584d80 100644 --- a/apps/mobile/src/lib/services/externalPlaylistService.ts +++ b/apps/mobile/src/lib/services/externalPlaylistService.ts @@ -1,9 +1,18 @@ import { decode } from 'he' -import { ResultAsync, errAsync } from 'neverthrow' +import { ResultAsync, errAsync, okAsync } from 'neverthrow' import { bilibiliApi } from '@/lib/api/bilibili/api' import { neteaseApi } from '@/lib/api/netease/api' import { qqMusicApi } from '@/lib/api/qqmusic/api' +import { + BilibiliApiError, + isBilibiliRateLimitError, +} from '@/lib/errors/thirdparty/bilibili' +import { + createImportJobId, + externalImportJobService, +} from '@/lib/services/externalImportJobService' +import { playlistOutService } from '@/lib/services/playlistOutService' import type { BilibiliSearchVideo } from '@/types/apis/bilibili' import type { GenericPlaylist, GenericTrack } from '@/types/external_playlist' import log from '@/utils/log' @@ -12,17 +21,43 @@ import { parseDurationString } from '@/utils/time' const logger = log.extend('Services.ExternalPlaylist') -// 全局配置 -const MIN_DELAY = 1200 // 防封号延迟 (ms) +// 全局配置(保持单并发与保守请求间隔,优先响应明确风控信号) +export const DEFAULT_REQUEST_DELAY_MS = 2800 const SEARCH_TIMEOUT = 15_000 const BLACKLIST_ZONES = new Set([26, 29, 31, 201, 238]) // 黑名单分区 (音MAD, 现场, 翻唱, 科普, 运动) const PRIORITY_ZONES = new Set([193, 130, 267]) // 优先分区 (MV, 音乐综合, 电台) -const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - -const createSearchSignal = (parentSignal?: AbortSignal) => { +const wait = (ms: number, signal?: AbortSignal) => + new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error('Aborted')) + return + } + if (ms <= 0) { + resolve() + return + } + const onAbort = () => { + clearTimeout(timer) + reject(new Error('Aborted')) + } + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort) + resolve() + }, ms) + signal?.addEventListener('abort', onAbort, { once: true }) + }) + +const createSearchSignal = ( + parentSignal?: AbortSignal, + timeoutMs = SEARCH_TIMEOUT, +) => { const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), SEARCH_TIMEOUT) + let timedOut = false + const timeoutId = setTimeout(() => { + timedOut = true + controller.abort() + }, timeoutMs) const abortFromParent = () => controller.abort() if (parentSignal?.aborted) { @@ -33,6 +68,7 @@ const createSearchSignal = (parentSignal?: AbortSignal) => { return { signal: controller.signal, + didTimeout: () => timedOut, cleanup: () => { clearTimeout(timeoutId) parentSignal?.removeEventListener('abort', abortFromParent) @@ -45,17 +81,136 @@ interface MatchCandidate { score: number } +export type MatchResultStatus = + | 'pending' + | 'matched' + | 'unmatched' + | 'error' + | 'rate_limited' + +export type MatchErrorType = + | 'network' + | 'timeout' + | 'api' + | 'rate_limited' + | 'unknown' + export interface MatchResult { track: GenericTrack matchedVideo: BilibiliSearchVideo | null + status?: MatchResultStatus + trackFingerprint?: string + errorType?: MatchErrorType + errorMessage?: string +} + +export function getTrackFingerprint(track: GenericTrack): string { + const title = track.title.trim().toLowerCase() + const translatedTitle = (track.translatedTitle ?? '').trim().toLowerCase() + const artists = track.artists + .map((a) => a.trim().toLowerCase()) + .filter(Boolean) + .join('/') + const album = (track.album ?? '').trim().toLowerCase() + const durationSec = Math.round((track.duration ?? 0) / 1000) + return `${title}::${translatedTitle}::${artists}::${album}::${durationSec}` +} + +export function getMatchResultFingerprint(result: MatchResult): string { + return result.trackFingerprint ?? getTrackFingerprint(result.track) +} + +export function isMatchResultForTrack( + result: MatchResult | undefined, + track: GenericTrack | undefined, +): result is MatchResult { + if (!result || !track) return false + return getMatchResultFingerprint(result) === getTrackFingerprint(track) } +export function getMatchResultStatus( + result?: MatchResult | null, +): MatchResultStatus { + if (!result) return 'pending' + if (result.status) return result.status + return result.matchedVideo ? 'matched' : 'unmatched' +} + +export type ExternalPlaylistSource = + | 'netease' + | 'qq' + | 'playlistout' + | 'local_json' + export class ExternalPlaylistService { + public getPersistedPlaylistSync( + playlistId: string, + source: ExternalPlaylistSource, + ): { playlist: GenericPlaylist; tracks: GenericTrack[] } | null { + if (!playlistId) return null + try { + const jobId = createImportJobId(source, playlistId) + let snap = externalImportJobService.getJobSnapshot(jobId) + if (!snap) { + const draft = externalImportJobService + .listActiveDraftJobs() + .find( + (j) => + j.source === source && + (j.sourcePlaylistId === playlistId || + j.sourcePlaylistId.startsWith(playlistId) || + playlistId.startsWith(j.sourcePlaylistId)), + ) + if (draft) { + snap = externalImportJobService.getJobSnapshot(draft.jobId) + } + } + if (snap && snap.items.length > 0 && snap.job.savedPlaylistId === null) { + return { + playlist: snap.job.playlistMetadata, + tracks: snap.items.map((item) => item.originalTrack), + } + } + } catch { + // Ignore and return null + } + return null + } + public fetchExternalPlaylist( playlistId: string, - source: 'netease' | 'qq', + source: ExternalPlaylistSource, ): ResultAsync<{ playlist: GenericPlaylist; tracks: GenericTrack[] }, Error> { - if (source === 'netease') { + const persisted = this.getPersistedPlaylistSync(playlistId, source) + if (persisted) { + return okAsync(persisted) + } + + const fallbackToPersistedDraft = (err: Error) => { + try { + const jobId = createImportJobId(source, playlistId) + const snap = externalImportJobService.getJobSnapshot(jobId) + if (snap && snap.items.length > 0) { + return okAsync({ + playlist: snap.job.playlistMetadata, + tracks: snap.items.map((item) => item.originalTrack), + }) + } + } catch { + // Fall through to original error + } + return errAsync(err) + } + + if (source === 'playlistout') { + return playlistOutService + .resolvePlaylist(playlistId) + .orElse(fallbackToPersistedDraft) + } else if (source === 'local_json') { + return playlistOutService + .getCachedPlaylist(playlistId) + .orElse(fallbackToPersistedDraft) + } else if (source === 'netease') { return neteaseApi.getPlaylist(playlistId).map((response) => { if (!response.playlist) { return { @@ -83,17 +238,42 @@ export class ExternalPlaylistService { translatedTitle: track.tns?.[0], })) + const createDate = + response.playlist.createTime > 0 + ? new Date( + response.playlist.createTime > 1e11 + ? response.playlist.createTime + : response.playlist.createTime * 1000, + ) + : null + const createTime = + createDate && !isNaN(createDate.getTime()) + ? `${createDate.getFullYear()}-${String(createDate.getMonth() + 1).padStart(2, '0')}-${String(createDate.getDate()).padStart(2, '0')}` + : undefined + return { playlist: { id: response.playlist.id.toString(), - title: response.playlist.name, + title: decode(response.playlist.name), coverUrl: response.playlist.coverImgUrl, - description: response.playlist.description ?? '', + description: decode( + (response.playlist.description ?? '').replace( + //gi, + '\n', + ), + ).trim(), trackCount: response.playlist.trackCount, author: { name: response.playlist.creator?.nickname ?? 'Unknown', id: response.playlist.creator?.userId ?? 0, }, + createTime, + tags: + Array.isArray(response.playlist.tags) && + response.playlist.tags.length > 0 + ? response.playlist.tags + : undefined, + platform: 'netease', }, tracks, } @@ -115,24 +295,46 @@ export class ExternalPlaylistService { } const tracks = playlist.songlist.map((track) => ({ - title: track.name, - artists: track.singer.map((s) => s.name), - album: track.album.name, + title: decode(track.name), + artists: track.singer.map((s) => decode(s.name)), + album: decode(track.album.name), duration: track.interval * 1000, coverUrl: `https://y.gtimg.cn/music/photo_new/T002R300x300M000${track.album.mid}.jpg`, - translatedTitle: track.subtitle, + translatedTitle: track.subtitle ? decode(track.subtitle) : undefined, })) + const createDate = + typeof playlist.ctime === 'number' && playlist.ctime > 0 + ? new Date( + playlist.ctime > 1e11 ? playlist.ctime : playlist.ctime * 1000, + ) + : null + const createTime = + createDate && !isNaN(createDate.getTime()) + ? `${createDate.getFullYear()}-${String(createDate.getMonth() + 1).padStart(2, '0')}-${String(createDate.getDate()).padStart(2, '0')}` + : undefined + + const tags = Array.isArray(playlist.tags) + ? playlist.tags + .map((t) => (t?.name ? decode(t.name).trim() : '')) + .filter(Boolean) + : [] + return { playlist: { id: playlistId, - title: playlist.dissname, + title: decode(playlist.dissname).trim(), coverUrl: playlist.logo, - description: playlist.desc || '', + description: decode( + (playlist.desc || '').replace(//gi, '\n'), + ).trim(), trackCount: playlist.songnum, author: { - name: playlist.nickname, + name: decode(playlist.nickname).trim(), }, + createTime, + tags: tags.length > 0 ? tags : undefined, + platform: 'qqmusic', }, tracks, } @@ -153,6 +355,8 @@ export class ExternalPlaylistService { signal?: AbortSignal startIndex?: number trackIndexes?: number[] + requestDelayMs?: number + searchTimeoutMs?: number }, ): ResultAsync { return ResultAsync.fromPromise( @@ -167,6 +371,8 @@ export class ExternalPlaylistService { (_, index) => startIndex + index, ) const processingTotal = indexesToProcess.length + const delayMs = options?.requestDelayMs ?? DEFAULT_REQUEST_DELAY_MS + const searchTimeoutMs = options?.searchTimeoutMs ?? SEARCH_TIMEOUT for (const [processedCount, trackIndex] of indexesToProcess.entries()) { if (options?.signal?.aborted) { @@ -177,29 +383,47 @@ export class ExternalPlaylistService { if (!song) { continue } - // oxlint-disable-next-line no-await-in-loop - await wait(MIN_DELAY) + if (processedCount > 0) { + // oxlint-disable-next-line no-await-in-loop + await wait(delayMs, options?.signal) + } // Double check after wait if (options?.signal?.aborted) { throw new Error('Aborted') } - const artistNames = song.artists.join(' ') - const searchQuery = `${song.title} ${song.translatedTitle ?? ''} - ${artistNames}` + const titlePart = [song.title, song.translatedTitle] + .map((t) => t?.trim()) + .filter(Boolean) + .join(' ') + const artistPart = song.artists + .map((a) => a.trim()) + .filter(Boolean) + .join(' ') + const searchQuery = artistPart + ? `${titlePart} - ${artistPart}` + : titlePart + const trackFingerprint = getTrackFingerprint(song) let matchedVideo: BilibiliSearchVideo | null = null + let status: MatchResultStatus = 'unmatched' + let errorType: MatchErrorType | undefined + let errorMessage: string | undefined + + const { signal, didTimeout, cleanup } = createSearchSignal( + options?.signal, + searchTimeoutMs, + ) try { - const { signal, cleanup } = createSearchSignal(options?.signal) // oxlint-disable-next-line no-await-in-loop const searchResult = await (async () => { try { return await bilibiliApi.searchVideos({ keyword: searchQuery, page: 1, - // 一点小巧思:带 cookie 调用搜索是会有个性化内容的,但在匹配时我认为个性化内容反而会干扰准确度 - skipCookie: true, + signal, }) } finally { @@ -207,20 +431,105 @@ export class ExternalPlaylistService { } })() + if (options?.signal?.aborted) { + throw new Error('Aborted') + } + if (searchResult.isOk()) { const decodedResults = searchResult.value.result.map((video) => ({ ...video, title: decode(video.title), })) matchedVideo = this.findBestMatchSimple(decodedResults, song) + status = matchedVideo ? 'matched' : 'unmatched' } else { - logger.error( - `Search failed for ${song.title}:`, - searchResult.error, - ) + const err = searchResult.error + if (isBilibiliRateLimitError(err)) { + logger.warning(`Rate limit triggered on ${song.title}:`, err) + const rateLimitedResult: MatchResult = { + track: song, + matchedVideo: null, + status: 'rate_limited', + trackFingerprint, + errorType: 'rate_limited', + errorMessage: + err.message && err.message !== 'OK' + ? err.message + : '请求暂时受限,进度已保存', + } + onProgress( + processedCount, + processingTotal, + rateLimitedResult, + trackIndex, + ) + throw new BilibiliApiError({ + message: + err.message && err.message !== 'OK' + ? err.message + : '请求暂时受限,进度已保存', + msgCode: err.data?.msgCode || 412, + rawData: err.data?.rawData, + type: 'RateLimited', + cause: err, + }) + } + + logger.error(`Search failed for ${song.title}:`, err) + status = 'error' + const isTimeout = didTimeout() || err.type === 'RequestAborted' + errorType = isTimeout + ? 'timeout' + : err.type === 'ResponseFailed' + ? 'api' + : 'network' + errorMessage = isTimeout + ? '搜索请求超时' + : err.message || '网络或接口异常' } } catch (e) { + if (options?.signal?.aborted) { + throw new Error('Aborted', { cause: e }) + } + if (isBilibiliRateLimitError(e)) { + if ( + !(e instanceof BilibiliApiError && e.type === 'RateLimited') + ) { + const rateLimitedResult: MatchResult = { + track: song, + matchedVideo: null, + status: 'rate_limited', + trackFingerprint, + errorType: 'rate_limited', + errorMessage: + e instanceof Error && e.message !== 'OK' + ? e.message + : '请求暂时受限,进度已保存', + } + onProgress( + processedCount, + processingTotal, + rateLimitedResult, + trackIndex, + ) + } + throw e instanceof Error + ? e + : new BilibiliApiError({ + message: String(e), + msgCode: 412, + type: 'RateLimited', + cause: e, + }) + } logger.error(`Error processing ${song.title}:`, e) + status = 'error' + errorType = didTimeout() ? 'timeout' : 'network' + errorMessage = didTimeout() + ? '搜索请求超时' + : e instanceof Error + ? e.message + : String(e) } if (options?.signal?.aborted) { @@ -229,7 +538,11 @@ export class ExternalPlaylistService { const result: MatchResult = { track: song, - matchedVideo: matchedVideo, + matchedVideo, + status, + trackFingerprint, + ...(errorType ? { errorType } : {}), + ...(errorMessage ? { errorMessage } : {}), } results.push(result) onProgress(processedCount + 1, processingTotal, result, trackIndex) diff --git a/apps/mobile/src/lib/services/playlistOutService.ts b/apps/mobile/src/lib/services/playlistOutService.ts new file mode 100644 index 000000000..f2504ba21 --- /dev/null +++ b/apps/mobile/src/lib/services/playlistOutService.ts @@ -0,0 +1,1196 @@ +import * as Application from 'expo-application' +import { File, Paths } from 'expo-file-system' +import * as Sharing from 'expo-sharing' +import { decode } from 'he' +import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' +import { Platform } from 'react-native' +import { fetch } from 'react-native-nitro-fetch' + +import { + parseKugouCredentialsInput, + playlistOutStorage, +} from '@/lib/storage/playlistOutStorage' +import type { Playlist, Track } from '@/types/core/media' +import type { + BilibiliPlaylistExtension, + BilibiliTrackExtension, + CanonicalExportPlaylist, + CanonicalExportTrack, + GenericPlaylist, + GenericTrack, +} from '@/types/external_playlist' +import log from '@/utils/log' +import { formatDurationToText } from '@/utils/time' +import toast from '@/utils/toast' + +const logger = log.extend('Services.PlaylistOut') + +const API_BASE = 'https://playlistout-api.lengxiqwq.com' +const REQUEST_TIMEOUT_MS = 25000 +const FALLBACK_APP_VERSION = '2.7.0' + +export interface PlaylistOutClientEnv { + deviceClass: 'mobile' | 'desktop' + os: 'android' | 'ios' | 'windows' | 'macos' | 'linux' | 'unknown' + version: string +} + +function decodeHtmlText(raw: string): string { + if (!raw) return '' + return decode(raw.replace(//gi, '\n')).trim() +} + +function formatDateTimeString( + raw: Date | number | string | null | undefined, +): string | undefined { + if (raw === undefined || raw === null || raw === '') return undefined + const d = raw instanceof Date ? raw : new Date(raw) + if (isNaN(d.getTime()) || d.getTime() <= 0) return undefined + const yyyy = d.getFullYear() + const mm = String(d.getMonth() + 1).padStart(2, '0') + const dd = String(d.getDate()).padStart(2, '0') + const hh = String(d.getHours()).padStart(2, '0') + const min = String(d.getMinutes()).padStart(2, '0') + const ss = String(d.getSeconds()).padStart(2, '0') + return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss}` +} + +function formatPlaylistDate(raw: unknown): string | undefined { + if (raw === undefined || raw === null || raw === '') return undefined + + if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) { + const ms = raw > 1e11 ? raw : raw * 1000 + const d = new Date(ms) + if (!isNaN(d.getTime())) { + const yyyy = d.getFullYear() + const mm = String(d.getMonth() + 1).padStart(2, '0') + const dd = String(d.getDate()).padStart(2, '0') + return `${yyyy}-${mm}-${dd}` + } + return undefined + } + + if (typeof raw === 'string') { + const trimmed = raw.trim() + if (!trimmed) return undefined + if (/^\d+$/.test(trimmed)) { + return formatPlaylistDate(Number(trimmed)) + } + const dateMatch = trimmed.match(/^(\d{4}[-/]\d{1,2}[-/]\d{1,2})/) + if (dateMatch) { + return dateMatch[1].replace(/\//g, '-') + } + const parsed = Date.parse(trimmed.replace(' ', 'T')) + if (!isNaN(parsed) && parsed > 0) { + return formatPlaylistDate(parsed) + } + return trimmed + } + + return undefined +} + +function extractTags(rawTags: unknown): string[] | undefined { + if (!Array.isArray(rawTags) || rawTags.length === 0) return undefined + const tags = rawTags + .map((t: unknown) => { + if (typeof t === 'string') return decodeHtmlText(t) + if ( + t && + typeof t === 'object' && + 'name' in t && + typeof t.name === 'string' + ) { + return decodeHtmlText(t.name) + } + if ( + t && + typeof t === 'object' && + 'tagname' in t && + typeof t.tagname === 'string' + ) { + return decodeHtmlText(t.tagname) + } + return '' + }) + .filter(Boolean) + return tags.length > 0 ? tags : undefined +} + +function normalizeBilibiliPlaylistType( + raw: unknown, +): BilibiliPlaylistExtension['playlistType'] { + if (typeof raw !== 'string') return undefined + const lower = raw.trim().toLowerCase().replace(/_/g, '') + if (lower === 'local') return 'local' + if (lower === 'favorite') return 'favorite' + if (lower === 'collection') return 'collection' + if (lower === 'series') return 'series' + if (lower === 'multipage') return 'multiPage' + if (lower === 'dynamic') return 'dynamic' + return undefined +} + +// In-memory cache for locally loaded JSON playlists +const localPlaylistCache = new Map< + string, + { playlist: GenericPlaylist; tracks: GenericTrack[] } +>() + +export class PlaylistOutService { + /** + * Detect host runtime environment (aligned with MusicFree plugin detectClientEnvironment) + */ + public detectClientEnvironment(): PlaylistOutClientEnv { + const rawOs = Platform.OS + let os: PlaylistOutClientEnv['os'] = 'unknown' + if (rawOs === 'android') os = 'android' + else if (rawOs === 'ios') os = 'ios' + else if (rawOs === 'windows') os = 'windows' + else if (rawOs === 'macos') os = 'macos' + + const deviceClass: PlaylistOutClientEnv['deviceClass'] = + os === 'windows' || os === 'macos' ? 'desktop' : 'mobile' + + const rawVer = ( + Application.nativeApplicationVersion || FALLBACK_APP_VERSION + ).trim() + const version = /^[0-9A-Za-z][0-9A-Za-z._+-]{0,31}$/.test(rawVer) + ? rawVer + : FALLBACK_APP_VERSION + + return { + deviceClass, + os, + version, + } + } + + /** + * Build standardized PlaylistOut telemetry & attribution headers + * Strictly aligned with PlaylistOut Analytics V2 & MusicFree plugin contract + */ + public getPlaylistOutHeaders(): Record { + const envInfo = this.detectClientEnvironment() + return { + Accept: 'application/json, text/plain, */*', + 'User-Agent': `PlaylistOut-BBPlayer/${envInfo.version} (${envInfo.deviceClass}; ${envInfo.os})`, + 'X-PlaylistOut-Client-Type': 'plugin', + 'X-PlaylistOut-Client-Id': 'bbplayer', + 'X-PlaylistOut-Client-Version': envInfo.version, + 'X-PlaylistOut-Device-Class': envInfo.deviceClass, + 'X-PlaylistOut-Host': envInfo.os, + } + } + + /** + * Detect music platform from URL or text + */ + public detectPlatform( + text: string, + ): 'netease' | 'qq' | 'kugou' | 'qishui' | 'json' | null { + const trimmed = text.trim() + if (!trimmed) return null + if ( + trimmed.startsWith('{') || + trimmed.startsWith('[') || + /^https?:\/\/[^\s]+\.json(?:\?[^\s]*)?$/i.test(trimmed) + ) { + return 'json' + } + if (/163\.com|126\.net|163cn\.tv/i.test(trimmed)) return 'netease' + if (/qq\.com/i.test(trimmed)) return 'qq' + if (/kugou\.com/i.test(trimmed)) return 'kugou' + if (/qishui|douyin/i.test(trimmed)) return 'qishui' + return null + } + + private pendingIncomingFileUri: string | null = null + + public setPendingIncomingFileUri(uri: string): void { + this.pendingIncomingFileUri = uri + } + + public consumePendingIncomingFileUri(): string | null { + const uri = this.pendingIncomingFileUri + this.pendingIncomingFileUri = null + return uri + } + + /** + * Cache a locally parsed playlist for subsequent sync retrieval + */ + public setCachedPlaylist( + id: string, + data: { playlist: GenericPlaylist; tracks: GenericTrack[] }, + ): void { + localPlaylistCache.set(id, data) + } + + /** + * Retrieve a cached locally parsed playlist + */ + public getCachedPlaylist( + id: string, + ): ResultAsync<{ playlist: GenericPlaylist; tracks: GenericTrack[] }, Error> { + const cached = localPlaylistCache.get(id) + if (!cached) { + return errAsync(new Error('未找到缓存的本地歌单数据,请重新选择文件导入')) + } + return okAsync(cached) + } + + /** + * Check whether parsed playlist is already a Bilibili playlist with BV metadata + * so it can be directly imported without re-matching. + */ + public isDirectBilibiliPlaylist(data: { + playlist: GenericPlaylist + tracks: GenericTrack[] + }): boolean { + if (!data.tracks || data.tracks.length === 0) return false + const allTracksHaveBvid = data.tracks.every((t) => + Boolean(t.bilibili?.bvid?.trim()), + ) + return allTracksHaveBvid + } + + /** + * Parse JSON playlist string (exported from PlaylistOut or raw tracks list) + */ + public parseJsonPlaylist( + jsonStr: string, + fallbackTitle = '本地导入歌单', + ): Result<{ playlist: GenericPlaylist; tracks: GenericTrack[] }, Error> { + try { + const parsed = JSON.parse(jsonStr) + let rawTracks: unknown[] = [] + let playlistMeta: Record = {} + + if (Array.isArray(parsed)) { + rawTracks = parsed + } else if (parsed && typeof parsed === 'object') { + playlistMeta = parsed as Record + if (Array.isArray(parsed.tracks)) { + rawTracks = parsed.tracks + } else if ( + parsed.data?.result?.tracks && + Array.isArray(parsed.data.result.tracks) + ) { + rawTracks = parsed.data.result.tracks + if (parsed.data.result && typeof parsed.data.result === 'object') { + playlistMeta = parsed.data.result + } + } else if ( + parsed.result?.tracks && + Array.isArray(parsed.result.tracks) + ) { + rawTracks = parsed.result.tracks + if (parsed.result && typeof parsed.result === 'object') { + playlistMeta = parsed.result + } + } else if (parsed.data?.tracks && Array.isArray(parsed.data.tracks)) { + rawTracks = parsed.data.tracks + } + } + + if (rawTracks.length === 0) { + return err(new Error('JSON 数据中未包含歌曲列表或列表为空')) + } + + const tracks = this.normalizeTracks(rawTracks) + const playlistId = `local_json_${Date.now()}` + const rawTitle = + (typeof playlistMeta.name === 'string' && playlistMeta.name) || + (typeof playlistMeta.title === 'string' && playlistMeta.title) || + fallbackTitle + const title = decodeHtmlText(rawTitle) || fallbackTitle + const coverUrl = + (typeof playlistMeta.coverUrl === 'string' && playlistMeta.coverUrl) || + (typeof playlistMeta.cover === 'string' && playlistMeta.cover) || + (typeof playlistMeta.picUrl === 'string' && playlistMeta.picUrl) || + '' + const rawDesc = + (typeof playlistMeta.description === 'string' && + playlistMeta.description) || + (typeof playlistMeta.intro === 'string' && playlistMeta.intro) || + (typeof playlistMeta.desc === 'string' && playlistMeta.desc) || + '' + const description = decodeHtmlText(rawDesc) + + let authorName = '' + if ( + typeof playlistMeta.creator === 'string' && + playlistMeta.creator.trim() + ) { + authorName = playlistMeta.creator.trim() + } else if ( + typeof playlistMeta.author === 'string' && + playlistMeta.author.trim() + ) { + authorName = playlistMeta.author.trim() + } else if ( + typeof playlistMeta.creator === 'object' && + playlistMeta.creator + ) { + if ( + 'nickname' in playlistMeta.creator && + typeof playlistMeta.creator.nickname === 'string' + ) { + authorName = playlistMeta.creator.nickname.trim() + } else if ( + 'name' in playlistMeta.creator && + typeof playlistMeta.creator.name === 'string' + ) { + authorName = playlistMeta.creator.name.trim() + } + } else if ( + typeof playlistMeta.author === 'object' && + playlistMeta.author + ) { + if ( + 'name' in playlistMeta.author && + typeof playlistMeta.author.name === 'string' + ) { + authorName = playlistMeta.author.name.trim() + } else if ( + 'nickname' in playlistMeta.author && + typeof playlistMeta.author.nickname === 'string' + ) { + authorName = playlistMeta.author.nickname.trim() + } + } + + authorName = decodeHtmlText(authorName) || '本地导入' + + const createTime = formatPlaylistDate( + playlistMeta.createTime ?? + playlistMeta.ctime ?? + playlistMeta.publishTime ?? + playlistMeta.publishtime ?? + playlistMeta.create_time, + ) + const updateTime = formatPlaylistDate( + playlistMeta.updateTime ?? + playlistMeta.mtime ?? + playlistMeta.update_time, + ) + const tags = extractTags(playlistMeta.tags) + const playCount = + typeof playlistMeta.playCount === 'number' && playlistMeta.playCount > 0 + ? playlistMeta.playCount + : undefined + + let bilibiliExt: BilibiliPlaylistExtension | undefined + if ( + playlistMeta.bilibili && + typeof playlistMeta.bilibili === 'object' && + !Array.isArray(playlistMeta.bilibili) + ) { + const rawBili = playlistMeta.bilibili as Record + const playlistType = normalizeBilibiliPlaylistType(rawBili.playlistType) + const remoteSyncId = + typeof rawBili.remoteSyncId === 'number' && + Number.isFinite(rawBili.remoteSyncId) + ? rawBili.remoteSyncId + : undefined + const creatorMid = + typeof rawBili.creatorMid === 'string' && rawBili.creatorMid.trim() + ? rawBili.creatorMid.trim() + : typeof rawBili.creatorMid === 'number' + ? String(rawBili.creatorMid) + : undefined + if (playlistType || remoteSyncId !== undefined || creatorMid) { + bilibiliExt = { + ...(playlistType ? { playlistType } : {}), + ...(remoteSyncId !== undefined ? { remoteSyncId } : {}), + ...(creatorMid ? { creatorMid } : {}), + } + } + } + + const rawPlatform = + typeof playlistMeta.platform === 'string' && + playlistMeta.platform.trim() + ? playlistMeta.platform.trim().toLowerCase() + : undefined + const platform = + rawPlatform ?? + (bilibiliExt || tracks.every((t) => Boolean(t.bilibili?.bvid)) + ? 'bilibili' + : undefined) + + return ok({ + playlist: { + id: playlistId, + title, + coverUrl, + description, + trackCount: tracks.length, + author: { + name: authorName, + id: bilibiliExt?.creatorMid ?? 0, + }, + createTime, + updateTime, + tags, + playCount, + platform, + ...(bilibiliExt ? { bilibili: bilibiliExt } : {}), + }, + tracks, + }) + } catch (e) { + return err( + new Error( + `解析 JSON 失败: ${e instanceof Error ? e.message : String(e)}`, + ), + ) + } + } + + /** + * Call Playlist Out production API to resolve playlist from query/link + */ + public resolvePlaylist( + query: string, + signal?: AbortSignal, + ): ResultAsync<{ playlist: GenericPlaylist; tracks: GenericTrack[] }, Error> { + const trimmed = query.trim() + if (!trimmed) { + return errAsync(new Error('歌单链接不能为空')) + } + + // Direct JSON text handling + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + const jsonRes = this.parseJsonPlaylist(trimmed, '直接粘贴的 JSON 歌单') + if (jsonRes.isErr()) { + return errAsync(jsonRes.error) + } + return okAsync(jsonRes.value) + } + + // Online JSON URL handling (aligned with MusicFree plugin) + const jsonUrlMatch = trimmed.match( + /^https?:\/\/[^\s]+\.json(?:\?[^\s]*)?$/i, + ) + if (jsonUrlMatch) { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) + if (signal) { + signal.addEventListener('abort', () => controller.abort(), { + once: true, + }) + } + + return ResultAsync.fromPromise( + fetch(jsonUrlMatch[0], { + headers: this.getPlaylistOutHeaders(), + signal: controller.signal, + }) + .then(async (res) => { + clearTimeout(timeoutId) + if (!res.ok) { + throw new Error(`HTTP ${res.status}`) + } + return res.text() + }) + .finally(() => { + clearTimeout(timeoutId) + }), + (e) => { + clearTimeout(timeoutId) + return new Error( + `拉取在线 JSON 歌单失败: ${e instanceof Error ? e.message : String(e)}`, + ) + }, + ).andThen((rawText) => { + const jsonRes = this.parseJsonPlaylist(rawText, '在线 JSON 歌单') + if (jsonRes.isErr()) { + return errAsync(jsonRes.error) + } + return okAsync(jsonRes.value) + }) + } + + const apiUrl = `${API_BASE}/api/v1/resolve?q=${encodeURIComponent(trimmed)}&type=playlist` + const headers: Record = { + ...this.getPlaylistOutHeaders(), + } + + let kugouToken = playlistOutStorage.getKugouToken() + let kugouUserid = playlistOutStorage.getKugouUserid() + + // If token contains combined credentials, auto-parse + if (kugouToken) { + const parsed = parseKugouCredentialsInput(kugouToken) + if (parsed) { + kugouToken = parsed.token + if (!kugouUserid && parsed.userid) { + kugouUserid = parsed.userid + } + } + } + + if (kugouToken) { + headers.Authorization = `Bearer ${kugouToken}` + headers['X-Kugou-Token'] = kugouToken + if (kugouUserid) { + headers['X-Kugou-Userid'] = kugouUserid + } + } + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) + + if (signal) { + signal.addEventListener('abort', () => controller.abort(), { once: true }) + } + + return ResultAsync.fromPromise( + fetch(apiUrl, { + headers, + signal: controller.signal, + }) + .then(async (res) => { + clearTimeout(timeoutId) + if (!res.ok) { + let detail = `HTTP ${res.status}` + try { + const errBody = await res.json() + if (errBody?.error?.message) { + detail = errBody.error.message + } + } catch { + // fallback + } + throw new Error(`解析失败: ${detail}`) + } + return res.json() + }) + .finally(() => { + clearTimeout(timeoutId) + }), + (e) => { + clearTimeout(timeoutId) + if (controller.signal.aborted) { + return new Error('请求 Playlist Out 超时,请检查网络或稍后重试') + } + return new Error( + `请求 Playlist Out API 失败: ${e instanceof Error ? e.message : String(e)}`, + ) + }, + ).andThen((data) => { + if (!data?.success) { + const errMsg = + data?.error?.message || + data?.error?.code || + 'Playlist Out 解析未能返回有效数据' + return errAsync(new Error(`歌单解析失败: ${errMsg}`)) + } + + if (data.meta?.client) { + logger.debug('PlaylistOut attribution acknowledged', { + channel: data.meta.client.channel, + clientId: data.meta.client.id, + clientVersion: data.meta.client.version, + hostPlatform: data.meta.client.hostPlatform, + country: data.meta.server?.country, + region: data.meta.server?.region, + }) + } + + const result = data.data?.result || data.result + if (!result) { + return errAsync(new Error('未在解析结果中找到歌单详情')) + } + + const rawTracks = result.tracks + if (!Array.isArray(rawTracks) || rawTracks.length === 0) { + return errAsync(new Error('解析成功,但该歌单内未找到有效歌曲')) + } + + const tracks = this.normalizeTracks(rawTracks) + const rawPlaylistTitle = + (typeof result.name === 'string' && result.name) || + (typeof result.title === 'string' && result.title) || + '导入外部歌单' + const playlistTitle = decodeHtmlText(rawPlaylistTitle) || '导入外部歌单' + const playlistCoverUrl = + (typeof result.coverUrl === 'string' && result.coverUrl) || + (typeof result.cover === 'string' && result.cover) || + (typeof result.picUrl === 'string' && result.picUrl) || + '' + const platformName = + (typeof data.data?.platform === 'string' && data.data.platform) || + (typeof result.platform === 'string' && result.platform) || + '外部歌单' + const rawPlaylistDesc = + (typeof result.description === 'string' && result.description) || + (typeof result.intro === 'string' && result.intro) || + (typeof result.desc === 'string' && result.desc) || + '' + const playlistDesc = decodeHtmlText(rawPlaylistDesc) + + let authorName = '' + let authorId = 0 + + // 1. String creator / author / owner / user + if (typeof result.creator === 'string' && result.creator.trim()) { + authorName = result.creator.trim() + } else if (typeof result.author === 'string' && result.author.trim()) { + authorName = result.author.trim() + } else if (typeof result.owner === 'string' && result.owner.trim()) { + authorName = result.owner.trim() + } else if (typeof result.user === 'string' && result.user.trim()) { + authorName = result.user.trim() + } + + // 2. Object author / creator + if (!authorName) { + if (typeof result.author === 'object' && result.author) { + if ( + 'name' in result.author && + typeof result.author.name === 'string' + ) { + authorName = result.author.name.trim() + } else if ( + 'nickname' in result.author && + typeof result.author.nickname === 'string' + ) { + authorName = result.author.nickname.trim() + } + if ('id' in result.author && typeof result.author.id === 'number') { + authorId = result.author.id + } + } else if (typeof result.creator === 'object' && result.creator) { + if ( + 'nickname' in result.creator && + typeof result.creator.nickname === 'string' + ) { + authorName = result.creator.nickname.trim() + } else if ( + 'name' in result.creator && + typeof result.creator.name === 'string' + ) { + authorName = result.creator.name.trim() + } + if ( + 'userId' in result.creator && + typeof result.creator.userId === 'number' + ) { + authorId = result.creator.userId + } + } + } + + // 3. Fallback to platform user or "未知创建者" + authorName = + decodeHtmlText(authorName) || + (platformName ? `${platformName}用户` : '未知创建者') + + const createTime = formatPlaylistDate( + result.createTime ?? + result.ctime ?? + result.publishTime ?? + result.publishtime ?? + result.create_time, + ) + const updateTime = formatPlaylistDate( + result.updateTime ?? result.mtime ?? result.update_time, + ) + const tags = extractTags(result.tags) + const playCount = + typeof result.playCount === 'number' && result.playCount > 0 + ? result.playCount + : undefined + + const playlist: GenericPlaylist = { + id: + typeof result.id === 'string' || typeof result.id === 'number' + ? String(result.id) + : String(Date.now()), + title: playlistTitle, + coverUrl: playlistCoverUrl, + description: playlistDesc, + trackCount: + typeof result.trackCount === 'number' + ? result.trackCount + : tracks.length, + author: { + name: authorName, + id: authorId, + }, + createTime, + updateTime, + tags, + playCount, + platform: platformName, + } + + const isPreview = + result.retrieval?.mode === 'preview' || result.isPartialPreview === true + if (isPreview && platformName === 'kugou') { + toast.info( + '当前未配置或酷狗凭据已失效,仅展示前 10 首预览曲目。可在设置中填入酷狗 Token + UID 解锁完整歌单。', + ) + } + + logger.info( + `Successfully resolved playlist "${playlist.title}" with ${tracks.length} tracks via Playlist Out`, + ) + + return okAsync({ playlist, tracks }) + }) + } + + /** + * Normalize raw track objects into standard GenericTrack + */ + private normalizeTracks(rawTracks: unknown[]): GenericTrack[] { + return rawTracks + .map((item): GenericTrack | null => { + if (!item || typeof item !== 'object') return null + const t = item as Record + + const rawTitle = + typeof t.title === 'string' + ? t.title + : typeof t.name === 'string' + ? t.name + : '' + const title = decodeHtmlText(rawTitle) + if (!title) return null + + let artists: string[] = [] + if (Array.isArray(t.artists)) { + artists = t.artists + .map((a) => { + if (typeof a === 'string') return decodeHtmlText(a) + if ( + a && + typeof a === 'object' && + 'name' in a && + typeof a.name === 'string' + ) { + return decodeHtmlText(a.name) + } + return '' + }) + .filter(Boolean) + } else if (typeof t.artist === 'string' && t.artist.trim()) { + artists = [decodeHtmlText(t.artist)] + } else if ( + t.artist && + typeof t.artist === 'object' && + 'name' in t.artist && + typeof t.artist.name === 'string' + ) { + artists = [decodeHtmlText(t.artist.name)] + } + + if (artists.length === 0) { + artists = ['未知歌手'] + } + + let album = '' + if (typeof t.album === 'string') { + album = decodeHtmlText(t.album) + } else if ( + t.album && + typeof t.album === 'object' && + 'name' in t.album && + typeof t.album.name === 'string' + ) { + album = decodeHtmlText(t.album.name) + } + + const rawDuration = Number( + t.durationMs ?? t.duration ?? t.dt ?? t.interval ?? 0, + ) + // Convert seconds to milliseconds if duration is in seconds + const duration = + rawDuration > 0 && rawDuration < 10000 && t.durationMs === undefined + ? Math.round(rawDuration * 1000) + : Math.round(rawDuration) + + const coverUrl = + (typeof t.coverUrl === 'string' && t.coverUrl) || + (typeof t.cover === 'string' && t.cover) || + (typeof t.picUrl === 'string' && t.picUrl) || + (typeof t.artwork === 'string' && t.artwork) || + undefined + + const translatedTitle = + typeof t.translatedTitle === 'string' + ? decodeHtmlText(t.translatedTitle) + : undefined + + const isAvailable = + typeof t.isAvailable === 'boolean' + ? t.isAvailable + : !( + typeof t.status === 'string' && + t.status.toLowerCase() === 'unplayable' + ) + + let bilibili: BilibiliTrackExtension | undefined + const rawBili = + t.bilibili && + typeof t.bilibili === 'object' && + !Array.isArray(t.bilibili) + ? (t.bilibili as Record) + : undefined + + let bvid = + typeof rawBili?.bvid === 'string' && rawBili.bvid.trim() + ? rawBili.bvid.trim() + : typeof t.bvid === 'string' && t.bvid.trim() + ? t.bvid.trim() + : '' + + let cid: number | undefined = + typeof rawBili?.cid === 'number' && + Number.isFinite(rawBili.cid) && + rawBili.cid > 0 + ? rawBili.cid + : typeof t.cid === 'number' && Number.isFinite(t.cid) && t.cid > 0 + ? t.cid + : undefined + + // Tolerant fallback: extract bvid (and optional _cid) from track id or sourceUrl + if (!bvid && typeof t.id === 'string') { + const idMatch = t.id.trim().match(/^(BV[0-9A-Za-z]{10})(?:_(\d+))?$/i) + if (idMatch) { + bvid = idMatch[1] + if (!cid && idMatch[2]) { + const parsedCid = Number(idMatch[2]) + if (Number.isFinite(parsedCid) && parsedCid > 0) { + cid = parsedCid + } + } + } + } + if (!bvid && typeof t.sourceUrl === 'string') { + const urlMatch = t.sourceUrl.match( + /bilibili\.com\/video\/(BV[0-9A-Za-z]{10})/i, + ) + if (urlMatch) { + bvid = urlMatch[1] + } + } + + if (bvid) { + const isMultiPage = + typeof rawBili?.isMultiPage === 'boolean' + ? rawBili.isMultiPage + : Boolean(cid) + const mainTrackTitle = + typeof rawBili?.mainTrackTitle === 'string' && + rawBili.mainTrackTitle.trim() + ? decodeHtmlText(rawBili.mainTrackTitle) + : isMultiPage && album + ? album + : undefined + const upMid = + typeof rawBili?.upMid === 'string' && rawBili.upMid.trim() + ? rawBili.upMid.trim() + : typeof rawBili?.upMid === 'number' && + Number.isFinite(rawBili.upMid) + ? String(rawBili.upMid) + : undefined + const upAvatarUrl = + typeof rawBili?.upAvatarUrl === 'string' && + rawBili.upAvatarUrl.trim() + ? rawBili.upAvatarUrl.trim() + : undefined + const upSignature = + typeof rawBili?.upSignature === 'string' && + rawBili.upSignature.trim() + ? rawBili.upSignature.trim() + : undefined + + bilibili = { + bvid, + ...(cid !== undefined ? { cid } : {}), + isMultiPage, + ...(mainTrackTitle ? { mainTrackTitle } : {}), + ...(upMid ? { upMid } : {}), + ...(upAvatarUrl ? { upAvatarUrl } : {}), + ...(upSignature ? { upSignature } : {}), + } + } + + return { + title, + artists, + album, + duration, + coverUrl, + translatedTitle, + isAvailable, + ...(bilibili ? { bilibili } : {}), + } + }) + .filter((t): t is GenericTrack => t !== null) + } + + /** + * Build Canonical PlaylistOut JSON object from a BBPlayer playlist and its tracks + */ + public buildBilibiliPlaylistJson( + playlist: Playlist, + tracks: Track[], + ): Result { + if (!tracks || tracks.length === 0) { + return err(new Error('该歌单内没有任何曲目,无法导出')) + } + + let totalDurationMs = 0 + const exportedTracks: CanonicalExportTrack[] = tracks.map((track, idx) => { + const durationMs = Math.max(0, Math.round((track.duration ?? 0) * 1000)) + totalDurationMs += durationMs + + const artistName = track.artist?.name?.trim() || '未知UP主' + const rawCover = track.coverUrl?.trim() + const validCoverUrl = + rawCover && /^https?:\/\//i.test(rawCover) ? rawCover : undefined + + if (track.source === 'bilibili') { + const biliMeta = track.bilibiliMetadata + const bvid = biliMeta.bvid + const cid = + typeof biliMeta.cid === 'number' && biliMeta.cid > 0 + ? biliMeta.cid + : undefined + const isMultiPage = biliMeta.isMultiPage + const mainTrackTitle = biliMeta.mainTrackTitle?.trim() || undefined + const isAvailable = biliMeta.videoIsValid + const status = isAvailable ? 'playable' : 'unplayable' + const statusText = isAvailable ? '正常' : '视频已失效' + + const upMid = track.artist?.remoteId?.trim() || undefined + const rawAvatar = track.artist?.avatarUrl?.trim() + const upAvatarUrl = + rawAvatar && /^https?:\/\//i.test(rawAvatar) ? rawAvatar : undefined + const upSignature = track.artist?.signature?.trim() || undefined + + const bilibiliExt: BilibiliTrackExtension = { + bvid, + ...(cid !== undefined ? { cid } : {}), + isMultiPage, + ...(isMultiPage && mainTrackTitle ? { mainTrackTitle } : {}), + ...(upMid ? { upMid } : {}), + ...(upAvatarUrl ? { upAvatarUrl } : {}), + ...(upSignature ? { upSignature } : {}), + } + + const canonicalTrack: CanonicalExportTrack = { + index: idx + 1, + title: track.title, + artist: artistName, + ...(isMultiPage && mainTrackTitle ? { album: mainTrackTitle } : {}), + id: isMultiPage && cid ? `${bvid}_${cid}` : bvid, + ...(durationMs > 0 ? { durationMs } : {}), + sourceUrl: `https://www.bilibili.com/video/${bvid}`, + ...(validCoverUrl ? { coverUrl: validCoverUrl } : {}), + isAvailable, + status, + statusText, + bilibili: bilibiliExt, + } + return canonicalTrack + } + + // Fallback for local audio tracks inside the playlist + const canonicalLocalTrack: CanonicalExportTrack = { + index: idx + 1, + title: track.title, + artist: artistName, + id: String(track.id), + ...(durationMs > 0 ? { durationMs } : {}), + ...(validCoverUrl ? { coverUrl: validCoverUrl } : {}), + isAvailable: true, + status: 'playable', + statusText: '正常', + } + return canonicalLocalTrack + }) + + const exportedAt = + formatDateTimeString(new Date()) ?? new Date().toISOString() + const createTime = formatDateTimeString(playlist.createdAt) + const updateTime = formatDateTimeString(playlist.updatedAt) + const totalDurationSec = Math.round(totalDurationMs / 1000) + const totalDurationText = formatDurationToText(totalDurationSec) + + const creator = playlist.author?.name?.trim() || undefined + const creatorMid = playlist.author?.remoteId?.trim() || undefined + const rawPlaylistCover = playlist.coverUrl?.trim() + const validPlaylistCover = + rawPlaylistCover && /^https?:\/\//i.test(rawPlaylistCover) + ? rawPlaylistCover + : exportedTracks[0]?.coverUrl + + const description = playlist.description?.trim() || undefined + const playlistType: BilibiliPlaylistExtension['playlistType'] = + playlist.type === 'multi_page' ? 'multiPage' : playlist.type + const remoteSyncId = + typeof playlist.remoteSyncId === 'number' && playlist.remoteSyncId > 0 + ? playlist.remoteSyncId + : undefined + + let sourceUrl: string | undefined + if (remoteSyncId !== undefined) { + if (playlist.type === 'favorite') { + sourceUrl = creatorMid + ? `https://space.bilibili.com/${creatorMid}/favlist?fid=${remoteSyncId}` + : `https://www.bilibili.com/medialist/detail/ml${remoteSyncId}` + } else if (playlist.type === 'collection' && creatorMid) { + sourceUrl = `https://space.bilibili.com/${creatorMid}/lists/${remoteSyncId}?type=season` + } else if (playlist.type === 'series' && creatorMid) { + sourceUrl = `https://space.bilibili.com/${creatorMid}/lists/${remoteSyncId}?type=series` + } + } + + const bilibiliPlaylistExt: BilibiliPlaylistExtension = { + playlistType, + ...(remoteSyncId !== undefined ? { remoteSyncId } : {}), + ...(creatorMid ? { creatorMid } : {}), + } + + const exportPayload: CanonicalExportPlaylist = { + generator: 'PlaylistOut', + generatorUrl: 'https://playlistout.lengxiqwq.com', + exportedFrom: 'BBPlayer', + exportedAt, + name: playlist.title, + ...(creator ? { creator } : {}), + ...(validPlaylistCover ? { coverUrl: validPlaylistCover } : {}), + platform: 'bilibili', + id: String(remoteSyncId ?? playlist.id), + ...(sourceUrl ? { sourceUrl } : {}), + trackCount: exportedTracks.length, + loadedTrackCount: exportedTracks.length, + isPartial: false, + ...(createTime ? { createTime } : {}), + ...(updateTime ? { updateTime } : {}), + totalDuration: totalDurationText, + totalDurationMs, + loadedDuration: totalDurationText, + loadedDurationMs: totalDurationMs, + ...(description ? { description } : {}), + bilibili: bilibiliPlaylistExt, + tracks: exportedTracks, + } + + return ok(exportPayload) + } + + /** + * Export a BBPlayer playlist into a Canonical JSON file and invoke the native share sheet + */ + public async exportPlaylistToJsonFile( + playlist: Playlist, + tracks: Track[], + ): Promise> { + const buildRes = this.buildBilibiliPlaylistJson(playlist, tracks) + if (buildRes.isErr()) { + return err(buildRes.error) + } + + try { + const jsonString = JSON.stringify(buildRes.value, null, 2) + const safeTitle = + playlist.title + .replace(/[\\/:*?"<>|\r\n]+/g, '_') + .trim() + .slice(0, 60) || 'bilibili_playlist' + const fileName = `${safeTitle}.json` + const file = new File(Paths.cache, fileName) + file.write(jsonString) + + const canShare = await Sharing.isAvailableAsync() + if (!canShare) { + return err(new Error('当前设备不支持系统文件分享')) + } + + await Sharing.shareAsync(file.uri, { + mimeType: 'application/json', + dialogTitle: `导出歌单:${playlist.title}`, + UTI: 'public.json', + }) + + return ok(file.uri) + } catch (e) { + logger.error('Export playlist to JSON failed', e) + return err( + new Error( + `导出 JSON 文件失败: ${e instanceof Error ? e.message : String(e)}`, + ), + ) + } + } + + /** + * Verify Kugou token and userid against PlaylistOut official auth status endpoint + */ + public async verifyKugouCredentials( + token: string, + userid: string, + ): Promise<{ valid: boolean; message: string }> { + let cleanToken = token.trim() + let cleanUserid = userid.trim() + + const parsedToken = parseKugouCredentialsInput(cleanToken) + const parsedUserid = parseKugouCredentialsInput(cleanUserid) + cleanToken = parsedToken?.token || parsedUserid?.token || cleanToken + cleanUserid = parsedToken?.userid || parsedUserid?.userid || cleanUserid + + if (!cleanToken) { + return { valid: false, message: 'Token 不能为空' } + } + if (!cleanUserid) { + return { + valid: false, + message: + '用户 ID (UID) 不能为空。酷狗官方接口必须同时提供 Token 与 UID 才能解锁完整歌单。', + } + } + + try { + const res = await fetch(`${API_BASE}/api/kugou/auth/status`, { + headers: { + ...this.getPlaylistOutHeaders(), + Authorization: `Bearer ${cleanToken}`, + 'X-Kugou-Token': cleanToken, + 'X-Kugou-Userid': cleanUserid, + }, + }) + + const data = await res.json() + if (res.ok && data?.success && data?.data?.status === 'valid') { + return { valid: true, message: '酷狗账号凭据有效,已成功连接!' } + } + + if (data?.data?.status === 'invalid') { + return { + valid: false, + message: + data.data.message || + '凭据已失效或已过期,请前往网页端重新扫码登录获取。', + } + } + + const errMsg = data?.error?.message || `验证失败 (HTTP ${res.status})` + return { valid: false, message: errMsg } + } catch (e) { + return { + valid: false, + message: `网络请求失败: ${e instanceof Error ? e.message : String(e)}`, + } + } + } +} + +export const playlistOutService = new PlaylistOutService() diff --git a/apps/mobile/src/lib/storage/playlistOutStorage.ts b/apps/mobile/src/lib/storage/playlistOutStorage.ts new file mode 100644 index 000000000..749067b28 --- /dev/null +++ b/apps/mobile/src/lib/storage/playlistOutStorage.ts @@ -0,0 +1,150 @@ +import { storage } from '@/utils/mmkv' + +const KUGOU_TOKEN_KEY = 'playlistout_kugou_token' as const +const KUGOU_USERID_KEY = 'playlistout_kugou_userid' as const +const IMPORT_MODE_KEY = 'playlistout_import_mode' as const + +export type PlaylistOutImportMode = 'smart' | 'legacy' + +export interface KugouCredentials { + token: string + userid: string +} + +/** + * Parses composite credentials input (JSON format or token:userid delimiter) + */ +export function parseKugouCredentialsInput( + input: string, +): KugouCredentials | null { + const trimmed = input.trim() + if (!trimmed) return null + + // JSON format: {"token":"...","userid":"..."} + if (trimmed.startsWith('{') && trimmed.endsWith('}')) { + try { + const obj = JSON.parse(trimmed) + if (obj && typeof obj === 'object') { + return { + token: String( + obj.token || obj.kugou_token || obj.kugouToken || '', + ).trim(), + userid: String( + obj.userid || obj.kugou_userid || obj.kugouUserid || obj.uid || '', + ).trim(), + } + } + } catch { + // fallback + } + } + + // Delimited: token:userid or userid:token (colon, comma, or pipe) + if (trimmed.includes(':') || trimmed.includes(',') || trimmed.includes('|')) { + const parts = trimmed + .split(/[:|,]/) + .map((s) => s.trim()) + .filter(Boolean) + if (parts.length >= 2) { + const p0 = parts[0] + const p1 = parts[1] + if (/^\d{5,12}$/.test(p0) && !/^\d{5,12}$/.test(p1)) { + return { userid: p0, token: p1 } + } else if (/^\d{5,12}$/.test(p1) && !/^\d{5,12}$/.test(p0)) { + return { token: p0, userid: p1 } + } else { + return { token: p0, userid: p1 } + } + } + } + + return null +} + +export const playlistOutStorage = { + getKugouToken(): string { + return storage.getString(KUGOU_TOKEN_KEY) ?? '' + }, + + setKugouToken(token: string): void { + const trimmed = token.trim() + if (!trimmed) { + storage.remove(KUGOU_TOKEN_KEY) + return + } + + const parsed = parseKugouCredentialsInput(trimmed) + if (parsed) { + if (parsed.token) storage.set(KUGOU_TOKEN_KEY, parsed.token) + if (parsed.userid) storage.set(KUGOU_USERID_KEY, parsed.userid) + return + } + + storage.set(KUGOU_TOKEN_KEY, trimmed) + }, + + getKugouUserid(): string { + return storage.getString(KUGOU_USERID_KEY) ?? '' + }, + + setKugouUserid(userid: string): void { + const trimmed = userid.trim() + if (!trimmed) { + storage.remove(KUGOU_USERID_KEY) + } else { + storage.set(KUGOU_USERID_KEY, trimmed) + } + }, + + setKugouCredentials(token: string, userid: string): void { + const cleanToken = token.trim() + const cleanUserid = userid.trim() + + const parsedToken = parseKugouCredentialsInput(cleanToken) + const parsedUserid = parseKugouCredentialsInput(cleanUserid) + + const finalToken = parsedToken?.token || parsedUserid?.token || cleanToken + const finalUserid = + parsedToken?.userid || parsedUserid?.userid || cleanUserid + + if (finalToken) { + storage.set(KUGOU_TOKEN_KEY, finalToken) + } else { + storage.remove(KUGOU_TOKEN_KEY) + } + + if (finalUserid) { + storage.set(KUGOU_USERID_KEY, finalUserid) + } else { + storage.remove(KUGOU_USERID_KEY) + } + }, + + clearKugouCredentials(): void { + storage.remove(KUGOU_TOKEN_KEY) + storage.remove(KUGOU_USERID_KEY) + }, + + isKugouConfigured(): { + configured: boolean + hasToken: boolean + hasUserid: boolean + } { + const token = (storage.getString(KUGOU_TOKEN_KEY) ?? '').trim() + const userid = (storage.getString(KUGOU_USERID_KEY) ?? '').trim() + return { + configured: Boolean(token && userid), + hasToken: Boolean(token), + hasUserid: Boolean(userid), + } + }, + + getImportMode(): PlaylistOutImportMode { + const val = storage.getString(IMPORT_MODE_KEY) + return val === 'legacy' ? 'legacy' : 'smart' + }, + + setImportMode(mode: PlaylistOutImportMode): void { + storage.set(IMPORT_MODE_KEY, mode) + }, +} diff --git a/apps/mobile/src/lib/workers/ExternalPlaylistImportWorker.ts b/apps/mobile/src/lib/workers/ExternalPlaylistImportWorker.ts new file mode 100644 index 000000000..f2467a3b6 --- /dev/null +++ b/apps/mobile/src/lib/workers/ExternalPlaylistImportWorker.ts @@ -0,0 +1,681 @@ +import { isBilibiliRateLimitError } from '@/lib/errors/thirdparty/bilibili' +import { + type ExternalImportJobService, + type ExternalTrackMappingRecord, + externalImportJobService, + type ImportJobSnapshot, + type ImportJobStatus, +} from '@/lib/services/externalImportJobService' +import { + DEFAULT_REQUEST_DELAY_MS, + type ExternalPlaylistService, + externalPlaylistService, + getMatchResultStatus, +} from '@/lib/services/externalPlaylistService' +import type { GenericTrack } from '@/types/external_playlist' +import log from '@/utils/log' + +const logger = log.extend('Workers.ExternalPlaylistImport') + +export type ImportWorkerEventType = + | 'started' + | 'progress' + | 'paused' + | 'rate_limited' + | 'completed' + | 'cancelled' + | 'failed' + | 'updated' + +export interface ImportWorkerEvent { + type: ImportWorkerEventType + jobId: string + snapshot: ImportJobSnapshot | null + etaSeconds: number | null + currentTrackTitle: string | null + error?: Error +} + +export interface ImportWorkerRunOptions { + trackIndexes?: number[] + requestDelayMs?: number + searchTimeoutMs?: number +} + +export interface ImportWorkerRunOutcome { + status: ImportJobStatus + snapshot: ImportJobSnapshot | null + error?: Error +} + +export interface PlaylistRematchOptions { + mode?: 'unfinished' | 'failed' | 'all' + trackIds?: number[] + requestDelayMs?: number + searchTimeoutMs?: number + onProgress?: ( + current: number, + total: number, + mapping: ExternalTrackMappingRecord | null, + ) => void +} + +export interface PlaylistRematchOutcome { + status: 'completed' | 'paused' | 'rate_limited' | 'failed' + processed: number + total: number + matched: number + unmatched: number + error: number + rateLimited: number + errorCause?: Error +} + +export function formatTrackDisplayTitle(track?: GenericTrack | null): string { + if (!track) return '' + const artistText = + Array.isArray(track.artists) && track.artists.length > 0 + ? ` - ${track.artists.join(' / ')}` + : '' + return `${track.title}${artistText}` +} + +export function selectJobIndexesToProcess(snapshot: ImportJobSnapshot): { + indexesToProcess: number[] + didResetAll: boolean +} { + const pendingIndexes: number[] = [] + const errorIndexes: number[] = [] + const unmatchedIndexes: number[] = [] + + for (const item of snapshot.items) { + if (item.status === 'pending' || item.status === 'rate_limited') { + pendingIndexes.push(item.originalIndex) + } else if (item.status === 'error') { + errorIndexes.push(item.originalIndex) + } else if (item.status === 'unmatched') { + unmatchedIndexes.push(item.originalIndex) + } + } + + if (pendingIndexes.length > 0) { + return { + indexesToProcess: [...pendingIndexes, ...errorIndexes].sort( + (a, b) => a - b, + ), + didResetAll: false, + } + } + if (errorIndexes.length > 0) { + return { + indexesToProcess: errorIndexes, + didResetAll: false, + } + } + if (unmatchedIndexes.length > 0) { + return { + indexesToProcess: unmatchedIndexes, + didResetAll: false, + } + } + + return { + indexesToProcess: snapshot.items.map((item) => item.originalIndex), + didResetAll: true, + } +} + +export class ExternalPlaylistImportWorker { + private activeJobId: string | null = null + private activeRematchPlaylistId: number | null = null + private abortController: AbortController | null = null + private activeRunPromise: Promise | null = null + private activeRematchPromise: Promise | null = null + private readonly etaSecondsByJobId = new Map() + private readonly currentTrackByJobId = new Map() + private readonly listeners = new Set<(event: ImportWorkerEvent) => void>() + + constructor( + private readonly jobService: ExternalImportJobService = externalImportJobService, + private readonly playlistService: ExternalPlaylistService = externalPlaylistService, + ) { + this.jobService.setActiveJobChecker((jobId) => this.isJobRunning(jobId)) + this.jobService.setWorkerCancellationHooks({ + cancelJob: (jobId) => this.cancel(jobId), + cancelPlaylist: (playlistId) => this.pausePlaylistRematch(playlistId), + }) + } + + public isJobRunning(jobId: string): boolean { + return ( + this.activeJobId === jobId && + Boolean(this.abortController && !this.abortController.signal.aborted) + ) + } + + public isPlaylistRematchRunning(playlistId: number): boolean { + return ( + this.activeRematchPlaylistId === playlistId && + Boolean(this.abortController && !this.abortController.signal.aborted) + ) + } + + public getActiveJobId(): string | null { + return this.activeJobId + } + + public getActiveRematchPlaylistId(): number | null { + return this.activeRematchPlaylistId + } + + public getEtaSeconds(jobId: string): number | null { + return this.etaSecondsByJobId.get(jobId) ?? null + } + + public getCurrentTrackTitle(jobId: string): string | null { + return this.currentTrackByJobId.get(jobId) ?? null + } + + public subscribe(listener: (event: ImportWorkerEvent) => void): () => void { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + public notifyUpdated( + jobId: string, + type: ImportWorkerEventType = 'updated', + error?: Error, + ): void { + const snapshot = this.jobService.getJobSnapshot(jobId) + const etaSeconds = this.getEtaSeconds(jobId) + const currentTrackTitle = this.getCurrentTrackTitle(jobId) + const event: ImportWorkerEvent = { + type, + jobId, + snapshot, + etaSeconds, + currentTrackTitle, + ...(error ? { error } : {}), + } + for (const listener of this.listeners) { + try { + listener(event) + } catch (e) { + logger.error('Worker listener threw an error:', e) + } + } + } + + /** + * 启动或继续执行指定的 Import Job。 + * - 同一 jobId 已在运行时:幂等返回当前执行 Promise,绝不重复启动; + * - 其他 jobId 正在运行时:自动暂停旧任务并等待其安全退出后再启动新任务,严格保证全局单任务单并发。 + */ + public async start( + jobId: string, + options?: ImportWorkerRunOptions, + ): Promise { + if (this.isJobRunning(jobId) && this.activeRunPromise) { + logger.debug(`Job ${jobId} 已在运行中,忽略重复启动`) + return this.activeRunPromise + } + + if (this.activeRematchPlaylistId !== null && this.activeRematchPromise) { + const previousRematchPromise = this.activeRematchPromise + this.pausePlaylistRematch(this.activeRematchPlaylistId) + try { + await previousRematchPromise + } catch { + // Ignore + } + } + + if ( + this.activeJobId && + this.activeJobId !== jobId && + this.activeRunPromise + ) { + const previousJobId = this.activeJobId + const previousPromise = this.activeRunPromise + logger.info( + `启动新任务 ${jobId} 前,先暂停正在运行的任务 ${previousJobId}`, + ) + this.pause(previousJobId) + try { + await previousPromise + } catch { + // Ignore previous run cancellation + } + } + + let initialSnapshot = this.jobService.getJobSnapshot(jobId) + if (!initialSnapshot || initialSnapshot.items.length === 0) { + return { + status: initialSnapshot?.job.status ?? 'completed', + snapshot: initialSnapshot, + } + } + + let indexesToProcess: number[] + if (options?.trackIndexes && options.trackIndexes.length > 0) { + indexesToProcess = options.trackIndexes + } else { + const selection = selectJobIndexesToProcess(initialSnapshot) + indexesToProcess = selection.indexesToProcess + if (selection.didResetAll) { + initialSnapshot = this.jobService.resetJob(jobId) ?? initialSnapshot + } + } + + const controller = new AbortController() + this.activeJobId = jobId + this.abortController = controller + + const delayMs = options?.requestDelayMs ?? DEFAULT_REQUEST_DELAY_MS + this.etaSecondsByJobId.set( + jobId, + (indexesToProcess.length * delayMs) / 1000, + ) + const firstTrackIndex = indexesToProcess[0] + const firstTrack = + firstTrackIndex !== undefined + ? initialSnapshot.items[firstTrackIndex]?.originalTrack + : undefined + this.currentTrackByJobId.set( + jobId, + firstTrack ? formatTrackDisplayTitle(firstTrack) : null, + ) + + this.jobService.updateJobStatus(jobId, 'running') + this.notifyUpdated(jobId, 'started') + + const tracks = initialSnapshot.items.map((item) => item.originalTrack) + const sessionStartTime = Date.now() + + const runPromise = (async (): Promise => { + try { + const result = await this.playlistService.matchExternalPlaylist( + tracks, + (current, total, matchResult, trackIndex) => { + this.jobService.updateItemResult(jobId, trackIndex, matchResult) + + const nextIndexInQueue = indexesToProcess[current] + const nextTrack = + nextIndexInQueue !== undefined + ? tracks[nextIndexInQueue] + : matchResult.track + this.currentTrackByJobId.set( + jobId, + nextTrack ? formatTrackDisplayTitle(nextTrack) : null, + ) + + if (getMatchResultStatus(matchResult) !== 'rate_limited') { + const elapsed = Date.now() - sessionStartTime + if (current > 0) { + const avgTimePerItem = elapsed / current + const remainingItems = Math.max(total - current, 0) + this.etaSecondsByJobId.set( + jobId, + (avgTimePerItem * remainingItems) / 1000, + ) + } + } + this.notifyUpdated(jobId, 'progress') + }, + { + trackIndexes: indexesToProcess, + signal: controller.signal, + requestDelayMs: options?.requestDelayMs, + searchTimeoutMs: options?.searchTimeoutMs, + }, + ) + + this.etaSecondsByJobId.delete(jobId) + this.currentTrackByJobId.delete(jobId) + + if (result.isErr()) { + const err = result.error + if (isBilibiliRateLimitError(err)) { + const updated = this.jobService.updateJobStatus( + jobId, + 'rate_limited', + ) + this.notifyUpdated(jobId, 'rate_limited', err) + return { + status: 'rate_limited', + snapshot: updated, + error: err, + } + } + + if (err.message === 'Aborted') { + if (this.abortController !== controller) { + // 当前 run 已被取代或已被 pause/cancel 处理,直接返回,避免覆写新 run 状态 + const superseded = this.jobService.getJobSnapshot(jobId) + return { + status: + superseded?.job.status === 'cancelled' + ? 'cancelled' + : 'paused', + snapshot: superseded, + } + } + const latest = this.jobService.getJobSnapshot(jobId) + const statusAfterAbort: ImportJobStatus = + latest?.job.status === 'cancelled' + ? 'cancelled' + : latest?.job.status === 'rate_limited' + ? 'rate_limited' + : 'paused' + const updated = this.jobService.updateJobStatus( + jobId, + statusAfterAbort, + ) + this.notifyUpdated( + jobId, + statusAfterAbort === 'cancelled' ? 'cancelled' : 'paused', + ) + return { + status: statusAfterAbort, + snapshot: updated, + } + } + + if (this.abortController !== controller) { + return { + status: 'failed', + snapshot: this.jobService.getJobSnapshot(jobId), + error: err, + } + } + + const updated = this.jobService.updateJobStatus(jobId, 'failed') + this.notifyUpdated(jobId, 'failed', err) + return { + status: 'failed', + snapshot: updated, + error: err, + } + } + + const latest = this.jobService.getJobSnapshot(jobId) + const finalStatus: ImportJobStatus = + latest && + latest.job.totalCount > 0 && + latest.job.processedCount === latest.job.totalCount + ? 'completed' + : latest && latest.job.errorCount > 0 + ? 'paused' + : 'completed' + const updated = this.jobService.updateJobStatus(jobId, finalStatus) + this.notifyUpdated(jobId, finalStatus) + return { + status: finalStatus, + snapshot: updated, + } + } finally { + this.etaSecondsByJobId.delete(jobId) + this.currentTrackByJobId.delete(jobId) + if (this.abortController === controller) { + this.activeJobId = null + this.abortController = null + this.activeRunPromise = null + } + } + })() + + this.activeRunPromise = runPromise + return runPromise + } + + /** + * 阶段 6:对已保存为正式本地歌单的歌曲发起继续匹配 / 重试失败项 / 重新匹配。 + * 复用同一套串行节流、412/429 风控熔断和原地更新逻辑。 + */ + public async rematchPlaylistTracks( + playlistId: number, + options?: PlaylistRematchOptions, + ): Promise { + if ( + this.isPlaylistRematchRunning(playlistId) && + this.activeRematchPromise + ) { + return this.activeRematchPromise + } + + if (this.activeJobId && this.activeRunPromise) { + const previousJobId = this.activeJobId + const previousJobPromise = this.activeRunPromise + this.pause(previousJobId) + try { + await previousJobPromise + } catch { + // Ignore + } + } + + if ( + this.activeRematchPlaylistId !== null && + this.activeRematchPlaylistId !== playlistId && + this.activeRematchPromise + ) { + const previousRematchPromise = this.activeRematchPromise + this.pausePlaylistRematch(this.activeRematchPlaylistId) + try { + await previousRematchPromise + } catch { + // Ignore + } + } + + const allMappings = this.jobService.getPlaylistTrackMappings(playlistId) + const mode = options?.mode ?? 'unfinished' + const targetTrackIdSet = + options?.trackIds && options.trackIds.length > 0 + ? new Set(options.trackIds) + : null + + const targetMappings = allMappings.filter((m) => { + if (targetTrackIdSet) { + return targetTrackIdSet.has(m.trackId) + } + if (mode === 'failed') { + return m.matchStatus === 'error' || m.matchStatus === 'rate_limited' + } + if (mode === 'all') { + return true + } + return m.matchStatus !== 'matched' || !m.matchedBvid + }) + + if (targetMappings.length === 0) { + return { + status: 'completed', + processed: 0, + total: 0, + matched: 0, + unmatched: 0, + error: 0, + rateLimited: 0, + } + } + + const controller = new AbortController() + this.activeRematchPlaylistId = playlistId + this.abortController = controller + + const tracks = targetMappings.map((m) => m.originalTrack) + let processed = 0 + let matched = 0 + let unmatched = 0 + let errorCount = 0 + let rateLimited = 0 + + const rematchPromise = (async (): Promise => { + try { + const res = await this.playlistService.matchExternalPlaylist( + tracks, + (current, total, matchResult, trackIndex) => { + const mapping = targetMappings[trackIndex] + if (!mapping) return + + const updatedMapping = this.jobService.updateTrackMatchInPlace( + mapping.trackId, + matchResult, + ) + const itemStatus = getMatchResultStatus(matchResult) + if (itemStatus === 'matched') { + matched++ + processed++ + } else if (itemStatus === 'unmatched') { + unmatched++ + processed++ + } else if (itemStatus === 'error') { + errorCount++ + } else if (itemStatus === 'rate_limited') { + rateLimited++ + } + options?.onProgress?.(current, total, updatedMapping) + }, + { + signal: controller.signal, + requestDelayMs: options?.requestDelayMs, + searchTimeoutMs: options?.searchTimeoutMs, + }, + ) + + if (res.isErr()) { + const err = res.error + if (isBilibiliRateLimitError(err)) { + return { + status: 'rate_limited', + processed, + total: targetMappings.length, + matched, + unmatched, + error: errorCount, + rateLimited, + errorCause: err, + } + } + if (err.message === 'Aborted') { + return { + status: 'paused', + processed, + total: targetMappings.length, + matched, + unmatched, + error: errorCount, + rateLimited, + } + } + return { + status: 'failed', + processed, + total: targetMappings.length, + matched, + unmatched, + error: errorCount, + rateLimited, + errorCause: err, + } + } + + return { + status: 'completed', + processed, + total: targetMappings.length, + matched, + unmatched, + error: errorCount, + rateLimited, + } + } finally { + if (this.abortController === controller) { + this.activeRematchPlaylistId = null + this.abortController = null + this.activeRematchPromise = null + } + } + })() + + this.activeRematchPromise = rematchPromise + return rematchPromise + } + + public pausePlaylistRematch(playlistId: number): void { + if (this.activeRematchPlaylistId === playlistId) { + this.abortController?.abort() + this.activeRematchPlaylistId = null + this.abortController = null + this.activeRematchPromise = null + } + } + + public pause(jobId: string): ImportJobSnapshot | null { + const wasRunning = this.activeJobId === jobId + if (wasRunning) { + this.abortController?.abort() + this.activeJobId = null + this.abortController = null + this.activeRunPromise = null + } + this.etaSecondsByJobId.delete(jobId) + this.currentTrackByJobId.delete(jobId) + const updated = this.jobService.updateJobStatus(jobId, 'paused') + this.notifyUpdated(jobId, 'paused') + return updated + } + + public async pauseAndWait(jobId: string): Promise { + const wasRunning = this.activeJobId === jobId + const promise = this.activeRunPromise + const res = this.pause(jobId) + if (wasRunning && promise) { + try { + await promise + } catch { + // Ignore + } + } + return res + } + + public cancel(jobId: string): ImportJobSnapshot | null { + const wasRunning = this.activeJobId === jobId + if (wasRunning) { + this.abortController?.abort() + this.activeJobId = null + this.abortController = null + this.activeRunPromise = null + } + this.etaSecondsByJobId.delete(jobId) + this.currentTrackByJobId.delete(jobId) + const updated = this.jobService.updateJobStatus(jobId, 'cancelled') + this.notifyUpdated(jobId, 'cancelled') + return updated + } + + public recoverStuckJobs(): number { + const activeIds = this.activeJobId + ? new Set([this.activeJobId]) + : new Set() + return this.jobService.recoverStuckJobs(activeIds) + } + + public resetForTesting(): void { + this.abortController?.abort() + this.activeJobId = null + this.activeRematchPlaylistId = null + this.abortController = null + this.activeRunPromise = null + this.activeRematchPromise = null + this.etaSecondsByJobId.clear() + this.currentTrackByJobId.clear() + this.listeners.clear() + } +} + +export const externalPlaylistImportWorker = new ExternalPlaylistImportWorker() diff --git a/apps/mobile/src/types/apis/qqmusic.ts b/apps/mobile/src/types/apis/qqmusic.ts index 1e2f86e77..3b82076a6 100644 --- a/apps/mobile/src/types/apis/qqmusic.ts +++ b/apps/mobile/src/types/apis/qqmusic.ts @@ -85,5 +85,7 @@ export interface QQMusicPlaylist { songnum: number logo: string nickname: string + ctime?: number + tags?: Array<{ id?: number; name?: string }> songlist: QQMusicSong[] } diff --git a/apps/mobile/src/types/external_playlist.ts b/apps/mobile/src/types/external_playlist.ts index dc0bb0388..150d6e516 100644 --- a/apps/mobile/src/types/external_playlist.ts +++ b/apps/mobile/src/types/external_playlist.ts @@ -1,3 +1,26 @@ +export interface BilibiliTrackExtension { + bvid: string + cid?: number | undefined + isMultiPage?: boolean | undefined + mainTrackTitle?: string | undefined + upMid?: string | undefined + upAvatarUrl?: string | undefined + upSignature?: string | undefined +} + +export interface BilibiliPlaylistExtension { + playlistType?: + | 'local' + | 'favorite' + | 'collection' + | 'series' + | 'multiPage' + | 'dynamic' + | undefined + remoteSyncId?: number | undefined + creatorMid?: string | undefined +} + export interface GenericTrack { title: string artists: string[] @@ -5,6 +28,8 @@ export interface GenericTrack { duration: number // milliseconds coverUrl?: string | undefined translatedTitle?: string | undefined + isAvailable?: boolean | undefined + bilibili?: BilibiliTrackExtension | undefined } export interface GenericPlaylist { @@ -17,4 +42,62 @@ export interface GenericPlaylist { name: string id?: string | number } + createTime?: string + updateTime?: string + tags?: string[] + playCount?: number + platform?: string + bilibili?: BilibiliPlaylistExtension +} + +export interface CanonicalExportTrack { + index: number + title: string + artist: string + album?: string + id?: string + isrc?: string + durationMs?: number + releaseDate?: string + trackNumber?: number + discNumber?: number + sourceUrl?: string + playbackUrl?: string + coverUrl?: string + isOriginalSound?: boolean + isVip?: boolean + isAvailable?: boolean + status?: 'playable' | 'unplayable' | 'vip' | 'paid' | 'geo_blocked' + statusText?: string + maxQuality?: string + mvId?: string + mvUrl?: string + bilibili?: BilibiliTrackExtension +} + +export interface CanonicalExportPlaylist { + generator: 'PlaylistOut' + generatorUrl: 'https://playlistout.lengxiqwq.com' + exportedFrom: 'BBPlayer' | 'PlaylistOutWeb' | 'MusicFree' + exportedAt: string + name: string + creator?: string + coverUrl?: string + platform: 'qqmusic' | 'netease' | 'kugou' | 'qishui' | 'bilibili' + id: string + sourceUrl?: string + trackCount: number + loadedTrackCount?: number + isPartial?: boolean + createTime?: string + updateTime?: string + totalDuration?: string + totalDurationMs?: number + loadedDuration?: string + loadedDurationMs?: number + playCount?: number + tags?: string[] + description?: string + bilibili?: BilibiliPlaylistExtension + tracks: CanonicalExportTrack[] } diff --git a/apps/mobile/src/types/storage.ts b/apps/mobile/src/types/storage.ts index 659ae8839..1da92b6c2 100644 --- a/apps/mobile/src/types/storage.ts +++ b/apps/mobile/src/types/storage.ts @@ -34,6 +34,9 @@ export interface AppStorageSchema { webdav_backup_url: string webdav_backup_username: string webdav_backup_directory: string + playlistout_kugou_token: string + playlistout_kugou_userid: string + playlistout_import_mode: 'smart' | 'legacy' } export type StorageKey = keyof AppStorageSchema diff --git a/apps/mobile/src/utils/player.ts b/apps/mobile/src/utils/player.ts index 37e7def88..16d78c023 100644 --- a/apps/mobile/src/utils/player.ts +++ b/apps/mobile/src/utils/player.ts @@ -10,6 +10,7 @@ import type { PlayerError } from '@/lib/errors/player' import { createPlayerError } from '@/lib/errors/player' import type { BilibiliApiError } from '@/lib/errors/thirdparty/bilibili' import { enqueueTracks, startPlayback } from '@/lib/player/playbackSession' +import { externalImportJobService } from '@/lib/services/externalImportJobService' import { trackService } from '@/lib/services/trackService' import type { Track } from '@/types/core/media' @@ -25,37 +26,32 @@ const logger = log.extend('Utils.Player') */ function convertToOrpheusTrack( track: Track, + playlistId?: number, ): Result { - // logger.debug('转换 Track 为 OrpheusTrack', { - // trackId: track.id, - // title: track.title, - // artist: track.artist, - // }) - - const url = getInternalPlayUri(track) + const resolvedTrack = externalImportJobService.resolveTrackForPlayback( + track, + playlistId, + ) + const url = getInternalPlayUri(resolvedTrack) // 如果没有有效的 URL,返回错误 if (!url) { const errorMsg = '没有找到有效的音频流 URL' - logger.warning(errorMsg, track) + logger.warning(errorMsg, resolvedTrack) return err( - createPlayerError('AudioUrlNotFound', `${errorMsg}: ${track.id}`), + createPlayerError('AudioUrlNotFound', `${errorMsg}: ${resolvedTrack.id}`), ) } const orpheusTrack: OrpheusTrack = { - id: track.uniqueKey, + id: resolvedTrack.uniqueKey, url, - title: track.title, - artist: track.artist?.name, - artwork: track.coverUrl ?? undefined, - duration: track.duration, + title: resolvedTrack.title, + artist: resolvedTrack.artist?.name, + artwork: resolvedTrack.coverUrl ?? undefined, + duration: resolvedTrack.duration, } - // logger.debug('OrpheusTrack 转换完成', { - // title: orpheusTrack.title, - // id: orpheusTrack.id, - // }) return ok(orpheusTrack) } @@ -78,7 +74,7 @@ async function reportPlaybackHistory( return } const track = trackResult.value - if (track.source !== 'bilibili') { + if (track.source !== 'bilibili' || !track.bilibiliMetadata.bvid) { return } let cid = track.bilibiliMetadata.cid @@ -155,7 +151,7 @@ async function addToQueue({ try { const orpheusTracks: OrpheusTrack[] = [] for (const track of tracks) { - const result = convertToOrpheusTrack(track) + const result = convertToOrpheusTrack(track, playlistId) if (result.isOk()) orpheusTracks.push(result.value) } const submit = clearQueue ? startPlayback : enqueueTracks @@ -175,6 +171,9 @@ async function addToQueue({ function getInternalPlayUri(track: Track) { if (track.source === 'bilibili') { + if (!track.bilibiliMetadata.bvid) { + return undefined + } return track.bilibiliMetadata.isMultiPage ? `orpheus://bilibili?bvid=${track.bilibiliMetadata.bvid}&cid=${track.bilibiliMetadata.cid}&hires=0&dolby=0` : `orpheus://bilibili?bvid=${track.bilibiliMetadata.bvid}&hires=0&dolby=0` diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index 2cfcf367b..7c546472b 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -19,5 +19,11 @@ ] } }, - "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] + "include": [ + "**/*.ts", + "**/*.tsx", + ".expo/types/**/*.ts", + "expo-env.d.ts", + "../backend/worker-configuration.d.ts" + ] } diff --git a/docs/features/external-playlist-import/README.md b/docs/features/external-playlist-import/README.md new file mode 100644 index 000000000..54f57218e --- /dev/null +++ b/docs/features/external-playlist-import/README.md @@ -0,0 +1,127 @@ +# 外部歌单全流程导入与持久化匹配增强 + +> **PR 提案文档与功能说明** +> 对应分支:`feat/playlist-export` +> 目标分支:`dev` +> 模块:`@bbplayer/mobile`、`@bbplayer/native` + +--- + +## 📌 概述 (Overview) + +本 PR 全面重构并升级了 BBPlayer 的**外部歌单导入与音源匹配能力**。 + +在原实现中,外部歌单导入存在若干核心痛点: +1. **进程绑定与不可中断**:匹配依赖当前页面生命周期,用户退出页面或误触返回即丢失进度,必须死守界面等待。 +2. **缺乏系统级后台能力**:切换到后台或锁屏时无法获知匹配进度,没有常驻通知。 +3. **风控脆弱性**:批量匹配容易触发哔哩哔哩风控接口(HTTP 412 / 429),以往容易直接抛错中断且丢弃已有数据。 +4. **单向元数据绑定**:导入后歌单只能固定展示 B 站视频信息,无法回看外部原曲元数据(原唱、专辑、原版封面)。 +5. **界面重叠 Bug**:底部播放条(NowPlayingBar)会遮挡匹配界面的底部操作栏。 + +本 PR 引入了基于 SQLite 的持久化任务模型、解耦式 Background Worker、原生系统进度通知、风控自适应熔断机制、双模(原曲/B站)视图切换与单曲重匹配体系,彻底解决了上述问题。 + +--- + +## 📸 功能演示与改动对照 (Screenshots & Features) + +### 1. 音乐库常驻草稿卡片 & 系统原生通知(任务与页面解耦) + +用户触发外部歌单导入后,任务由 `ExternalPlaylistImportWorker` 独立调度,**无需停留在导入页面**: +- **音乐库草稿卡片**:导入任务常驻在音乐库播放列表顶部,展示来源平台 Badge、当前实时匹配条目、完成百分比进度条与暂停/恢复/删除控制。 +- **系统原生通知**:调用 Android 原生通知渠道,在通知栏与控制中心常驻显示歌单标题、当前曲目及原生进度条,点击通知可通过 DeepLink 瞬间直达任务界面。 + +| 音乐库草稿进度卡片 (`04_library_draft_jobs.jpg`) | Android 系统原生进度通知 (`05_android_progress_notification.jpg`) | +| :---: | :---: | +| 音乐库草稿进度卡片 | Android 系统原生进度通知 | + +--- + +### 2. 多源外部歌单智能解析与凭证配置 + +- **多平台解析与本地导入**:支持网易云音乐、QQ音乐、酷狗音乐、汽水音乐等链接解析,并支持通过本地导出的 JSON 文件离线导入,兼容旧版直连模式。 +- **酷狗专属凭据配置**:因酷狗官方限制第三方接口仅能获取前 10 首曲目,新增专属设置弹窗配置 Token 与 UID,提供网页端获取引导与一键测试连通性。 + +| 导入多源歌单弹窗 (`02_import_modal_sources.jpg`) | 酷狗凭据设置弹窗 (`03_kugou_settings_modal.jpg`) | +| :---: | :---: | +| 导入多源歌单弹窗 | 酷狗凭据设置弹窗 | + +--- + +### 3. 实时匹配状态与风控熔断机制 + +- **实时状态与耗时估算**:匹配过程中实时展示单曲匹配状态、预计剩余时长估算(如“预计还需 6.1 分钟”),支持单曲手动编辑,支持随时“保存为本地歌单”。 +- **B 站风控智能熔断**:当请求触发 B 站高频限制(412 / 429 / Code -412)时,自动触发熔断暂停,安全持久化当前进度,给出清晰的用户警示,防止高频重试导致封号或封禁 IP。 + +| 实时匹配与进度估算 (`06_matching_page_live_progress.jpg`) | 触发风控熔断自动暂停 (`07_circuit_breaker_rate_limit.jpg`) | +| :---: | :---: | +| 实时匹配与进度估算 | 触发风控熔断自动暂停 | + +--- + +### 4. 歌单原曲/B站“双模视图”与未完成管理 + +- **双模视图一键切换**:歌单详情页操作栏新增 B 站图标切换按钮。点击可在“原曲模式”(原歌曲名、原歌手、原专辑、原封面)与“B 站音源模式”(B站视频标题、UP主、视频封面)间无缝切换,播放与列表同步跟随。 +- **未匹配待办横幅**:保存未完全匹配的歌单后,详情页顶部常驻待办横幅,提供“继续自动匹配”与“查看未完成清单”。 +- **未完成专属清单**:独立路由页面 (`unmatched.tsx`),支持筛选待办歌曲,支持单曲直达手动匹配。 + +| 本地歌单待办横幅与双模切换 (`08_local_playlist_dual_view_and_todo.jpg`) | 未完成歌曲专属清单 (`10_unmatched_tracks_list.jpg`) | +| :---: | :---: | +| 本地歌单待办横幅与双模切换 | 未完成歌曲专属清单 | + +--- + +### 5. 单曲操作与手动匹配(带列表内即时试听) + +- **单曲菜单快捷项**:歌曲操作菜单新增“手动匹配 B 站音源”入口。 +- **搜索与即时试听**:手动匹配弹窗自动预填充歌曲关键词,搜索候选视频列表,**支持直接在弹窗内点击播放按钮试听音源**,确认后一键原位更新音源映射,不改变歌单原始排序与信息。 + +| 单曲操作菜单新增项 (`09_track_menu_manual_match_option.jpg`) | 手动匹配与即时试听弹窗 (`11_manual_search_and_preview_modal.jpg`) | +| :---: | :---: | +| 单曲操作菜单新增项 | 手动匹配与即时试听弹窗 | + +--- + +### 6. 🐛 关键 Bug 修复:NowPlayingBar 遮挡问题 + +- **问题现象**:当正在播放歌曲时,悬浮底栏(`NowPlayingBar`)会遮住匹配界面最下方的“开始匹配”与控制操作按钮。 +- **修复方案**:在 `external-sync.tsx` 中结合 `useCurrentTrack()` 与 `nowPlayingBarStyle` 动态计算安全垫片间距(`nowPlayingOffset`),确保底部操作栏随悬浮播放条自适应抬升。 + +| 修复前:播放条遮挡匹配操作栏 (`01_bug_nowplayingbar_overlap.jpg`) | +| :---: | +| 修复前:播放条遮挡匹配操作栏 | + +--- + +## 🏗️ 架构与数据表设计 (Technical Architecture) + +### 1. 数据库迁移 (Drizzle Migration `0023`) +新增三张核心持久化表结构: +- **`external_import_jobs`**:导入任务主表。记录来源、原始元数据快照、统计指标(总数、已处理、匹配、未匹配、失败、限流数)、任务状态(`pending` / `running` / `paused` / `rate_limited` / `completed_waiting_confirmation` / `completed`)、关联本地歌单 ID。 +- **`external_import_items`**:单曲导入项表。记录单曲指纹(`fingerprint`)、原始序号、原始单曲 JSON 快照、匹配状态与匹配到的视频数据。与主任务级联删除(`ON DELETE CASCADE`)。 +- **`external_track_mappings`**:正式歌单映射表。记录本地 `trackId`、`playlistId` 与外部原曲元数据、B 站音源视频数据的映射关联,支持双模视图转换与就地重匹配。 + +### 2. 状态机与 Background Worker +- **单任务互斥与状态管理**:`ExternalPlaylistImportWorker` 保证同一时间仅有一个任务处于活跃执行态,切换任务时自动保存前序任务状态。 +- **冷启动与僵尸任务恢复**:应用启动或强杀恢复时,`recoverStuckJobs()` 自动将异常残留的 `running` 状态回退至 `paused`。 +- **孤立任务级联清理**:当本地歌单被删除时,自动联动清理关联的导入任务与音源映射,避免历史数据残留。 + +### 3. 主题色调色板缓存 (MMKV + Memory) +- 优化 `usePlaylistBackgroundColor`:对于重复打开的歌单封面,将提取的主题色调色板持久化至 MMKV,避免高开销的原生图像像素提取运算。 + +--- + +## 🧪 校验与测试结果 (Verification) + +本 PR 经过完整的质量验证与自动化测试覆盖: + +1. **类型检查**:`pnpm type-check` 全量通过(0 类型错误)。 +2. **代码风格检查**:`pnpm lint` (`oxlint --type-aware`) 全量通过,扫描 476 个文件,0 错误 0 警告。 +3. **原生构建**:`packages/native` 模块在 Android Gradle 下通过 `compileDebugKotlin` 编译。 +4. **数据库一致性**:`drizzle-kit generate` 验证无多余 schema diff,迁移文件与 snapshot 100% 同步。 +5. **自动化单元测试**:编写了针对 Stage 1 ~ Stage 6 的全量 Jest 测试套件(`jest.external-sync.config.cjs`),**28 个测试用例全部通过**: + - Stage 1: 风控熔断判定与防误关联机制 + - Stage 2: 持久化 Job/Item 模型与 MMKV 兼容迁移 + - Stage 3: 解耦式 ImportWorker 与后台状态转移 + - Stage 4: 原生通知与 Live Activity 载荷生成 + - Stage 5: 音乐库草稿卡片列举与冷启动恢复 + - Stage 6: 正式歌单双模元数据变换与就地单曲重匹配 diff --git a/docs/features/external-playlist-import/images/01_bug_nowplayingbar_overlap.jpg b/docs/features/external-playlist-import/images/01_bug_nowplayingbar_overlap.jpg new file mode 100644 index 000000000..cda969301 Binary files /dev/null and b/docs/features/external-playlist-import/images/01_bug_nowplayingbar_overlap.jpg differ diff --git a/docs/features/external-playlist-import/images/02_import_modal_sources.jpg b/docs/features/external-playlist-import/images/02_import_modal_sources.jpg new file mode 100644 index 000000000..122cb22b3 Binary files /dev/null and b/docs/features/external-playlist-import/images/02_import_modal_sources.jpg differ diff --git a/docs/features/external-playlist-import/images/03_kugou_settings_modal.jpg b/docs/features/external-playlist-import/images/03_kugou_settings_modal.jpg new file mode 100644 index 000000000..5a5c59edc Binary files /dev/null and b/docs/features/external-playlist-import/images/03_kugou_settings_modal.jpg differ diff --git a/docs/features/external-playlist-import/images/04_library_draft_jobs.jpg b/docs/features/external-playlist-import/images/04_library_draft_jobs.jpg new file mode 100644 index 000000000..746c7bd29 Binary files /dev/null and b/docs/features/external-playlist-import/images/04_library_draft_jobs.jpg differ diff --git a/docs/features/external-playlist-import/images/05_android_progress_notification.jpg b/docs/features/external-playlist-import/images/05_android_progress_notification.jpg new file mode 100644 index 000000000..29fa318d5 Binary files /dev/null and b/docs/features/external-playlist-import/images/05_android_progress_notification.jpg differ diff --git a/docs/features/external-playlist-import/images/06_matching_page_live_progress.jpg b/docs/features/external-playlist-import/images/06_matching_page_live_progress.jpg new file mode 100644 index 000000000..af3679730 Binary files /dev/null and b/docs/features/external-playlist-import/images/06_matching_page_live_progress.jpg differ diff --git a/docs/features/external-playlist-import/images/07_circuit_breaker_rate_limit.jpg b/docs/features/external-playlist-import/images/07_circuit_breaker_rate_limit.jpg new file mode 100644 index 000000000..9ac094c4d Binary files /dev/null and b/docs/features/external-playlist-import/images/07_circuit_breaker_rate_limit.jpg differ diff --git a/docs/features/external-playlist-import/images/08_local_playlist_dual_view_and_todo.jpg b/docs/features/external-playlist-import/images/08_local_playlist_dual_view_and_todo.jpg new file mode 100644 index 000000000..78ee05d2f Binary files /dev/null and b/docs/features/external-playlist-import/images/08_local_playlist_dual_view_and_todo.jpg differ diff --git a/docs/features/external-playlist-import/images/09_track_menu_manual_match_option.jpg b/docs/features/external-playlist-import/images/09_track_menu_manual_match_option.jpg new file mode 100644 index 000000000..cea3953fc Binary files /dev/null and b/docs/features/external-playlist-import/images/09_track_menu_manual_match_option.jpg differ diff --git a/docs/features/external-playlist-import/images/10_unmatched_tracks_list.jpg b/docs/features/external-playlist-import/images/10_unmatched_tracks_list.jpg new file mode 100644 index 000000000..b1c2242bf Binary files /dev/null and b/docs/features/external-playlist-import/images/10_unmatched_tracks_list.jpg differ diff --git a/docs/features/external-playlist-import/images/11_manual_search_and_preview_modal.jpg b/docs/features/external-playlist-import/images/11_manual_search_and_preview_modal.jpg new file mode 100644 index 000000000..55f0cf96a Binary files /dev/null and b/docs/features/external-playlist-import/images/11_manual_search_and_preview_modal.jpg differ diff --git a/packages/native/android/src/main/AndroidManifest.xml b/packages/native/android/src/main/AndroidManifest.xml index 0cac7d3d7..2806f8937 100644 --- a/packages/native/android/src/main/AndroidManifest.xml +++ b/packages/native/android/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + diff --git a/packages/native/android/src/main/java/expo/modules/bbplayernative/BBPlayerNativeModule.kt b/packages/native/android/src/main/java/expo/modules/bbplayernative/BBPlayerNativeModule.kt index 43cd600cd..de372bb37 100644 --- a/packages/native/android/src/main/java/expo/modules/bbplayernative/BBPlayerNativeModule.kt +++ b/packages/native/android/src/main/java/expo/modules/bbplayernative/BBPlayerNativeModule.kt @@ -1,6 +1,9 @@ package expo.modules.bbplayernative import android.app.DownloadManager +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent import android.content.ContentValues import android.content.Context import android.content.Intent @@ -11,6 +14,7 @@ import android.os.Build import android.os.Environment import android.provider.MediaStore import android.provider.Settings +import androidx.core.app.NotificationCompat import expo.modules.kotlin.functions.Coroutine import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition @@ -30,6 +34,13 @@ class BBPlayerNativeModule : Module() { override fun definition() = ModuleDefinition { Name("BBPlayerNative") + OnCreate { + val context = appContext.reactContext ?: return@OnCreate + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager + notificationManager?.let { ensureImportNotificationChannel(it) } + } + Constant("apkSigningCertificateSha256") { apkSigningCertificateSha256 } @@ -119,6 +130,18 @@ class BBPlayerNativeModule : Module() { android.net.Uri.fromFile(destFile).toString() } } + + Function("updateImportProgressNotification") { options: ImportProgressNotificationOptions -> + val context = requireContext() + showImportProgressNotification(context, options) + } + + Function("cancelImportProgressNotification") { + val context = requireContext() + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(IMPORT_NOTIFICATION_ID) + } } private fun requireContext(): Context = @@ -336,9 +359,88 @@ class BBPlayerNativeModule : Module() { else -> throw IllegalArgumentException("GIF 输出路径必须是 file URI 或本地路径") } } + + private fun ensureImportNotificationChannel(notificationManager: NotificationManager) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val existing = notificationManager.getNotificationChannel(IMPORT_NOTIFICATION_CHANNEL_ID) + if (existing == null) { + val channel = NotificationChannel( + IMPORT_NOTIFICATION_CHANNEL_ID, + "外部歌单导入进度", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "展示外部歌单自动匹配 Bilibili 视频的后台进度" + setShowBadge(false) + } + notificationManager.createNotificationChannel(channel) + } + } + } + + private fun showImportProgressNotification( + context: Context, + options: ImportProgressNotificationOptions, + ) { + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + ensureImportNotificationChannel(notificationManager) + + val smallIconRes = context.applicationInfo.icon.takeIf { it != 0 } + ?: android.R.drawable.stat_sys_download + + val builder = NotificationCompat.Builder(context, IMPORT_NOTIFICATION_CHANNEL_ID) + .setSmallIcon(smallIconRes) + .setContentTitle(options.title) + .setContentText(options.body) + .setOnlyAlertOnce(true) + .setOngoing(options.ongoing) + .setAutoCancel(!options.ongoing) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + + if (!options.subText.isNullOrBlank()) { + builder.setSubText(options.subText) + builder.setStyle( + NotificationCompat.BigTextStyle() + .bigText("${options.body}\n${options.subText}"), + ) + } + + if (options.ongoing && options.maxProgress > 0) { + builder.setProgress(options.maxProgress, options.progress, false) + } else { + builder.setProgress(0, 0, false) + } + + val contentIntent = if (!options.deepLinkUri.isNullOrBlank()) { + Intent(Intent.ACTION_VIEW, Uri.parse(options.deepLinkUri)).apply { + setPackage(context.packageName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + } else { + context.packageManager.getLaunchIntentForPackage(context.packageName) + } + + if (contentIntent != null) { + val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or + (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0) + val pendingIntent = PendingIntent.getActivity( + context, + IMPORT_NOTIFICATION_ID, + contentIntent, + pendingFlags, + ) + builder.setContentIntent(pendingIntent) + } + + notificationManager.notify(IMPORT_NOTIFICATION_ID, builder.build()) + } + companion object { private const val APK_MIME_TYPE = "application/vnd.android.package-archive" private const val DOWNLOAD_POLL_INTERVAL_MS = 1_000L + private const val IMPORT_NOTIFICATION_CHANNEL_ID = "bbplayer_external_import_progress" + private const val IMPORT_NOTIFICATION_ID = 41023 } } @@ -384,5 +486,36 @@ class UnzipResult : Record { var fileCount: Int = 0 } +@OptimizedRecord +class ImportProgressNotificationOptions : Record { + @Field + var jobId: String = "" + + @Field + var title: String = "" + + @Field + var body: String = "" + + @Field + var subText: String? = null + + @Field + var progress: Int = 0 + + @Field + var maxProgress: Int = 0 + + @Field + var ongoing: Boolean = false + + @Field + var deepLinkUri: String? = null + + @Field + var status: String = "running" +} + + diff --git a/packages/native/src/BBPlayerNative.types.ts b/packages/native/src/BBPlayerNative.types.ts index 806342084..af430ce18 100644 --- a/packages/native/src/BBPlayerNative.types.ts +++ b/packages/native/src/BBPlayerNative.types.ts @@ -45,3 +45,15 @@ export interface StorageEntry { /** 文件占用的字节数;目录固定为 `0`(不统计目录大小)。 */ sizeBytes: number } + +export interface ImportProgressNotificationOptions { + jobId: string + title: string + body: string + subText?: string + progress: number + maxProgress: number + ongoing: boolean + deepLinkUri?: string + status: 'running' | 'paused' | 'rate_limited' | 'completed' | 'failed' +} diff --git a/packages/native/src/BBPlayerNativeModule.ts b/packages/native/src/BBPlayerNativeModule.ts index 372b6aeaa..df4a0ed95 100644 --- a/packages/native/src/BBPlayerNativeModule.ts +++ b/packages/native/src/BBPlayerNativeModule.ts @@ -4,6 +4,7 @@ import { Platform } from 'react-native' import type { AppUpdateDownloadOptions, AppUpdateInstallResult, + ImportProgressNotificationOptions, StorageEntry, StorageUsage, UnzipOptions, @@ -44,6 +45,10 @@ declare class BBPlayerNativeModule extends NativeModule { fileName: string, mimeType: string, ): string | null + updateImportProgressNotification?( + options: ImportProgressNotificationOptions, + ): void + cancelImportProgressNotification?(): void } let nativeModule: BBPlayerNativeModule | null = null @@ -122,3 +127,33 @@ export const exportBackupToDownloads = ( fileName: string, mimeType: string, ) => getNativeModule().exportBackupToDownloads(sourceUri, fileName, mimeType) + +export const updateImportProgressNotification = ( + options: ImportProgressNotificationOptions, +): boolean => { + if (Platform.OS !== 'android') return false + try { + const mod = getNativeModule() + if (typeof mod.updateImportProgressNotification === 'function') { + mod.updateImportProgressNotification(options) + return true + } + return false + } catch { + return false + } +} + +export const cancelImportProgressNotification = (): boolean => { + if (Platform.OS !== 'android') return false + try { + const mod = getNativeModule() + if (typeof mod.cancelImportProgressNotification === 'function') { + mod.cancelImportProgressNotification() + return true + } + return false + } catch { + return false + } +}