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

26 KiB

Music_Server Search And Range Streaming 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_Server song-name search plus real single-range local streaming with correct MIME headers so MusicFree and future clients can search playable songs and seek within local files reliably.

Architecture: Extend CatalogReader with an active-file-aware search_tracks(...) query that prioritizes song-name matches over singer weak matches, then expose it via /mf/v1/search/songs. Keep byte-range parsing and MIME inference in a focused local_streaming service so mf_media.py stays thin and only decides between local streaming and object-storage redirect.

Tech Stack: Python 3.11, FastAPI, sqlite3, unittest


Repository root: D:\source\musicdl-catalog-sync-worktrees\Music_Server

Scope Split

This plan intentionally covers only the first executable slice of the previously deferred feature line:

  1. Music_Server song search
  2. Music_Server local Range / MIME correctness

The remaining two slices stay as follow-up work after this contract is stable:

  1. MusicFree pure Music_Server plugin conversion
  2. catalog-sync artist enhancement and tests

File Structure

  • Create: src/music_server/services/local_streaming.py
  • Create: tests/test_local_streaming.py
  • Modify: src/music_server/services/catalog_reader.py
  • Modify: src/music_server/routes/mf_catalog.py
  • Modify: src/music_server/routes/mf_media.py
  • Modify: tests/test_catalog_reader.py
  • Modify: tests/test_mf_catalog_routes.py
  • Modify: tests/test_mf_media_routes.py

Task 1: Add active-file-aware song search to CatalogReader

Files:

  • Modify: src/music_server/services/catalog_reader.py

  • Modify: tests/test_catalog_reader.py

  • Step 1: Write the failing test

Append this test to tests/test_catalog_reader.py inside CatalogReaderTests and extend setUp() so the same temp DB also creates catalog_tracks and catalog_track_files:

    def test_search_tracks_prefers_name_match_and_requires_active_file(self):
        conn = sqlite3.connect(self._db_path)
        conn.executescript(
            """
            create table catalog_tracks (
                song_id integer primary key,
                platform text not null,
                remote_song_id text not null,
                name text not null,
                singers text,
                album text,
                cover_url text,
                duration_ms integer,
                metadata_json text
            );
            create table catalog_track_files (
                song_id integer not null,
                quality_label text not null,
                ext text not null,
                file_size_bytes integer,
                backend_type text not null,
                backend_name text not null,
                locator text not null,
                public_url text,
                status text not null,
                is_primary integer not null
            );
            """
        )
        conn.executemany(
            """
            insert into catalog_tracks (
                song_id, platform, remote_song_id, name, singers, album, cover_url, duration_ms, metadata_json
            ) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            [
                (1, "netease", "n1", "Moonlight", "Artist A", "A", "https://img/1.jpg", 210000, "{}"),
                (2, "netease", "n2", "Moonlight Demo", "Artist B", "B", "https://img/2.jpg", 180000, "{}"),
                (3, "qq", "q3", "Sunrise", "Moonlight Singer", "C", "https://img/3.jpg", 200000, "{}"),
            ],
        )
        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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            [
                (1, "super", "flac", 100, "object_storage", "cdn", "song-1.flac", "https://cdn/1.flac", "active", 1),
                (2, "super", "flac", 101, "object_storage", "cdn", "song-2.flac", "https://cdn/2.flac", "inactive", 1),
                (3, "standard", "mp3", 99, "object_storage", "cdn", "song-3.mp3", "https://cdn/3.mp3", "active", 1),
            ],
        )
        conn.commit()
        conn.close()

        reader = CatalogReader(db_path=str(self._db_path))
        rows = reader.search_tracks(query="Moonlight", page=1, page_size=10)

        self.assertEqual([1, 3], [row["song_id"] for row in rows])
        self.assertEqual("Moonlight", rows[0]["name"])
        self.assertEqual("Moonlight Singer", rows[1]["singers"])
  • Step 2: Run test to verify it fails

Run:

python -m unittest tests.test_catalog_reader.CatalogReaderTests.test_search_tracks_prefers_name_match_and_requires_active_file -v

Expected:

  • ERROR

  • message includes AttributeError: 'CatalogReader' object has no attribute 'search_tracks'

  • Step 3: Write minimal implementation

In src/music_server/services/catalog_reader.py, add a search row type and search_tracks(...):

class SearchTrackRow(TypedDict):
    song_id: int
    name: str
    singers: str | None
    album: str | None
    cover_url: str | None
    duration_ms: int


    def search_tracks(self, query: str, page: int, page_size: int) -> list[SearchTrackRow]:
        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()
        prefix_query = f"{exact_query}%"
        like_query = f"%{exact_query}%"

        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_tracks t
                where exists (
                    select 1
                    from catalog_track_files f
                    where f.song_id = t.song_id
                      and f.status = 'active'
                )
                  and (
                    lower(t.name) like ?
                    or lower(coalesce(t.singers, '')) like ?
                  )
                order by
                    case
                        when lower(t.name) = ? then 0
                        when lower(t.name) like ? then 1
                        when lower(t.name) like ? then 2
                        when lower(coalesce(t.singers, '')) like ? then 3
                        else 9
                    end,
                    lower(t.name) asc,
                    t.song_id asc
                limit ? offset ?
                """,
                (
                    like_query,
                    like_query,
                    exact_query,
                    prefix_query,
                    like_query,
                    like_query,
                    page_size,
                    offset,
                ),
            ).fetchall()
        return [cast(SearchTrackRow, dict(row)) for row in rows]
  • Step 4: Run test to verify it passes

