Music Playlist Organizer MCP
# Music Playlist Organizer MCP
目前產品版本:**v0.3.0**。MCP server、HTTP `/version` 與音樂庫備份的版本資訊都從 `package.json` 讀取。
這是一個以 Node.js 撰寫的 MCP Server,讓你把找到的歌曲名稱或 YouTube/YouTube Music 連結,辨識後分類、去重,並加入自己的 YouTube 播放清單,之後可以直接回到 YouTube 觀看。
目前以 YouTube 為主要 provider;Spotify 工具仍保留,但屬於選配的 legacy provider。
## 收藏流程
建議的統一入口是 `save_music`:一次呼叫完成「辨識 → 綁定影片 → canonical 去重 → 分類 → 寫入本機音樂庫 →(可選)同步 YouTube 播放清單」,並回傳完整 receipt。
```text
save_music({
"input": "https://music.youtube.com/watch?v=dQw4w9WgXcQ",
"mode": "apply"
})
```
- `input` 接受歌曲名稱、YouTube/YouTube Music 影片連結或 11 字元 video ID;`videoId` 可指定候選影片。
- `mode` 預設 `"preview"`(不寫入);`"apply"` 才會實際寫入。
- `syncToYouTube` 預設 `true`;preview 模式仍會預覽 YouTube 步驟,設 `false` 可只寫本機音樂庫。
- `remoteDedupe` 預設 `"canonical"`:目標播放清單已有同一首歌時不再加入——判斷依據包含同一 canonical track 在 Library 裡的其他 YouTube 來源,以及 metadata canonicalize 出相同 key 的播放清單項目(例如 Official MV 與 Official Audio,即使 Library 沒見過該上傳);命中時回報 `skipped_canonical_duplicate`。傳 `"source"` 則只擋相同 `videoId`,允許同一首歌的多個來源版本同列。live/cover/remix 等不同 version 的 canonical key 不同,不會被歌曲層級去重吞掉。receipt 以 `youtube.duplicateKind`(`exact_source`/`canonical_track`)與 `youtube.matchedVideoId` 明確指出遠端判斷依據。
- `tags`/`category` 會成為使用者標籤(只增不覆寫既有使用者標籤);`category` 同時決定目標播放清單名稱,`playlist` 可直接指定播放清單(名稱、URL 或 ID)。
- 精確連結或 video ID 走 fast path;自由文字無法唯一綁定時回傳 `selection_required` 與 `candidates`,**不會**默默收藏搜尋第一名——請用回傳的 `videoId` 重新呼叫。
- 本機音樂庫寫入與 YouTube 寫入是獨立步驟:一邊失敗時 receipt 會回報真實的 partial `writeState`(如 `UNKNOWN_AFTER_WRITE`、`PARTIAL_PLAYLIST_CREATED`)、`completedSteps` 與安全的 `nextStep`,不會把部分成功包成一般錯誤。重複收藏同一 `videoId` 是冪等的(`skipped_duplicate`);同一首歌的不同來源在預設 `remoteDedupe: "canonical"` 下也冪等(`skipped_canonical_duplicate`)。
若使用底層工具,對「歌曲名稱或搜尋文字」仍可採用兩階段流程:
1. 呼叫 `youtube_identify_track`,取得候選影片與 `videoId`。
2. 使用者確認候選後,把選定的 `videoId` 傳給 `youtube_save_track`,並設定 `mode: "apply"`。
3. Server 依分類找到或建立播放清單,檢查相同 `videoId` 後才加入。
如果輸入本身是精確的 YouTube/YouTube Music 影片連結,可以直接套用;播放清單連結不能當成單一歌曲輸入。
## YouTube 工具
- `save_music`:**建議的收藏入口**。辨識、綁定、canonical 去重、多維度分類、寫入本機音樂庫並可選同步 YouTube 播放清單,回傳含 exact IDs、duplicate level、syncState 與 nextStep 的完整 receipt。
- `youtube_search_videos`:搜尋歌曲、藝人或影片。
- `youtube_identify_track`:從 URL、YouTube Music URL、影片 ID 或文字搜尋辨識影片。
- `youtube_resolve_links`:批次解析多個連結或搜尋文字。
- `youtube_list_playlists`:列出已授權帳號的播放清單。
- `youtube_create_playlist`:建立私人、未列出或公開播放清單。
- `youtube_check_playlist_duplicates`:檢查播放清單內的重複影片。
- `youtube_classify_playlist`:依標題與頻道關鍵字預覽分類。
- `youtube_add_to_playlist`:預覽或將影片加入既有播放清單。
- `youtube_save_track`:辨識、分類、去重,並加入指定或自動建立的分類播放清單。
- `youtube_auth_status`:查看憑證狀態,不會顯示 token。
- `youtube_auth_revoke`:撤銷 OAuth 憑證並刪除本機加密憑證檔。
- `library_status`:查看本機音樂庫路徑、schema version 與曲目數,不會回傳任何列內容或 secret。
- `classify_track`:用固定 taxonomy 預覽單曲的多維度分類(genre、mood、language、activity、energy、era、artist、custom_tags),每個值附來源(`user`/`rule`/`model`)與信心度;唯讀,不寫入音樂庫。
## 多維度分類
`src/classify.js` 的 `classifyMusic({ title, artist, channelTitle, description, userClassification })` 回傳 `{ taxonomyVersion, dimensions, provenance, needsReview }`:
- 固定 taxonomy 定義在 `src/taxonomy.js`(`TAXONOMY_VERSION = 1`);同義詞會正規化(`jpop`/`J-Pop`/`J-POP` → `j-pop`)。
- 無法對應官方值的輸入會導向 `custom_tags` 並列入 `needsReview`,不會憑空擴充官方 taxonomy。
- 預設走決定性規則(`source: "rule"`,沿用 `DEFAULT_RULES` 關鍵字加上 taxonomy 掃描);可注入本地 `model` stub,失敗或缺模型時自動落回規則,不呼叫外部 LLM API。
- `userClassification` 的欄位永遠優先(`source: "user"`);自動分類只填空的維度,provider metadata 只算證據而非事實(信心度 < 1)。
- 透過 MusicLibrary 公共 API 持久化:`persistClassification(library, trackInput, result)` 用 `upsertTrack` 寫維度欄位、`addTag` 寫 `custom_tags`、完整 provenance JSON 存進 `sync_state` 的 `classification.<trackId>`。重新分類時使用者設過的維度與標籤不會被覆寫,tags 只增不減。
## 本機音樂庫
Server 啟動時會開啟一個本機 SQLite 音樂庫(`node:sqlite`),作為 Personal Music Library 的持久層:YouTube 仍是播放器,本機 DB 負責保存曲目、來源對應、tags、播放清單 mapping、aliases 與 `sync_state`。`save_music` 會寫入此庫(tracks、sources、tags、播放清單 mapping 與分類 provenance);底層的 `youtube_save_track` 仍只操作 YouTube。
- 預設位置:使用者設定目錄下的 `music-playlist-organizer/library.sqlite`(Windows 為 `%APPDATA%\music-playlist-organizer\library.sqlite`;其他平台為 `~/.config/music-playlist-organizer/library.sqlite`)。
- 覆寫路徑:設定環境變數 `MUSIC_LIBRARY_FILE`(相對路徑會解析為絕對路徑)。
- 備份:先關閉 server(關閉時會做 WAL checkpoint),再複製 `library.sqlite`;若仍看到 `-wal`/`-shm` 檔,請一併複製。
- 重置:關閉 server,刪除 `library.sqlite`,重新啟動即會重建空 schema。
- OAuth token、refresh token、client secret 與 `YOUTUBE_CREDENTIAL_PASSPHRASE` 一律留在加密憑證檔,不會寫入音樂庫 DB、log 或 MCP 輸出;寫入端也會拒絕疑似 secret 的欄位名稱。
## 音樂庫查詢與維護
音樂庫內容透過以下工具查詢與整理(實作在 `src/library-query.js`,SQL 集中在 `MusicLibrary`)。所有查詢皆唯讀、分頁有界(`limit` ≤ 100),穩定識別一律用本機 `trackId` 與精確 YouTube ID,不用顯示名稱當唯一鍵:
- `search_library`:依 `title`/`artist`(LIKE+正規化,支援 CJK)、`tag`、`genre`、`mood`、`language`、`activity` 過濾,回傳 `items`+`total`+`hasMore`。
- `list_music`:全庫分頁(`limit`/`offset`),最新收藏在前。
- `recent_music`:最近收藏的曲目,有界。
- `get_music`:單一 `trackId` 的完整檔案——canonical 欄位、sources、tags、playlists、分類記錄、`sync` 狀態與 identity review 項目。
- `update_music_tags`:對單曲新增/移除自訂標籤,回傳 before/after;不碰其他維度或 user-set metadata。
- `update_music_classification`:對單曲做持久化人工分類修正,`set` 寫入維度(genre/mood/language/activity/energy/era/artist,值過 taxonomy 驗證——未知值導向 custom tags+review,不擴張官方維度),`clear` 明確移除使用者設過的維度;`mode` 預設 preview 顯示 before/after/provenance diff。人工值記為 `source: user`,`reclassify_music` 不會覆寫;空值不算清除(清除只能走 `clear`)。
- `reclassify_music`:對單曲重跑自動分類,使用者設過的維度與標籤保留,回傳變更前後的 per-dimension diff。
- `remove_music`:預設 `preview`。`apply` 只執行明確授權的 effect——`local: true` 刪本機曲目(連同 sources、tags、playlist mapping、aliases、identity candidates、sync_state);`youtubePlaylist`(**精確 playlist ID 或 URL**,名稱會被拒絕)+可選 `videoId` 刪 YouTube playlist item。兩個 effect 獨立執行、各自回報 `writeState`,一邊失敗不會回滾另一邊;未授權任何 effect 的 apply 是明確 no-op(`no_effect_authorized`)。
- `list_unsynced_music`:列出需要注意的曲目——`not_synced`(不在任何 provider playlist)、`identity_conflict`(`needs_review`)、`provider_unavailable`(`sync.<trackId>` 標記為非 synced 狀態);可用 `reason` 過濾。
## YouTube ↔ 音樂庫同步
同步工具在 `src/library-sync.js`,讓音樂庫與 YouTube playlist 不再無限漂移。設計原則:預設不做雙向破壞性同步;先產生 plan;`push`/`pull`/`reconcile` 語意分離;playlist 只用精確 ID 識別(名稱可改名,ID 不變);provider read-back 是事實來源,但不會覆寫本機 tags 或 canonical 合併決策。
- `sync_status`:唯讀掃描。回報每首歌的穩定狀態——`in_sync`、`local_only`(音樂庫有、playlist 沒有)、`conflict`(`needs_review`)、`unknown_after_write`(上次寫入結果不確定)、`unknown`(playlist 讀取失敗);以及 remote 端的 `youtube_only`、`unlinked`(本機有該 source 但缺 playlist 關聯)、`unavailable`(deleted/private)。可用 `playlist`(精確 ID 或 URL)限定範圍。
- `sync_youtube`:預設 `preview` 回傳明確 plan(`additions`/`imports`/`removals`/`renames`/`links`/`sourceMarks`/`markerResolutions`),不做任何 provider 寫入。`apply` 只執行預覽授權的 `direction` 與範圍:
- `push`:把 `local_only` 曲目補進 playlist;已在 playlist 內的不會重複加;`allowRemoval: true` 才會另外移除 `youtube_only` 項目(破壞性操作需額外授權)。
- `pull`:把 `youtube_only` 影片以 `upsertTrack` 匯入音樂庫,走原有 dedup 與 playlist 精確 ID 關聯。
- `reconcile`:只修本機狀態,不做 provider 寫入——同步 playlist 改名(不會新建重複 playlist)、標記 `unavailable` source、補 `unlinked` 關聯、用已讀回的項目解 `unknown_after_write` marker。
- 寫入遇到 timeout/5xx/429 不盲目 retry:該筆標記 `unknown_after_write`,整體回 `reconciliation_required`。
- `reconcile_track`:對單曲做 exact-ID read-back——只有明確 missing/deleted/private 才把 source 標 `unavailable`(**不刪** canonical track);timeout、取消、429、5xx、網路或認證失敗會保留 source availability,將待重試的 `unknown` 狀態存入 Library(原有 `unknown_after_write` 也保留),重啟後仍可再次 reconcile。receipt 對每個失敗 lookup 回傳不含 provider 原始訊息的錯誤類型與安全下一步;確認成功後才解為 `synced`/`local_only`/`unavailable`。
同步狀態存在 `sync.<trackId>` marker(JSON,無 secrets),與 `list_unsynced_music` 的 `provider_unavailable` 過濾相容。schema v3 在 `track_sources` 增加 `status` 欄位(`ok`/`unavailable`),source 失效不等於歌曲消失。
## HTTP 介面(本機限定)
`src/http-server.js` 提供受保護的 REST facade,供手機/Web UI 操作**同一套** service layer——所有路由直接委派 `saveMusic`、`library-query`、`library-sync`,preview/apply、exact ID、reconciliation 語意與 stdio MCP 完全一致,不重複實作。
啟動(只綁 localhost):
```bash
node src/http-server.js # 127.0.0.1:8741
MUSIC_HTTP_PORT=9000 node src/http-server.js
```
Session 流程:
```bash
curl -X POST http://127.0.0.1:8741/session \
-H 'Content-Type: application/json' \
-d '{"token":"<MUSIC_HTTP_BOOTSTRAP_TOKEN>"}'
# → {"token":"<session>","expiresAt":"..."}
curl http://127.0.0.1:8741/api/library/tracks -H "Authorization: Bearer <session>"
curl -X DELETE http://127.0.0.1:8741/session -H "Authorization: Bearer <session>" # revoke
```
環境變數:`MUSIC_HTTP_HOST`(預設 `127.0.0.1`,**不要**設 `0.0.0.0` 除非前面有 TLS + 反向代理+自有認證層)、`MUSIC_HTTP_PORT`(預設 8741)、`MUSIC_HTTP_BOOTSTRAP_TOKEN`(未設時啟動產生隨機值印在 stderr)、`MUSIC_HTTP_ALLOWED_ORIGINS`(逗號分隔;預設只允許 `http://localhost`/`http://127.0.0.1`,攜帶其他 Origin 的瀏覽器請求一律 403)。
安全界線:session token 與 YouTube OAuth 憑證完全分離,API 回應永不含 access/refresh token 或 credential passphrase;request body 上限 64 KB 且每個路由只收白名單欄位(client 無法注入 credential path);effectful endpoint 併發上限 4,超出回 429;所有錯誤為 `{error:{code,message}}` 結構。
路由:`GET /health`、`GET /version`(免認證);`POST /session`、`DELETE /session`;`GET /api/library/{tracks,recent,search,unsynced,tracks/:id}`、`POST /api/library/tracks/:id/{tags,reclassify}`、`POST /api/library/remove`;`POST /api/save_music`;`GET /api/sync/status`、`POST /api/sync`、`POST /api/reconcile`。
### 收藏 UI(`GET /`)
`public/index.html` 是一個免建置、行動裝置寬度(480px)的單頁收藏介面,由 `GET /` 直接送出(靜態 shell 不需 session;所有 `/api/*` 呼叫仍要 Bearer token)。流程:貼上歌名或 YouTube/YouTube Music 連結 → `POST /session`(貼 bootstrap token)→ preview;free text 回 `selection_required` 時列出候選、必須明確選 `videoId`(絕不自動選第一個);confirm 畫面顯示分類 chips、duplicate badge(含 playlist 內 canonical 重複的「different upload」標示)、目標 playlist 與 `playlistAction`;apply 後顯示 `saved`/`skipped_duplicate`/`partial_failure`/`reconciliation_required` receipt——YouTube step 回 `skipped_canonical_duplicate` 時改顯示「Same song already in playlist」(同一首歌的其他上傳已在 playlist,library 改為連結既有 source),失敗步驟列出各自 error message;`UNKNOWN_AFTER_WRITE`/`PARTIAL_PLAYLIST_CREATED` 提供一鍵 `POST /api/reconcile` read-back 與安全 nextStep。首頁列出 recent(`/api/library/recent`)與 needs-attention(`/api/library/unsynced`,含 reason badge 與 reconcile 按鈕)。前端不重複實作 canonicalization/dedup/sync——全部走 facade;回應與 bundle 皆不含 provider 憑證。
## 批次匯入
`src/batch-import.js` 把既有 YouTube / YouTube Music 收藏一次帶進音樂庫,不必逐首 `save_music`。管線:parse → resolve → canonicalize → dedupe → preview plan → apply → 可選 YouTube 同步。
- `preview_import`:接受 `items`(混合 URL/video ID/每行純文字歌名)與/或 `playlist`(精確 ID 或 URL)。回傳 `batchId` + `counts`(`total`/`new`/`exactDuplicate`/`canonicalDuplicate`/`unresolved`/`retryable`/`unavailable`)+逐項解析狀態(`resolvedBy`: `url`/`id`/`search`/`playlist`,失敗項附 `error`)。精確 ID 的 provider 暫時錯誤標為 `retryable`;僅確認不存在、私人或刪除的影片標為 `unavailable`。不寫曲目、不碰 provider;plan 存進 `import.<batchId>` sync_state 供 apply 使用。單筆超過 500 項時 `items` 截斷並標 `truncated`。
- `import_music_batch`:傳同樣輸入(重新解析)或 `batchId`+`resume:true`(接續中斷或暫時失敗的批次)。只寫入已解析項目;逐項回 `imported`/`exact_duplicate`/`canonical_duplicate`/`review`(低信心身份留待確認)/`unresolved`/`retryable`/`unavailable`/`failed`,單項失敗不回滾其他項;同批重跑全部報 `exact_duplicate`,是冪等的。預設**只寫本機 Library**——要同步到某個 YouTube playlist 必須每次呼叫明確傳 `syncPlaylist`(精確 ID/URL),已在 playlist 內的不重複加。`results` 與 `sync.results` 皆以 500 筆為上限,超過時標 `resultsTruncated`/`resultsTotal`;完整逐項紀錄(含逐項 sync 結果)存於 plan,用 `import_status` 分頁取回。
- `import_status`:查已存批次的 `counts`+`done`/`pending`,並以 `offset`/`limit`(預設 100、上限 500)回傳有界的逐項 `status`/`result`/`syncResult`/`error` 視窗,`itemsTruncated`/`nextOffset` 標示續頁。apply 回應因逾時或中斷遺失時,這裡是逐項對帳點。`syncResult` 有四個值:`added`(provider 已確認加入)、`already_present`(這次同步時該影片已在目標 playlist,未發出 append)、`failed`(確定失敗,可直接重試)、`unknown_after_write`(**寫入結果不明**——重試前必須先用 exact video ID 核對 playlist,否則會重複加入)。
計數口徑:`sync.results`/`sync.failed`/`sync.unknown` 是**每次實際 append 嘗試**(同一 `videoId` 只算一次,批內重複列不重複加),`import_status.items` 則是**逐 plan 列**(重複列鏡射同一 `videoId` 的結果)。所以一個 videoId 失敗會讓 `import_status` 看到兩列 `syncResult:"failed"` 而 `sync.failed` 仍是 1——兩者都對,只是分母不同。`already_present` 不算 append 嘗試,所以只出現在 `import_status`。
安全:batch import 落進 plan(sync_state)的 provider 錯誤訊息與**呼叫端貼上的 `items` 文字**,以及 **MCP tool 丟出的 `error`/`nextStep` 與 HTTP facade 的錯誤 `message`**,都會先過 `redactSecretishText`:憑證形狀的子字串(`Bearer …`、`ya29.…`、`AIza…`、`sk-…`、`GOCSPX-…`、含前綴的 OAuth 指派如 `invalid_client_id=`、JSON 引號形如 `"client_secret":"…"`、`SID=`)一律換成 `[REDACTED]`,`code`/`status` 保留供重試判讀。因此 `preview_import`/`import_status` 回傳的 `input` 會與你貼的原文不同——那是刻意的。讀取端同樣會 redact,所以舊版本寫入、當時未經處理的 plan 也不會在 `import_status` 回吐憑證。這組較寬鬆的樣式(OAuth 指派與 `GOCSPX-…`)**只**影響 redact;決定整列 sync_state 要不要從備份剔除的,仍是較嚴格的憑證值樣式——否則像 `?client_id=12345` 這種無害 query 就會讓整份 import plan 從備份消失。`save_music`/`library_sync`/`library_query` 各自在**成功回應**內嵌的 `errorInfo` 尚未接上這層 redact,不受本保證涵蓋。
同步 preflight 失敗記在 plan 的 `syncError`,描述**該次 apply**並自帶 `playlistId`(此欄位只有本版之後寫入的 plan 才有,早期 plan 沒有):下一次沒傳 `syncPlaylist` 的呼叫會清掉它,傳了但 preflight 成功也會清掉。唯一保留舊值的情況是本次呼叫有傳 `syncPlaylist` 卻在同步前就被取消——那時本次沒有任何同步嘗試,上次失敗(連同它所屬的目標 playlist)仍是最後已知狀態。apply 回應的 `sync.error` 是同一份內容(`playlistId`/`code`/`message`/`status`):preflight 失敗時它取代了舊版塞在 `results` 裡的 `sync_failed` 項目,所以**光看 `results` 會看不到這筆失敗**,要改看 `sync.error`。`import_status` 的逐項視窗另含 `syncPlaylistId`,指出該列最後一次同步寫進哪個 playlist。
`import_music_batch` 回應裡的 `counts` 是用 `countBy(plan.items)` 重述 plan 的**解析狀態**(`new`/`exactDuplicate`/… ,preview 那一套),不是這次 apply 的**結果**分佈;逐項結果看 `results`,逐項 sync 結果看 `import_status`。已匯入的項目仍會留在 `counts.new`,因為它解析時就是 `new`。
取消/逾時/暫時 provider 錯誤:apply 每 25 項 chunk flush 一次 plan;未完成項目保持 pending,回 `remaining` + `batchId`,之後用 `resume:true` 安全續作。caller abort 另回 `action:"cancelled"`。
## 備份與還原
`src/library-backup.js` 讓音樂庫可離線備份、搬移與還原,不綁死單一 SQLite 檔。
- `export_library`:`format:"json"` 輸出 deterministic、versioned JSON——含 canonical tracks、YouTube sources/exact IDs、tags、playlist mappings、aliases、identity decisions、`sync_state` 快照(`meta.syncStateIsSnapshot` 明確標示不保證 provider 端仍相同)。`format:"csv"` 輸出每曲一列的可讀分析格式(**非**無損,restore 一律走 JSON)。`sync_state` 匯出會排除憑證形狀的 key/value,並將無法安全解析的結構化值另外列入 `excluded.malformedSyncState`;兩者分別計數。任意純文字仍可能包含無法辨識的秘密,因此憑證應只放在 credential store。
- `restore_library`:預設 `preview` 回報 `insert`/`update`/`unchanged`/`conflict`/`unsupported` 計數,不寫任何東西;`apply` 在單一 transaction 內寫入(失敗整批 rollback,不會部分破壞)。同一 backup 重複 restore 冪等(`INSERT OR IGNORE`+id/canonical_key 比對);`schemaVersion` 不相容直接 fail safe。Restore **不觸發任何 provider 寫入**——還原後用 `sync_status`/`sync_youtube` 對帳。
## YouTube Playlist 管理
`src/playlist-admin.js` 補齊日常 playlist 維護,provider mutation 與 Library CRUD 嚴格分離:
- `youtube_get_playlist`/`youtube_list_playlist_items`:唯讀;items 有界分頁(`limit` ≤ 100)。
- `youtube_rename_playlist`:preview 顯示 old/new name;apply 後 exact-ID read-back 驗證,回 `RENAMED` 或 `UNKNOWN_AFTER_WRITE`(lost response 但實際落地時,read-back 如實回報已改名)。
- `youtube_remove_from_playlist`:依精確 playlist ID+`videoId` 移除 playlist item;**只動 provider**,canonical track 不變(要刪本機曲目走 `remove_music` 的獨立授權)。重複 video item 以 playlistItemId 驗證,只移除一個實例。
- `youtube_delete_playlist`:preview 回 exact ID/name/itemCount;apply 必須另傳 `confirmPlaylistId` 等於該精確 ID——獨立授權,sync/cleanup 絕不自動觸發。
所有 mutation 遇 timeout/5xx/429 不盲目 retry:read-back 能確認就如實回報,無法確認回 `UNKNOWN_AFTER_WRITE`+safe next step。
## Canonical 曲目識別與去重
音樂庫以「歌曲」為單位去重(schema v2):一筆 `tracks` 是一個 canonical track,一個 canonical track 可掛多筆 `track_sources`(不同 `videoId` 的 MV、Official Audio、歌詞版等)。正規化邏輯集中在 `src/canonical.js`:
- 標題/藝人先經 NFKC、大小寫、拉丁 diacritics、標點與全半形正規化(CJK 組合符如濁點保留),再剝除 `feat.`/`ft.`、`(Official Video)`、`[MV]`、`Official Audio`、`Lyrics`、`- Topic` 後綴、`Artist - Title` 前綴等包裝性詞彙。
- `canonical_key = ct|<normalizedArtist>|<normalizedTitle>|<version>`:`version` 只在 live、cover、remix、remaster、acoustic 等「不同錄音版本」時才有值(如 `live:at wembley`);Official MV 與 Official Audio 的 key 相同,因此會掛成同一首歌的兩個 source。
- 每筆 source 記錄 `source_type`(`official_video | official_audio | live | lyrics | cover | remix | remaster | unknown`)、匹配置信度與 provenance(JSON)。
`upsertTrack` 回傳 `identity` 欄位:`state` 為 `created | existing | same_canonical | possible_match`,`level` 為四級去重結果:
| level | 意義 |
|---|---|
| `EXACT_SOURCE_DUPLICATE` | 同一 provider + sourceId,冪等更新 |
| `SAME_CANONICAL_TRACK` | canonical key 相同(含 merge 記憶 alias),掛為新 source |
| `POSSIBLE_MATCH` | 模糊命中(同名不同版本、同名不同藝人、近似標題):**不**自動合併,另建曲目並標 `needs_review`,候選寫入 `identity_candidates` |
| `DISTINCT_TRACK` | 無相近候選,建新曲目 |
人工決策永遠優先於自動流程:
- `mergeTracks(intoId, fromId)`:把 from 的 sources、tags、playlists、aliases 全部併入 into 並刪除 from;from 的 canonical key 會存成 into 的 `canonical_key` alias,之後同 key 的新來源仍自動掛進來。
- `splitTrack(trackId, sourceIds, { title?, artist?, ... })`:把指定 sources 拆到一個新曲目(新曲目 `identity_locked = 1`),並撤銷原曲目上對應的 canonical_key alias。
- `identity_locked` 的曲目不會成為自動掛載目標:同 key 新來源會落入 `possible_match`(reason `identity_locked`)。`setIdentityLocked(trackId, false)` 可解除。
- `previewIdentity(input)` 為唯讀預覽:回傳正規化結果與將採用的去重決策,不寫入任何資料。
- `identityReviewQueue()` 列出所有待審候選配對(含信心值與原因);`setNeedsReview(trackId, false)` 可手動清除標記。
## 識別審核 MCP 工具
上述 domain 能力已透過 MCP 工具開放(`src/identity-admin.js`,全部 preview-first、只用穩定 ID,不碰 provider playlist):
| 工具 | 行為 |
|---|---|
| `list_identity_reviews` | 列出待審候選配對:confidence、reason、雙方 exact sources |
| `merge_music_tracks` | preview 列出 sources/tags/playlists/aliases 變化與 `from` 刪除;apply 併入並記住 canonical key alias |
| `split_music_track` | preview 驗證 `sourceIds`(track_sources row id)歸屬並列出移動項;apply 拆出 `identity_locked` 新曲目,保留 tags/playlists |
| `resolve_identity_review` | preview/apply 將配對駁回為 distinct;`lock:true` 同時鎖定雙方避免再次被自動合併 |
| `set_identity_lock` | 切換 `identity_locked`(回報 before/after);鎖定後相同來源只進 review 不自動掛載 |
## 需求
- Node.js 24 或更新版本(`node:sqlite`)。
- YouTube Data API key:用於公開搜尋與影片資訊;若不設定,catalog read 會改用 YouTube OAuth。
- Google OAuth 2.0 client:列出、建立與修改自己的播放清單時需要。
- OAuth scope:`https://www.googleapis.com/auth/youtube`。
## 安裝與設定
```powershell
Copy-Item .env.example .env
npm install
npm test
```
`.env` 的 YouTube 相關設定:
```dotenv
YOUTUBE_API_KEY=
GOOGLE_CLIENT_ID=your-google-oauth-client-id
GOOGLE_CLIENT_SECRET=your-google-oauth-client-secret
YOUTUBE_OAUTH_REDIRECT_URI=http://127.0.0.1:53682/oauth2callback
# 建議使用加密憑證檔,不要把 token 貼進設定檔。
YOUTUBE_CREDENTIAL_FILE=
YOUTUBE_CREDENTIAL_PASSPHRASE=use-a-local-secret-not-committed-to-git
YOUTUBE_REGION=TW
YOUTUBE_PLAYLIST_PREFIX=
PROVIDER_TIMEOUT_MS=15000
PROVIDER_MAX_READ_RETRIES=1
```
### Provider 請求界線(timeout/取消)
所有對 YouTube、Spotify、OAuth token 與 YouTube oEmbed 的 HTTP 請求都經過同一層 deadline/取消包裝(`src/http.js`):
- 每個請求與其回應 body 讀取都有有限 deadline:預設 15000ms,`PROVIDER_TIMEOUT_MS` 可調(1–120000ms 之間收敛)。
- MCP `notifications/cancelled` 會把進行中的 provider 請求 abort;HTTP facade 在 client 斷線時同樣傳遞取消。caller 取消回 `CALLER_CANCELLED`,與 `TIMEOUT`、`NETWORK_ERROR`、`HTTP_429`、`HTTP_5XX`、`AUTH_REFRESH_FAILED` 分開分類。
- GET 讀取遇到 429/5xx 依 Retry-After/指數退避做有限次重試(`PROVIDER_MAX_READ_RETRIES`,0–2);寫入永不自動重試——timeout、取消或網路錯誤視為 ambiguous,回 `UNKNOWN_AFTER_WRITE`/`reconciliation_required`,由 read-back 對帳(見「YouTube ↔ 音樂庫同步」)。
- timeout/cancel/error 輸出不含 access token、refresh token、client secret 或 authorization code;計時器與 abort listener 在請求結束後一律釋放。
首次授權請執行:
```powershell
npm run youtube:auth
```
`youtube:auth`、MCP stdio server 與 HTTP facade 啟動時都會自動載入專案根目錄的 `.env`(Node 原生 `process.loadEnvFile`)——已存在的環境變數優先,`.env` 只補缺少的 key;缺少 `.env` 不算錯誤。
這個流程會使用 OAuth state 與 PKCE,開啟瀏覽器完成 Google 授權,並把 token 寫入本機 AES-256-GCM 加密檔。終端機只會顯示檔案位置與完成狀態,不會顯示 access token 或 refresh token。執行 MCP Server 時,仍須讓它取得同一個 `YOUTUBE_CREDENTIAL_PASSPHRASE`;不要把 passphrase、`.env` 或憑證檔提交到 Git。
`YOUTUBE_CREDENTIAL_FILE` 未設定時,預設位置是使用者設定目錄下的 `music-playlist-organizer/youtube-credentials.json`。`YOUTUBE_ACCESS_TOKEN` 與 `YOUTUBE_REFRESH_TOKEN` 仍可作為明確的本機 fallback,但不建議在一般部署中使用,也不要提交到 repository。
## 使用範例
先取得候選,不會寫入帳號:
```text
youtube_identify_track({
"input": "Daft Punk One More Time",
"limit": 5
})
```
使用者從回傳的 `candidates` 選定 `videoId` 後再收藏:
```text
youtube_save_track({
"input": "Daft Punk One More Time",
"videoId": "dQw4w9WgXcQ",
"category": "Party",
"mode": "apply",
"createIfMissing": true,
"privacyStatus": "private",
"dedupe": true
})
```
精確連結可以直接使用:
```text
youtube_save_track({
"input": "https://music.youtube.com/watch?v=dQw4w9WgXcQ",
"category": "Chill",
"mode": "apply"
})
```
若寫入期間發生 timeout、取消、網路錯誤、429 或 5xx,工具會回傳 `UNKNOWN_AFTER_WRITE` 或 `PARTIAL_PLAYLIST_CREATED`,包含已確認的 playlist ID、video ID 與 `completedSteps`。請先依回傳的 exact ID 讀取播放清單,再決定是否重試;寫入不會自動重試。
## MCP Client 設定
把專案的絕對路徑填入 `args`,並讓 MCP process 取得加密憑證的 passphrase:
```json
{
"mcpServers": {
"music-playlist-organizer": {
"command": "node",
"args": ["C:\\path\\to\\spotify-playlist-organizer-mcp\\src\\server.js"],
"env": {
"YOUTUBE_API_KEY": "your-youtube-data-api-key",
"GOOGLE_CLIENT_ID": "your-google-oauth-client-id",
"GOOGLE_CLIENT_SECRET": "your-google-oauth-client-secret",
"YOUTUBE_CREDENTIAL_PASSPHRASE": "load-this-from-your-local-secret-manager",
"YOUTUBE_REGION": "TW"
}
}
}
}
```
stdio MCP 與 HTTP facade 共用同一套 service layer;手機/Web 收藏介面由 `GET /` 提供(見「收藏 UI」),經 bootstrap→session Bearer 流程呼叫 `/api/*`。
## Spotify legacy provider
既有的 `spotify_*` 工具仍保留,包括搜尋、辨識、批次解析、重複檢查、分類與播放清單整理;不設定 Spotify 環境變數時,不會影響 YouTube 工具。
## 品質控制
```powershell
npm test # 每個 commit 必跑(node:test)
npm run smoke # MCP stdio server 啟動煙霧測試
npm run lint # ESLint flat config(CI 也跑)
npm run test:coverage # 測試 + V8 coverage + 棘輪門檻(行≥80/分支≥75/函數≥88,校準於 CI Node 24)
npm run crap # CRAP 分數:cyclomatic complexity × coverage
npm run crap -- --gate 30 # 有任何函數 CRAP > 30 就 exit 1(可做門檻)
npm run mutate # Stryker mutation testing(全檔很慢,見下)
npx stryker run --mutate src/library.js # 單檔 scope
```
### 品質閘門定位
| 工具 | 何時跑 | 量什麼 |
|---|---|---|
| `npm test` | 每個 commit(CI 也跑) | 行為正確性——spec 裡的每個驗收條件 |
| `npm run lint` | CI 每個 push | 靜態錯誤(unused vars、無效 escape 等) |
| `npm run test:coverage` | CI 每個 push | 覆蓋率報表 + 棘輪門檻,只准升不准降 |
| `npm run crap` | PR 前/清理複雜度時 | CRAP = comp²×(1−cov)³+comp,找出「又複雜又沒測」的函數 |
| `npm run mutate` | 定期深檢(約 7 分鐘/檔,不進 CI) | 變異測試分數——測試是否真的抓得到 bug |
- **Mutation testing 刻意不進 CI**:`node --test` command runner 每個 mutant 跑整套測試,單檔約 7 分鐘、全 `src/` 估約 2 小時。用 `--mutate` scope 到正在改的檔案。
- **`src/spotify.js` 不計入 coverage**:legacy provider,YouTube-first 路線下去留未定;`server.js` 註冊層 6% 覆蓋是可接受的薄 wiring(mcp-smoke 涵蓋啟動)。
- **CRAP 目前只做報表不做閘**:`saveMusic`(comp 99)與 `importRows`(comp 79)即使高覆蓋也因複雜度上榜——先看基線再定閘值。
- Baseline(2026-09,spotify.js 不計):行 ~85% / 分支 ~78% / 函數 ~93%;`src/core.js` mutation score 46.6%(268 mutants)。
### GitHub Actions 驗證
`.github/workflows/ci.yml` 會在每次 push 與 pull request 以 Ubuntu、Node.js 24 執行:
1. `npm ci`
2. `npm run lint`
3. `npm run test:coverage`
`npm run test:coverage` 使用與 `npm test` 相同的 `node --test` 完整回歸套件,因此會包含 `test/mcp-smoke.test.js`;coverage 閘門另外要求行 ≥ 80%、分支 ≥ 75%、函數 ≥ 88%。Job 設有 5 分鐘上限,權限只有 `contents: read`,不提供 YouTube/Spotify OAuth 或 API key;測試使用 stub,不會修改真實播放清單,也不應在輸出中出現 secrets。
每次驗證請保存 Actions run URL、被測 commit SHA、實際命令與結果(可從 run summary 的 step log 取得)。這個 workflow 證明的是 Ubuntu/Node.js 24 下的本機與 stub provider 路徑;真實 OAuth/provider 網路行為,以及 Windows、macOS 或其他 Node 版本,仍需另外驗證。Issue #5 的實際 run 證據(含隔離負向測試失敗→復原)見 [`.github/quality-audits/2026-10-01-ci-regression-validation.md`](.github/quality-audits/2026-10-01-ci-regression-validation.md)。
## 官方文件
- [YouTube Data API](https://developers.google.com/youtube/v3)
- [YouTube OAuth 2.0](https://developers.google.com/youtube/v3/guides/authentication)
- [YouTube 播放清單實作](https://developers.google.com/youtube/v3/guides/implementation/playlists)
- [playlistItems.insert](https://developers.google.com/youtube/v3/docs/playlistItems/insert)
- [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk)
TDQS
Scored across 15 tools
There is notable overlap between search, identify, and resolve_links on both platforms, as identify accepts search text and resolve_links handles links in batch. youtube_save_track also bundles identify, classify, and add, which could be confused with youtube_add_to_playlist. However, most tools have clear, distinct resource-action targets.
All tools follow a consistent platform_verb_noun pattern in snake_case, with spotify_ and youtube_ prefixes. The verbs are descriptive and predictable, with no mixed conventions or camelCase exceptions.
15 tools is at the upper edge of the ideal range, but justified by covering two platforms with search, identify, resolve, classify, duplicate-check, and playlist management operations. A few could be consolidated (e.g., identify vs resolve_links), but the count is not excessive for the scope.
The YouTube side includes list, create, add, and save operations, but the Spotify side lacks basic playlist management like listing, creating, or adding tracks to playlists. This is a significant gap for a playlist organizer, as users cannot directly manage Spotify playlists beyond organizing derived categories.