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,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"
```