# 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`: ```python 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: ```powershell 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(...)`: ```python 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: ```powershell python -m unittest tests.test_catalog_reader.CatalogReaderTests.test_search_tracks_prefers_name_match_and_requires_active_file -v ``` Expected: - `OK` - [ ] **Step 5: Commit** ```bash 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" ``` ### Task 2: Expose `GET /mf/v1/search/songs` for MusicFree-compatible 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`: ```python 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: ```powershell 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: ```python @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: ```powershell python -m unittest tests.test_mf_catalog_routes.MfCatalogRouteTests.test_search_songs_returns_musicfree_shape -v ``` Expected: - `OK` - [ ] **Step 5: Commit** ```bash 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`: ```python 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: ```powershell 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`: ```python 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: ```powershell python -m unittest tests.test_local_streaming -v ``` Expected: - `OK` - [ ] **Step 5: Commit** ```bash 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`: ```python 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: ```powershell 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: ```python 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`: ```python 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: ```powershell 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** ```bash 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: ```powershell 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: ```powershell 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: ```powershell 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** ```bash 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" ```