Files
musicdl-catalog-sync-suite/Music_Server/docs/superpowers/plans/2026-04-19-musicfree-plugin-implementation.md
T

16 KiB

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

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

{
  "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

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

function parsePublicId(publicId) {
  return String(publicId || "").split(":").pop();
}

module.exports = { parsePublicId };

src/catalogsync.plugin.js

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
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

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

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
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

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

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
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

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

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

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
git add src/catalogsync.plugin.js dist/catalogsync_musicfree.js tests/plugin.test.cjs
git commit -m "feat: add musicfree media resolve method"