# Music_Server Multi-Search Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add `music / artist / sheet` search for `Music_Server`, make artist results open into playable song lists only, and let sheet search include toplists without breaking detail playback. **Architecture:** Extend `catalog_read.db` export with artist read-model tables sourced from `catalog-sync`'s `artists` and `artist_songs`, then add focused `CatalogReader` methods plus `/mf/v1/*` routes for songs, artists, and unified sheet search. Keep MusicFree compatibility in the plugin layer by dispatching search types and teaching `getMusicSheetInfo(...)` to route toplist ids correctly, while the MusicFree client only makes the artist detail tabs plugin-aware so `Music_Server` can hide albums without affecting other plugins. **Tech Stack:** Python 3.11, FastAPI, SQLite, `unittest`, CommonJS plugin JavaScript, React Native, Jest, TypeScript --- ## Repository Roots - `Music_Server`: `D:\source\musicdl-catalog-sync-worktrees\Music_Server` - `MusicFree`: `D:\source\musicdl-catalog-sync-worktrees\MusicFree` ## File Map - `D:\source\musicdl-catalog-sync-worktrees\Music_Server\scripts\export_catalog_read.py` - Builds `catalog_read.db` from `catalogsync.db`. This is where artist tables and artist-track links must be exported. - `D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\services\catalog_reader.py` - Owns read-model queries for tracks, playlists, toplists, and the new artist/sheet search methods. - `D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\routes\mf_catalog.py` - Maps `CatalogReader` rows into MusicFree-compatible `/mf/v1/*` payloads. - `D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\plugin_assets\music_server.js` - Private plugin asset served by `/plugins/music_server.js`. - `D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\plugin_assets\music_server_lan.js` - LAN plugin asset served by `/plugins/music_server_lan.js`. - `D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_export_catalog_read.py` - Export regression tests. - `D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_catalog_reader.py` - Reader-layer unit tests. - `D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_mf_catalog_routes.py` - MusicFree catalog route tests. - `D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_plugin_routes.py` - Served plugin asset smoke assertions. - `D:\source\musicdl-catalog-sync-worktrees\MusicFree\src\pages\artistDetail\components\body.tsx` - Artist detail UI that currently hardcodes `music` and `album` tabs. - `D:\source\musicdl-catalog-sync-worktrees\MusicFree\src\pages\artistDetail\components\body.test.tsx` - New Jest regression test for single-tab artist detail behavior. - `D:\source\musicdl-catalog-sync-worktrees\MusicFree\src\types\artist.d.ts` - Type surface for `supportedArtistTabs`. ### Task 1: Export playable artists into `catalog_read.db` **Files:** - Modify: `D:\source\musicdl-catalog-sync-worktrees\Music_Server\scripts\export_catalog_read.py` - Modify: `D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_export_catalog_read.py` - [ ] **Step 1: Write the failing export test** Extend `ExportCatalogReadTests.setUp()` so the source DB also creates `artists` and `artist_songs`, then append this test to `D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_export_catalog_read.py`: ```python def test_build_catalog_read_exports_playable_artists_and_tracks(self): conn = sqlite3.connect(self._source_db) conn.executemany( """ insert into artists ( id, artist_key, platform, remote_artist_id, name, normalized_name, metadata_json ) values (?, ?, ?, ?, ?, ?, ?) """, [ ( 1, "netease:artist-a", "netease", "artist-a", "Singer A", "singer a", '{"avatar":"https://img/artist-a.jpg","description":"desc-a"}', ), ( 2, "qq:artist-b", "qq", "artist-b", "Singer B", "singer b", '{"avatar":"https://img/artist-b.jpg","description":"desc-b"}', ), ], ) conn.executemany( """ insert into artist_songs (artist_id, song_id, discovered_at) values (?, ?, ?) """, [ (1, 101, "2026-04-23T00:00:00+00:00"), (1, 102, "2026-04-23T00:00:00+00:00"), (2, 103, "2026-04-23T00:00:00+00:00"), ], ) conn.commit() conn.close() export_catalog_read.build_catalog_read( source_db=str(self._source_db), target_db=str(self._target_db), ) conn = sqlite3.connect(self._target_db) artist_rows = conn.execute( """ select artist_id, platform, remote_artist_id, name, avatar_url, description, playable_song_count from catalog_artists order by artist_id """ ).fetchall() artist_track_rows = conn.execute( """ select artist_id, song_id, position from catalog_artist_tracks order by artist_id, position """ ).fetchall() conn.close() self.assertEqual( [ (1, "netease", "artist-a", "Singer A", "https://img/artist-a.jpg", "desc-a", 1), (2, "qq", "artist-b", "Singer B", "https://img/artist-b.jpg", "desc-b", 1), ], artist_rows, ) self.assertEqual([(1, 101, 1), (2, 103, 1)], artist_track_rows) ``` Update the source-schema DDL in the same test file by appending these tables inside `setUp()`: ```python create table artists ( id integer primary key, artist_key text not null unique, platform text not null, remote_artist_id text, name text not null, normalized_name text not null, metadata_json text ); create table artist_songs ( artist_id integer not null, song_id integer not null, discovered_at text ); ``` - [ ] **Step 2: Run the targeted export test and verify it fails** Run: ```powershell python -m unittest tests.test_export_catalog_read.ExportCatalogReadTests.test_build_catalog_read_exports_playable_artists_and_tracks -v ``` Expected: - `ERROR` - message includes `sqlite3.OperationalError: no such table: catalog_artists` - [ ] **Step 3: Implement artist export in `export_catalog_read.py`** In `D:\source\musicdl-catalog-sync-worktrees\Music_Server\scripts\export_catalog_read.py`, add the artist tables to `create_schema(...)`, add small metadata extractors, add `export_artists(...)`, and call it from `build_catalog_read(...)` after `export_tracks(...)`: ```python def create_schema(conn: sqlite3.Connection) -> None: conn.executescript( """ create table catalog_artists ( artist_id integer primary key, artist_key text not null unique, platform text not null, remote_artist_id text, name text not null, normalized_name text not null, avatar_url text, description text, playable_song_count integer not null ); create table catalog_artist_tracks ( artist_id integer not null, song_id integer not null, position integer not null ); create index idx_catalog_artist_tracks_artist on catalog_artist_tracks (artist_id, position); """ ) def _extract_artist_avatar(metadata_json: str | None) -> str | None: if not metadata_json: return None try: payload = json.loads(metadata_json) except json.JSONDecodeError: return None avatar = payload.get("avatar") or payload.get("avatar_url") or payload.get("cover_url") return avatar if isinstance(avatar, str) and avatar else None def _extract_artist_description(metadata_json: str | None) -> str | None: if not metadata_json: return None try: payload = json.loads(metadata_json) except json.JSONDecodeError: return None description = payload.get("description") or payload.get("desc") return description if isinstance(description, str) and description else None def export_artists(source: sqlite3.Connection, target: sqlite3.Connection) -> None: artist_rows = source.execute( """ select a.id as artist_id, a.artist_key, a.platform, a.remote_artist_id, a.name, a.normalized_name, a.metadata_json, count(distinct artist_songs.song_id) as playable_song_count from artists a join artist_songs on artist_songs.artist_id = a.id where exists ( select 1 from file_assets fa join file_locations fl on fl.file_asset_id = fa.id where fa.song_id = artist_songs.song_id and fl.status = 'active' ) group by a.id, a.artist_key, a.platform, a.remote_artist_id, a.name, a.normalized_name, a.metadata_json order by a.id asc """ ).fetchall() target.executemany( """ insert into catalog_artists ( artist_id, artist_key, platform, remote_artist_id, name, normalized_name, avatar_url, description, playable_song_count ) values (?, ?, ?, ?, ?, ?, ?, ?, ?) """, [ ( int(row["artist_id"]), row["artist_key"], row["platform"], row["remote_artist_id"], row["name"], row["normalized_name"], _extract_artist_avatar(row["metadata_json"]), _extract_artist_description(row["metadata_json"]), int(row["playable_song_count"] or 0), ) for row in artist_rows ], ) track_rows = source.execute( """ select a.id as artist_id, s.song_id from artists a join artist_songs s on s.artist_id = a.id join songs songs on songs.id = s.song_id where exists ( select 1 from file_assets fa join file_locations fl on fl.file_asset_id = fa.id where fa.song_id = s.song_id and fl.status = 'active' ) order by a.id asc, lower(songs.name) asc, s.song_id asc """ ).fetchall() positions: dict[int, int] = {} payload: list[tuple[int, int, int]] = [] for row in track_rows: artist_id = int(row["artist_id"]) positions[artist_id] = positions.get(artist_id, 0) + 1 payload.append((artist_id, int(row["song_id"]), positions[artist_id])) target.executemany( """ insert into catalog_artist_tracks (artist_id, song_id, position) values (?, ?, ?) """, payload, ) def build_catalog_read(source_db: str, target_db: str) -> None: ... export_tracks(source, target) export_artists(source, target) export_playlist_tracks(source, target) ... ``` - [ ] **Step 4: Re-run the targeted export test and verify it passes** Run: ```powershell python -m unittest tests.test_export_catalog_read.ExportCatalogReadTests.test_build_catalog_read_exports_playable_artists_and_tracks -v ``` Expected: - `OK` - [ ] **Step 5: Commit** ```powershell git -C 'D:\source\musicdl-catalog-sync-worktrees\Music_Server' add scripts/export_catalog_read.py tests/test_export_catalog_read.py git -C 'D:\source\musicdl-catalog-sync-worktrees\Music_Server' commit -m "feat: export playable artist read model" ``` ### Task 2: Add artist search and artist track readers **Files:** - Modify: `D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\services\catalog_reader.py` - Modify: `D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_catalog_reader.py` - [ ] **Step 1: Write the failing reader test** Append this test to `D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_catalog_reader.py` inside `CatalogReaderTests`: ```python def test_search_artists_get_artist_and_list_artist_tracks(self): conn = sqlite3.connect(self._db_path) conn.executescript( """ create table catalog_artists ( artist_id integer primary key, artist_key text not null unique, platform text not null, remote_artist_id text, name text not null, normalized_name text not null, avatar_url text, description text, playable_song_count integer not null ); create table catalog_artist_tracks ( artist_id integer not null, song_id integer not null, position integer not null ); """ ) conn.executemany( """ insert into catalog_artists ( artist_id, artist_key, platform, remote_artist_id, name, normalized_name, avatar_url, description, playable_song_count ) values (?, ?, ?, ?, ?, ?, ?, ?, ?) """, [ (1, "netease:artist-a", "netease", "artist-a", "Singer A", "singer a", "https://img/a.jpg", "desc-a", 1), (2, "qq:artist-b", "qq", "artist-b", "Singer B", "singer b", "https://img/b.jpg", "desc-b", 2), ], ) conn.executemany( """ insert into catalog_tracks ( song_id, platform, remote_song_id, name, singers, album, cover_url, duration_ms, metadata_json ) values (?, ?, ?, ?, ?, ?, ?, ?, ?) """, [ (101, "netease", "n-101", "Alpha Song", "Singer A", "Album A", "https://img/song-a.jpg", 210000, "{}"), (102, "netease", "n-102", "Blocked Song", "Singer A", "Album A", "https://img/song-b.jpg", 180000, "{}"), (103, "qq", "q-103", "Bravo Song", "Singer B", "Album B", "https://img/song-c.jpg", 220000, "{}"), ], ) conn.executemany( """ insert into catalog_artist_tracks (artist_id, song_id, position) values (?, ?, ?) """, [(1, 101, 1), (1, 102, 2), (2, 103, 1)], ) conn.executemany( """ insert into catalog_track_files ( song_id, quality_label, ext, file_size_bytes, backend_type, backend_name, locator, public_url, status, is_primary ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, [ (101, "super", "flac", 100, "object_storage", "cdn", "101.flac", "https://cdn/101.flac", "active", 1), (102, "standard", "mp3", 90, "object_storage", "cdn", "102.mp3", "https://cdn/102.mp3", "inactive", 1), (103, "super", "flac", 120, "object_storage", "cdn", "103.flac", "https://cdn/103.flac", "active", 1), ], ) conn.commit() conn.close() reader = CatalogReader(db_path=str(self._db_path)) artist_rows = reader.search_artists(query="Singer", page=1, page_size=10) artist = reader.get_artist(artist_id=1) tracks = reader.list_artist_tracks(artist_id=1, page=1, page_size=10) self.assertEqual([1, 2], [row["artist_id"] for row in artist_rows]) self.assertIsNotNone(artist) assert artist is not None self.assertEqual("netease", artist["platform"]) self.assertEqual("Singer A", artist["name"]) self.assertEqual([101], [row["song_id"] for row in tracks]) ``` - [ ] **Step 2: Run the targeted reader test and verify it fails** Run: ```powershell python -m unittest tests.test_catalog_reader.CatalogReaderTests.test_search_artists_get_artist_and_list_artist_tracks -v ``` Expected: - `ERROR` - message includes `AttributeError: 'CatalogReader' object has no attribute 'search_artists'` - [ ] **Step 3: Implement artist reader types and methods** In `D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\services\catalog_reader.py`, add an `ArtistRow` type plus the three artist methods: ```python class ArtistRow(TypedDict): artist_id: int artist_key: str platform: str remote_artist_id: str | None name: str normalized_name: str avatar_url: str | None description: str | None playable_song_count: int class CatalogReader: ... def search_artists(self, query: str, page: int, page_size: int) -> list[ArtistRow]: page, page_size = self._normalize_pagination(page, page_size) term = str(query or "").strip() if not term: return [] offset = (page - 1) * page_size exact_query = term.lower() escaped_query = self._escape_like_term(exact_query) prefix_query = f"{escaped_query}%" like_query = f"%{escaped_query}%" with closing(connect_sqlite(self._db_path)) as conn: rows = conn.execute( """ select artist_id, artist_key, platform, remote_artist_id, name, normalized_name, avatar_url, description, playable_song_count from catalog_artists where playable_song_count > 0 and lower(name) like ? escape '\\' order by case when lower(name) = ? then 0 when lower(name) like ? escape '\\' then 1 when lower(name) like ? escape '\\' then 2 else 9 end, playable_song_count desc, lower(name) asc, artist_id asc limit ? offset ? """, ( like_query, exact_query, prefix_query, like_query, page_size, offset, ), ).fetchall() return [cast(ArtistRow, dict(row)) for row in rows] def get_artist(self, artist_id: int) -> ArtistRow | None: with closing(connect_sqlite(self._db_path)) as conn: row = conn.execute( """ select artist_id, artist_key, platform, remote_artist_id, name, normalized_name, avatar_url, description, playable_song_count from catalog_artists where artist_id = ? """, (artist_id,), ).fetchone() return cast(ArtistRow, dict(row)) if row else None def list_artist_tracks( self, artist_id: int, page: int, page_size: int ) -> list[PlaylistTrackRow]: page, page_size = self._normalize_pagination(page, page_size) offset = (page - 1) * page_size with closing(connect_sqlite(self._db_path)) as conn: rows = conn.execute( """ select t.song_id, t.name, t.singers, t.album, t.cover_url, t.duration_ms from catalog_artist_tracks at join catalog_tracks t on t.song_id = at.song_id where at.artist_id = ? and exists ( select 1 from catalog_track_files f where f.song_id = t.song_id and f.status = 'active' ) order by at.position asc, t.song_id asc limit ? offset ? """, (artist_id, page_size, offset), ).fetchall() return [cast(PlaylistTrackRow, dict(row)) for row in rows] ``` - [ ] **Step 4: Re-run the targeted reader test and verify it passes** Run: ```powershell python -m unittest tests.test_catalog_reader.CatalogReaderTests.test_search_artists_get_artist_and_list_artist_tracks -v ``` Expected: - `OK` - [ ] **Step 5: Commit** ```powershell git -C 'D:\source\musicdl-catalog-sync-worktrees\Music_Server' add src/music_server/services/catalog_reader.py tests/test_catalog_reader.py git -C 'D:\source\musicdl-catalog-sync-worktrees\Music_Server' commit -m "feat: add catalog artist reader methods" ``` ### Task 3: Add unified sheet search to `CatalogReader` **Files:** - Modify: `D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\services\catalog_reader.py` - Modify: `D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_catalog_reader.py` - [ ] **Step 1: Write the failing sheet-search test** Append this test to `D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_catalog_reader.py`: ```python def test_search_sheets_merges_playlists_and_toplists(self): conn = sqlite3.connect(self._db_path) conn.executescript( """ create table catalog_toplists ( toplist_id text primary key, platform text not null, name text not null, description text, cover_url text, play_count integer not null, song_count integer not null, playable_song_count integer not null, group_name text not null ); """ ) self._insert_playlists( [ (1, "netease", "p-1", "Mix Daily", "playlist-desc", "https://img/p1.jpg", 300, 10, 6), (2, "qq", "p-2", "Hidden Mix", "playlist-desc", "https://img/p2.jpg", 200, 10, 0), ] ) conn.executemany( """ insert into catalog_toplists ( toplist_id, platform, name, description, cover_url, play_count, song_count, playable_song_count, group_name ) values (?, ?, ?, ?, ?, ?, ?, ?, ?) """, [ ("qq_top_mix", "qq", "Mix Rank", "top-desc", "https://img/top.jpg", 250, 8, 5, "QQ"), ("qq_top_blocked", "qq", "Mix Blocked", "top-desc", "https://img/top2.jpg", 260, 8, 0, "QQ"), ], ) conn.commit() conn.close() reader = CatalogReader(db_path=str(self._db_path)) rows = reader.search_sheets(query="Mix", page=1, page_size=10) self.assertEqual( [("playlist", "1"), ("toplist", "qq_top_mix")], [(row["item_type"], row["item_id"]) for row in rows], ) self.assertEqual([300, 250], [row["play_count"] for row in rows]) ``` - [ ] **Step 2: Run the targeted sheet-search test and verify it fails** Run: ```powershell python -m unittest tests.test_catalog_reader.CatalogReaderTests.test_search_sheets_merges_playlists_and_toplists -v ``` Expected: - `ERROR` - message includes `AttributeError: 'CatalogReader' object has no attribute 'search_sheets'` - [ ] **Step 3: Implement `search_sheets(...)`** In `D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\services\catalog_reader.py`, add this row type and method: ```python class SheetSearchRow(TypedDict): item_type: str item_id: str platform: str name: str description: str | None cover_url: str | None play_count: int song_count: int playable_song_count: int class CatalogReader: ... def search_sheets(self, query: str, page: int, page_size: int) -> list[SheetSearchRow]: page, page_size = self._normalize_pagination(page, page_size) term = str(query or "").strip() if not term: return [] offset = (page - 1) * page_size exact_query = term.lower() escaped_query = self._escape_like_term(exact_query) prefix_query = f"{escaped_query}%" like_query = f"%{escaped_query}%" with closing(connect_sqlite(self._db_path)) as conn: rows = conn.execute( """ select * from ( select 'playlist' as item_type, cast(playlist_id as text) as item_id, platform, name, description, cover_url, play_count, song_count, playable_song_count from catalog_playlists where playable_song_count > 0 and lower(name) like ? escape '\\' union all select 'toplist' as item_type, toplist_id as item_id, platform, name, description, cover_url, play_count, song_count, playable_song_count from catalog_toplists where playable_song_count > 0 and lower(name) like ? escape '\\' ) sheets order by case when lower(name) = ? then 0 when lower(name) like ? escape '\\' then 1 when lower(name) like ? escape '\\' then 2 else 9 end, play_count desc, case when item_type = 'playlist' then 0 else 1 end, item_id asc limit ? offset ? """, ( like_query, like_query, exact_query, prefix_query, like_query, page_size, offset, ), ).fetchall() return [cast(SheetSearchRow, dict(row)) for row in rows] ``` - [ ] **Step 4: Re-run the targeted sheet-search test and verify it passes** Run: ```powershell python -m unittest tests.test_catalog_reader.CatalogReaderTests.test_search_sheets_merges_playlists_and_toplists -v ``` Expected: - `OK` - [ ] **Step 5: Commit** ```powershell git -C 'D:\source\musicdl-catalog-sync-worktrees\Music_Server' add src/music_server/services/catalog_reader.py tests/test_catalog_reader.py git -C 'D:\source\musicdl-catalog-sync-worktrees\Music_Server' commit -m "feat: add unified sheet search reader" ``` ### Task 4: Expose MusicFree search and artist routes **Files:** - Modify: `D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\routes\mf_catalog.py` - Modify: `D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_mf_catalog_routes.py` - [ ] **Step 1: Write the failing route tests** First, extend `_prepare_playlist_toplist_catalog_db(...)` in `D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_mf_catalog_routes.py` so it also creates and seeds `catalog_artists` and `catalog_artist_tracks`: ```python create table catalog_artists ( artist_id integer primary key, artist_key text not null unique, platform text not null, remote_artist_id text, name text not null, normalized_name text not null, avatar_url text, description text, playable_song_count integer not null ); create table catalog_artist_tracks ( artist_id integer not null, song_id integer not null, position integer not null ); ``` and seed: ```python conn.execute( """ insert into catalog_artists ( artist_id, artist_key, platform, remote_artist_id, name, normalized_name, avatar_url, description, playable_song_count ) values (?, ?, ?, ?, ?, ?, ?, ?, ?) """, (1, "netease:artist-a", "netease", "artist-a", "Singer A", "singer a", "https://img/artist-a.jpg", "artist-desc", 1), ) conn.execute( """ insert into catalog_artist_tracks (artist_id, song_id, position) values (?, ?, ?) """, (1, 1, 1), ) ``` Then append these tests: ```python def test_search_songs_returns_musicfree_shape(self): with tempfile.TemporaryDirectory() as tmpdir: db_path = Path(tmpdir) / "catalog_read.db" player_db_path = Path(tmpdir) / "player.db" self._prepare_playlist_toplist_catalog_db(db_path) with patch.dict( "os.environ", {"CATALOG_DB_PATH": str(db_path), "PLAYER_DB_PATH": str(player_db_path)}, clear=False, ): client = TestClient(create_app()) response = client.get( "/mf/v1/search/songs?q=Playable&page=1&page_size=20", headers=auth_headers(player_db_path), ) self.assertEqual(200, response.status_code) payload = response.json() self.assertTrue(payload["isEnd"]) self.assertEqual(["catalogsync:song:1"], [item["id"] for item in payload["data"]]) def test_search_artists_and_artist_detail_routes_return_musicfree_shape(self): with tempfile.TemporaryDirectory() as tmpdir: db_path = Path(tmpdir) / "catalog_read.db" player_db_path = Path(tmpdir) / "player.db" self._prepare_playlist_toplist_catalog_db(db_path) with patch.dict( "os.environ", {"CATALOG_DB_PATH": str(db_path), "PLAYER_DB_PATH": str(player_db_path)}, clear=False, ): client = TestClient(create_app()) search_response = client.get( "/mf/v1/search/artists?q=Singer&page=1&page_size=20", headers=auth_headers(player_db_path), ) detail_response = client.get( "/mf/v1/artists/1", headers=auth_headers(player_db_path), ) tracks_response = client.get( "/mf/v1/artists/1/tracks?page=1&page_size=20", headers=auth_headers(player_db_path), ) self.assertEqual(200, search_response.status_code) self.assertEqual(200, detail_response.status_code) self.assertEqual(200, tracks_response.status_code) self.assertEqual("catalogsync:artist:1", search_response.json()["data"][0]["id"]) self.assertEqual(["music"], search_response.json()["data"][0]["supportedArtistTabs"]) self.assertEqual("Singer A", detail_response.json()["name"]) self.assertEqual("catalogsync:song:1", tracks_response.json()["musicList"][0]["id"]) def test_search_sheets_returns_playlists_and_toplists(self): with tempfile.TemporaryDirectory() as tmpdir: db_path = Path(tmpdir) / "catalog_read.db" player_db_path = Path(tmpdir) / "player.db" self._prepare_playlist_toplist_catalog_db(db_path) with patch.dict( "os.environ", {"CATALOG_DB_PATH": str(db_path), "PLAYER_DB_PATH": str(player_db_path)}, clear=False, ): client = TestClient(create_app()) response = client.get( "/mf/v1/search/sheets?q=1&page=1&page_size=20", headers=auth_headers(player_db_path), ) self.assertEqual(200, response.status_code) payload = response.json() self.assertEqual( ["catalogsync:playlist:1", "catalogsync:toplist:tl-1"], [item["id"] for item in payload["data"]], ) ``` - [ ] **Step 2: Run the targeted route tests and verify they fail** Run: ```powershell python -m unittest tests.test_mf_catalog_routes.MfCatalogRouteTests.test_search_songs_returns_musicfree_shape tests.test_mf_catalog_routes.MfCatalogRouteTests.test_search_artists_and_artist_detail_routes_return_musicfree_shape tests.test_mf_catalog_routes.MfCatalogRouteTests.test_search_sheets_returns_playlists_and_toplists -v ``` Expected: - `FAIL` or `ERROR` - at least one response has `404 Not Found` - [ ] **Step 3: Implement the new `/mf/v1` catalog routes** In `D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\routes\mf_catalog.py`, update `_to_sheet_item(...)`, add `_to_artist_item(...)`, and add the new search/detail routes: ```python def _to_sheet_item(row: dict) -> dict: if "playlist_id" in row: item_type = "playlist" item_id = row["playlist_id"] elif row.get("item_type") == "playlist": item_type = "playlist" item_id = row["item_id"] else: item_type = "toplist" item_id = row.get("toplist_id") or row["item_id"] return { "id": f"catalogsync:{item_type}:{item_id}", "platform": "catalogsync", "title": row["name"], "coverImg": row["cover_url"] or "", "description": row["description"] or "", "worksNum": row["song_count"], "playableSongCount": row.get("playable_song_count", 0), "playCount": row["play_count"], } def _to_artist_item(row: dict) -> dict: return { "id": f"catalogsync:artist:{row['artist_id']}", "platform": row["platform"], "name": row["name"], "avatar": row.get("avatar_url") or "", "description": row.get("description") or "", "worksNum": row.get("playable_song_count", 0), "supportedArtistTabs": ["music"], } @router.get("/search/songs") def search_songs( q: str = Query(default=""), page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=200), ) -> dict: rows = _reader().search_tracks(query=q, page=page, page_size=page_size) return {"isEnd": len(rows) < page_size, "data": [_to_music_item(row) for row in rows]} @router.get("/search/artists") def search_artists( q: str = Query(default=""), page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=200), ) -> dict: rows = _reader().search_artists(query=q, page=page, page_size=page_size) return {"isEnd": len(rows) < page_size, "data": [_to_artist_item(row) for row in rows]} @router.get("/artists/{artist_id}") def get_artist(artist_id: int) -> dict: row = _reader().get_artist(artist_id=artist_id) if row is None: raise HTTPException(status_code=404, detail="artist not found") return _to_artist_item(row) @router.get("/artists/{artist_id}/tracks") def list_artist_tracks( artist_id: int, page: int = Query(default=1, ge=1), page_size: int = Query(default=60, ge=1, le=200), ) -> dict: rows = _reader().list_artist_tracks(artist_id=artist_id, page=page, page_size=page_size) return {"isEnd": len(rows) < page_size, "musicList": [_to_music_item(row) for row in rows]} @router.get("/search/sheets") def search_sheets( q: str = Query(default=""), page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=200), ) -> dict: rows = _reader().search_sheets(query=q, page=page, page_size=page_size) return {"isEnd": len(rows) < page_size, "data": [_to_sheet_item(row) for row in rows]} ``` - [ ] **Step 4: Re-run the targeted route tests and verify they pass** Run: ```powershell python -m unittest tests.test_mf_catalog_routes.MfCatalogRouteTests.test_search_songs_returns_musicfree_shape tests.test_mf_catalog_routes.MfCatalogRouteTests.test_search_artists_and_artist_detail_routes_return_musicfree_shape tests.test_mf_catalog_routes.MfCatalogRouteTests.test_search_sheets_returns_playlists_and_toplists -v ``` Expected: - `OK` - [ ] **Step 5: Commit** ```powershell git -C 'D:\source\musicdl-catalog-sync-worktrees\Music_Server' add src/music_server/routes/mf_catalog.py tests/test_mf_catalog_routes.py git -C 'D:\source\musicdl-catalog-sync-worktrees\Music_Server' commit -m "feat: expose artist and sheet search routes" ``` ### Task 5: Expand the served Music_Server plugins to support `artist` and `sheet` **Files:** - Modify: `D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\plugin_assets\music_server.js` - Modify: `D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\plugin_assets\music_server_lan.js` - Modify: `D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_plugin_routes.py` - [ ] **Step 1: Write the failing plugin-asset tests** Append these tests to `D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_plugin_routes.py`: ```python def test_private_plugin_asset_exposes_artist_and_sheet_support(self): client = TestClient(create_app()) response = client.get("/plugins/music_server.js") self.assertEqual(200, response.status_code) body = response.text self.assertIn('supportedSearchType: ["music", "artist", "sheet"]', body) self.assertIn("/mf/v1/search/artists", body) self.assertIn("/mf/v1/search/sheets", body) self.assertIn("function getArtistWorks(", body) def test_lan_plugin_asset_exposes_artist_and_sheet_support(self): client = TestClient(create_app()) response = client.get("/plugins/music_server_lan.js") self.assertEqual(200, response.status_code) body = response.text self.assertIn('supportedSearchType: ["music", "artist", "sheet"]', body) self.assertIn("/mf/v1/search/artists", body) self.assertIn("/mf/v1/search/sheets", body) self.assertIn("function getArtistWorks(", body) ``` - [ ] **Step 2: Run the plugin-route tests and verify they fail** Run: ```powershell python -m unittest tests.test_plugin_routes.PluginRouteTests -v ``` Expected: - `FAIL` - assertion mismatch on `supportedSearchType` - [ ] **Step 3: Implement search dispatch, artist mapping, and toplist-aware sheet detail** In `D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\plugin_assets\music_server.js`, add `mapArtistItem(...)`, add `parsePublicItemRef(...)`, replace `search(...)`, replace `getMusicSheetInfo(...)`, add `getArtistWorks(...)`, then mirror the same logic into `music_server_lan.js` while preserving each file’s existing `platform`, `description`, and `srcUrl` metadata. Use these implementations as the source of truth: ```javascript function parsePublicItemRef(publicId) { var raw = toTrimmedString(publicId); var segments = []; if (!raw) { return { kind: "", id: "" }; } segments = raw.split(":"); if (segments.length >= 3 && segments[0] === "catalogsync") { return { kind: toTrimmedString(segments[1]), id: toTrimmedString(segments.slice(2).join(":")), }; } return { kind: "", id: parsePublicId(raw), }; } function mapArtistItem(item) { var id = ""; var name = ""; var worksNum = null; if (!item || typeof item !== "object") { return null; } id = toTrimmedString(item.id || item.artistId || item.artist_id); name = toTrimmedString(item.name || item.title); if (!id && !name) { return null; } worksNum = toNonNegativeInt(item.worksNum || item.musicCount || item.playableSongCount); return { id: id || name, name: name || id, avatar: toTrimmedString(item.avatar || item.avatarUrl || item.coverImg || item.artwork), description: toTrimmedString(item.description || item.desc), worksNum: worksNum !== null ? worksNum : 0, platform: toTrimmedString(item.platform || "catalogsync"), supportedArtistTabs: Array.isArray(item.supportedArtistTabs) ? item.supportedArtistTabs : ["music"], }; } async function search(query, page, type) { var normalizedPage = toPositivePage(page); var endpoint = ""; var payload = null; var rawList = []; var data = []; var i = 0; var mapped = null; var mapper = null; if (type === "music") { endpoint = "/mf/v1/search/songs"; mapper = mapMusicItem; } else if (type === "artist") { endpoint = "/mf/v1/search/artists"; mapper = mapArtistItem; } else if (type === "sheet") { endpoint = "/mf/v1/search/sheets"; mapper = mapSheetItem; } else { return { isEnd: true, data: [] }; } try { payload = await requestGet(endpoint, { q: toTrimmedString(query), page: normalizedPage, page_size: SEARCH_PAGE_SIZE, }); rawList = extractList(payload); for (i = 0; i < rawList.length; i += 1) { mapped = mapper(rawList[i]); if (mapped) { data.push(mapped); } } return { isEnd: resolveIsEnd(payload, normalizedPage, SEARCH_PAGE_SIZE, rawList.length), data: data, }; } catch (_error) { return { isEnd: true, data: [] }; } } async function getMusicSheetInfo(sheetItem, page) { var normalizedPage = toPositivePage(page); var sourceItem = sheetItem && typeof sheetItem === "object" ? sheetItem : {}; var itemRef = parsePublicItemRef(sourceItem.id); var mappedSheetItem = mapSheetItem(sourceItem); var endpointBase = ""; var detail = null; var tracksPayload = null; var rawList = []; var musicList = []; var i = 0; var mapped = null; var result = null; if (!itemRef.id) { return { isEnd: true, sheetItem: normalizedPage === 1 ? mappedSheetItem || sourceItem : sourceItem, musicList: [], }; } endpointBase = itemRef.kind === "toplist" ? "/mf/v1/toplists/" + itemRef.id : "/mf/v1/playlists/" + itemRef.id; try { if (normalizedPage === 1) { try { detail = await requestGet(endpointBase); mappedSheetItem = mapSheetItem(detail) || mappedSheetItem; } catch (_error) { detail = null; } } tracksPayload = await requestGet(endpointBase + "/tracks", { page: normalizedPage, page_size: LIST_PAGE_SIZE, }); rawList = extractList(tracksPayload); for (i = 0; i < rawList.length; i += 1) { mapped = mapMusicItem(rawList[i]); if (mapped) { musicList.push(mapped); } } result = { isEnd: resolveIsEnd( tracksPayload, normalizedPage, LIST_PAGE_SIZE, rawList.length, ), musicList: musicList, }; if (normalizedPage === 1) { result.sheetItem = mappedSheetItem || mapSheetItem(sourceItem) || { id: toTrimmedString(sourceItem.id || itemRef.id), }; } return result; } catch (_error2) { result = { isEnd: true, musicList: [] }; if (normalizedPage === 1) { result.sheetItem = mappedSheetItem || mapSheetItem(sourceItem) || { id: toTrimmedString(sourceItem.id || itemRef.id), }; } return result; } } async function getArtistWorks(artistItem, page, type) { var normalizedPage = toPositivePage(page); var itemRef = parsePublicItemRef(artistItem && artistItem.id); var payload = null; var rawList = []; var data = []; var i = 0; var mapped = null; if (type !== "music" || itemRef.kind !== "artist" || !itemRef.id) { return { isEnd: true, data: [] }; } try { payload = await requestGet("/mf/v1/artists/" + itemRef.id + "/tracks", { page: normalizedPage, page_size: LIST_PAGE_SIZE, }); rawList = extractList(payload); for (i = 0; i < rawList.length; i += 1) { mapped = mapMusicItem(rawList[i]); if (mapped) { data.push(mapped); } } return { isEnd: resolveIsEnd(payload, normalizedPage, LIST_PAGE_SIZE, rawList.length), data: data, }; } catch (_error) { return { isEnd: true, data: [] }; } } ``` Update the exports block in both plugin files to include the new capability: ```javascript module.exports = { ... version: "17010.0.8", supportedSearchType: ["music", "artist", "sheet"], ... search: search, getArtistWorks: getArtistWorks, getMusicSheetInfo: getMusicSheetInfo, ... }; ``` - [ ] **Step 4: Re-run the plugin-route tests and verify they pass** Run: ```powershell python -m unittest tests.test_plugin_routes.PluginRouteTests -v ``` Expected: - `OK` - [ ] **Step 5: Commit** ```powershell git -C 'D:\source\musicdl-catalog-sync-worktrees\Music_Server' add src/music_server/plugin_assets/music_server.js src/music_server/plugin_assets/music_server_lan.js tests/test_plugin_routes.py git -C 'D:\source\musicdl-catalog-sync-worktrees\Music_Server' commit -m "feat: support artist and sheet search in music server plugins" ``` ### Task 6: Make MusicFree artist detail tabs plugin-aware **Files:** - Create: `D:\source\musicdl-catalog-sync-worktrees\MusicFree\src\pages\artistDetail\components\body.test.tsx` - Modify: `D:\source\musicdl-catalog-sync-worktrees\MusicFree\src\pages\artistDetail\components\body.tsx` - Modify: `D:\source\musicdl-catalog-sync-worktrees\MusicFree\src\types\artist.d.ts` - [ ] **Step 1: Write the failing MusicFree regression test** Create `D:\source\musicdl-catalog-sync-worktrees\MusicFree\src\pages\artistDetail\components\body.test.tsx` with this content: ```tsx import React from "react"; import renderer from "react-test-renderer"; import Body from "./body"; let currentArtistItem: any = {}; let capturedRoutes: any[] | null = null; let renderedTabs: string[] = []; jest.mock("@/core/router", () => ({ useParams: () => ({ artistItem: currentArtistItem, }), })); jest.mock("@/core/i18n", () => ({ useI18N: () => ({ t: (key: string) => key, }), })); jest.mock("@/hooks/useColors", () => () => ({ text: "#000", primary: "#f00", })); jest.mock("@/utils/rpx", () => (value: number) => value); jest.mock("jotai", () => ({ useAtomValue: () => ({ music: {}, album: {}, }), })); jest.mock("./resultList", () => (props: any) => { renderedTabs.push(props.tab); return null; }); jest.mock("./content", () => ({ __esModule: true, default: { music: () => null, album: () => null, }, })); jest.mock("react-native-tab-view", () => ({ SceneMap: (map: any) => (props: any) => { const Component = map[props.route.key]; return ; }, TabBar: () => null, TabView: (props: any) => { capturedRoutes = props.navigationState.routes; return null; }, })); describe("ArtistDetail Body", () => { beforeEach(() => { currentArtistItem = {}; capturedRoutes = null; renderedTabs = []; }); it("keeps music and album tabs for plugins without restrictions", () => { renderer.create(); expect(capturedRoutes?.map(route => route.key)).toEqual(["music", "album"]); }); it("renders only the music list when supportedArtistTabs is music only", () => { currentArtistItem = { supportedArtistTabs: ["music"], }; renderer.create(); expect(capturedRoutes).toBeNull(); expect(renderedTabs).toEqual(["music"]); }); }); ``` - [ ] **Step 2: Run the targeted Jest test and verify it fails** Run: ```powershell Set-Location 'D:\source\musicdl-catalog-sync-worktrees\MusicFree' npm test -- --runTestsByPath src/pages/artistDetail/components/body.test.tsx --runInBand ``` Expected: - `FAIL` - the second test fails because `Body` still renders `TabView` with `music` and `album` - [ ] **Step 3: Implement plugin-aware artist tabs** First, fix `D:\source\musicdl-catalog-sync-worktrees\MusicFree\src\types\artist.d.ts` so the tab type is explicit and the new property is typed: ```ts declare namespace IArtist { export type ArtistMediaType = "music" | "album"; export interface IArtistItemBase extends ICommon.IMediaBase { name: string; id: string; fans?: number; description?: string; platform: string; avatar: string; worksNum: number; supportedArtistTabs?: ArtistMediaType[]; } export interface IArtistItem extends IArtistItemBase { musicList: IMusic.IMusicItemBase; albumList: IAlbum.IAlbumItemBase; [k: string]: any; } } ``` Then replace `D:\source\musicdl-catalog-sync-worktrees\MusicFree\src\pages\artistDetail\components\body.tsx` with a plugin-aware route selection: ```tsx import React, { useMemo, useState } from "react"; import { StyleSheet, Text } from "react-native"; import rpx from "@/utils/rpx"; import { SceneMap, TabBar, TabView } from "react-native-tab-view"; import { fontWeightConst } from "@/constants/uiConst"; import ResultList from "./resultList"; import { useAtomValue } from "jotai"; import { queryResultAtom } from "../store/atoms"; import content from "./content"; import useColors from "@/hooks/useColors"; import { useI18N } from "@/core/i18n"; import { useParams } from "@/core/router"; const sceneMap: Record = { album: BodyContentWrapper, music: BodyContentWrapper, }; const allRoutes = [ { key: "music", i18nKey: "common.singleMusic", title: "单曲" }, { key: "album", i18nKey: "common.album", title: "专辑" }, ] as const; export default function Body() { const [index, setIndex] = useState(0); const colors = useColors(); const { t } = useI18N(); const { artistItem } = useParams<"artist-detail">(); const routes = useMemo(() => { const supportedTabs = Array.isArray(artistItem?.supportedArtistTabs) ? artistItem.supportedArtistTabs.filter( (tab): tab is IArtist.ArtistMediaType => tab === "music" || tab === "album", ) : ["music", "album"]; const filtered = allRoutes.filter(route => supportedTabs.includes(route.key), ); return filtered.length ? filtered : allRoutes; }, [artistItem]); if (routes.length === 1) { return ; } return ( ( null} pressColor="transparent" inactiveColor={colors.text} activeColor={colors.primary} renderLabel={({ route, focused, color }) => ( {t(route.i18nKey as any) ?? route.title} )} /> )} renderScene={SceneMap(sceneMap)} onIndexChange={setIndex} initialLayout={{ width: rpx(750) }} /> ); } ``` - [ ] **Step 4: Re-run the targeted Jest test and verify it passes** Run: ```powershell Set-Location 'D:\source\musicdl-catalog-sync-worktrees\MusicFree' npm test -- --runTestsByPath src/pages/artistDetail/components/body.test.tsx --runInBand ``` Expected: - `PASS` - [ ] **Step 5: Commit** ```powershell git -C 'D:\source\musicdl-catalog-sync-worktrees\MusicFree' add src/pages/artistDetail/components/body.tsx src/pages/artistDetail/components/body.test.tsx src/types/artist.d.ts git -C 'D:\source\musicdl-catalog-sync-worktrees\MusicFree' commit -m "feat: make artist detail tabs plugin aware" ``` ### Task 7: Run cross-repo verification and manual smoke checks **Files:** - No file changes - [ ] **Step 1: Run the focused Music_Server test suite** Run: ```powershell Set-Location 'D:\source\musicdl-catalog-sync-worktrees\Music_Server' python -m unittest tests.test_export_catalog_read tests.test_catalog_reader tests.test_mf_catalog_routes tests.test_plugin_routes -v ``` Expected: - all tests `OK` - [ ] **Step 2: Re-run the focused MusicFree Jest test** Run: ```powershell Set-Location 'D:\source\musicdl-catalog-sync-worktrees\MusicFree' npm test -- --runTestsByPath src/pages/artistDetail/components/body.test.tsx --runInBand ``` Expected: - `PASS` - [ ] **Step 3: Smoke-test the served plugin asset** Run: ```powershell Set-Location 'D:\source\musicdl-catalog-sync-worktrees\Music_Server' python -m uvicorn music_server.app:create_app --factory --host 127.0.0.1 --port 18081 ``` In a second shell, verify the plugin asset contains the new search types: ```powershell Invoke-WebRequest http://127.0.0.1:18081/plugins/music_server.js | Select-Object -ExpandProperty Content ``` Expected: - output contains `supportedSearchType: ["music", "artist", "sheet"]` - [ ] **Step 4: Manual MusicFree validation** Use the locally served plugin and verify these flows in the app: 1. Install or refresh `http://127.0.0.1:18081/plugins/music_server.js`. 2. Search a term that returns a song, an artist, and at least one playlist/toplist. 3. In the `歌手` tab, open a result and confirm the page goes straight to a song list with no album tab. 4. Play one song from the artist detail page and confirm playback resolves normally. 5. In the `歌单` tab, open a normal playlist result and confirm detail loads. 6. Open a toplist result returned from the same `歌单` search and confirm detail loads and songs play.