Files
musicdl-catalog-sync-suite/Music_Server/docs/superpowers/plans/2026-04-23-music-server-multi-search-implementation.md
T

55 KiB
Raw Blame History

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:

    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:

  • 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:

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 files 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 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:

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:

  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.