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。
- access/refresh token 存於加密憑證檔;client secret 與 `YOUTUBE_CREDENTIAL_PASSPHRASE` 由本機環境或 secret manager 提供。這些值不會寫入音樂庫 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 從備份消失。MCP 的共用輸出入口另會遞迴遮罩成功/錯誤回應內的憑證欄位、上述樣式,以及本次 client 已載入/輪替的 token 與已設定的 secret/passphrase;包含 `save_music`/`library_sync`/`library_query` 成功回應內嵌的 `errorInfo`。不會改寫原始音樂資料;HTTP facade 的成功回應不在這項 MCP 保證範圍內。
同步 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_PASSPHRASE` 且 stdin/stdout 為 TTY 時,`youtube:auth` 會以 raw mode 關閉終端機回顯後才讀取 passphrase——輸入不會出現在螢幕、一般終端輸出錄影或 PTY output capture 中。若 recorder 或 host 另行擷取 stdin/按鍵,仍可能記錄輸入;輸入 passphrase 時不要啟用這類 capture。若執行環境無法保證關閉回顯(非 TTY、不支援 raw mode 的 host),腳本會在啟動 OAuth callback 或任何網路請求之前 fail closed,要求改用環境變數提供 passphrase。中途取消(Ctrl+C)或終端送來損壞、不完整的位元組也會走同一條 fail-closed 路徑,不會把被截斷的輸入當成 passphrase 存進憑證檔。
原生終端回歸可用 `python scripts/test-hidden-input-terminal.py`(macOS/Linux 用 `python3`)執行,不需 npm 套件、帳號或 OAuth:Windows 直接呼叫 Win32 `CreatePseudoConsole`,macOS/Linux 使用 POSIX `openpty`,不偽造 `isTTY`。先以開啟 echo 的原生終端確認 capture 偵測器能抓到假哨兵,再驗證隱藏輸入、Unicode/backspace、Enter、Ctrl+C/Ctrl+D、同一終端恢復 cooked echo,以及真實 auth entrypoint 的 pre-OAuth 失敗。子程序只取得隔離的假設定,禁止載入 `.env`、callback 或任何網路連線;不保存輸入或 transcript,只印平台/Node/TTY 與結果。ConPTY 會先轉譯輸入 UTF-8,因此 malformed-byte 拒絕另由 helper unit tests 驗證,不把 ConPTY 的轉碼當成 helper 收到原始位元組的證據。
`.github/workflows/hidden-input-platforms.yml` 在相關 PR 變更時,於標準 `windows-latest`/`macos-latest` runner 以 Node 24 跑上述 focused harness;job 上限 3 分鐘、step 上限 2 分鐘、`contents: read`,不安裝 npm 依賴、不跑跨 OS 全套件、不用 larger runner、secret、artifact 或付費服務。驗收請保存 exact head SHA、Actions run URL 與輸出的 OS/architecture/Node/TTY;通過只涵蓋記錄到的 image/TTY 組合。
Windows 的 Node/libuv `setRawMode(false)` 會將原生模式設為 processed/line/echo,並不逐位保存所有其他 console flags。harness 記錄完整 before/during/after mode 值,要求相關三個 flags 恢復,且在同一 console 實際讀取 cooked line、觀察 echo;不能將此結果當成所有 Win32 console flags 都原樣恢復的保證。
「MCP host launch/capture」在此只涉及 **auth setup 腳本**被其他 host 啟動時的 stdin/stdout 契約;`src/server.js` 的 MCP stdio 路徑不啟動此腳本,也不互動索取 passphrase。harness 以真正 pipe handles 驗證 auth 無 passphrase 時 fail closed、有假環境 passphrase 時跳過 prompt,兩者都在 callback/network 前停止。這可離線驗證 repository 的 host 契約;不是某個未指名 MCP client 的 UI/錄影證據。若實際 host 另外提供 TTY 或錄影,需記錄它的版本、launch/TTY 類型並確認沒有 stdin/按鍵擷取;隱藏輸出無法阻止 host 自行記錄輸入。不需真實帳號來完成這些檢查。
`YOUTUBE_CREDENTIAL_FILE` 未設定時,預設位置是使用者設定目錄下的 `music-playlist-organizer/youtube-credentials.json`。`YOUTUBE_ACCESS_TOKEN` 與 `YOUTUBE_REFRESH_TOKEN` 仍可作為明確的本機 fallback,但不建議在一般部署中使用,也不要提交到 repository。
憑證邊界沿用 `YouTubeClient.getUserToken`/`refreshUserToken`/`credentialStatus`/`revokeUserCredentials`,持久化由 `CredentialStore.load`/`save`/`remove`/`status` 負責。setup 與 refresh 都必須取得完整的 `https://www.googleapis.com/auth/youtube` scope;refresh 省略 scope 時只沿用同一 refresh token 先前已驗證的授權,明確空值、縮減授權與無已知授權都以 `AUTH_SCOPE_INSUFFICIENT` 拒絕,且不保存/使用新 token。未具備 scope 的舊憑證需要重新授權。成功刷新後會沿用新憑證,包含輪替的 refresh token。
`youtube_auth_status({})` 只回傳狀態與非秘密的來源/scope/expiry:`READY`(有效)、`EXPIRED`(已到期,`refreshable` 表示可嘗試刷新)、`REVOKED`(刷新收到 `invalid_grant`)、`MISSING`(無憑證檔)、`UNKNOWN`(無法解密、缺 scope/expiry,或使用環境 fallback)。`invalid_grant` 會清除失效的 token,並為同一組已存授權保存不含 token 的撤銷標記;失敗的環境覆寫不會覆蓋另一組已存授權。執行 `npm run youtube:auth` 重新授權即可恢復,既有 client 可在 status/後續載入時取得新憑證。明確環境 fallback 的 scope/expiry 無法驗證,status 會附上本機開發用途的警示;client 的記憶體快取與撤銷操作不會修改其他 client 的環境設定。`youtube_auth_revoke({})` 撤銷遠端授權並刪除本機憑證檔,也可刪除無 token 的撤銷標記,之後查詢為 `MISSING`。只有成功或遠端明確回報 `invalid_token` 才刪除本機檔案,其他失敗保留供復原;撤銷會等待在途 refresh,並阻止舊讀取/刷新恢復已刪憑證。
POSIX 憑證目錄使用 `0700`、檔案使用 `0600`;Windows 的 POSIX mode 不等於 DACL,請使用本人設定目錄並確認只有本人可存取。不要將 passphrase 與憑證檔放在同一處,亦不要把自訂路徑內的憑證檔加入 Git。此加密檔搭配使用者管理的 passphrase,並未另接 OS Keychain。流程依據 [Google Desktop OAuth / PKCE](https://developers.google.com/identity/protocols/oauth2/native-app) 與 [OAuth 安全指引](https://developers.google.com/identity/protocols/oauth2/resources/best-practices);完整帳號與 host 驗收見 [Issue #3 驗收紀錄](docs/issue-3-acceptance.md)。
## 使用範例
先取得候選,不會寫入帳號:
```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.