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