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:
Music_Serversong searchMusic_Serverlocal Range / MIME correctness
The remaining two slices stay as follow-up work after this contract is stable:
- MusicFree pure
Music_Serverplugin conversion catalog-syncartist 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"
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:
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:
-
FAILorERROR -
route
/mf/v1/search/songsdoes not exist yet, so the response is not200 -
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 -
ModuleNotFoundErrorbecausemusic_server.services.local_streamingdoes 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
200full-fileFileResponse -
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"