Skip to main content
Glama
Reese-max

Music Playlist Organizer MCP

by Reese-max
README.md
# 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

B3.1/5.0

Scored across 15 tools

Disambiguation3/5

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.

Naming Consistency5/5

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.

Tool Count4/5

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.

Completeness2/5

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.

Maintenance

ActivityMaintained
ResponsivenessWithin a week