Run:

python -m unittest tests.test_catalog_reader.CatalogReaderTests.test_search_tracks_prefers_name_match_and_requires_active_file -v

Expected:

  • OK

  • Step 5: Commit

git add src/music_server/services/catalog_reader.py tests/test_catalog_reader.py
git commit -m "feat: add active file aware catalog song search"

Files:

  • Modify: src/music_server/routes/mf_catalog.py

  • Modify: tests/test_mf_catalog_routes.py

  • Step 1: Write the failing test

Append this test to tests/test_mf_catalog_routes.py:

    def test_search_songs_returns_musicfree_shape(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            db_path = Path(tmpdir) / "catalog_read.db"
            conn = sqlite3.connect(db_path)
            conn.executescript(
                """
                create table catalog_tracks (
                    song_id integer primary key,
                    platform text not null,
                    remote_song_id text not null,
                    name text not null,
                    singers text,
                    album text,
                    cover_url text,
                    duration_ms integer,
                    metadata_json text
                );
                create table catalog_track_files (
                    song_id integer not null,
                    quality_label text not null,
                    ext text not null,
                    file_size_bytes integer,
                    backend_type text not null,
                    backend_name text not null,
                    locator text not null,
                    public_url text,
                    status text not null,
                    is_primary integer not null
                );
                """
            )
            conn.execute(
                """
                insert into catalog_tracks (
                    song_id, platform, remote_song_id, name, singers, album, cover_url, duration_ms, metadata_json
                ) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
                """,
                (3476, "netease", "65800", "Moonlight", "Ariana", "Album A", "https://img/3476.jpg", 245000, "{}"),
            )
            conn.execute(
                """
                insert into catalog_track_files (
                    song_id, quality_label, ext, file_size_bytes, backend_type, backend_name, locator, public_url, status, is_primary
                ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                """,
                (3476, "super", "flac", 123456, "object_storage", "cdn", "moonlight.flac", "https://cdn/moonlight.flac", "active", 1),
            )
            conn.commit()
            conn.close()

            with patch.dict("os.environ", {"CATALOG_DB_PATH": str(db_path)}, clear=False):
                client = TestClient(create_app())
                response = client.get(
                    "/mf/v1/search/songs?q=Moonlight&page=1&page_size=20",
                    headers={"Authorization": "Bearer dev-token"},
                )

        self.assertEqual(200, response.status_code)
        payload = response.json()
        self.assertFalse(payload["isEnd"])
        self.assertEqual("catalogsync:song:3476", payload["data"][0]["id"])
        self.assertEqual("Moonlight", payload["data"][0]["title"])
        self.assertEqual("Ariana", payload["data"][0]["artist"])
        self.assertEqual("Album A", payload["data"][0]["album"])
        self.assertEqual(245, payload["data"][0]["duration"])
  • Step 2: Run test to verify it fails

Run:

python -m unittest tests.test_mf_catalog_routes.MfCatalogRouteTests.test_search_songs_returns_musicfree_shape -v

Expected:

  • FAIL or ERROR

  • route /mf/v1/search/songs does not exist yet, so the response is not 200

  • Step 3: Write minimal implementation

In src/music_server/routes/mf_catalog.py, add the route:

@router.get("/search/songs")
def search_songs(
    q: str = Query(..., min_length=1),
    page: int = Query(default=1, ge=1),
    page_size: int = Query(default=20, ge=1, le=100),
) -> dict:
    query = q.strip()
    if not query:
        raise HTTPException(status_code=400, detail="q is required")

    rows = _reader().search_tracks(query=query, page=page, page_size=page_size)
    return {
        "isEnd": len(rows) < page_size,
        "data": [_to_music_item(row) for row in rows],
    }
  • Step 4: Run test to verify it passes

Run:

python -m unittest tests.test_mf_catalog_routes.MfCatalogRouteTests.test_search_songs_returns_musicfree_shape -v

Expected:

  • OK

  • Step 5: Commit

git add src/music_server/routes/mf_catalog.py tests/test_mf_catalog_routes.py
git commit -m "feat: expose musicfree song search endpoint"

Task 3: Add reusable single-range parsing and MIME inference helpers

Files:

  • Create: src/music_server/services/local_streaming.py

  • Create: tests/test_local_streaming.py

  • Step 1: Write the failing test

Create tests/test_local_streaming.py:

import unittest

from music_server.services.local_streaming import (
    RangeNotSatisfiable,
    guess_audio_media_type,
    parse_single_range,
)


class LocalStreamingTests(unittest.TestCase):
    def test_guess_audio_media_type_maps_known_extensions(self):
        self.assertEqual("audio/flac", guess_audio_media_type("track.flac"))
        self.assertEqual("audio/mpeg", guess_audio_media_type("track.mp3"))
        self.assertEqual("audio/mp4", guess_audio_media_type("track.m4a"))
        self.assertEqual("audio/wav", guess_audio_media_type("track.wav"))
        self.assertEqual("audio/ogg", guess_audio_media_type("track.ogg"))
        self.assertEqual("audio/ape", guess_audio_media_type("track.ape"))

    def test_parse_single_range_accepts_open_and_suffix_ranges(self):
        self.assertEqual((2, 5), parse_single_range("bytes=2-5", file_size=10))
        self.assertEqual((6, 9), parse_single_range("bytes=-4", file_size=10))
        self.assertEqual((2, 9), parse_single_range("bytes=2-", file_size=10))
        self.assertIsNone(parse_single_range(None, file_size=10))

    def test_parse_single_range_rejects_invalid_or_multi_ranges(self):
        with self.assertRaises(RangeNotSatisfiable):
            parse_single_range("bytes=99-100", file_size=10)
        with self.assertRaises(RangeNotSatisfiable):
            parse_single_range("bytes=5-2", file_size=10)
        with self.assertRaises(RangeNotSatisfiable):
            parse_single_range("bytes=0-1,4-5", file_size=10)


if __name__ == "__main__":
    unittest.main()
  • Step 2: Run test to verify it fails

Run:

python -m unittest tests.test_local_streaming -v

Expected:

  • ERROR

  • ModuleNotFoundError because music_server.services.local_streaming does not exist yet

  • Step 3: Write minimal implementation

Create src/music_server/services/local_streaming.py:

from pathlib import Path


class RangeNotSatisfiable(ValueError):
    pass


_AUDIO_MIME_BY_SUFFIX = {
    ".flac": "audio/flac",
    ".mp3": "audio/mpeg",
    ".m4a": "audio/mp4",
    ".wav": "audio/wav",
    ".ogg": "audio/ogg",
    ".ape": "audio/ape",
}


def guess_audio_media_type(path_like: str | Path) -> str:
    suffix = Path(str(path_like)).suffix.lower()
    return _AUDIO_MIME_BY_SUFFIX.get(suffix, "application/octet-stream")


def parse_single_range(range_header: str | None, file_size: int) -> tuple[int, int] | None:
    if range_header is None:
        return None

    raw = str(range_header).strip()
    if not raw:
        return None
    if not raw.startswith("bytes="):
        raise RangeNotSatisfiable("unsupported range unit")

    spec = raw[len("bytes="):]
    if "," in spec:
        raise RangeNotSatisfiable("multiple ranges not supported")

    start_text, sep, end_text = spec.partition("-")
    if not sep:
        raise RangeNotSatisfiable("invalid range")

    if start_text == "":
        if not end_text.isdigit():
            raise RangeNotSatisfiable("invalid suffix range")
        suffix_length = int(end_text)
        if suffix_length <= 0:
            raise RangeNotSatisfiable("invalid suffix range")
        start = max(file_size - suffix_length, 0)
        end = file_size - 1
        return (start, end)

    if not start_text.isdigit():
        raise RangeNotSatisfiable("invalid range start")
    start = int(start_text)

    if end_text == "":
        end = file_size - 1
    else:
        if not end_text.isdigit():
            raise RangeNotSatisfiable("invalid range end")
        end = int(end_text)

    if file_size <= 0 or start >= file_size or start > end:
        raise RangeNotSatisfiable("range outside file")

    return (start, min(end, file_size - 1))
  • Step 4: Run test to verify it passes

Run:

python -m unittest tests.test_local_streaming -v

Expected:

  • OK

  • Step 5: Commit

git add src/music_server/services/local_streaming.py tests/test_local_streaming.py
git commit -m "feat: add local stream range and mime helpers"

Task 4: Wire local byte-range streaming into /mf/v1/media/stream/{token}

Files:

  • Modify: src/music_server/routes/mf_media.py

  • Modify: src/music_server/services/local_streaming.py

  • Modify: tests/test_mf_media_routes.py

  • Step 1: Write the failing test

Append these tests to tests/test_mf_media_routes.py:

    def test_media_stream_returns_206_and_content_range_for_local_file(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            db_path = Path(tmpdir) / "catalog_read.db"
            library_root = Path(tmpdir) / "library"
            local_file = library_root / "music" / "netease" / "test.flac"
            local_file.parent.mkdir(parents=True, exist_ok=True)
            local_file.write_bytes(b"flac-bytes")
            self._prepare_catalog_db(
                db_path,
                backend_type="local_fs",
                backend_name="default-local",
                locator="music/netease/test.flac",
                public_url=None,
                file_size_bytes=10,
            )

            with patch.dict(
                "os.environ",
                {
                    "CATALOG_DB_PATH": str(db_path),
                    "PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token",
                    "LOCAL_LIBRARY_ROOT": str(library_root),
                },
                clear=False,
            ):
                client = TestClient(create_app())
                resolve_response = client.post(
                    "/mf/v1/media/resolve",
                    headers={"Authorization": "Bearer dev-token"},
                    json={"song_id": "catalogsync:song:3476", "quality": "super"},
                )
                stream_url = resolve_response.json()["stream"]["url"]
                stream_response = client.get(
                    stream_url,
                    headers={"Range": "bytes=2-5"},
                )

        self.assertEqual(206, stream_response.status_code)
        self.assertEqual("bytes", stream_response.headers.get("accept-ranges"))
        self.assertEqual("bytes 2-5/10", stream_response.headers.get("content-range"))
        self.assertEqual("4", stream_response.headers.get("content-length"))
        self.assertEqual("audio/flac", stream_response.headers.get("content-type"))
        self.assertEqual(b"ac-b", stream_response.content)

    def test_media_stream_returns_416_for_invalid_local_range(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            db_path = Path(tmpdir) / "catalog_read.db"
            library_root = Path(tmpdir) / "library"
            local_file = library_root / "music" / "netease" / "test.flac"
            local_file.parent.mkdir(parents=True, exist_ok=True)
            local_file.write_bytes(b"flac-bytes")
            self._prepare_catalog_db(
                db_path,
                backend_type="local_fs",
                backend_name="default-local",
                locator="music/netease/test.flac",
                public_url=None,
                file_size_bytes=10,
            )

            with patch.dict(
                "os.environ",
                {
                    "CATALOG_DB_PATH": str(db_path),
                    "PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token",
                    "LOCAL_LIBRARY_ROOT": str(library_root),
                },
                clear=False,
            ):
                client = TestClient(create_app())
                resolve_response = client.post(
                    "/mf/v1/media/resolve",
                    headers={"Authorization": "Bearer dev-token"},
                    json={"song_id": "catalogsync:song:3476", "quality": "super"},
                )
                stream_url = resolve_response.json()["stream"]["url"]
                stream_response = client.get(
                    stream_url,
                    headers={"Range": "bytes=99-100"},
                )

        self.assertEqual(416, stream_response.status_code)
        self.assertEqual("bytes */10", stream_response.headers.get("content-range"))
        self.assertEqual("bytes", stream_response.headers.get("accept-ranges"))
  • Step 2: Run test to verify it fails

Run:

python -m unittest tests.test_mf_media_routes.MfMediaRouteTests.test_media_stream_returns_206_and_content_range_for_local_file tests.test_mf_media_routes.MfMediaRouteTests.test_media_stream_returns_416_for_invalid_local_range -v

Expected:

  • first test fails because current route returns 200 full-file FileResponse

  • second test fails because current route does not return 416

  • Step 3: Write minimal implementation

First, extend src/music_server/services/local_streaming.py with a range file iterator:

def iter_file_range(file_path: Path, start: int, end: int, chunk_size: int = 64 * 1024):
    with Path(file_path).open("rb") as handle:
        handle.seek(start)
        remaining = end - start + 1
        while remaining > 0:
            chunk = handle.read(min(chunk_size, remaining))
            if not chunk:
                break
            remaining -= len(chunk)
            yield chunk

Then replace the local-file branch in src/music_server/routes/mf_media.py:

from fastapi import APIRouter, Depends, Header, HTTPException
from fastapi.responses import RedirectResponse, Response, StreamingResponse

from ..services.local_streaming import (
    RangeNotSatisfiable,
    guess_audio_media_type,
    iter_file_range,
    parse_single_range,
)


@stream_router.get("/media/stream/{token}")
def stream_media(token: str, range_header: str | None = Header(default=None, alias="Range")):
    settings = get_settings()
    try:
        parsed = parse_stream_token(secret=settings.access_token, token=token)
    except ValueError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc

    try:
        resolved = MediaResolver(db_path=settings.catalog_db_path).resolve_by_locator(
            song_id=int(parsed["song_id"]),
            locator=str(parsed["locator"]),
        )
    except LookupError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc

    if resolved.get("backend_type") == "local_fs":
        file_path = _resolve_local_stream_path(str(resolved["locator"]))
        file_size = file_path.stat().st_size
        media_type = guess_audio_media_type(file_path)

        try:
            byte_window = parse_single_range(range_header, file_size=file_size)
        except RangeNotSatisfiable:
            return Response(
                status_code=416,
                headers={
                    "Accept-Ranges": "bytes",
                    "Content-Range": f"bytes */{file_size}",
                },
            )

        if byte_window is None:
            return StreamingResponse(
                iter_file_range(file_path, 0, file_size - 1),
                media_type=media_type,
                headers={
                    "Accept-Ranges": "bytes",
                    "Content-Length": str(file_size),
                },
            )

        start, end = byte_window
        return StreamingResponse(
            iter_file_range(file_path, start, end),
            status_code=206,
            media_type=media_type,
            headers={
                "Accept-Ranges": "bytes",
                "Content-Range": f"bytes {start}-{end}/{file_size}",
                "Content-Length": str(end - start + 1),
            },
        )

    public_url = resolved.get("public_url")
    if not public_url:
        raise HTTPException(status_code=404, detail="public stream url not found")
    return RedirectResponse(url=str(public_url), status_code=307)
  • Step 4: Run test to verify it passes

Run:

python -m unittest tests.test_mf_media_routes.MfMediaRouteTests.test_media_stream_returns_206_and_content_range_for_local_file tests.test_mf_media_routes.MfMediaRouteTests.test_media_stream_returns_416_for_invalid_local_range -v

Expected:

  • OK

  • Step 5: Commit

git add src/music_server/routes/mf_media.py src/music_server/services/local_streaming.py tests/test_mf_media_routes.py
git commit -m "feat: add range aware local media streaming"

Task 5: Run the focused regression suite for the new contract

Files:

  • Modify: none

  • Test: tests/test_catalog_reader.py

  • Test: tests/test_mf_catalog_routes.py

  • Test: tests/test_local_streaming.py

  • Test: tests/test_mf_media_routes.py

  • Step 1: Run the search-related tests

Run:

python -m unittest tests.test_catalog_reader tests.test_mf_catalog_routes -v

Expected:

  • all catalog-reader and catalog-route tests pass

  • Step 2: Run the range/MIME tests

Run:

python -m unittest tests.test_local_streaming tests.test_mf_media_routes -v

Expected:

  • all local streaming and media route tests pass

  • Step 3: Run the combined focused suite

Run:

python -m unittest tests.test_catalog_reader tests.test_mf_catalog_routes tests.test_local_streaming tests.test_mf_media_routes -v

Expected:

  • OK

  • no search-route regressions

  • no media-route regressions

  • Step 4: Commit

git add src/music_server/services/catalog_reader.py src/music_server/routes/mf_catalog.py src/music_server/services/local_streaming.py src/music_server/routes/mf_media.py tests/test_catalog_reader.py tests/test_mf_catalog_routes.py tests/test_local_streaming.py tests/test_mf_media_routes.py
git commit -m "test: lock music server search and range streaming behavior"