Initial import: Music_Server, MusicFree, catalog-sync
This commit is contained in:
+1045
File diff suppressed because it is too large
Load Diff
+774
@@ -0,0 +1,774 @@
|
||||
# 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"
|
||||
```
|
||||
+1406
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,598 @@
|
||||
# MusicFree Catalogsync Plugin 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:** Build a thin MusicFree plugin that talks only to the public music service, loads recommended playlists and toplists, paginates playlist details, and resolves playable tracks via `/mf/v1/media/resolve`.
|
||||
|
||||
**Architecture:** Keep the plugin as a single-distribution JavaScript artifact with a couple of small helper modules for ID parsing and HTTP calls. The plugin should not know anything about SQLite, NAS paths, or multi-platform fallback logic; it should translate MusicFree method calls into HTTP requests and map the service response into MusicFree's expected object shape.
|
||||
|
||||
**Tech Stack:** JavaScript, CommonJS, Axios, Node.js built-in test runner
|
||||
|
||||
---
|
||||
|
||||
Repository root: `D:\source\musicdl-catalog-sync-worktrees\Music_Server\integrations\musicfree-plugin`
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create: `package.json`
|
||||
- Create: `src/http.js`
|
||||
- Create: `src/ids.js`
|
||||
- Create: `src/catalogsync.plugin.js`
|
||||
- Create: `dist/catalogsync_musicfree.js`
|
||||
- Create: `tests/plugin.test.cjs`
|
||||
|
||||
### Task 1: Scaffold the plugin metadata, config variables, and request helper
|
||||
|
||||
**Files:**
|
||||
- Create: `package.json`
|
||||
- Create: `src/http.js`
|
||||
- Create: `src/ids.js`
|
||||
- Create: `src/catalogsync.plugin.js`
|
||||
- Test: `tests/plugin.test.cjs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```javascript
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const plugin = require("../src/catalogsync.plugin");
|
||||
|
||||
test("plugin exposes metadata and user variables", () => {
|
||||
assert.equal(plugin.platform, "catalogsync");
|
||||
assert.deepEqual(
|
||||
plugin.userVariables.map((item) => item.key),
|
||||
["apiBase", "accessToken"],
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test tests/plugin.test.cjs`
|
||||
Expected: `ERR_MODULE_NOT_FOUND` or `Cannot find module '../src/catalogsync.plugin'`
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
`package.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "musicfree-catalogsync-plugin",
|
||||
"version": "0.1.0",
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"test": "node --test tests/plugin.test.cjs",
|
||||
"build": "node -e \"require('fs').copyFileSync('src/catalogsync.plugin.js', 'dist/catalogsync_musicfree.js')\""
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.7"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`src/http.js`
|
||||
|
||||
```javascript
|
||||
const axios = require("axios");
|
||||
|
||||
function createClient(apiBase, accessToken) {
|
||||
return axios.create({
|
||||
baseURL: String(apiBase || "").replace(/\/+$/, ""),
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken || ""}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { createClient };
|
||||
```
|
||||
|
||||
`src/ids.js`
|
||||
|
||||
```javascript
|
||||
function parsePublicId(publicId) {
|
||||
return String(publicId || "").split(":").pop();
|
||||
}
|
||||
|
||||
module.exports = { parsePublicId };
|
||||
```
|
||||
|
||||
`src/catalogsync.plugin.js`
|
||||
|
||||
```javascript
|
||||
const plugin = {
|
||||
platform: "catalogsync",
|
||||
version: "0.1.0",
|
||||
author: "Codex",
|
||||
userVariables: [
|
||||
{ key: "apiBase", name: "API Base", hint: "https://your-host" },
|
||||
{ key: "accessToken", name: "Access Token", hint: "Bearer token value only" },
|
||||
],
|
||||
};
|
||||
|
||||
module.exports = plugin;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `node --test tests/plugin.test.cjs`
|
||||
Expected: `pass 1`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json src/http.js src/ids.js src/catalogsync.plugin.js tests/plugin.test.cjs
|
||||
git commit -m "feat: scaffold musicfree catalogsync plugin"
|
||||
```
|
||||
|
||||
### Task 2: Implement recommend tags and recommend sheet listing
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/catalogsync.plugin.js`
|
||||
- Test: `tests/plugin.test.cjs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```javascript
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const plugin = require("../src/catalogsync.plugin");
|
||||
|
||||
test("getRecommendSheetsByTag maps playlist rows into MusicFree sheet items", async () => {
|
||||
plugin.__setHttpClientForTests({
|
||||
get: async (path) => {
|
||||
if (path === "/mf/v1/recommend/sheets") {
|
||||
return {
|
||||
data: {
|
||||
isEnd: false,
|
||||
data: [
|
||||
{
|
||||
id: "catalogsync:playlist:18165",
|
||||
title: "娴嬭瘯姝屽崟",
|
||||
coverImg: "https://img/1.jpg",
|
||||
description: "netease / 姝屽崟骞垮満",
|
||||
worksNum: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected path ${path}`);
|
||||
},
|
||||
});
|
||||
|
||||
const result = await plugin.getRecommendSheetsByTag({ id: "all" }, 1);
|
||||
|
||||
assert.equal(result.isEnd, false);
|
||||
assert.equal(result.data[0].platform, "catalogsync");
|
||||
assert.equal(result.data[0].title, "娴嬭瘯姝屽崟");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test tests/plugin.test.cjs`
|
||||
Expected: `TypeError: plugin.getRecommendSheetsByTag is not a function`
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
`src/catalogsync.plugin.js`
|
||||
|
||||
```javascript
|
||||
const { createClient } = require("./http");
|
||||
|
||||
let testClient = null;
|
||||
|
||||
function getClient() {
|
||||
if (testClient) {
|
||||
return testClient;
|
||||
}
|
||||
return createClient("http://127.0.0.1:18081", "dev-token");
|
||||
}
|
||||
|
||||
function mapSheetItem(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
platform: "catalogsync",
|
||||
title: item.title || "",
|
||||
coverImg: item.coverImg || "",
|
||||
description: item.description || "",
|
||||
worksNum: item.worksNum || 0,
|
||||
};
|
||||
}
|
||||
|
||||
const plugin = {
|
||||
platform: "catalogsync",
|
||||
version: "0.1.0",
|
||||
author: "Codex",
|
||||
userVariables: [
|
||||
{ key: "apiBase", name: "API Base", hint: "https://your-host" },
|
||||
{ key: "accessToken", name: "Access Token", hint: "Bearer token value only" },
|
||||
],
|
||||
__setHttpClientForTests(client) {
|
||||
testClient = client;
|
||||
},
|
||||
async getRecommendSheetTags() {
|
||||
const response = await getClient().get("/mf/v1/recommend/tags");
|
||||
return response.data;
|
||||
},
|
||||
async getRecommendSheetsByTag(tag, page = 1) {
|
||||
const response = await getClient().get("/mf/v1/recommend/sheets", {
|
||||
params: { tag: tag.id, page, page_size: 60 },
|
||||
});
|
||||
return {
|
||||
isEnd: Boolean(response.data.isEnd),
|
||||
data: (response.data.data || []).map(mapSheetItem),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = plugin;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `node --test tests/plugin.test.cjs`
|
||||
Expected: `pass 2`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/catalogsync.plugin.js tests/plugin.test.cjs
|
||||
git commit -m "feat: add musicfree recommend sheet methods"
|
||||
```
|
||||
|
||||
### Task 3: Implement playlist detail pagination and toplist methods
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/catalogsync.plugin.js`
|
||||
- Test: `tests/plugin.test.cjs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```javascript
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const plugin = require("../src/catalogsync.plugin");
|
||||
|
||||
test("getMusicSheetInfo returns sheetItem on page 1 and musicList for all pages", async () => {
|
||||
plugin.__setHttpClientForTests({
|
||||
get: async (path) => {
|
||||
if (path === "/mf/v1/playlists/18165") {
|
||||
return { data: { title: "娴嬭瘯姝屽崟", coverImg: "https://img/1.jpg", worksNum: 2 } };
|
||||
}
|
||||
if (path === "/mf/v1/playlists/18165/tracks") {
|
||||
return {
|
||||
data: {
|
||||
isEnd: true,
|
||||
musicList: [
|
||||
{
|
||||
id: "catalogsync:song:3476",
|
||||
title: "娴峰笨浣?,
|
||||
artist: "椹篃 / Crabbit",
|
||||
artwork: "https://img/song.jpg",
|
||||
duration: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected path ${path}`);
|
||||
},
|
||||
});
|
||||
|
||||
const result = await plugin.getMusicSheetInfo({ id: "catalogsync:playlist:18165" }, 1);
|
||||
|
||||
assert.equal(result.sheetItem.title, "娴嬭瘯姝屽崟");
|
||||
assert.equal(result.musicList[0].id, "catalogsync:song:3476");
|
||||
assert.equal(result.isEnd, true);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test tests/plugin.test.cjs`
|
||||
Expected: `TypeError: plugin.getMusicSheetInfo is not a function`
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
`src/catalogsync.plugin.js`
|
||||
|
||||
```javascript
|
||||
const { createClient } = require("./http");
|
||||
const { parsePublicId } = require("./ids");
|
||||
|
||||
let testClient = null;
|
||||
|
||||
function getClient() {
|
||||
if (testClient) {
|
||||
return testClient;
|
||||
}
|
||||
return createClient("http://127.0.0.1:18081", "dev-token");
|
||||
}
|
||||
|
||||
function mapSheetItem(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
platform: "catalogsync",
|
||||
title: item.title || "",
|
||||
coverImg: item.coverImg || "",
|
||||
description: item.description || "",
|
||||
worksNum: item.worksNum || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function mapMusicItem(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
platform: "catalogsync",
|
||||
title: item.title || "",
|
||||
artist: item.artist || "",
|
||||
album: item.album || "",
|
||||
artwork: item.artwork || "",
|
||||
duration: item.duration || 0,
|
||||
};
|
||||
}
|
||||
|
||||
const plugin = {
|
||||
platform: "catalogsync",
|
||||
version: "0.1.0",
|
||||
author: "Codex",
|
||||
userVariables: [
|
||||
{ key: "apiBase", name: "API Base", hint: "https://your-host" },
|
||||
{ key: "accessToken", name: "Access Token", hint: "Bearer token value only" },
|
||||
],
|
||||
__setHttpClientForTests(client) {
|
||||
testClient = client;
|
||||
},
|
||||
async getRecommendSheetTags() {
|
||||
const response = await getClient().get("/mf/v1/recommend/tags");
|
||||
return response.data;
|
||||
},
|
||||
async getRecommendSheetsByTag(tag, page = 1) {
|
||||
const response = await getClient().get("/mf/v1/recommend/sheets", {
|
||||
params: { tag: tag.id, page, page_size: 60 },
|
||||
});
|
||||
return {
|
||||
isEnd: Boolean(response.data.isEnd),
|
||||
data: (response.data.data || []).map(mapSheetItem),
|
||||
};
|
||||
},
|
||||
async getMusicSheetInfo(sheetItem, page = 1) {
|
||||
const playlistId = parsePublicId(sheetItem.id);
|
||||
const tracksResponse = await getClient().get(`/mf/v1/playlists/${playlistId}/tracks`, {
|
||||
params: { page, page_size: 100 },
|
||||
});
|
||||
let resolvedSheetItem = undefined;
|
||||
if (page === 1) {
|
||||
const playlistResponse = await getClient().get(`/mf/v1/playlists/${playlistId}`);
|
||||
resolvedSheetItem = mapSheetItem({
|
||||
id: `catalogsync:playlist:${playlistId}`,
|
||||
...playlistResponse.data,
|
||||
});
|
||||
}
|
||||
return {
|
||||
isEnd: Boolean(tracksResponse.data.isEnd),
|
||||
sheetItem: resolvedSheetItem,
|
||||
musicList: (tracksResponse.data.musicList || []).map(mapMusicItem),
|
||||
};
|
||||
},
|
||||
async getTopLists() {
|
||||
const response = await getClient().get("/mf/v1/toplists");
|
||||
return response.data;
|
||||
},
|
||||
async getTopListDetail(topListItem, page = 1) {
|
||||
const toplistId = parsePublicId(topListItem.id);
|
||||
const tracksResponse = await getClient().get(`/mf/v1/toplists/${toplistId}/tracks`, {
|
||||
params: { page, page_size: 100 },
|
||||
});
|
||||
return {
|
||||
isEnd: Boolean(tracksResponse.data.isEnd),
|
||||
topListItem,
|
||||
musicList: (tracksResponse.data.musicList || []).map(mapMusicItem),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = plugin;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `node --test tests/plugin.test.cjs`
|
||||
Expected: `pass 3`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/catalogsync.plugin.js tests/plugin.test.cjs
|
||||
git commit -m "feat: add musicfree playlist and toplist detail methods"
|
||||
```
|
||||
|
||||
### Task 4: Implement `getMediaSource` and build the distributable plugin file
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/catalogsync.plugin.js`
|
||||
- Create: `dist/catalogsync_musicfree.js`
|
||||
- Test: `tests/plugin.test.cjs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```javascript
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const plugin = require("../src/catalogsync.plugin");
|
||||
|
||||
test("getMediaSource maps resolve response into MusicFree media source format", async () => {
|
||||
plugin.__setHttpClientForTests({
|
||||
get: async () => {
|
||||
throw new Error("unexpected GET");
|
||||
},
|
||||
post: async (path) => {
|
||||
if (path === "/mf/v1/media/resolve") {
|
||||
return {
|
||||
data: {
|
||||
stream: {
|
||||
url: "https://public-host/mf/v1/media/stream/token-123",
|
||||
headers: { Range: "bytes=0-" },
|
||||
},
|
||||
selected_source: {
|
||||
quality: "super",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected POST ${path}`);
|
||||
},
|
||||
});
|
||||
|
||||
const result = await plugin.getMediaSource({ id: "catalogsync:song:3476" }, "super");
|
||||
|
||||
assert.equal(result.url, "https://public-host/mf/v1/media/stream/token-123");
|
||||
assert.equal(result.quality, "super");
|
||||
assert.deepEqual(result.headers, { Range: "bytes=0-" });
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test tests/plugin.test.cjs`
|
||||
Expected: `TypeError: plugin.getMediaSource is not a function`
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
`src/catalogsync.plugin.js`
|
||||
|
||||
```javascript
|
||||
const { createClient } = require("./http");
|
||||
const { parsePublicId } = require("./ids");
|
||||
|
||||
let testClient = null;
|
||||
|
||||
function getClient() {
|
||||
if (testClient) {
|
||||
return testClient;
|
||||
}
|
||||
return createClient("http://127.0.0.1:18081", "dev-token");
|
||||
}
|
||||
|
||||
function mapSheetItem(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
platform: "catalogsync",
|
||||
title: item.title || "",
|
||||
coverImg: item.coverImg || "",
|
||||
description: item.description || "",
|
||||
worksNum: item.worksNum || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function mapMusicItem(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
platform: "catalogsync",
|
||||
title: item.title || "",
|
||||
artist: item.artist || "",
|
||||
album: item.album || "",
|
||||
artwork: item.artwork || "",
|
||||
duration: item.duration || 0,
|
||||
};
|
||||
}
|
||||
|
||||
const plugin = {
|
||||
platform: "catalogsync",
|
||||
version: "0.1.0",
|
||||
author: "Codex",
|
||||
userVariables: [
|
||||
{ key: "apiBase", name: "API Base", hint: "https://your-host" },
|
||||
{ key: "accessToken", name: "Access Token", hint: "Bearer token value only" },
|
||||
],
|
||||
__setHttpClientForTests(client) {
|
||||
testClient = client;
|
||||
},
|
||||
async getRecommendSheetTags() {
|
||||
const response = await getClient().get("/mf/v1/recommend/tags");
|
||||
return response.data;
|
||||
},
|
||||
async getRecommendSheetsByTag(tag, page = 1) {
|
||||
const response = await getClient().get("/mf/v1/recommend/sheets", {
|
||||
params: { tag: tag.id, page, page_size: 60 },
|
||||
});
|
||||
return {
|
||||
isEnd: Boolean(response.data.isEnd),
|
||||
data: (response.data.data || []).map(mapSheetItem),
|
||||
};
|
||||
},
|
||||
async getMusicSheetInfo(sheetItem, page = 1) {
|
||||
const playlistId = parsePublicId(sheetItem.id);
|
||||
const tracksResponse = await getClient().get(`/mf/v1/playlists/${playlistId}/tracks`, {
|
||||
params: { page, page_size: 100 },
|
||||
});
|
||||
let resolvedSheetItem = undefined;
|
||||
if (page === 1) {
|
||||
const playlistResponse = await getClient().get(`/mf/v1/playlists/${playlistId}`);
|
||||
resolvedSheetItem = mapSheetItem({
|
||||
id: `catalogsync:playlist:${playlistId}`,
|
||||
...playlistResponse.data,
|
||||
});
|
||||
}
|
||||
return {
|
||||
isEnd: Boolean(tracksResponse.data.isEnd),
|
||||
sheetItem: resolvedSheetItem,
|
||||
musicList: (tracksResponse.data.musicList || []).map(mapMusicItem),
|
||||
};
|
||||
},
|
||||
async getTopLists() {
|
||||
const response = await getClient().get("/mf/v1/toplists");
|
||||
return response.data;
|
||||
},
|
||||
async getTopListDetail(topListItem, page = 1) {
|
||||
const toplistId = parsePublicId(topListItem.id);
|
||||
const tracksResponse = await getClient().get(`/mf/v1/toplists/${toplistId}/tracks`, {
|
||||
params: { page, page_size: 100 },
|
||||
});
|
||||
return {
|
||||
isEnd: Boolean(tracksResponse.data.isEnd),
|
||||
topListItem,
|
||||
musicList: (tracksResponse.data.musicList || []).map(mapMusicItem),
|
||||
};
|
||||
},
|
||||
async getMediaSource(musicItem, quality) {
|
||||
const response = await getClient().post("/mf/v1/media/resolve", {
|
||||
song_id: musicItem.id,
|
||||
quality,
|
||||
});
|
||||
return {
|
||||
url: response.data.stream.url,
|
||||
headers: response.data.stream.headers || {},
|
||||
quality: response.data.selected_source.quality || quality,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = plugin;
|
||||
```
|
||||
|
||||
`dist/catalogsync_musicfree.js`
|
||||
|
||||
```javascript
|
||||
module.exports = require("../src/catalogsync.plugin");
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `node --test tests/plugin.test.cjs && npm run build`
|
||||
Expected: tests all pass and `dist/catalogsync_musicfree.js` exists
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/catalogsync.plugin.js dist/catalogsync_musicfree.js tests/plugin.test.cjs
|
||||
git commit -m "feat: add musicfree media resolve method"
|
||||
```
|
||||
|
||||
+1423
File diff suppressed because it is too large
Load Diff
+1665
File diff suppressed because it is too large
Load Diff
+1540
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
# Music Server LAN Plugin 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 a second MusicFree plugin that prefers a NAS LAN endpoint when reachable, while keeping the existing Music_Server plugin unchanged and publish both through the subscription manifest.
|
||||
|
||||
**Architecture:** Keep the current `music_server.js` path and behavior intact. Add a new `music_server_lan.js` asset with its own platform/name and LAN-first endpoint selection logic, then expose both assets from `Music_Server` plugin routes and the subscription manifest.
|
||||
|
||||
**Tech Stack:** Node-style MusicFree plugin JS, Node test runner, FastAPI plugin routes, unittest.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Lock route publishing behavior with tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_plugin_routes.py`
|
||||
|
||||
- [ ] Add failing tests for a second plugin asset route and a manifest that returns both plugin entries.
|
||||
- [ ] Run `python -m pytest D:\source\musicdl-catalog-sync-worktrees\Music_Server\tests\test_plugin_routes.py -q` and confirm failure.
|
||||
|
||||
### Task 2: Lock LAN-first plugin behavior with tests
|
||||
|
||||
**Files:**
|
||||
- Create: `D:\source\musicdl-catalog-sync-worktrees\MusicFree\keep-alive-master\Music_Free\music_server_lan.test.cjs`
|
||||
|
||||
- [ ] Add failing tests covering `lanBaseUrl` exposure, LAN probe success choosing LAN base URL, LAN probe failure falling back to public base URL, and relative media URL joining against the chosen active base URL.
|
||||
- [ ] Run `node --test D:\source\musicdl-catalog-sync-worktrees\MusicFree\keep-alive-master\Music_Free\music_server_lan.test.cjs` and confirm failure.
|
||||
|
||||
### Task 3: Implement and publish the new plugin
|
||||
|
||||
**Files:**
|
||||
- Create: `D:\source\musicdl-catalog-sync-worktrees\MusicFree\keep-alive-master\Music_Free\music_server_lan.js`
|
||||
- Create: `D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\plugin_assets\music_server_lan.js`
|
||||
- Create: `D:\source\musicdl-catalog-sync-worktrees\MusicFree\release\music_server_lan_latest.js`
|
||||
- Modify: `D:\source\musicdl-catalog-sync-worktrees\Music_Server\src\music_server\routes\plugins.py`
|
||||
- Modify: `D:\source\musicdl-catalog-sync-worktrees\MusicFree\release\music_server_subscription.json`
|
||||
|
||||
- [ ] Implement LAN-first base URL resolution in the new plugin only, using `GET /healthz` reachability with short cache and fallback to the public base URL.
|
||||
- [ ] Expose the new plugin from `Music_Server` at its own JS route and add it to the shared subscription manifest without removing the original plugin.
|
||||
- [ ] Copy the released LAN plugin file into the subscription-facing release location.
|
||||
|
||||
### Task 4: Verify end to end
|
||||
|
||||
**Files:**
|
||||
- Verify only
|
||||
|
||||
- [ ] Run the focused MusicFree LAN plugin tests.
|
||||
- [ ] Run the plugin route tests.
|
||||
- [ ] Fetch the manifest locally and confirm it lists both plugin URLs.
|
||||
- [ ] If requested, sync/deploy the updated `Music_Server` plugin asset and manifest to NAS and restart the service.
|
||||
+1607
File diff suppressed because it is too large
Load Diff
+53
@@ -0,0 +1,53 @@
|
||||
# Music_Server Inline Lyrics 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:** Make `Music_Server` include local `.lrc` content as `rawLrc` in distributed song items and keep the MusicFree plugins passing that field through.
|
||||
|
||||
**Architecture:** Extend catalog track queries to expose the best local file locator, resolve a sibling `.lrc` under `LOCAL_LIBRARY_ROOT`, and inject the text into the existing song payload builder. Update both served plugin assets so their `mapMusicItem()` output preserves `rawLrc`.
|
||||
|
||||
**Tech Stack:** Python 3.11, FastAPI, sqlite3, unittest, plain JavaScript plugin assets.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add failing backend and plugin tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/test_mf_catalog_routes.py`
|
||||
- Modify: `tests/test_plugin_routes.py`
|
||||
|
||||
- [ ] Add route assertions that expect `rawLrc` when a same-name `.lrc` exists beside a local audio file.
|
||||
- [ ] Add route assertions that verify requests still succeed without `LOCAL_LIBRARY_ROOT`.
|
||||
- [ ] Add plugin asset assertions that both served JS assets contain `rawLrc` mapping code.
|
||||
- [ ] Run only the new/updated tests and confirm they fail for the expected missing-lyrics behavior.
|
||||
|
||||
### Task 2: Implement inline lyric loading
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/music_server/services/catalog_reader.py`
|
||||
- Modify: `src/music_server/routes/mf_catalog.py`
|
||||
|
||||
- [ ] Extend track row queries to expose the preferred active `local_fs` locator for each song.
|
||||
- [ ] Add a helper that safely resolves a sibling `.lrc` under `LOCAL_LIBRARY_ROOT` and reads it as text.
|
||||
- [ ] Inject `rawLrc` into `_to_music_item()` only when a lyric file is present.
|
||||
- [ ] Re-run the focused backend tests and confirm they pass.
|
||||
|
||||
### Task 3: Preserve lyrics in plugin assets
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/music_server/plugin_assets/music_server.js`
|
||||
- Modify: `src/music_server/plugin_assets/music_server_lan.js`
|
||||
- Test: `tests/test_plugin_routes.py`
|
||||
|
||||
- [ ] Update `mapMusicItem()` in both plugin assets to copy `rawLrc` from the server payload.
|
||||
- [ ] Re-run the plugin asset tests and confirm they pass.
|
||||
|
||||
### Task 4: Verify the feature end to end in repo tests
|
||||
|
||||
**Files:**
|
||||
- Modify: none
|
||||
|
||||
- [ ] Run the focused unittest command for `test_mf_catalog_routes` and `test_plugin_routes`.
|
||||
- [ ] If those pass, run `test_catalog_reader.py` as a regression check for reader query changes.
|
||||
- [ ] Record any deployment-only follow-up separately; do not expand scope in code.
|
||||
|
||||
Reference in New Issue
Block a user