55 KiB
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_ServerMusicFree: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.dbfromcatalogsync.db. This is where artist tables and artist-track links must be exported.
- Builds
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
CatalogReaderrows into MusicFree-compatible/mf/v1/*payloads.
- Maps
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.
- Private plugin asset served by
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.
- LAN plugin asset served by
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
musicandalbumtabs.
- Artist detail UI that currently hardcodes
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.
- Type surface for
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:
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():
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:
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(...):
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:
python -m unittest tests.test_export_catalog_read.ExportCatalogReadTests.test_build_catalog_read_exports_playable_artists_and_tracks -v
Expected:
-
OK -
Step 5: Commit
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:
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:
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:
python -m unittest tests.test_catalog_reader.CatalogReaderTests.test_search_artists_get_artist_and_list_artist_tracks -v
Expected:
-
OK -
Step 5: Commit
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:
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:
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:
python -m unittest tests.test_catalog_reader.CatalogReaderTests.test_search_sheets_merges_playlists_and_toplists -v
Expected:
-
OK -
Step 5: Commit
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:
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:
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:
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:
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:
-
FAILorERROR -
at least one response has
404 Not Found -
Step 3: Implement the new
/mf/v1catalog 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:
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:
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
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:
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:
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:
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:
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:
python -m unittest tests.test_plugin_routes.PluginRouteTests -v
Expected:
-
OK -
Step 5: Commit
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:
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 <Component route={props.route} />;
},
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(<Body />);
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(<Body />);
expect(capturedRoutes).toBeNull();
expect(renderedTabs).toEqual(["music"]);
});
});
- Step 2: Run the targeted Jest test and verify it fails
Run:
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
Bodystill rendersTabViewwithmusicandalbum -
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:
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:
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<string, React.FC> = {
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 <BodyContentWrapper route={routes[0]} />;
}
return (
<TabView
lazy
style={style.wrapper}
navigationState={{ index, routes: routes as any }}
renderTabBar={props => (
<TabBar
{...props}
style={style.transparentColor}
tabStyle={{ width: "auto" }}
renderIndicator={() => null}
pressColor="transparent"
inactiveColor={colors.text}
activeColor={colors.primary}
renderLabel={({ route, focused, color }) => (
<Text
numberOfLines={1}
style={{
width: rpx(160),
fontWeight: focused
? fontWeightConst.bolder
: fontWeightConst.medium,
color,
textAlign: "center",
}}>
{t(route.i18nKey as any) ?? route.title}
</Text>
)}
/>
)}
renderScene={SceneMap(sceneMap)}
onIndexChange={setIndex}
initialLayout={{ width: rpx(750) }}
/>
);
}
- Step 4: Re-run the targeted Jest test and verify it passes
Run:
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
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:
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:
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:
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:
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:
- Install or refresh
http://127.0.0.1:18081/plugins/music_server.js. - Search a term that returns a song, an artist, and at least one playlist/toplist.
- In the
歌手tab, open a result and confirm the page goes straight to a song list with no album tab. - Play one song from the artist detail page and confirm playback resolves normally.
- In the
歌单tab, open a normal playlist result and confirm detail loads. - Open a toplist result returned from the same
歌单search and confirm detail loads and songs play.