Initial import: Music_Server, MusicFree, catalog-sync

This commit is contained in:
2026-05-23 16:51:14 +08:00
commit 069af30dba
847 changed files with 179878 additions and 0 deletions
@@ -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"
```
@@ -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"
```
@@ -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.
@@ -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.