diff --git a/db/migrations/0005_inbox_rejudge.sql b/db/migrations/0005_inbox_rejudge.sql new file mode 100644 index 00000000..85d3628b --- /dev/null +++ b/db/migrations/0005_inbox_rejudge.sql @@ -0,0 +1,40 @@ +-- 再判定(D-114)。jobs は CHECK の列挙に inbox_rejudge を足すため作り直す +-- (foreign_keys=OFF で適用。migrations.rs の FOREIGN_KEYS_OFF) + +CREATE TABLE jobs_new ( + id INTEGER PRIMARY KEY, + type TEXT NOT NULL + CHECK (type IN ('scan','rip','verify','rg','transcode','tagwrite','rename', + 'normalize','thumbnail','flaccheck','inbox','ytdl','gc','backup','hirescheck', + 'playlist_sync','source_hash','device_scan','device_sync','device_verify', + 'rgwrite','inbox_rejudge')), + dedup_key TEXT, + payload TEXT NOT NULL CHECK (json_valid(payload)), + state TEXT NOT NULL DEFAULT 'queued' + CHECK (state IN ('queued','running','done','failed','cancelled')), + edit_batch_id INTEGER REFERENCES edit_batches(id) ON DELETE SET NULL, + priority INTEGER NOT NULL DEFAULT 0, + run_after INTEGER, + progress REAL CHECK (progress IS NULL OR (progress >= 0.0 AND progress <= 1.0)), + total INTEGER CHECK (total IS NULL OR total >= 0), + done INTEGER CHECK (done IS NULL OR done >= 0), + attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0), + max_attempts INTEGER NOT NULL DEFAULT 5 CHECK (max_attempts >= 1), + last_error TEXT, + cancel_requested_at INTEGER, + created_at INTEGER NOT NULL, + started_at INTEGER, + finished_at INTEGER, + note TEXT +) STRICT; + +INSERT INTO jobs_new SELECT * FROM jobs; +DROP TABLE jobs; +ALTER TABLE jobs_new RENAME TO jobs; + +CREATE UNIQUE INDEX idx_jobs_dedup_active ON jobs(dedup_key) + WHERE dedup_key IS NOT NULL AND state IN ('queued','running'); +CREATE INDEX idx_jobs_queue ON jobs(state, run_after, priority DESC, created_at) + WHERE state IN ('queued','running'); +CREATE INDEX idx_jobs_finished ON jobs(finished_at) WHERE finished_at IS NOT NULL; +CREATE INDEX idx_jobs_batch ON jobs(edit_batch_id) WHERE edit_batch_id IS NOT NULL; diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 8de6037b..73a38050 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -4876,3 +4876,73 @@ mp3 13 曲が「書き込み結果が意図と一致しない(この形式で **却下**: 候補を下書きの初期値として入れる(確認せずに承認すると誤判定が書かれる)。CD の取り込みの画面にも同じ欄を置く (CD も Inbox の承認を通るので二重になる)。部分一致(キーの途中)で絞る(`TRACK` で `TOTALTRACKS` まで出て、狙いの候補が 埋もれる)。 + +--- + +## D-114 Inbox の YouTube 取り込みを再判定し、判定したプラグインの版を記録する + +**背景**(2026-10-18): YouTube の曲名・アーティストはメタデータプラグイン(`spindle-ytmusic-meta`)がタイトルから決める。 +ルールに穴があると、どれにも合わず `Inbox/youtube/_unmatched//` に動画タイトルのまま置かれるか、判定はされても +値が違う(全角 `#` で ARTIST が `花譜 #171` になる等)。プラグインを直しても取り込み済みの曲は直らず、補正フォームで +人が埋め直すしかない。却下して取り込み直そうにも、同じ `SOURCE_URL` が Inbox に残るのでダウンローダが「取り込み済み」で止まる。 +プラグインはホストからマウントしたバイナリを判定のたびに起動するので、`cp` で置き換えれば spindle を再起動せずに新しい +ルールが効く。あとはその判定をやり直す経路と、どの版で判定したかが画面から分かることが要る。 + +**決定**: + +1. **件ごとの「再判定」ボタン**(`POST /api/inbox/:id/rejudge` → 202 + `job_id`)。対象は state が `pending` で、サイドカーに + `source = youtube` の項を持つ曲が 1 本以上ある件。件の YouTube 由来の全曲を対象にし、未判定が無くても押せる(誤読の救済)。 + 出どころの無い曲(人が置いたもの)は触らない。承認済み・配置中・配置済み・失敗・却下の件にはボタンを出さない +2. **ジョブ `inbox_rejudge`**(並列 1、dedup `inbox_rejudge:`、`max_attempts` 1)。曲ごとにプラグインを起動し + yt-dlp も呼ぶのでリクエストの中では済ませない。人が直す前に繰り返しても意味がないので再試行しない。開始時と曲ごとに件が + `pending` かを確かめ、外れたら止める。終わったら `inbox` の走査を投入する +3. **版は Response の任意欄 `plugin`**(`{ name, version }`)で受ける。未知の欄はプラグインが無視する規則なので後方互換。 + 無ければ「不明」。形が違えば捨てるだけで応答は不正にしない。サイドカーの曲の項に `plugin_version` と `judged_at` を + ダウンローダの初回判定でも再判定でも残す +4. **動画タイトルの出どころ**: これからの取り込みは、プラグインへ渡した `Item` をサイドカーの `item` に残す。既存の曲 + (`item` が無い)は再判定のときにダウンローダの手順 1 と同じ引数で yt-dlp を呼んで取り直し、サイドカーに書き足す。 + 取れなければその曲だけ失敗(`rejudge_error`)とし、タグは触らず他の曲は続ける +5. **1 曲の写像**は初回の取り込みと同じ関数を共有する。購読由来(`subscription_id` があり購読が残る)は初回と同じく + albumartist / album / category を購読の値で上書きし、`ok: false`(skip を含む)でも合成 Track を書く。購読由来でない曲は + `ok` → TITLE / ARTIST / ALBUM / ALBUMARTIST / DATE / 追加タグを書く。`ok: false`(unmatched / unknown_channel / 未知)→ + TITLE を動画タイトルに戻しプラグイン由来のタグを消す。`skip` → 判定を記録するだけでタグは触らない。プラグインの故障 → その曲は + 失敗でタグもサイドカーも触らない。購読が消えた購読由来の曲で `ok` でないときは今のタグを保つ。追加タグは書いたキーを + サイドカーの `plugin_tags` に残し、今回の応答に無いものを消す(記録の無い既存の曲は消さず上書きだけ。タグの慣習を + spindle に持ち込まない。D-69)。ファイル名も初回と同じ規則(` [<id>].opus`)で付け直し、TRACKNUMBER / + PICTURE / SOURCE_URL は触らない。件の `category` は判定の値で書き換え(無ければ消す)、語彙に無ければ足す +6. **移動の規則**: 判定が ok か否かと、**曲が今いるディレクトリ**(`youtube/_unmatched/` の下か)が食い違うときだけ初回の規則 + (`inbox_dir`)の宛先へ移す。unmatched → ok は本来の件(`youtube/花譜/花譜のお歌/`)へ、ok → unmatched は + `youtube/_unmatched/<channel>/` へ。ok のまま宛先だけ変わる場合と、購読由来の曲(購読が消えていても)は移さず、件の中で + タグだけ直す。移動先に件が無いか `pending` のときだけ移し、それ以外なら移さずタグだけ直して「移せなかった(移動先が + <state>)」を結果に出す。移動先のサイドカーに項を先に書いてから元を消し、元の項を消す +7. **冪等性**: 曲ごとに「タグを tmp に写して書く → `RENAME_NOREPLACE` で新しい名前へ → 元を消す → サイドカー」。新しい名前に + 既にあるファイルは、その `SOURCE_URL` が同じなら前回の途中の自分の成果物なので今回の書いたもので置き換える。 + サイドカーの項のファイルが無いときは、項を持たないファイルのうち `SOURCE_URL` が項の `url` と同じものを前回の途中で名前を + 変えた後のファイルとして拾う。それも無い項は消す(宛先の項は先に書いてあるので、元の項を消す前に落ちた残りは捨ててよい) +8. **下書きは捨てる**: 補正の下書き(`inbox_items.draft`)は承認後に再開した件などに残り、提案でタグより勝つ。ジョブの + 最初に消す(ユーザの判断)。押す前に、保存済みの下書きがある件は「保存済みの補正は消え、判定結果で置き換わります」、 + 画面に未送信の編集がある件は「未保存の補正は消えます」と確認し、どちらも無ければ確認しない +9. **画面**: 件の見出しに「再判定」(実行中は無効で `再判定中 done/total`)と判定の版(揃っていれば + `判定: spindle-ytmusic-meta v…`、混ざれば `判定: v… ほか`、記録が無ければ `判定: 版不明`)。③ の判定列の詳細に版と + 判定時刻と失敗の理由。失敗した曲があれば件の警告に「再判定できなかった曲が N 曲」。終わったら一覧を取り直し、 + 件の全曲が別の件へ移ったらそれを開き、一部なら「N 曲を <移動先> へ移した」と知らせる。API は `[ytmusic].enabled` で + なければ 404、件が無ければ 404、`pending` でない・YouTube 由来の曲が無ければ 409 `state`、同じ件のジョブが + あれば 409 `duplicate`、Inbox が使えなければ 503 `inbox_unavailable`(他の Inbox の API と同じ) +10. **再判定の間は件を動かさない**: 件の再判定ジョブが queued / running の間、承認・却下・再開は 409 `rejudging`。ジョブも + 判定の応答を待った後、書く前に件が `pending` かを確かめ直す。曲ごとの確認だけでは、プラグインを待つ間の承認を見逃して + 承認済みの件のファイルを書き換え、走査が承認を pending に戻してしまうため + +**理由**: 版を `--version` の別起動で取る案は、プロトコル外のフラグに依存し、答えたバイナリと版が食い違いうる。Response の +欄ならその応答を返した実体の版そのものが取れる。動画タイトルは ok の曲の TITLE が解析後の値で元に戻せないため、 +プラグインに渡した `Item` を残すのが確実で、既存の曲だけ取り直せばよい。移動をサイドカーの前回の判定ではなく実際の +ディレクトリで決めるのは、移せなかった曲(移動先が承認済み等)の判定だけが先に書き換わっても、次の再判定でその曲を +移せるようにするため。移動先を `pending` に限るのは、承認済みの下書きが件のファイル集合を前提にし、却下・破棄待ちの件に +足すと走査が破棄を解いてしまうため(D-90)。購読由来を移さないのは、その曲の置き場所が購読の設定で決まりタイトル解析では +変わらないため。新しい名前に同じ `SOURCE_URL` のファイルが残るのは前回の中断の産物なので置き換えて収束させる(ダウンローダの +手順 8 は「違えば Fatal」を保つが、そちらは別の動画との衝突を見分けるため)。再試行しないのは、失敗の多くがプラグインや +yt-dlp の状態に依り、人がルールを直してもう一度押すのが正しい復帰だから。 + +**却下**: 承認済み・配置済みの件の再判定(Library に入った曲は通常の一括編集で直す)。ok の曲を別のアーティスト・アルバムの件へ +移すこと(ok の albumartist / album はチャンネル定義か購読で決まる)。再判定前後の差分表示と確認ダイアログ(確認は失うものが +ある下書きのときだけ)。Inbox 全体の一括再判定(件ごとのボタンだけ)。動画タイトルの取り直しの常時実行(YouTube 側の変更は +追わない)。 diff --git a/docs/SPEC.md b/docs/SPEC.md index 198b189f..2cb6ec6c 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1007,12 +1007,17 @@ multi_value_separator = " & " # 多値フィールドの結合 "track": { "title": "…", "artists": ["…"], "albumartist": "…", "album": "…", "category": "<統制語彙の名前>" | null, // null は未分類(_Unsorted) "date": "YYYY[-MM[-DD]]" | null, - "tags": [["ORIGINALARTIST", "…"]] } } // 追加のタグ(キー, 値) + "tags": [["ORIGINALARTIST", "…"]] }, // 追加のタグ(キー, 値) + "plugin": { "name": "spindle-ytmusic-meta", "version": "v0.1.0-rc.2" } } // 任意。判定したプラグインの版(D-114) { "protocol": 1, "ok": false, "reason": "unmatched" | "unknown_channel" | "skip" | "unsupported", // 未知の値も通す(前方互換) - "message": "…" } // unmatched / unknown_channel は要対応(メッセージをそのまま見せる)、skip は取り込まない。 + "message": "…", // unmatched / unknown_channel は要対応(メッセージをそのまま見せる)、skip は取り込まない。 // reason / message は必須 + "plugin": { "name": "…", "version": "…" } } // 任意(ok の応答にも同じ) ``` + `plugin` は**任意**(未知の欄はプラグインが無視する規則と同じく後方互換)。無い・形が違う(name / version が空でない + 文字列でない)ときは「不明」として扱い、応答は不正にしない。spindle は応答の `plugin.version` をサイドカーに残し、 + Inbox に表示する(再判定、§7.8、D-114)。`spindle-ytmusic-meta` は `--version` と同じ値(git describe)を返す - **spindle 側の写像**: `track` → タグ(TITLE / ARTIST 多値 / ALBUM / ALBUMARTIST / DATE / TRACKNUMBER + `tags`)と `pathgen::TrackFields`(category / albumartist / artist(先頭)/ album / title / track_no / year)。 `category` は `categories` に同じ canonical key の語彙が無ければ追加する(プラグインの定義が正。 @@ -1063,8 +1068,15 @@ multi_value_separator = " & " # 多値フィールドの結合 "files": { "<ファイル名>": { "source": "youtube", "url": "<webpage_url>", "channel": "<uploader>", "verdict": "ok" | "unmatched" | "unknown_channel" | "skip" | "<未知の reason>", "message": "…" | null, - "subscription_id": 3, "position": 17 } } } // 購読由来のときだけ(P4-16) + "subscription_id": 3, "position": 17, // 購読由来のときだけ(P4-16) + "item": { … }, // プラグインへ渡した Item(再判定の材料。D-114) + "plugin_version": "v0.1.0-rc.2" | null, // 判定したプラグインの版(Response の plugin.version) + "judged_at": 1760000000, // 判定した時刻(UNIX epoch 秒) + "plugin_tags": ["ORIGINALARTIST"], // ok のときにプラグインが書いた追加タグのキー(大文字) + "rejudge_error": "…" } } } // 直近の再判定がこの曲で失敗した理由(成功で消す) ``` + `item` 以降の 4 欄は任意(無いサイドカーも読める。`version` は 1 のまま)。ダウンローダが初回の判定で `item` / + `plugin_version` / `judged_at` / `plugin_tags` を書き、再判定(§7.8)が書き換える - **失敗の区分**: `Fatal`(再試行なし)は webm の音声なし・プラグインの故障・対応していない URL・宛先の 同名で別の内容(取り込み済みは失敗でなく `done` + note)。それ以外(yt-dlp / ffmpeg の非ゼロ終了、I/O、仕上げ)は `Failed` で指数 バックオフ。作業領域はどの終わり方でも消す。**再実行は冪等**: Archive は同じ id なら採用、Inbox は @@ -1325,6 +1337,50 @@ Inbox/ に配置(ポーリング検出) - **承認後にファイルが増えた取り込み**(同じ album への追加ダウンロード)は既存の規則で `pending` に戻る。そのとき 提案は「保存した下書き(既知のファイルの分)+ 新しいファイルの提案」を merge して返す(補正をやり直させない) +- **再判定**(`inbox_rejudge` ジョブ、`src/import/ytmusic/rejudge.rs`、D-114): プラグインのルールを直した後に、承認前の + YouTube の件をボタン 1 つで判定し直す。`POST /api/inbox/:id/rejudge`(pending で、サイドカーに `source = youtube` の項が + ある件だけ)が、その件の YouTube 由来の全曲を対象にジョブを投入する。出どころの無い曲は触らない。プラグインはバイナリを + 判定のたびに起動するので、`cp` で置き換えれば再起動は要らない。 + - **材料**: 項の `item`(プラグインへ渡した Item)。無い既存の曲は、ダウンローダの手順 1 と同じ引数で yt-dlp を呼んで + 取り直して項に書き足す。取れなければその曲は失敗(`rejudge_error`。タグは触らない)で、他の曲は続ける + - **1 曲**: 初回と同じ判定と写像(`downloader::judge` / `tag_changes_for` を共有)。購読由来(購読が残る)は albumartist / + album / category を購読の値で上書きし、`ok: false` でも初回と同じ合成 Track を書く。購読由来でない曲は `ok` → + TITLE / ARTIST / ALBUM / ALBUMARTIST / DATE / 追加タグを書き、前回書いた追加タグ(`plugin_tags`)のうち今回の応答に無い + ものを消す(記録の無い曲は上書きだけ)。`ok: false` → TITLE を動画タイトルに戻しプラグイン由来のタグを消す。`skip` → + 判定の記録だけでタグは触らない。プラグインの故障 → その曲は失敗でタグもサイドカーも触らない。購読が消えた購読由来の + 曲で `ok` でないときは今のタグを保ち、判定だけ記録する。ファイル名も `<YYYYMMDD> <title> [<id>].opus` で付け直し、 + TRACKNUMBER / PICTURE / SOURCE_URL は触らない。件の `category` は判定の値で書き換え(判定に無ければ消す。タグを + 触らない判定では category も触らない)、語彙に無ければ足す。サイドカーの項に `verdict` / `message` / `plugin_version` / `judged_at` / `plugin_tags` を書く + - **移動**: 判定が ok か否かと、曲が**今いるディレクトリ**(`youtube/_unmatched/` の下か)が食い違うときだけ、初回の規則の + 宛先へ移す(unmatched → ok は `youtube/<albumartist>/<album>/`、ok → unmatched は `youtube/_unmatched/<channel>/`)。 + ok のまま宛先だけ違う場合と購読由来の曲(購読が消えていても)は移さない。移動先に件が無いか `pending` のときだけ移し、 + それ以外は移さずタグだけ直して結果に「移せなかった(移動先が <state>)」を出す。宛先のサイドカーに項を先に書き、 + 元のファイルを消してから元の項を消す。空になった件は走査が消す + - **下書きは捨てる**: ジョブの最初に件の `draft` を消す(提案でタグより勝つため)。画面は、保存済みの下書きがある件では + 「保存済みの補正は消え、判定結果で置き換わります」、画面に未送信の編集がある件では「未保存の補正は消えます」と確認し、 + どちらも無ければ確認しない + - **冪等・排他**: 開始時と曲ごと、それに曲ごとの判定(yt-dlp・プラグインの応答)の後でファイルを書く前に件が + `pending` かを確かめ、外れたら止める(続けると走査が承認を pending に戻す)。件の再判定ジョブが queued / running の + 間は、件の承認・却下・再開を API が 409 `rejudging` で断る(状態の検査と同じトランザクションで見るので、確かめてから + 書くまでの間に承認されることはない)。曲ごとに「tmp に写してタグ → `RENAME_NOREPLACE` で新しい名前 → 元を unlink → サイドカー」。新しい名前に同名があれば、 + その `SOURCE_URL` が同じときは前回の途中の自分の成果物として今回の書いたもので置き換える。項のファイルが無いときは + 項を持たないファイルのうち `SOURCE_URL` が項の `url` と同じものを拾う。それも無い項は消す(移動で宛先の項を書いて + 元を消した後、元の項を消す前に落ちた残り。失敗には数えない)。ファイルは排他ロックしない。サイドカーの + 読んで直して書くは、並んで走る ytdl と共有するプロセス内の鍵(`DownloaderEnv::sidecar_lock`)で直列にする。ジョブは + `max_attempts` 1・dedup `inbox_rejudge:<item_id>`。終わったら書いたディレクトリ(移動で新しく作ったディレクトリの + 親を含む)を fsync して `inbox` の走査を投入する。キャンセル・件が pending でなくなって途中で止まっても、書き換えた + 曲があれば同じく投入する。結果(`note`)は + `再判定: N/M 曲、移動 K 曲、失敗 F 曲` と、移せなかった曲・失敗した曲の行 + - **画面**: 件の見出しに「再判定」(YouTube 機能が無効なら出さない。実行中は無効で `再判定中 done/total`、承認・却下・ + 下書きに戻すも無効)と `判定: spindle-ytmusic-meta v…`(版が混ざれば `判定: v… ほか`、記録が無ければ `判定: 版不明`)。 + ③ の判定列の詳細に版と判定時刻、失敗の理由。失敗した曲があれば件の `warnings` に「再判定できなかった曲が N 曲」。 + 終わったら一覧を取り直し、押した件が消えるか曲の形(名前・判定の記録)が変わった一覧が届いたところで、全曲が別の件へ + 移ったらその件を開き、一部なら「N 曲を <移動先> へ移した」と知らせる(終わってから 30 秒届かなければ「再判定した」 + だけ)。押してから投入が受理されて一覧に載るまでも無効(二度押し)。控えは件ごとに持ち、後から失敗した投入 + (409 `duplicate` 等)は受理済みの控えを消さない。`lib/inbox.ts` の `canRejudge` / `pluginVersionLabel` / + `rejudgeConfirmText` / `rejudgeLanding` / `rejudgeSettle` / `rejudgeOutcome` / `rejudgeBusy` / `rejudgeStarting*` / + `rejudgeWatch*`、`hooks/useInboxRejudge` + ### 7.9 FLAC 健全性チェック(移行時 + 任意) ``` @@ -1818,6 +1874,7 @@ CLAUDE.md の不変条件と禁止事項は Library(ユーザデータ)を | `inbox` | 1 | 固定 | | `ytdl` | 1 | `ytdl:<url>`(playlist の展開・購読の同期で投入する分も同じ。D-70、D-78) | | `playlist_sync` | 1 | `playlist_sync:<subscription_id>`(列挙 → 番号揃え → ytdl 投入。§7.7「再生リストの購読と同期」、D-78) | +| `inbox_rejudge` | 1 | `inbox_rejudge:<item_id>`(Inbox の件の再判定。`max_attempts` 1。§7.8、D-114) | | `source_hash` | 2(CPU 系の共通予算も取る) | `source_hash:<track_id>:<source>`(source = `master` / `opus` / `aac`。送る元の SHA-256。§7.11、D-95) | | `device_scan` | 2(同じ端末は排他。下記) | `device_scan:<device_id>`(回復してキャッシュを置き換える。未接続なら待たずに終わる。§7.11) | | `device_sync` | 2(同上) | `device_sync:<device_id>`(payload に `plan_id`。最大 3 回。確定した計画を実行。§7.11「同期」) | @@ -2140,7 +2197,7 @@ GET /api/inbox 承認キュー { "items": [{ proposal, draft, warnings, destination, tracks: [{ rel_path, codec, lossless, sample_rate, bit_depth, channels, duration_ms, tags, source, same_title: [{ track_id, rel_path, duration_ms }] }] }], - watch: { checked_at, poll_interval_secs }, discard_retention_days, + watch: { checked_at, poll_interval_secs }, discard_retention_days, ytmusic_enabled, subscriptions: [{ id, album, synced_at, moved, renamed, tags_batch_id, rename_batch_id }] } # P4-18 / P4-19 / P4-22 / D-90 (§7.8、D-68。destination と source は D-70: source は spindle-inbox.json の @@ -2157,12 +2214,17 @@ GET /api/inbox/:id/artwork/:hash 取り込みのファイルの POST /api/inbox/scan inbox ジョブを投入(202 + job_id。queued / running があれば 409 duplicate) POST /api/inbox/:id/approve { category, albumartist, album, date, album_gain?, tracks: [{ rel_path, disc_no, track_no, title, artist, keep_artists?, tags?, picture? }] }。検証に通らなければ 400、pending / failed 以外は 409 → approved + - ジョブ投入。album_gain は既定 false(D-74)。tags / picture は §7.8(D-86)。 + ジョブ投入。件の再判定ジョブが queued / running なら 409 `rejudging`(message 付き。 + YouTube 機能が無効なら見ない。§7.8、D-114)。album_gain は既定 false(D-74)。tags / picture は §7.8(D-86)。 差し替える画像の artwork 行が無ければ 400 POST /api/inbox/:id/preview { draft }(approve と同じ形)。音声を読まずに配置の計画を引く → 200 { rel_dir, paths, error }(決められなければ rel_dir は null で error に 理由)。自分の成果物の再利用は見ないので見込み(D-86) -POST /api/inbox/:id/reject, /reopen rejected へ / pending へ戻す(approved / rejected / failed から) +POST /api/inbox/:id/rejudge pending の件の YouTube 由来の曲を今のプラグインで判定し直すジョブを投入(202 + job_id)。 + `[ytmusic].enabled` でなければ 404 / 件が無ければ 404 / pending でない・YouTube 由来の曲が + 無ければ 409 `state` / 同じ件のジョブがあれば 409 `duplicate` / 503 `inbox_unavailable`(§7.8、D-114) +POST /api/inbox/:id/reject, /reopen rejected へ / pending へ戻す(approved / rejected / failed から)。件の再判定ジョブが + queued / running なら 409 `rejudging`(approve と同じ) POST /api/inbox/:id/discard, /undiscard 却下した取り込みを破棄待ちにする / 取り消す(rejected だけ。消すのは GC。D-90) POST /api/ytmusic/download { urls: [string] }(1 件以上、各 1〜2048 文字)。URL ごとに ytdl ジョブを投入 POST /api/ytmusic/lookup { urls: [string] }(200 件まで)→ { items: [{ url, kind: video|playlist|other|invalid, @@ -2912,6 +2974,19 @@ GC は削除件数・バイト数と区分ごとの内訳・見送りと失敗 横並びで出す: `配置 → RG → opus → aac → <端末…>`(`GET /api/inbox` の `stages`)。段ごとに 完了 / 進行中 (n/m)/ 未 / 対象外。RG は書き込みが要る設定なら書き込みまで、系統は無効なら「(無効)」で対象外、端末は その端末の選曲に入る曲の数を母数に反映済みを数える(対象外へ向かう曲は数えない) +- **Inbox の再判定**(D-114、§7.8): 件の見出しの操作群に「再判定」。出すのは YouTube 機能が有効(`GET /api/inbox` の + `ytmusic_enabled`)で、`pending` で YouTube 由来の曲(`source` を持つ)がある件だけ。実行中は無効にして + `再判定中 done/total`(ジョブの進捗)を出し、「承認して配置」「却下」「下書きに戻す」も無効にする(押してから投入が + 受理されて一覧に載るまでも同じ。`rejudgeBusy`。API も再判定の間は 409 `rejudging`)。押す前に、保存済みの下書きがある件は + 「保存済みの補正は消え、判定結果で置き換わります。再判定しますか?」、画面に未送信の編集がある件は「未保存の補正は消えます。 + 再判定しますか?」と確認し、どちらも無ければ確認しない(`rejudgeConfirmText`)。見出しのバッジは + `判定: spindle-ytmusic-meta <版>`(曲ごとの版が揃うとき)/ `判定: <版> ほか`(混ざるとき)/ `判定: 版不明` + (`pluginVersionLabel`)。③ の判定列を開いた詳細に、判定の版(無ければ「不明」)と判定時刻、再判定が失敗した曲は + 赤字で `再判定できなかった: <理由>`。ジョブが終わったら一覧を取り直す。ジョブの終わりは走査より先に来るので、終わった + 後に取り直した一覧で押した件が消えるか曲の形(名前・判定の記録)が押した時点と変わるまで待ち(`rejudgeSettle`)、 + 押した件が消えて全曲が別の 1 件に揃っていればその件を開き、一部だけ移ったら「再判定した。N 曲を <移動先> へ移した」と + 知らせる(移していなければ「再判定した」。`rejudgeOutcome` / `rejudgeLanding`)。終わってから 30 秒変わらなければ + 「再判定した」とだけ知らせる。失敗した曲があれば件の警告に「再判定できなかった曲が N 曲」 - **操作タブの「album gain」**(P4-5、D-74): 「ReplayGain / FLAC」節に、選択行が属する album ごとの チェックボックス(`PATCH /api/albums/:id`。20 album を超えたら絞るよう促す)。アルバム画面は無く アルバム一覧は表を絞るだけなので、切り替えはここに置く diff --git a/docs/TASKS.md b/docs/TASKS.md index 03dc8358..92f15077 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -2065,3 +2065,8 @@ ALAC は無傷)。D-89 は「ffmpeg は `stts` 末尾の長さ 0 のサンプ 保持中を既定に 100 件ずつ、照合・吸い出しログを API で読む(`GET /api/logs`)、ジョブ画面の結果列と行の展開、 GC・スキャン・バックアップの結果をジョブの note に、設定画面の category を使用数つきの表に。 仕様 docs/superpowers/specs/2026-10-15-records-view-design.md +- [x] Inbox の再判定(2026-10-18。D-114): pending の件の YouTube 由来の曲を `POST /api/inbox/:id/rejudge`(`inbox_rejudge` + ジョブ)で今のプラグインに問い合わせ直し、タグ・ファイル名・置き場所・サイドカーを初回と同じ形に書き直す。 + 未判定 → ok は本来の件へ移す(移動先が pending のときだけ)。Response に任意の `plugin`、サイドカーに `item` / + `plugin_version` / `judged_at` / `plugin_tags` / `rejudge_error` を足し、判定の版を件の見出しと判定列に出す。 + 仕様 docs/superpowers/specs/2026-10-18-inbox-rejudge-design.md diff --git a/src/api/inbox.rs b/src/api/inbox.rs index 8623e237..c4114865 100644 --- a/src/api/inbox.rs +++ b/src/api/inbox.rs @@ -21,6 +21,7 @@ use crate::import::inbox::{ }; use crate::import::sidecar::FileEntry; use crate::jobs::handlers::inbox::new_inbox_job; +use crate::jobs::handlers::inbox_rejudge::{new_inbox_rejudge_job, rejudge_active}; use crate::jobs::EnqueueResult; use super::error::{error_response, error_response_with_message, ApiError}; @@ -63,6 +64,8 @@ pub struct ItemList { pub watch: WatchView, /// 破棄待ちの件を GC が消すまでの日数(`[gc].retention_days`。D-90) pub discard_retention_days: u32, + /// YouTube 機能(`[ytmusic].enabled`)。無効なら画面は再判定のボタンを出さない(D-114) + pub ytmusic_enabled: bool, } /// 購読の直近の同期の要約(`last_result` から。P4-22) @@ -251,6 +254,7 @@ pub async fn list(State(state): State<AppState>) -> Result<Response, ApiError> { poll_interval_secs: state.config.inbox.poll_interval_secs, }, discard_retention_days: state.config.gc.retention_days, + ytmusic_enabled: state.config.ytmusic.enabled, }) .into_response()) } @@ -278,6 +282,7 @@ pub async fn approve( let value = serde_json::to_value(&draft) .map_err(|e| ApiError::Internal(format!("下書きを JSON にできない: {e}")))?; let layout = state.config.layout.clone(); + let ytmusic = state.config.ytmusic.enabled; // 状態の検査・下書きの検証・保存・遷移を 1 トランザクションで行い、遷移は CAS // (読んでから書くまでに worker / 走査 / 別の API が動かした件を上書きしない) let outcome = state @@ -286,6 +291,9 @@ pub async fn approve( let Some(item) = dbinbox::get(c, id)? else { return Ok(Approve::NotFound); }; + if ytmusic && rejudge_active(c, id)? { + return Ok(Approve::Rejudging); + } if !matches!(item.state, ItemState::Pending | ItemState::Failed) { return Ok(Approve::State); } @@ -332,6 +340,7 @@ pub async fn approve( match outcome { Approve::NotFound => return Ok(error_response(StatusCode::NOT_FOUND, "not_found")), Approve::State => return Ok(error_response(StatusCode::CONFLICT, "state")), + Approve::Rejudging => return Ok(rejudging("承認")), Approve::Bad(msg) => { return Ok(error_response_with_message( StatusCode::BAD_REQUEST, @@ -345,9 +354,58 @@ pub async fn approve( Ok((StatusCode::ACCEPTED, Json(Accepted { job_id })).into_response()) } +/// `POST /api/inbox/{id}/rejudge`: pending の件の YouTube 由来の曲を、今のプラグイン・ルールで +/// 判定し直すジョブを投入する。YouTube 機能が無効なら 404(ハンドラが登録されない) +pub async fn rejudge( + State(state): State<AppState>, + Path(id): Path<i64>, +) -> Result<Response, ApiError> { + if !state.config.ytmusic.enabled { + return Ok(error_response(StatusCode::NOT_FOUND, "not_found")); + } + let Some(inbox) = state.inbox.clone() else { + return Ok(error_response( + StatusCode::SERVICE_UNAVAILABLE, + "inbox_unavailable", + )); + }; + let Some(item) = state.db.read(move |c| dbinbox::get(c, id)).await? else { + return Ok(error_response(StatusCode::NOT_FOUND, "not_found")); + }; + if item.state != ItemState::Pending { + return Ok(error_response(StatusCode::CONFLICT, "state")); + } + // サイドカーの読みはファイル I/O。YouTube 由来の曲が 1 つも無い件(手置き・CD)は対象外 + let from_youtube = tokio::task::spawn_blocking(move || { + let sidecar = match RelPath::parse(&item.rel_dir) { + Ok(dir) if !item.rel_dir.is_empty() => { + crate::import::sidecar::Sidecar::read(&inbox, &dir) + } + _ => return false, + }; + sidecar + .ok() + .flatten() + .is_some_and(|s| s.files.values().any(|f| f.source == "youtube")) + }) + .await + .map_err(|e| ApiError::Internal(format!("サイドカーの読み込みが異常終了: {e}")))?; + if !from_youtube { + return Ok(error_response(StatusCode::CONFLICT, "state")); + } + Ok(match state.jobs.enqueue(new_inbox_rejudge_job(id)).await? { + EnqueueResult::Inserted(job_id) => { + (StatusCode::ACCEPTED, Json(Accepted { job_id })).into_response() + } + EnqueueResult::Duplicate(_) => error_response(StatusCode::CONFLICT, "duplicate"), + }) +} + enum Approve { NotFound, State, + /// 件の再判定ジョブが queued / running + Rejudging, Bad(String), Ok, } @@ -401,30 +459,60 @@ pub async fn preview( }) } +/// 409 rejudging: 件の再判定ジョブが queued / running の間は `action`(承認・却下・再開)できない。 +/// YouTube 機能が無効ならハンドラが登録されず待ちのジョブは走らないので、見ない(件を塞ぎ続けない) +fn rejudging(action: &str) -> Response { + error_response_with_message( + StatusCode::CONFLICT, + "rejudging", + format!("再判定の実行中は{action}できない。終わるまで待つ"), + ) +} + +enum Transition { + Done, + State, + Rejudging, + NotFound, +} + async fn transition( state: &AppState, id: i64, from: &[ItemState], to: ItemState, + action: &'static str, ) -> Result<Response, ApiError> { if let Some(r) = unavailable(state) { return Ok(r); } - // 遷移は CAS(今の状態が `from` のどれかのときだけ)。外れたら 404 か 409 を状態で分ける + // 遷移は CAS(今の状態が `from` のどれかのときだけ)。外れたら 404 か 409 を状態で分ける。 + // 件の再判定が queued / running なら動かさない(同じトランザクションで見るので投入と競合しない) let from = from.to_vec(); + let ytmusic = state.config.ytmusic.enabled; let moved = state .db .transaction(move |c| { - if dbinbox::transition(c, id, &from, to, None, now_epoch())? { - return Ok(Some(true)); + if dbinbox::get(c, id)?.is_none() { + return Ok(Transition::NotFound); } - Ok(dbinbox::get(c, id)?.map(|_| false)) + if ytmusic && rejudge_active(c, id)? { + return Ok(Transition::Rejudging); + } + Ok( + if dbinbox::transition(c, id, &from, to, None, now_epoch())? { + Transition::Done + } else { + Transition::State + }, + ) }) .await?; Ok(match moved { - Some(true) => StatusCode::NO_CONTENT.into_response(), - Some(false) => error_response(StatusCode::CONFLICT, "state"), - None => error_response(StatusCode::NOT_FOUND, "not_found"), + Transition::Done => StatusCode::NO_CONTENT.into_response(), + Transition::State => error_response(StatusCode::CONFLICT, "state"), + Transition::Rejudging => rejudging(action), + Transition::NotFound => error_response(StatusCode::NOT_FOUND, "not_found"), }) } @@ -437,6 +525,7 @@ pub async fn reject( id, &[ItemState::Pending, ItemState::Failed, ItemState::Approved], ItemState::Rejected, + "却下", ) .await } @@ -450,6 +539,7 @@ pub async fn reopen( id, &[ItemState::Approved, ItemState::Rejected, ItemState::Failed], ItemState::Pending, + "再開", ) .await } diff --git a/src/api/mod.rs b/src/api/mod.rs index 089ecad6..dcafdeb1 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -150,6 +150,7 @@ pub fn router(state: AppState) -> Router { .route("/inbox/summary", get(inbox::summary)) .route("/inbox/scan", post(inbox::scan)) .route("/inbox/{id}/approve", post(inbox::approve)) + .route("/inbox/{id}/rejudge", post(inbox::rejudge)) .route("/inbox/{id}/reject", post(inbox::reject)) .route("/inbox/{id}/reopen", post(inbox::reopen)) .route("/inbox/{id}/discard", post(inbox::discard)) diff --git a/src/db/inbox.rs b/src/db/inbox.rs index f6871f15..1cb93418 100644 --- a/src/db/inbox.rs +++ b/src/db/inbox.rs @@ -359,6 +359,15 @@ pub fn set_draft(conn: &Connection, id: i64, draft: &serde_json::Value) -> Resul Ok(()) } +/// pending の件の下書きを消す(再判定の開始時)。pending でなければ何もせず false +pub fn clear_pending_draft(conn: &Connection, id: i64) -> Result<bool> { + let n = conn.execute( + "UPDATE inbox_items SET draft = NULL WHERE id = ?1 AND state = 'pending'", + [id], + )?; + Ok(n > 0) +} + /// 配置済み pub fn set_placed(conn: &Connection, id: i64, album_id: i64, now: i64) -> Result<()> { conn.execute( diff --git a/src/db/jobs.rs b/src/db/jobs.rs index cd0dcce0..7dc12094 100644 --- a/src/db/jobs.rs +++ b/src/db/jobs.rs @@ -45,10 +45,12 @@ pub enum JobType { DeviceSync, /// 端末の内容の検証(全曲の sha256) DeviceVerify, + /// Inbox の 1 件の再判定(メタデータの取り直し。D-114) + InboxRejudge, } impl JobType { - pub const ALL: [JobType; 21] = [ + pub const ALL: [JobType; 22] = [ JobType::Scan, JobType::Rip, JobType::Verify, @@ -70,6 +72,7 @@ impl JobType { JobType::DeviceScan, JobType::DeviceSync, JobType::DeviceVerify, + JobType::InboxRejudge, ]; pub fn as_str(self) -> &'static str { @@ -95,6 +98,7 @@ impl JobType { JobType::DeviceScan => "device_scan", JobType::DeviceSync => "device_sync", JobType::DeviceVerify => "device_verify", + JobType::InboxRejudge => "inbox_rejudge", } } @@ -139,6 +143,8 @@ impl JobType { JobType::Rgwrite => 1, // 端末ごとの排他は `job_mutexes` の `device:<id>`。別の端末なら並べてよい JobType::DeviceScan | JobType::DeviceSync | JobType::DeviceVerify => 2, + // 1 件ずつ外部のメタデータを引き直す。Inbox の取り込みと同じく直列 + JobType::InboxRejudge => 1, } } @@ -350,6 +356,7 @@ pub fn subject_of( JobType::DeviceScan | JobType::DeviceSync | JobType::DeviceVerify => { id_of("device_id").map(|id| format!("device #{id}")) } + JobType::InboxRejudge => id_of("item_id").map(|id| format!("item #{id}")), JobType::Rip | JobType::Inbox | JobType::Gc | JobType::Backup => None, } } diff --git a/src/db/migrations.rs b/src/db/migrations.rs index a7c81641..7f2031f9 100644 --- a/src/db/migrations.rs +++ b/src/db/migrations.rs @@ -183,7 +183,8 @@ pub fn apply_list(conn: &mut Connection, list: &[Migration]) -> Result<Vec<u32>, /// CHECK を ALTER できないので、列挙値を足すたびに表を作り直す版がここに載る /// - 2: jobs.type に端末配信の 4 種を足す(D-95) /// - 4: jobs.type に rgwrite を足す(D-97) -const FOREIGN_KEYS_OFF: &[u32] = &[2, 4]; +/// - 5: jobs.type に inbox_rejudge を足す(D-114) +const FOREIGN_KEYS_OFF: &[u32] = &[2, 4, 5]; /// 1 版を 1 トランザクションで適用する。`check_fk` なら commit 前に `PRAGMA foreign_key_check` で /// 参照の整合を確かめる(違反があればロールバック) diff --git a/src/import/inbox.rs b/src/import/inbox.rs index 80ab0cf6..815ecd14 100644 --- a/src/import/inbox.rs +++ b/src/import/inbox.rs @@ -753,7 +753,7 @@ pub fn propose( .unwrap_or(0) + 1; number_missing(&mut draft, start); - let sources = files + let sources: Vec<Option<crate::import::sidecar::FileEntry>> = files .iter() .map(|f| { let name = f.rel_path.rsplit('/').next().unwrap_or(&f.rel_path); @@ -771,6 +771,16 @@ pub fn propose( None => draft.tracks.iter().map(|_| Vec::new()).collect(), }; warnings.extend(self::warnings(files, &draft)); + // 直近の再判定で失敗した曲(理由は ③ の判定列に出る) + let rejudge_failed = sources + .iter() + .filter(|s| s.as_ref().is_some_and(|e| e.rejudge_error.is_some())) + .count(); + if rejudge_failed > 0 { + warnings.push(format!( + "再判定できなかった曲が {rejudge_failed} 曲(③ の判定列を開くと理由が読める)" + )); + } // 吸い出しの記録が件と合わなければ配置で止まる(D-67 追記)。承認の前に見せる if let Some(entry) = sidecar.as_ref().and_then(|s| s.rip.as_ref()) { if let Err(r) = bind_rip(entry, &draft) { diff --git a/src/import/sidecar.rs b/src/import/sidecar.rs index 69f5e602..bcaee21f 100644 --- a/src/import/sidecar.rs +++ b/src/import/sidecar.rs @@ -20,6 +20,7 @@ use crate::cd::riplog::RipReport; use crate::cd::toc::{Toc, TocError}; use crate::domain::relpath::{canonical_key, RelPath}; use crate::fsroot::{FsError, RootDir}; +use crate::import::ytmusic::Item; pub const SIDECAR_NAME: &str = "spindle-inbox.json"; const VERSION: u32 = 1; @@ -54,6 +55,21 @@ pub struct FileEntry { pub subscription_id: Option<i64>, #[serde(default, skip_serializing_if = "Option::is_none")] pub position: Option<u32>, + /// プラグインへ渡した Item(再判定の材料。これより前の取り込みには無い) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub item: Option<Item>, + /// 判定したプラグインの版(応答の plugin.version。無ければ None) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin_version: Option<String>, + /// 判定した時刻(UNIX epoch 秒) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub judged_at: Option<i64>, + /// プラグインが書いた追加タグのキー(大文字。ok のときだけ。再判定で消す対象) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin_tags: Option<Vec<String>>, + /// 直近の再判定がこの曲で失敗した理由(成功で消す) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rejudge_error: Option<String>, } /// CD の吸い出しの記録(件のディレクトリ = 1 枚。P2-5、D-67 追記) diff --git a/src/import/ytmusic/downloader.rs b/src/import/ytmusic/downloader.rs index 55577a7d..b1f4eb8e 100644 --- a/src/import/ytmusic/downloader.rs +++ b/src/import/ytmusic/downloader.rs @@ -23,7 +23,7 @@ use std::fs::File; use std::io::Write as _; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::{Arc, Mutex, MutexGuard}; use std::time::Duration; use serde::Deserialize; @@ -149,6 +149,8 @@ pub fn subscription_target( })) } +/// ytdl と再判定が共有する環境。`clone` はサイドカーの鍵を共有する(ハンドラごとに作り直さない) +#[derive(Clone)] pub struct DownloaderEnv { pub db: Arc<Db>, pub inbox: Arc<RootDir>, @@ -161,6 +163,19 @@ pub struct DownloaderEnv { /// 作業領域の親(`<tmp_root>/<job_id>/`) pub tmp_root: PathBuf, pub download_timeout: Duration, + /// Inbox のサイドカー(`spindle-inbox.json`)の読んで直して書くを直列にするプロセス内の鍵。ytdl と + /// 再判定は別のジョブ枠で並んで走るので、鍵が無いと後に書いた方が先の項を消す。ファイルのロックでは + /// ない(他のプロセス・SMB は縛らない。不変条件 5) + pub sidecar_lock: Arc<Mutex<()>>, +} + +impl DownloaderEnv { + /// サイドカーの鍵を取る。毒されていても(鍵を持ったまま panic した)中身の無い鍵なので続ける + pub(crate) fn lock_sidecar(&self) -> MutexGuard<'_, ()> { + self.sidecar_lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } } #[derive(Debug, thiserror::Error)] @@ -392,30 +407,35 @@ impl VideoInfo { } } - /// Inbox に置くファイル名 `<YYYYMMDD> <title> [<id>].opus`(名前順 = 公開順 = 採番順。 - /// 日付が無ければ日付なし)。`title` は判定できたトラック名か動画タイトル + /// Inbox に置くファイル名 `<YYYYMMDD> <title> [<id>].opus`([`inbox_file_name_for`] に委ねる) pub fn inbox_file_name(&self, title: &str) -> String { - let id = sanitize_component(&self.id); - // 切り詰めは title だけに効かせる(id と日付は落とさない) - let suffix = format!(" [{id}].opus"); - let prefix = self - .upload_date - .as_deref() - .map(|d| format!("{d} ")) - .unwrap_or_default(); - let budget = crate::domain::relpath::MAX_COMPONENT_BYTES - .saturating_sub(prefix.len() + suffix.len()) - .max(1); - let mut title = sanitize_component(title); - while title.len() > budget { - title.pop(); - } - let title = title.trim_end(); - let title = if title.is_empty() { "_" } else { title }; - format!("{prefix}{title}{suffix}") + inbox_file_name_for(&self.item(), title) } } +/// Inbox に置くファイル名 `<YYYYMMDD> <title> [<id>].opus`(名前順 = 公開順 = 採番順。 +/// 日付が無ければ日付なし)。`title` は判定できたトラック名か動画タイトル。`uploaded_at` は YYYY-MM-DD +pub fn inbox_file_name_for(item: &Item, title: &str) -> String { + let id = sanitize_component(item.id.as_deref().unwrap_or("")); + // 切り詰めは title だけに効かせる(id と日付は落とさない) + let suffix = format!(" [{id}].opus"); + let prefix = item + .uploaded_at + .as_deref() + .map(|d| format!("{} ", d.replace('-', ""))) + .unwrap_or_default(); + let budget = crate::domain::relpath::MAX_COMPONENT_BYTES + .saturating_sub(prefix.len() + suffix.len()) + .max(1); + let mut title = sanitize_component(title); + while title.len() > budget { + title.pop(); + } + let title = title.trim_end(); + let title = if title.is_empty() { "_" } else { title }; + format!("{prefix}{title}{suffix}") +} + /// Inbox のディレクトリ: `youtube/<albumartist>/<album>`(判定できたとき)か /// `youtube/_unmatched/<channel>`(受け皿) pub fn inbox_dir(track: Option<&Track>, channel: &str) -> Result<RelPath, String> { @@ -430,24 +450,77 @@ pub fn inbox_dir(track: Option<&Track>, channel: &str) -> Result<RelPath, String RelPath::parse(&format!("{SUBDIR}/{a}/{b}")).map_err(|e| e.to_string()) } -// ---------------------------------------------------------------- 本体 +/// プラグインの判定(購読の上書き後) +pub struct Judged { + pub track: Option<Track>, + /// `ok` か、判定できなかった reason(`skip` / `unmatched` / `unknown_channel` 等) + pub verdict: String, + pub message: Option<String>, + /// 応答の `plugin.version`(無ければ None) + pub plugin_version: Option<String>, +} -/// URL 1 件を処理する。`job_id` は作業領域の名前に使う -pub async fn download_one( +/// プラグインへの問い合わせと購読の上書き(初回と再判定で共通)。購読由来(P4-16)は追記先 +/// (albumartist / album / category)を購読の値にし、判定不能でも合成した Track を返す +pub async fn judge( env: &DownloaderEnv, - job_id: i64, - request: &DownloadRequest, + item: &Item, + subscription: Option<&SubscriptionTarget>, token: &CancellationToken, -) -> Result<Downloaded, DownloadError> { - let url = request.url.as_str(); - let check_cancel = || { - if token.is_cancelled() { - Err(DownloadError::Cancelled) - } else { - Ok(()) +) -> Result<Judged, DownloadError> { + let resolved = match env.provider.resolve(item, token).await { + Ok(r) => r, + Err(ProviderError::Process(ProcessError::Cancelled)) => { + return Err(DownloadError::Cancelled) } + Err(e) => return Err(DownloadError::Fatal(e.to_string())), }; - // 1. dump + let plugin_version = resolved.plugin.map(|p| p.version); + let (track, verdict, message) = match resolved.outcome { + Outcome::Track(t) => (Some(t), "ok".to_owned(), None), + Outcome::Declined { reason, message } => { + tracing::info!(url = ?item.url, reason, "判定できない"); + (None, reason, Some(message)) + } + }; + // 購読由来: プラグインの判定は TITLE / ARTIST に使い、skip / 判定不能でも投入する + let track = match subscription { + Some(t) => Some(match track { + Some(mut tr) => { + tr.albumartist = t.albumartist.clone(); + tr.album = t.album.clone(); + tr.category = t.category.clone(); + tr + } + None => Track { + title: item.title.clone(), + artists: vec![t.albumartist.clone()], + albumartist: t.albumartist.clone(), + album: t.album.clone(), + category: t.category.clone(), + date: item.uploaded_at.clone(), + tags: Vec::new(), + }, + }), + None => track, + }; + Ok(Judged { + track, + verdict, + message, + plugin_version, + }) +} + +// ---------------------------------------------------------------- 本体 + +/// 手順 1 の dump(`--dump-single-json --flat-playlist`)。初回と再判定(`item` の無い既存の曲)で同じ +/// 引数を使う +async fn dump( + env: &DownloaderEnv, + url: &str, + token: &CancellationToken, +) -> Result<Dump, DownloadError> { let out = match ytdlp(env) .args([ "--dump-single-json", @@ -472,9 +545,39 @@ pub async fn download_one( } Err(e) => return Err(e.into()), }; - let video = match parse_dump(&out.stdout) - .map_err(|e| DownloadError::Fatal(format!("yt-dlp の出力を読めない: {e}")))? - { + parse_dump(&out.stdout) + .map_err(|e| DownloadError::Fatal(format!("yt-dlp の出力を読めない: {e}"))) +} + +/// 動画 1 本の dump(再判定がプラグインへ渡す `Item` を取り直す)。再生リストなら Fatal +pub async fn dump_video( + env: &DownloaderEnv, + url: &str, + token: &CancellationToken, +) -> Result<VideoInfo, DownloadError> { + match dump(env, url, token).await? { + Dump::Video(v) => Ok(v), + Dump::Playlist(_) => Err(DownloadError::Fatal(format!("動画でなく再生リスト: {url}"))), + } +} + +/// URL 1 件を処理する。`job_id` は作業領域の名前に使う +pub async fn download_one( + env: &DownloaderEnv, + job_id: i64, + request: &DownloadRequest, + token: &CancellationToken, +) -> Result<Downloaded, DownloadError> { + let url = request.url.as_str(); + let check_cancel = || { + if token.is_cancelled() { + Err(DownloadError::Cancelled) + } else { + Ok(()) + } + }; + // 1. dump + let video = match dump(env, url, token).await? { Dump::Playlist(urls) => { let mut enqueued = 0; let mut skipped = 0; @@ -513,50 +616,25 @@ pub async fn download_one( }; // 2. プラグイン(ダウンロードの前。skip ならここで終わり、宛先もここで決まる) let item = video.item(); - let (track, verdict, message) = match env.provider.resolve(&item, token).await { - Ok(Outcome::Track(t)) => (Some(t), "ok".to_owned(), None), - // 購読由来は skip でも投入する(再生リストは人が選んだもの。P4-16) - Ok(Outcome::Declined { reason, message }) if reason == "skip" && subscription.is_none() => { - tracing::info!(url, message, "プラグインが skip"); - return Ok(Downloaded::Skipped { message }); - } - Ok(Outcome::Declined { reason, message }) => { - tracing::info!(url, reason, "判定できないので受け皿へ"); - (None, reason, Some(message)) - } - Err(ProviderError::Process(ProcessError::Cancelled)) => { - return Err(DownloadError::Cancelled) - } - Err(e) => return Err(DownloadError::Fatal(e.to_string())), - }; - // 購読由来(P4-16): 追記先(albumartist / album / category)は購読の値。プラグインの判定は - // TITLE / ARTIST に使い、skip / 判定不能でも投入する(再生リストは人が選んだもの) - let (track, verdict) = match &subscription { - Some(t) => { - let track = match track { - Some(mut tr) => { - tr.albumartist = t.albumartist.clone(); - tr.album = t.album.clone(); - tr.category = t.category.clone(); - tr - } - None => Track { - title: video.title.clone(), - artists: vec![t.albumartist.clone()], - albumartist: t.albumartist.clone(), - album: t.album.clone(), - category: t.category.clone(), - date: video - .upload_date - .as_deref() - .map(|d| format!("{}-{}-{}", &d[..4], &d[4..6], &d[6..8])), - tags: Vec::new(), - }, - }; - (Some(track), verdict) - } - None => (track, verdict), - }; + let judged = judge(env, &item, subscription.as_ref(), token).await?; + // 購読由来は skip でも投入する(再生リストは人が選んだもの。P4-16) + if judged.verdict == "skip" && subscription.is_none() { + tracing::info!(url, message = ?judged.message, "プラグインが skip"); + return Ok(Downloaded::Skipped { + message: judged.message.unwrap_or_default(), + }); + } + let Judged { + track, + verdict, + message, + plugin_version, + } = judged; + // 再判定で消す対象(プラグインが書いた追加タグのキー)は ok のときだけ + let plugin_tags = track + .as_ref() + .filter(|_| verdict == "ok") + .map(plugin_tag_keys); let title = track .as_ref() .map(|t| t.title.clone()) @@ -574,6 +652,11 @@ pub async fn download_one( message, subscription_id: subscription.as_ref().and(request.subscription_id), position: subscription.as_ref().and(request.position), + item: Some(item), + plugin_version, + judged_at: Some(crate::db::now_epoch()), + plugin_tags, + rejudge_error: None, }; let category = track.as_ref().and_then(|t| t.category.clone()); // 揃える購読なら TRACKNUMBER = 再生リストの位置(同期が先に既存の行を揃えて隙間を空けている) @@ -666,7 +749,7 @@ pub async fn download_one( .run(token) .await?; // 6. タグ - let changes = tag_changes(track.as_ref(), &video, track_no); + let changes = tag_changes_for(track.as_ref(), &video.title, &video.webpage_url, track_no); let picture = match std::fs::read(&thumb) { Ok(bytes) if !bytes.is_empty() => Some( lofty::picture::Picture::unchecked(bytes) @@ -738,9 +821,12 @@ async fn finish_staging( category: Option<&str>, entry: FileEntry, ) -> Result<(), DownloadError> { - Sidecar::upsert(&env.inbox, dir, category, name, entry).map_err(|e| { - DownloadError::Failed(anyhow::anyhow!("spindle-inbox.json を更新できない: {e}")) - })?; + { + let _guard = env.lock_sidecar(); + Sidecar::upsert(&env.inbox, dir, category, name, entry).map_err(|e| { + DownloadError::Failed(anyhow::anyhow!("spindle-inbox.json を更新できない: {e}")) + })?; + } env.inbox.fsync_dir(Some(dir))?; env.jobs .enqueue(crate::jobs::handlers::inbox::new_inbox_job()) @@ -748,8 +834,20 @@ async fn finish_staging( Ok(()) } +/// プラグインの追加タグのキー(大文字。重複なし。サイドカーの `plugin_tags`) +pub(crate) fn plugin_tag_keys(track: &Track) -> Vec<String> { + let mut keys: Vec<String> = Vec::new(); + for (k, _) in &track.tags { + let k = k.to_ascii_uppercase(); + if !keys.contains(&k) { + keys.push(k); + } + } + keys +} + /// `target` の `SOURCE_URL` が `url` なら自分の成果物(同じ動画の同じ remux) -fn is_own_product(inbox: &RootDir, target: &RelPath, url: &str) -> bool { +pub(crate) fn is_own_product(inbox: &RootDir, target: &RelPath, url: &str) -> bool { let Ok(file) = inbox.open_file(target) else { return false; }; @@ -770,12 +868,18 @@ fn ytdlp(env: &DownloaderEnv) -> ExternalCommand { ExternalCommand::new(program).args(env.ytdlp.iter().skip(1)) } -/// ファイルに書くタグ。判定できたら `Track::tags(track_no)`(`track_no` は購読の位置。無ければ -/// TRACKNUMBER 無しで採番は Inbox)、できなければ TITLE に動画タイトル。どちらも `SOURCE_URL` -fn tag_changes(track: Option<&Track>, video: &VideoInfo, track_no: Option<i64>) -> Vec<TagChange> { +/// 初回のダウンロードで Opus に書くタグ。判定できたら `Track::tags(track_no)`(`track_no` は購読の +/// 位置。無ければ TRACKNUMBER 無しで採番は Inbox)、できなければ TITLE に動画タイトルだけ。どちらも +/// `SOURCE_URL` を足す +pub fn tag_changes_for( + track: Option<&Track>, + video_title: &str, + url: &str, + track_no: Option<i64>, +) -> Vec<TagChange> { let pairs: Vec<(String, String)> = match track { Some(t) => t.tags(track_no), - None => vec![("TITLE".to_owned(), video.title.clone())], + None => vec![("TITLE".to_owned(), video_title.to_owned())], }; let mut changes: Vec<TagChange> = Vec::new(); for (k, v) in pairs { @@ -789,7 +893,7 @@ fn tag_changes(track: Option<&Track>, video: &VideoInfo, track_no: Option<i64>) } changes.push(TagChange { key: SOURCE_URL_KEY.to_owned(), - values: Some(vec![video.webpage_url.clone()]), + values: Some(vec![url.to_owned()]), }); changes } diff --git a/src/import/ytmusic/metadata.rs b/src/import/ytmusic/metadata.rs index dc166cab..808639ae 100644 --- a/src/import/ytmusic/metadata.rs +++ b/src/import/ytmusic/metadata.rs @@ -118,6 +118,39 @@ struct Response { reason: Option<String>, #[serde(default)] message: Option<String>, + /// 応答したプラグインの名前と版。形が違えば捨てる(応答全体は不正にしない) + #[serde(default)] + plugin: Option<serde_json::Value>, +} + +/// 応答したプラグインの名前と版(再判定の要否を決めるために sidecar へ残す) +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginInfo { + pub name: String, + pub version: String, +} + +impl PluginInfo { + /// name / version が空でない文字列のときだけ読む + fn from_value(v: &serde_json::Value) -> Option<PluginInfo> { + let field = |k: &str| { + v.get(k) + .and_then(|x| x.as_str()) + .filter(|x| !x.trim().is_empty()) + .map(str::to_owned) + }; + Some(PluginInfo { + name: field("name")?, + version: field("version")?, + }) + } +} + +/// `resolve` の結果(判定と、応答したプラグインの版) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Resolved { + pub outcome: Outcome, + pub plugin: Option<PluginInfo>, } /// プラグインの判定 @@ -173,7 +206,7 @@ impl MetadataProvider { &self, item: &Item, token: &CancellationToken, - ) -> Result<Outcome, ProviderError> { + ) -> Result<Resolved, ProviderError> { let request = serde_json::to_vec(&Request { protocol: PROTOCOL, op: "metadata", @@ -200,6 +233,7 @@ impl MetadataProvider { if res.protocol != PROTOCOL { return Err(ProviderError::Protocol(res.protocol)); } + let plugin = res.plugin.as_ref().and_then(PluginInfo::from_value); if !res.ok { // reason / message の欠落と空はプロトコル不正(未知の reason は前方互換で通す) let reason = res @@ -210,13 +244,19 @@ impl MetadataProvider { .message .filter(|m| !m.trim().is_empty()) .ok_or_else(|| ProviderError::Invalid("ok: false なのに message が無い".into()))?; - return Ok(Outcome::Declined { reason, message }); + return Ok(Resolved { + outcome: Outcome::Declined { reason, message }, + plugin, + }); } let track = res .track .ok_or_else(|| ProviderError::Invalid("ok なのに track が無い".into()))?; validate(&track)?; - Ok(Outcome::Track(track)) + Ok(Resolved { + outcome: Outcome::Track(track), + plugin, + }) } } diff --git a/src/import/ytmusic/mod.rs b/src/import/ytmusic/mod.rs index 28c52399..486e3cf8 100644 --- a/src/import/ytmusic/mod.rs +++ b/src/import/ytmusic/mod.rs @@ -4,5 +4,6 @@ pub mod downloader; pub mod metadata; pub mod playlist; +pub mod rejudge; -pub use metadata::{Item, MetadataProvider, Outcome, ProviderError, Track}; +pub use metadata::{Item, MetadataProvider, Outcome, PluginInfo, ProviderError, Resolved, Track}; diff --git a/src/import/ytmusic/rejudge.rs b/src/import/ytmusic/rejudge.rs new file mode 100644 index 00000000..32a449b2 --- /dev/null +++ b/src/import/ytmusic/rejudge.rs @@ -0,0 +1,646 @@ +//! Inbox の件の再判定(`inbox_rejudge` ジョブの本体)。承認前(pending)の件の YouTube 由来の曲を +//! メタデータプラグインに問い合わせ直し、タグ・ファイル名・置き場所・サイドカーを初回の取り込みと +//! 同じ形に書き直す。 +//! +//! ```text +//! 件 pending でなければ NotPending。下書き(inbox_items.draft)を消す +//! 対象 サイドカーに source = youtube の項がある音声ファイル(名前順)。項の名前のファイルが無ければ、 +//! 項を持たないファイルのうち SOURCE_URL が項の url と同じもの(前回の途中で名前を変えた後に落ちた)。 +//! それも無い項は消す(前回の移動で宛先の項を書いて元を消した後、元の項を消す前に落ちた残り) +//! 1 曲 Item(項の item か yt-dlp の dump)→ judge → 件がまだ pending か → タグ → 宛先(移動の規則)→ +//! 新しい名前 → tmp に写してタグ → RENAME_NOREPLACE(同名は SOURCE_URL が同じときだけ自分の +//! 成果物)→ サイドカー → 元を unlink +//! 仕上げ ディレクトリ(新しく作った親を含む)を fsync して inbox ジョブを投入。途中で止まっても、 +//! 書いた曲があれば投入する +//! ``` +//! +//! 曲ごとに独立に失敗させ(理由はサイドカーの項の `rejudge_error`)、他の曲は続ける。どこで落ちても、 +//! もう一度で同じ応答から同じ状態に収束する(冪等)。行はキャッシュで、走査が作り直す(D-68) + +use std::collections::{HashMap, HashSet}; + +use tokio_util::sync::CancellationToken; + +use crate::db::inbox::{self as dbinbox, ItemState}; +use crate::domain::relpath::{canonical_key, RelPath}; +use crate::domain::tags::{write_tag_changes, Codec, TagChange}; +use crate::fsroot::{copy_attrs, FileKind, FsError, RootDir}; +use crate::import::sidecar::{FileEntry, Sidecar, SidecarError}; +use crate::import::ytmusic::downloader::{ + dump_video, inbox_dir, inbox_file_name_for, is_own_product, judge, plugin_tag_keys, + subscription_target, tag_changes_for, DownloadError, DownloaderEnv, SUBDIR, UNMATCHED_DIR, +}; +use crate::import::ytmusic::metadata::SOURCE_URL_KEY; +use crate::import::ytmusic::{Item, Track}; + +/// 再判定の結果 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RejudgeReport { + /// 対象の曲数 + pub total: usize, + /// 判定し直して書いた曲数(移せなかった曲を含む) + pub rejudged: usize, + /// (元のファイル名, 移動先の rel_path) + pub moved: Vec<(String, RelPath)>, + /// (ファイル名, 理由) + pub failed: Vec<(String, String)>, + /// (ファイル名, 移動先の件の state) + pub not_moved: Vec<(String, String)>, + /// ファイルが無いので消したサイドカーの項(前回の移動の途中で落ちた残り) + pub dropped: Vec<String>, +} + +#[derive(Debug, thiserror::Error)] +pub enum RejudgeError { + /// 件が無い・pending でない(途中で外れたら、それまでの曲は書いたまま止める) + #[error("{0}")] + NotPending(String), + #[error(transparent)] + Download(#[from] DownloadError), +} + +impl From<crate::db::DbError> for RejudgeError { + fn from(e: crate::db::DbError) -> Self { + RejudgeError::Download(e.into()) + } +} + +impl From<FsError> for RejudgeError { + fn from(e: FsError) -> Self { + RejudgeError::Download(e.into()) + } +} + +/// 件 `item_id` を判定し直す。`progress` には曲を 1 つ終えるたびに (done, total) を送る +pub async fn rejudge_item( + env: &DownloaderEnv, + item_id: i64, + token: &CancellationToken, + progress: &tokio::sync::watch::Sender<(usize, usize)>, +) -> Result<RejudgeReport, RejudgeError> { + let item = ensure_pending(env, item_id).await?; + // 補正の下書きは提案でタグより勝つので、判定結果で置き換えるために消す(ユーザの判断) + if !env + .db + .write(move |c| dbinbox::clear_pending_draft(c, item_id)) + .await? + { + return Err(RejudgeError::NotPending(format!( + "件 {item_id} が承認前でなくなった" + ))); + } + let dir = RelPath::parse(&item.rel_dir) + .map_err(|e| DownloadError::Fatal(format!("件のディレクトリが不正: {e}")))?; + let (targets, stale) = targets(&env.inbox, &dir)?; + let total = targets.len(); + progress.send_replace((0, total)); + let mut report = RejudgeReport { + total, + ..RejudgeReport::default() + }; + if !stale.is_empty() { + report.dropped = drop_stale_entries(env, &dir, &stale).map_err(sidecar_failed)?; + } + let mut touched: Vec<Option<RelPath>> = vec![Some(dir.clone())]; + let result = rejudge_targets( + env, + item_id, + &dir, + targets, + token, + progress, + &mut report, + &mut touched, + ) + .await; + // 途中で止まっても(キャンセル・件が承認された等)、書き換えた曲があれば走査に見せる(行はキャッシュ) + let wrote = report.rejudged > 0 || !report.failed.is_empty() || !report.dropped.is_empty(); + if result.is_ok() || wrote { + if let Err(e) = finish(env, &touched).await { + if result.is_ok() { + return Err(e); + } + tracing::warn!(item_id, error = %e, "止まった再判定の仕上げに失敗した"); + } + } + result?; + tracing::info!( + item_id, + total, + rejudged = report.rejudged, + moved = report.moved.len(), + failed = report.failed.len(), + "Inbox の件を判定し直した" + ); + Ok(report) +} + +/// 対象の曲を順に判定し直す。止まる(キャンセル・件が pending でなくなった・サイドカーを書けない)と +/// それまでの曲は書いたまま Err。書いた曲は `report` に、fsync するディレクトリは `touched` に積む +#[allow(clippy::too_many_arguments)] +async fn rejudge_targets( + env: &DownloaderEnv, + item_id: i64, + dir: &RelPath, + targets: Vec<Target>, + token: &CancellationToken, + progress: &tokio::sync::watch::Sender<(usize, usize)>, + report: &mut RejudgeReport, + touched: &mut Vec<Option<RelPath>>, +) -> Result<(), RejudgeError> { + let total = targets.len(); + for (i, target) in targets.into_iter().enumerate() { + if token.is_cancelled() { + return Err(DownloadError::Cancelled.into()); + } + // 待っている間に人が承認した等。続けると走査が承認を pending に戻してしまう + ensure_pending(env, item_id).await?; + let Target { + entry_name, + file_name, + mut entry, + } = target; + match rejudge_track( + env, + item_id, + dir, + &file_name, + &entry_name, + &mut entry, + token, + touched, + ) + .await + { + Ok(done) => { + report.rejudged += 1; + if let Some(to) = done.moved { + touch(touched, to.parent()); + report.moved.push((file_name, to)); + } else if let Some(state) = done.not_moved { + report.not_moved.push((file_name, state)); + } + } + Err(TrackError::Abort(e)) => return Err(e), + Err(TrackError::Failed(reason)) => { + tracing::warn!(item_id, file = %file_name, reason, "再判定できない"); + entry.rejudge_error = Some(reason.clone()); + report.failed.push((file_name, reason)); + update_sidecar(env, dir, None, None, Some((&entry_name, entry))) + .map_err(sidecar_failed)?; + } + } + progress.send_replace((i + 1, total)); + } + Ok(()) +} + +/// 仕上げ: 書いたディレクトリ(`None` は root)を fsync して inbox の走査を投入する +async fn finish(env: &DownloaderEnv, touched: &[Option<RelPath>]) -> Result<(), RejudgeError> { + for d in touched { + env.inbox.fsync_dir(d.as_ref())?; + } + env.jobs + .enqueue(crate::jobs::handlers::inbox::new_inbox_job()) + .await + .map_err(DownloadError::from)?; + Ok(()) +} + +/// fsync するディレクトリに足す(同じものは 1 つ) +fn touch(touched: &mut Vec<Option<RelPath>>, d: Option<RelPath>) { + let key = d.as_ref().map(RelPath::key); + if !touched.iter().any(|t| t.as_ref().map(RelPath::key) == key) { + touched.push(d); + } +} + +/// `dir` とその祖先のうち、まだ無いディレクトリ(浅い順) +fn missing_dirs(root: &RootDir, dir: &RelPath) -> Vec<RelPath> { + let mut out = Vec::new(); + let mut prefix: Option<RelPath> = None; + for name in dir.components() { + let p = match &prefix { + Some(p) => p.join(name), + None => RelPath::parse(name), + }; + let Ok(p) = p else { + break; + }; + if root.stat(&p).is_err() { + out.push(p.clone()); + } + prefix = Some(p); + } + out +} + +/// 件を読み、pending でなければ NotPending +async fn ensure_pending(env: &DownloaderEnv, item_id: i64) -> Result<dbinbox::Item, RejudgeError> { + match env.db.read(move |c| dbinbox::get(c, item_id)).await? { + None => Err(RejudgeError::NotPending(format!("件 {item_id} が無い"))), + Some(it) if it.state != ItemState::Pending => Err(RejudgeError::NotPending(format!( + "件 {item_id} が承認前でない({})", + it.state.as_str() + ))), + Some(it) => Ok(it), + } +} + +/// 再判定する曲 1 つ +struct Target { + /// サイドカーの項の名前 + entry_name: String, + /// 実際のファイル名(ふつうは項の名前と同じ) + file_name: String, + entry: FileEntry, +} + +/// 件のディレクトリの対象の曲(項の名前順)と、ファイルが見つからない項の名前 +fn targets(root: &RootDir, dir: &RelPath) -> Result<(Vec<Target>, Vec<String>), RejudgeError> { + let Some(sidecar) = Sidecar::read(root, dir).map_err(sidecar_failed)? else { + return Ok((Vec::new(), Vec::new())); + }; + let audio: Vec<String> = root + .read_dir(Some(dir))? + .into_iter() + .filter(|e| e.kind == FileKind::File) + .filter_map(|e| e.name.into_string().ok()) + .filter(|n| { + !n.starts_with('.') + && n.rsplit_once('.') + .and_then(|(_, ext)| Codec::from_extension(ext)) + .is_some() + }) + .collect(); + let by_key: HashMap<String, &String> = audio.iter().map(|n| (canonical_key(n), n)).collect(); + let entry_keys: HashSet<String> = sidecar.files.keys().map(|n| canonical_key(n)).collect(); + let mut claimed: HashSet<String> = HashSet::new(); + let mut out = Vec::new(); + let mut stale = Vec::new(); + for (name, entry) in &sidecar.files { + if entry.source != "youtube" { + continue; + } + let file = match by_key.get(&canonical_key(name)) { + Some(f) => Some((*f).clone()), + // 前回の途中(新しい名前に置いて元を消した後、サイドカーの前)に落ちた + None => entry.url.as_deref().and_then(|url| { + audio + .iter() + .filter(|f| { + let k = canonical_key(f); + !entry_keys.contains(&k) && !claimed.contains(&k) + }) + .find(|f| dir.join(f).is_ok_and(|r| is_own_product(root, &r, url))) + .cloned() + }), + }; + let Some(file_name) = file else { + stale.push(name.clone()); + continue; + }; + claimed.insert(canonical_key(&file_name)); + out.push(Target { + entry_name: name.clone(), + file_name, + entry: entry.clone(), + }); + } + Ok((out, stale)) +} + +/// ファイルが無い項(`names`)をサイドカーから消し、消した名前を返す。ytdl はファイルを置いてから項を +/// 足すので、ファイルの無い項は前回の移動の途中(宛先の項を書いて元を消した後、元の項を消す前)に +/// 落ちた残りか、人が消したファイルの項。どちらも消してよい(宛先の項は先に書いてある)。鍵の中で +/// ファイルがまだ無いことを確かめ直す +fn drop_stale_entries( + env: &DownloaderEnv, + dir: &RelPath, + names: &[String], +) -> Result<Vec<String>, SidecarError> { + let _guard = env.lock_sidecar(); + let Some(mut s) = Sidecar::read(&env.inbox, dir)? else { + return Ok(Vec::new()); + }; + let mut dropped = Vec::new(); + for name in names { + let missing = dir + .join(name) + .is_ok_and(|r| matches!(env.inbox.stat(&r), Err(FsError::NotFound))); + if missing && s.files.remove(name).is_some() { + tracing::info!(dir = %dir, name, "サイドカーの項のファイルが無いので項を消す"); + dropped.push(name.clone()); + } + } + if !dropped.is_empty() { + s.write(&env.inbox, dir)?; + } + Ok(dropped) +} + +/// 1 曲の成功 +struct TrackDone { + /// 別の件へ移した先 + moved: Option<RelPath>, + /// 移すはずが移せなかった(移動先の件の state) + not_moved: Option<String>, +} + +enum TrackError { + /// その曲だけの失敗(他の曲は続ける) + Failed(String), + /// ジョブごと止める(キャンセル) + Abort(RejudgeError), +} + +impl From<DownloadError> for TrackError { + fn from(e: DownloadError) -> Self { + match e { + DownloadError::Cancelled => TrackError::Abort(DownloadError::Cancelled.into()), + other => TrackError::Failed(other.to_string()), + } + } +} + +impl From<FsError> for TrackError { + fn from(e: FsError) -> Self { + TrackError::Failed(e.to_string()) + } +} + +impl From<crate::db::DbError> for TrackError { + fn from(e: crate::db::DbError) -> Self { + TrackError::Failed(e.to_string()) + } +} + +impl From<SidecarError> for TrackError { + fn from(e: SidecarError) -> Self { + TrackError::Failed(format!("spindle-inbox.json を更新できない: {e}")) + } +} + +/// 1 曲を判定し直して書く。`entry` は yt-dlp で取り直した `item` を書き足したもの(失敗の記録に使う) +#[allow(clippy::too_many_arguments)] +async fn rejudge_track( + env: &DownloaderEnv, + item_id: i64, + dir: &RelPath, + file_name: &str, + entry_name: &str, + entry: &mut FileEntry, + token: &CancellationToken, + touched: &mut Vec<Option<RelPath>>, +) -> Result<TrackDone, TrackError> { + let current = dir + .join(file_name) + .map_err(|e| TrackError::Failed(e.to_string()))?; + // 1. Item(既存の曲には無いので、初回の手順 1 と同じ引数で取り直す) + let item: Item = match &entry.item { + Some(i) => i.clone(), + None => { + let url = entry + .url + .clone() + .ok_or_else(|| TrackError::Failed("サイドカーの項に URL が無い".into()))?; + let item = dump_video(env, &url, token).await?.item(); + entry.item = Some(item.clone()); + item + } + }; + // 2. 購読(消えていれば None)と判定 + let subscription = match entry.subscription_id { + Some(id) => env.db.read(move |c| subscription_target(c, id)).await?, + None => None, + }; + let judged = judge(env, &item, subscription.as_ref(), token).await?; + // yt-dlp・プラグインを待つ間に人が承認した等。書く前に確かめ直す(曲の前の確認だけでは遅い) + ensure_pending(env, item_id) + .await + .map_err(TrackError::Abort)?; + let from_subscription = entry.subscription_id.is_some(); + let ok = judged.verdict == "ok"; + // 3. / 4. タグを書くか。skip(購読でない)と「購読が消えて ok でない」は触らない + let rewrite = + subscription.is_some() || !(judged.verdict == "skip" || (from_subscription && !ok)); + let track = judged.track.as_ref().filter(|_| rewrite); + let changes = if rewrite { + rejudge_tag_changes(track, &item.title, entry.plugin_tags.as_deref()) + } else { + Vec::new() + }; + let plugin_tags = if !rewrite { + entry.plugin_tags.clone() + } else { + track.filter(|_| ok).map(plugin_tag_keys) + }; + // 移動の規則: 判定(ok か否か)と今の置き場所(受け皿か件のディレクトリか)が食い違うときだけ移す。 + // 置き場所はサイドカーの前回の判定でなく実際のディレクトリで見る(移せなかった曲を次で移せる)。 + // 購読由来は移さない。ok のまま宛先だけ違っても移さない + let mut not_moved = None; + let mut dest = dir.clone(); + if rewrite && !from_subscription && ok == in_unmatched_dir(dir) { + let channel = entry.channel.as_deref().unwrap_or(&item.channel); + let d = inbox_dir(track, channel).map_err(TrackError::Failed)?; + if d.key() != dir.key() { + let key = d.key(); + match env + .db + .read(move |c| dbinbox::find_by_dir_key(c, &key)) + .await? + { + Some(it) if it.state != ItemState::Pending => { + not_moved = Some(it.state.as_str().to_owned()); + } + _ => dest = d, + } + } + } + let moved = dest.key() != dir.key(); + // 5. 新しい名前(タグを触らないなら名前も変えない) + let new_name = match (rewrite, track) { + (true, Some(t)) => inbox_file_name_for(&item, &t.title), + (true, None) => inbox_file_name_for(&item, &item.title), + (false, _) => file_name.to_owned(), + }; + let target = dest + .join(&new_name) + .map_err(|e| TrackError::Failed(e.to_string()))?; + // 大文字小文字・正規化だけの違いは同じファイル(置き換える) + let in_place = target.key() == current.key(); + let final_rel = if in_place { current.clone() } else { target }; + if !(changes.is_empty() && in_place) { + if moved { + // 新しく作ったディレクトリは、その項を持つ親も fsync する + let created = missing_dirs(&env.inbox, &dest); + env.inbox.create_dir_all(&dest)?; + for c in created { + touch(touched, c.parent()); + } + } + let url = entry.url.as_deref().or(item.url.as_deref()).unwrap_or(""); + place(&env.inbox, ¤t, &dest, &final_rel, &changes, url)?; + } + // 6. サイドカー(件の category は判定の値。無くなったら消す。語彙に無ければ足す)。タグを触らない + // 判定(skip 等)では category も触らない + let category = track.and_then(|t| t.category.clone()); + let set_category = rewrite.then_some(category.as_deref()); + if let Some(cat) = category.clone() { + env.db + .write(move |c| crate::db::categories::ensure(c, &cat).map(|_| ())) + .await?; + } + let new_entry = FileEntry { + verdict: judged.verdict.clone(), + message: judged.message.clone(), + plugin_version: judged.plugin_version.clone(), + judged_at: Some(crate::db::now_epoch()), + plugin_tags, + item: Some(item), + rejudge_error: None, + ..entry.clone() + }; + let final_name = final_rel.file_name().to_owned(); + if moved { + // 宛先の項を先に書く(元を消した後に落ちても、宛先は自分の成果物として揃っている) + update_sidecar( + env, + &dest, + set_category, + None, + Some((&final_name, new_entry)), + )?; + unlink_if_exists(&env.inbox, ¤t)?; + update_sidecar(env, dir, None, Some(entry_name), None)?; + } else { + // 元を先に消す(サイドカーの前に落ちても、次の実行が SOURCE_URL で新しい名前を拾う) + if !in_place { + unlink_if_exists(&env.inbox, ¤t)?; + } + update_sidecar( + env, + dir, + set_category, + Some(entry_name), + Some((&final_name, new_entry)), + )?; + } + tracing::info!(from = %current, to = %final_rel, verdict = judged.verdict, "判定し直した"); + Ok(TrackDone { + moved: moved.then_some(final_rel), + not_moved, + }) +} + +/// `dir` が判定できなかったものの受け皿(`youtube/_unmatched/...`)か +fn in_unmatched_dir(dir: &RelPath) -> bool { + let mut c = dir.components().map(canonical_key); + c.next() == Some(canonical_key(SUBDIR)) && c.next() == Some(canonical_key(UNMATCHED_DIR)) +} + +/// 再判定で書くタグ: TITLE / ARTIST / ALBUM / ALBUMARTIST / DATE(今回無ければ削除)+ 応答の追加タグ + +/// 前回書いた追加タグ(`recorded`)のうち今回無いもの(削除)。SOURCE_URL / TRACKNUMBER / PICTURE は +/// 触らない。`track` が None なら TITLE を動画タイトルに戻す(初回の未判定と同じ形) +fn rejudge_tag_changes( + track: Option<&Track>, + video_title: &str, + recorded: Option<&[String]>, +) -> Vec<TagChange> { + let mut changes: Vec<TagChange> = tag_changes_for(track, video_title, "", None) + .into_iter() + .filter(|c| c.key != SOURCE_URL_KEY) + .collect(); + let removed = ["TITLE", "ARTIST", "ALBUM", "ALBUMARTIST", "DATE"] + .into_iter() + .map(str::to_owned) + .chain(recorded.unwrap_or_default().iter().cloned()); + for key in removed { + if !changes.iter().any(|c| c.key.eq_ignore_ascii_case(&key)) { + changes.push(TagChange { key, values: None }); + } + } + changes +} + +/// `current` を `dest` の tmp へ写してタグを書き、`final_rel` に置く。`final_rel` が `current` なら +/// 置き換え、違えば `RENAME_NOREPLACE`(同名があれば SOURCE_URL が `url` のときだけ自分の成果物 = +/// 前回の途中として置き換える)。元のファイルは消さない +fn place( + root: &RootDir, + current: &RelPath, + dest: &RelPath, + final_rel: &RelPath, + changes: &[TagChange], + url: &str, +) -> Result<(), TrackError> { + let mut from = root.open_file(current)?; + let (tmp_rel, mut tmp) = root.create_tmp(Some(dest))?; + let ext = current.file_name().rsplit_once('.').map(|(_, e)| e); + let written = (|| -> Result<(), String> { + std::io::copy(&mut from, &mut tmp).map_err(|e| e.to_string())?; + copy_attrs(&from, &tmp).map_err(|e| e.to_string())?; + write_tag_changes(&mut tmp, ext, changes, None) + .map_err(|e| format!("タグを書けない: {e}"))?; + tmp.sync_all().map_err(|e| e.to_string()) + })(); + let placed = written.and_then(|()| { + if final_rel.key() == current.key() { + return root + .replace_file(&tmp_rel, final_rel) + .map_err(|e| e.to_string()); + } + match root.rename_noreplace(&tmp_rel, final_rel) { + Ok(()) => Ok(()), + Err(FsError::Exists) if !url.is_empty() && is_own_product(root, final_rel, url) => { + tracing::info!(path = %final_rel, "前回の途中で置いた自分の成果物を置き換える"); + root.replace_file(&tmp_rel, final_rel) + .map_err(|e| e.to_string()) + } + Err(FsError::Exists) => Err(format!( + "移動先に同名で別の内容のファイルがある: {final_rel}" + )), + Err(e) => Err(e.to_string()), + } + }); + if let Err(e) = placed { + let _ = root.unlink(&tmp_rel); + return Err(TrackError::Failed(e)); + } + Ok(()) +} + +fn unlink_if_exists(root: &RootDir, rel: &RelPath) -> Result<(), FsError> { + match root.unlink(rel) { + Ok(()) | Err(FsError::NotFound) => Ok(()), + Err(e) => Err(e), + } +} + +/// `dir` のサイドカーを読んで、`remove` の項を消し、`insert` の項を足して書く。`category` が `Some` なら +/// 件の category もその値(`None` なら消す)にし、`None` なら触らない。読んでから書くまでサイドカーの +/// 鍵を持つ(並んで走る ytdl の項を消さない) +fn update_sidecar( + env: &DownloaderEnv, + dir: &RelPath, + category: Option<Option<&str>>, + remove: Option<&str>, + insert: Option<(&str, FileEntry)>, +) -> Result<(), SidecarError> { + let _guard = env.lock_sidecar(); + let mut s = Sidecar::read(&env.inbox, dir)?.unwrap_or_default(); + if let Some(c) = category { + s.category = c.map(str::to_owned); + } + if let Some(name) = remove { + s.files.remove(name); + } + if let Some((name, entry)) = insert { + s.files.insert(name.to_owned(), entry); + } + s.write(&env.inbox, dir) +} + +fn sidecar_failed(e: SidecarError) -> RejudgeError { + DownloadError::Failed(anyhow::anyhow!("spindle-inbox.json を扱えない: {e}")).into() +} diff --git a/src/jobs/handlers/inbox_rejudge.rs b/src/jobs/handlers/inbox_rejudge.rs new file mode 100644 index 00000000..b9d8cde8 --- /dev/null +++ b/src/jobs/handlers/inbox_rejudge.rs @@ -0,0 +1,101 @@ +//! `inbox_rejudge` ジョブ(Inbox の件の再判定)。並列 1、dedup `inbox_rejudge:<item_id>`、再試行しない。 +//! payload は `{ "item_id" }`。本体は `import::ytmusic::rejudge::rejudge_item` + +use std::sync::Arc; + +use crate::import::ytmusic::downloader::{DownloadError, DownloaderEnv}; +use crate::import::ytmusic::rejudge::{rejudge_item, RejudgeError, RejudgeReport}; +use crate::jobs::{ + BoxFuture, Handler, HandlerResult, JobContext, JobError, JobType, NewJob, Outcome, +}; + +const DEDUP_PREFIX: &str = "inbox_rejudge:"; + +/// 件 `item_id` の再判定ジョブ。同じ件は走行中・待ちの間 1 つにまとまる。人が直す前に失敗を +/// 繰り返しても意味がないので、再試行しない +pub fn new_inbox_rejudge_job(item_id: i64) -> NewJob { + NewJob::new( + JobType::InboxRejudge, + serde_json::json!({ "item_id": item_id }), + ) + .dedup_key(format!("{DEDUP_PREFIX}{item_id}")) + .max_attempts(1) +} + +/// 件 `item_id` の再判定ジョブが queued / running か。その間 approve / reject / reopen は 409 rejudging +/// にする(再判定が書いている最中に状態を動かすと、走査が承認を pending に戻す・却下した件に足す)。 +/// 書き込みは単一コネクションなので、この検査と遷移を同じ閉包で行えば投入と競合しない +pub fn rejudge_active(conn: &rusqlite::Connection, item_id: i64) -> crate::db::Result<bool> { + let n: i64 = conn.query_row( + "SELECT count(*) FROM jobs WHERE dedup_key = ?1 AND state IN ('queued', 'running')", + [format!("{DEDUP_PREFIX}{item_id}")], + |r| r.get(0), + )?; + Ok(n > 0) +} + +pub struct InboxRejudgeHandler { + env: Arc<DownloaderEnv>, +} + +impl InboxRejudgeHandler { + pub fn new(env: DownloaderEnv) -> Self { + Self { env: Arc::new(env) } + } +} + +/// 結果の 1 行目に件数、続けて移せなかった曲と失敗した曲を 1 行ずつ +fn note_of(report: &RejudgeReport) -> String { + let mut note = format!( + "再判定: {}/{} 曲、移動 {} 曲、失敗 {} 曲", + report.rejudged, + report.total, + report.moved.len(), + report.failed.len() + ); + for (file, state) in &report.not_moved { + note.push_str(&format!("\n{file}: 移せなかった(移動先が {state})")); + } + for (file, reason) in &report.failed { + note.push_str(&format!("\n{file}: {reason}")); + } + note +} + +impl Handler for InboxRejudgeHandler { + fn run(&self, ctx: JobContext) -> BoxFuture<'static, HandlerResult> { + let env = Arc::clone(&self.env); + Box::pin(async move { + let Some(item_id) = ctx.job.payload.get("item_id").and_then(|v| v.as_i64()) else { + return Err(JobError::Fatal(anyhow::anyhow!( + "payload に item_id が無い" + ))); + }; + let token = ctx.cancel_token(); + let (tx, mut rx) = tokio::sync::watch::channel((0usize, 0usize)); + let run = rejudge_item(&env, item_id, &token, &tx); + tokio::pin!(run); + // 本体を進めながら、曲を 1 つ終えるたびの進捗を job へ流す + let result = loop { + tokio::select! { + r = &mut run => break r, + changed = rx.changed() => { + if changed.is_ok() { + let (done, total) = *rx.borrow_and_update(); + ctx.progress(done as i64, total as i64).await?; + } + } + } + }; + match result { + Ok(report) => Ok(Outcome::DoneWith(note_of(&report))), + Err(RejudgeError::NotPending(m)) => Err(JobError::Fatal(anyhow::anyhow!(m))), + Err(RejudgeError::Download(DownloadError::Cancelled)) => Err(JobError::Cancelled), + Err(RejudgeError::Download(DownloadError::Fatal(m))) => { + Err(JobError::Fatal(anyhow::anyhow!(m))) + } + Err(RejudgeError::Download(DownloadError::Failed(e))) => Err(JobError::Failed(e)), + } + }) + } +} diff --git a/src/jobs/handlers/mod.rs b/src/jobs/handlers/mod.rs index 93b3150b..a7a640f5 100644 --- a/src/jobs/handlers/mod.rs +++ b/src/jobs/handlers/mod.rs @@ -7,6 +7,7 @@ pub mod flaccheck; pub mod gc; pub mod hirescheck; pub mod inbox; +pub mod inbox_rejudge; pub mod normalize; pub mod playlist_sync; pub mod rename; diff --git a/src/main.rs b/src/main.rs index f9b59380..fee329fb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,7 @@ use spindle::jobs::handlers::flaccheck::FlaccheckHandler; use spindle::jobs::handlers::gc::{self as gc_job, GcHandler}; use spindle::jobs::handlers::hirescheck::HirescheckHandler; use spindle::jobs::handlers::inbox::{self as inbox_job, InboxHandler}; +use spindle::jobs::handlers::inbox_rejudge::InboxRejudgeHandler; use spindle::jobs::handlers::normalize::NormalizeHandler; use spindle::jobs::handlers::playlist_sync::{self, PlaylistSyncHandler, SyncEnv}; use spindle::jobs::handlers::rename::RenameHandler; @@ -577,21 +578,29 @@ async fn main() -> anyhow::Result<()> { std::time::Duration::from_secs(u64::from(state.config.ytmusic.metadata_timeout_secs)), ) .context("ytmusic.metadata_command が空")?; + // ytdl と再判定で 1 つの環境を共有する(clone はサイドカーの鍵を共有する) + let downloader_env = DownloaderEnv { + db: Arc::clone(&state.db), + inbox: Arc::clone(&inbox_root), + archive: Arc::clone(&archive_root), + jobs: Arc::clone(&state.jobs), + provider: provider.clone(), + ytdlp: state.config.ytdlp_command(), + ffmpeg: PathBuf::from(&state.config.bin.ffmpeg), + tmp_root: ytdl_tmp.clone(), + download_timeout: std::time::Duration::from_secs(u64::from( + state.config.ytmusic.download_timeout_secs, + )), + sidecar_lock: Arc::new(std::sync::Mutex::new(())), + }; registry.register( JobType::Ytdl, - Arc::new(YtdlHandler::new(DownloaderEnv { - db: Arc::clone(&state.db), - inbox: Arc::clone(&inbox_root), - archive: Arc::clone(&archive_root), - jobs: Arc::clone(&state.jobs), - provider, - ytdlp: state.config.ytdlp_command(), - ffmpeg: PathBuf::from(&state.config.bin.ffmpeg), - tmp_root: ytdl_tmp, - download_timeout: std::time::Duration::from_secs(u64::from( - state.config.ytmusic.download_timeout_secs, - )), - })), + Arc::new(YtdlHandler::new(downloader_env.clone())), + ); + // Inbox の件の再判定(Ytdl と同じ環境。作業領域は使わない) + registry.register( + JobType::InboxRejudge, + Arc::new(InboxRejudgeHandler::new(downloader_env)), ); // 再生リストの購読の同期(P4-16、D-78)。列挙 → 番号揃え → ytdl 投入 let (pending_wait, pending_poll) = SyncEnv::default_waits(); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 65f08efd..52db0759 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -3,6 +3,8 @@ #![allow(dead_code)] +pub mod ytmusic; + use std::fs::File; use std::io::Write; use std::path::{Path, PathBuf}; diff --git a/tests/common/ytmusic.rs b/tests/common/ytmusic.rs new file mode 100644 index 00000000..739f377d --- /dev/null +++ b/tests/common/ytmusic.rs @@ -0,0 +1,284 @@ +//! YouTube のテストの土台: 偽の yt-dlp(bash。dump と download を演じる)と、偽のプラグインで動く +//! ダウンローダの環境(`Lib`)。プラグインの本文はテストごとに渡す + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use rusqlite::Connection; +use tokio_util::sync::CancellationToken; + +use spindle::db::Db; +use spindle::fsroot::RootDir; +use spindle::import::ytmusic::downloader::DownloaderEnv; +use spindle::import::ytmusic::MetadataProvider; +use spindle::jobs::handlers::inbox_rejudge::InboxRejudgeHandler; +use spindle::jobs::handlers::ytdl::{new_ytdl_job, YtdlHandler}; +use spindle::jobs::{EnqueueResult, JobState, JobType, Jobs, Registry}; + +/// 偽の yt-dlp。`--dump-single-json` なら `dump/<url をキー化した名前>.json` を返し、download なら +/// `-o` のテンプレートに `audio.webm` と `thumb.jpg` を置く。呼び出しは `calls.log` に残す +pub const FAKE_YTDLP: &str = r#" +FAKE="$1"; shift +printf '%s\n' "$*" >> "$FAKE/calls.log" +url="${@: -1}" +key=$(printf '%s' "$url" | tr -c 'A-Za-z0-9' '_') +if [[ " $* " == *" --dump-single-json "* ]]; then + if [ -f "$FAKE/dump/$key.json" ]; then cat "$FAKE/dump/$key.json"; exit 0; fi + if [ -f "$FAKE/unsupported" ]; then echo "ERROR: Unsupported URL: $url" >&2; exit 1; fi + echo "ERROR: [youtube] $url: Video unavailable" >&2; exit 1 +fi +if [ -f "$FAKE/fail_download" ]; then echo "ERROR: unable to download: network down" >&2; exit 1; fi +tmpl="" +prev="" +for a in "$@"; do + if [ "$prev" = "-o" ]; then tmpl="$a"; fi + prev="$a" +done +id=$(grep -o '"id": *"[^"]*"' "$FAKE/dump/$key.json" | head -1 | sed 's/.*"\([^"]*\)"$/\1/') +out="${tmpl//%(id)s/$id}"; out="${out//%(ext)s/webm}" +cp "$FAKE/audio.webm" "$out" +cp "$FAKE/thumb.jpg" "${out%.webm}.jpg" +"#; + +pub struct Lib { + pub dir: tempfile::TempDir, + pub db_path: PathBuf, + pub db: Arc<Db>, + pub jobs: Arc<Jobs>, + pub inbox: Arc<RootDir>, + pub fake: PathBuf, + pub shutdown: CancellationToken, + /// サイドカーの鍵(本番と同じく、`env()` の全部で共有する) + pub sidecar_lock: Arc<std::sync::Mutex<()>>, +} + +impl Lib { + /// `plugin` は偽のメタデータプラグイン(bash のスクリプト本文) + pub fn new(plugin: &str) -> Option<Self> { + let dir = tempfile::tempdir().unwrap(); + for d in ["Inbox", "Archive", "tmp", "fake", "fake/dump"] { + std::fs::create_dir(dir.path().join(d)).unwrap(); + } + let fake = dir.path().join("fake"); + super::make_audio(&fake, "audio.webm", "webm", 1)?; + // サムネイル(ffmpeg で 16x16 の JPEG を作る) + let st = std::process::Command::new(super::ffmpeg()?) + .args([ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-f", + "lavfi", + "-i", + ]) + .arg("color=c=red:s=16x16") + .args(["-frames:v", "1"]) + .arg(fake.join("thumb.jpg")) + .status() + .unwrap(); + assert!(st.success()); + std::fs::write(fake.join("ytdlp.sh"), FAKE_YTDLP).unwrap(); + std::fs::write(fake.join("plugin.sh"), plugin).unwrap(); + let db_path = dir.path().join("spindle.db"); + let db = Arc::new(Db::open(&db_path).unwrap()); + let jobs = Jobs::new(db.clone()); + let inbox = Arc::new(RootDir::open(&dir.path().join("Inbox")).unwrap()); + Some(Self { + dir, + db_path, + db, + jobs, + inbox, + fake, + shutdown: CancellationToken::new(), + sidecar_lock: Arc::new(std::sync::Mutex::new(())), + }) + } + + pub fn env(&self) -> DownloaderEnv { + let plugin = vec![ + "/bin/bash".to_owned(), + self.fake.join("plugin.sh").display().to_string(), + ]; + DownloaderEnv { + db: self.db.clone(), + inbox: self.inbox.clone(), + archive: Arc::new(RootDir::open(&self.dir.path().join("Archive")).unwrap()), + jobs: self.jobs.clone(), + provider: MetadataProvider::new(&plugin, Duration::from_secs(10)).unwrap(), + // 引数配列の先頭にスクリプトと fake ディレクトリを固定する(`ytdlp` は引数配列) + ytdlp: vec![ + "/bin/bash".to_owned(), + self.fake.join("ytdlp.sh").display().to_string(), + self.fake.display().to_string(), + ], + ffmpeg: super::ffmpeg().unwrap_or_else(|| PathBuf::from("ffmpeg")), + tmp_root: self.dir.path().join("tmp").join("ytdl"), + download_timeout: Duration::from_secs(60), + sidecar_lock: self.sidecar_lock.clone(), + } + } + + pub fn start(&self) { + let mut reg = Registry::new(); + reg.register(JobType::Ytdl, Arc::new(YtdlHandler::new(self.env()))); + reg.register( + JobType::InboxRejudge, + Arc::new(InboxRejudgeHandler::new(self.env())), + ); + self.jobs.start(reg, self.shutdown.clone()); + } + + /// dump の JSON を置く(動画) + pub fn video(&self, url: &str, id: &str, uploader: &str, title: &str, webm: bool) { + let formats = if webm { + r#"[{"format_id":"251","ext":"webm","acodec":"opus","vcodec":"none"},{"format_id":"140","ext":"m4a","acodec":"mp4a.40.2","vcodec":"none"}]"# + } else { + r#"[{"format_id":"140","ext":"m4a","acodec":"mp4a.40.2","vcodec":"none"}]"# + }; + let json = format!( + r#"{{"id":"{id}","title":"{title}","uploader":"{uploader}","channel":"{uploader} Channel", + "webpage_url":"https://www.youtube.com/watch?v={id}","upload_date":"20260901", + "duration":123.4,"_type":"video","formats":{formats}}}"# + ); + self.dump(url, &json); + } + + pub fn dump(&self, url: &str, json: &str) { + let key: String = url + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + std::fs::write(self.fake.join("dump").join(format!("{key}.json")), json).unwrap(); + } + + pub fn calls(&self) -> Vec<String> { + std::fs::read_to_string(self.fake.join("calls.log")) + .unwrap_or_default() + .lines() + .map(str::to_owned) + .collect() + } + + pub async fn run(&self, url: &str) -> (i64, JobState) { + let id = match self.jobs.enqueue(new_ytdl_job(url)).await.unwrap() { + EnqueueResult::Inserted(j) | EnqueueResult::Duplicate(j) => j, + }; + (id, self.wait(id).await) + } + + pub async fn wait(&self, id: i64) -> JobState { + let deadline = std::time::Instant::now() + Duration::from_secs(120); + while std::time::Instant::now() < deadline { + let (s, attempts): (String, i64) = self + .conn() + .query_row( + "SELECT state, attempts FROM jobs WHERE id = ?1", + [id], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + let st: JobState = s.parse().unwrap(); + // 再試行待ち(queued + attempts > 0)も終端として返す + if st.is_terminal() || (st == JobState::Queued && attempts > 0) { + return st; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("ytdl ジョブが終わらない"); + } + + pub fn note(&self, id: i64) -> Option<String> { + self.conn() + .query_row("SELECT note FROM jobs WHERE id = ?1", [id], |r| r.get(0)) + .unwrap() + } + + pub fn job(&self, id: i64) -> (i64, Option<String>) { + self.conn() + .query_row( + "SELECT attempts, last_error FROM jobs WHERE id = ?1", + [id], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap() + } + + pub fn conn(&self) -> Connection { + Connection::open(&self.db_path).unwrap() + } + + pub fn inbox_path(&self, rel: &str) -> PathBuf { + self.dir.path().join("Inbox").join(rel) + } + + pub fn count(&self, sql: &str) -> i64 { + self.conn().query_row(sql, [], |r| r.get(0)).unwrap() + } +} + +impl Drop for Lib { + fn drop(&mut self) { + self.shutdown.cancel(); + } +} + +/// テスト用: ファイルのタグを直接書き換える(`write_tag_changes` を tmp 無しで当てる) +pub fn set_tags(path: &std::path::Path, ext: &str, tags: &[(&str, &[&str])]) { + let changes: Vec<spindle::domain::tags::TagChange> = tags + .iter() + .map(|(k, vs)| spindle::domain::tags::TagChange { + key: (*k).to_owned(), + values: Some(vs.iter().map(|v| (*v).to_owned()).collect()), + }) + .collect(); + let mut f = std::fs::File::options() + .read(true) + .write(true) + .open(path) + .unwrap(); + spindle::domain::tags::write_tag_changes(&mut f, Some(ext), &changes, None).unwrap(); +} + +/// `Lib::new(plugin)`。ffmpeg が無ければテストを skip する +#[allow(unused_macros)] +macro_rules! lib { + ($plugin:expr) => { + match $crate::common::ytmusic::Lib::new($plugin) { + Some(l) => l, + None => { + eprintln!("ffmpeg が無いので skip"); + return; + } + } + }; +} +#[allow(unused_imports)] +pub(crate) use lib; + +/// 購読を登録して id を返す +pub async fn subscribe( + lib: &Lib, + albumartist: &str, + album: &str, + category: Option<&str>, + align: bool, +) -> i64 { + use spindle::db::subscriptions::{insert, NewSubscription, WriteOutcome}; + let s = NewSubscription { + list_id: "PL1".to_owned(), + url: "https://www.youtube.com/playlist?list=PL1".to_owned(), + albumartist: albumartist.to_owned(), + album: album.to_owned(), + category: category.map(str::to_owned), + align, + enabled: true, + max_enqueue: 50, + }; + match lib.db.write(move |c| insert(c, &s, 1)).await.unwrap() { + WriteOutcome::Ok(id) => id, + other => panic!("{other:?}"), + } +} diff --git a/tests/devices_schema.rs b/tests/devices_schema.rs index 5ab87702..8eb38d8b 100644 --- a/tests/devices_schema.rs +++ b/tests/devices_schema.rs @@ -178,7 +178,10 @@ fn upgrade_from_0001_keeps_jobs_and_children() { ) .unwrap(); - assert_eq!(migrations::apply_list(&mut c, &all).unwrap(), vec![2, 3, 4]); + assert_eq!( + migrations::apply_list(&mut c, &all).unwrap(), + vec![2, 3, 4, 5] + ); let count = |sql: &str| -> i64 { c.query_row(sql, [], |r| r.get(0)).unwrap() }; assert_eq!(count("PRAGMA foreign_keys"), 1, "適用後は FK が ON に戻る"); diff --git a/tests/inbox_api.rs b/tests/inbox_api.rs index 0162e705..89d947b2 100644 --- a/tests/inbox_api.rs +++ b/tests/inbox_api.rs @@ -423,6 +423,7 @@ async fn discard_and_undiscard_only_on_rejected_items() { assert!(list["items"][0]["discard_requested_at"].is_i64(), "{list}"); assert_eq!(list["items"][0]["state"], "rejected"); assert_eq!(list["discard_retention_days"], 7); + assert_eq!(list["ytmusic_enabled"], true); // 二度目は 409、取り消しで null に戻る let (st, _) = app.post(&c, &discard, json!({})).await; assert_eq!(st, StatusCode::CONFLICT); @@ -499,6 +500,11 @@ async fn youtube_item(app: &App, rel_dir: &str, category: Option<&str>, verdict: message: (verdict != "ok").then(|| "ルールを足してください".to_owned()), subscription_id: None, position: None, + item: None, + plugin_version: None, + judged_at: None, + plugin_tags: None, + rejudge_error: None, }, ) .unwrap(); @@ -722,6 +728,11 @@ async fn list_carries_the_last_sync_of_referenced_subscriptions() { message: None, subscription_id: Some(3), position: Some(165), + item: None, + plugin_version: None, + judged_at: None, + plugin_tags: None, + rejudge_error: None, }, ) .unwrap(); @@ -1462,3 +1473,247 @@ async fn inbox_files_404s_when_the_item_dir_became_a_file() { .await; assert_eq!(st, StatusCode::NOT_FOUND); } + +/// `POST /api/inbox/:id/rejudge`: pending で YouTube 由来の曲がある件だけ 202。 +/// approved・手置きだけの件は 409 state、無い id は 404、続けての 2 回目は 409 duplicate +#[tokio::test] +async fn rejudge_api_accepts_pending_youtube_items_only() { + let app = App::new().await; + let c = app.cookie().await; + let yt = youtube_item(&app, "Yt", None, "unmatched").await; + let uri = format!("/api/inbox/{yt}/rejudge"); + + let (st, body) = app.post(&c, "/api/inbox/9999/rejudge", json!({})).await; + assert_eq!(st, StatusCode::NOT_FOUND, "{body}"); + assert_eq!(body["error"], "not_found"); + + // 手置きだけの件(サイドカー無し) + let manual = app.item("Manual").await; + let (st, body) = app + .post(&c, &format!("/api/inbox/{manual}/rejudge"), json!({})) + .await; + assert_eq!(st, StatusCode::CONFLICT, "{body}"); + assert_eq!(body["error"], "state"); + + // pending でない件 + let approved = youtube_item(&app, "Yt2", None, "unmatched").await; + app.db + .write(move |c| inbox::set_state(c, approved, ItemState::Approved, None, 2000)) + .await + .unwrap(); + let (st, body) = app + .post(&c, &format!("/api/inbox/{approved}/rejudge"), json!({})) + .await; + assert_eq!(st, StatusCode::CONFLICT, "{body}"); + assert_eq!(body["error"], "state"); + + // 受理され、inbox_rejudge ジョブが入る。続けての 2 回目は重複 + let (st, body) = app.post(&c, &uri, json!({})).await; + assert_eq!(st, StatusCode::ACCEPTED, "{body}"); + let job_id = body["job_id"].as_i64().unwrap(); + let ty: String = app + .db + .read(move |c| { + Ok( + c.query_row("SELECT type FROM jobs WHERE id = ?1", [job_id], |r| { + r.get(0) + })?, + ) + }) + .await + .unwrap(); + assert_eq!(ty, "inbox_rejudge"); + let (st, body) = app.post(&c, &uri, json!({})).await; + assert_eq!(st, StatusCode::CONFLICT, "{body}"); + assert_eq!(body["error"], "duplicate"); +} + +/// 直近の再判定で失敗した曲があれば、一覧の警告に曲数と読み先が出る +#[tokio::test] +async fn list_warns_about_tracks_the_last_rejudge_failed_on() { + use spindle::import::sidecar::{FileEntry, Sidecar}; + let app = App::new().await; + let c = app.cookie().await; + let id = youtube_item(&app, "Yt", None, "unmatched").await; + let root = RootDir::open(&app.dir.path().join("Inbox")).unwrap(); + let dir = spindle::domain::relpath::RelPath::parse("Yt").unwrap(); + Sidecar::upsert( + &root, + &dir, + None, + "20260901 New [abc].opus", + FileEntry { + source: "youtube".into(), + url: Some("https://www.youtube.com/watch?v=abc".into()), + channel: Some("CH".into()), + verdict: "unmatched".into(), + message: None, + subscription_id: None, + position: None, + item: None, + plugin_version: None, + judged_at: None, + plugin_tags: None, + rejudge_error: Some("プラグインが落ちた".into()), + }, + ) + .unwrap(); + let (st, body) = app.get(&c, "/api/inbox").await; + assert_eq!(st, StatusCode::OK, "{body}"); + let it = body["items"] + .as_array() + .unwrap() + .iter() + .find(|i| i["id"] == id) + .unwrap(); + let w: Vec<&str> = it["warnings"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert!( + w.contains(&"再判定できなかった曲が 1 曲(③ の判定列を開くと理由が読める)"), + "{w:?}" + ); +} + +/// `[ytmusic].enabled = false` なら再判定のハンドラが無いので 404 +#[tokio::test] +async fn rejudge_api_is_not_found_when_ytmusic_is_disabled() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("Inbox")).unwrap(); + let db = Arc::new(Db::open(&dir.path().join("spindle.db")).unwrap()); + let mut root: toml::Table = toml::from_str(EXAMPLE).unwrap(); + root.insert( + "ytmusic".into(), + toml::Value::Table(toml::from_str("enabled = false").unwrap()), + ); + let config = Arc::new(Config::parse(&toml::to_string(&root).unwrap()).unwrap()); + let mode = auth::bootstrap(&db, Some("correct horse".to_owned())) + .await + .unwrap(); + let inbox = Arc::new(RootDir::open(&dir.path().join("Inbox")).unwrap()); + let router = api::router(AppState::new(config, db.clone(), mode).with_inbox(inbox)); + let login = req(Method::POST, "/api/auth/login") + .header(header::CONTENT_TYPE, "application/json") + .header("sec-fetch-site", "same-origin") + .body(Body::from(r#"{"password":"correct horse"}"#)) + .unwrap(); + let res = router.clone().oneshot(login).await.unwrap(); + let cookie = res + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_owned(); + let r = req(Method::POST, "/api/inbox/1/rejudge") + .header(header::COOKIE, cookie.clone()) + .header(header::CONTENT_TYPE, "application/json") + .header("sec-fetch-site", "same-origin") + .body(Body::from("{}")) + .unwrap(); + let res = router.clone().oneshot(r).await.unwrap(); + assert_eq!(res.status(), StatusCode::NOT_FOUND); + // 一覧は YouTube 機能が無効と知らせる(画面は再判定のボタンを出さない) + let r = req(Method::GET, "/api/inbox") + .header(header::COOKIE, cookie.clone()) + .body(Body::empty()) + .unwrap(); + let res = router.clone().oneshot(r).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let bytes = res.into_body().collect().await.unwrap().to_bytes(); + let list: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(list["ytmusic_enabled"], false, "{list}"); + + // 無効の間は再判定のジョブが走らないので、待ちが残っていても件の操作は止めない + let id = db + .write(|c| inbox::insert_item(c, "Yt", "yt", 1000)) + .await + .unwrap(); + spindle::jobs::Jobs::new(db.clone()) + .enqueue(spindle::jobs::handlers::inbox_rejudge::new_inbox_rejudge_job(id)) + .await + .unwrap(); + let r = req(Method::POST, &format!("/api/inbox/{id}/reject")) + .header(header::COOKIE, cookie) + .header(header::CONTENT_TYPE, "application/json") + .header("sec-fetch-site", "same-origin") + .body(Body::from("{}")) + .unwrap(); + let res = router.oneshot(r).await.unwrap(); + assert_eq!(res.status(), StatusCode::NO_CONTENT); +} + +/// 件の再判定ジョブが queued / running の間は approve / reject / reopen を 409 rejudging にする +/// (再判定が書いている最中に承認すると、走査が承認を pending に戻してしまう)。終われば通る +#[tokio::test] +async fn approve_reject_and_reopen_are_refused_while_rejudging() { + use spindle::jobs::handlers::inbox_rejudge::new_inbox_rejudge_job; + let app = App::new().await; + let c = app.cookie().await; + let id = app.item("AlbumA").await; + // ワーカーは動かないので queued のまま残る + let jobs = spindle::jobs::Jobs::new(app.db.clone()); + let job = jobs.enqueue(new_inbox_rejudge_job(id)).await.unwrap().id(); + // 他の件の再判定は関係ない + jobs.enqueue(new_inbox_rejudge_job(id + 1000)) + .await + .unwrap(); + + let set_job = |state: &'static str| { + let db = app.db.clone(); + async move { + db.write(move |c| { + c.execute( + "UPDATE jobs SET state = ?1 WHERE id = ?2", + rusqlite::params![state, job], + )?; + Ok(()) + }) + .await + .unwrap() + } + }; + for st in ["queued", "running"] { + set_job(st).await; + let (s, body) = app + .post(&c, &format!("/api/inbox/{id}/approve"), draft("AlbumA")) + .await; + assert_eq!(s, StatusCode::CONFLICT, "{st} {body}"); + assert_eq!(body["error"], "rejudging"); + assert!(body["message"].as_str().unwrap().contains("再判定")); + let (s, body) = app + .post(&c, &format!("/api/inbox/{id}/reject"), json!({})) + .await; + assert_eq!(s, StatusCode::CONFLICT, "{st} {body}"); + assert_eq!(body["error"], "rejudging"); + assert_eq!(app.state(id).await, ItemState::Pending); + } + // reopen(rejected の件)も止める + app.db + .write(move |c| inbox::set_state(c, id, ItemState::Rejected, None, 2000)) + .await + .unwrap(); + let (s, body) = app + .post(&c, &format!("/api/inbox/{id}/reopen"), json!({})) + .await; + assert_eq!(s, StatusCode::CONFLICT, "{body}"); + assert_eq!(body["error"], "rejudging"); + assert_eq!(app.state(id).await, ItemState::Rejected); + + // 終われば通る + set_job("failed").await; + let (s, body) = app + .post(&c, &format!("/api/inbox/{id}/reopen"), json!({})) + .await; + assert_eq!(s, StatusCode::NO_CONTENT, "{body}"); + let (s, body) = app + .post(&c, &format!("/api/inbox/{id}/approve"), draft("AlbumA")) + .await; + assert_eq!(s, StatusCode::ACCEPTED, "{body}"); +} diff --git a/tests/inbox_job.rs b/tests/inbox_job.rs index 7a71a64a..a04ea186 100644 --- a/tests/inbox_job.rs +++ b/tests/inbox_job.rs @@ -890,6 +890,11 @@ fn sidecar_entry() -> spindle::import::sidecar::FileEntry { message: None, subscription_id: None, position: None, + item: None, + plugin_version: None, + judged_at: None, + plugin_tags: None, + rejudge_error: None, } } diff --git a/tests/inbox_rejudge.rs b/tests/inbox_rejudge.rs new file mode 100644 index 00000000..c9a015eb --- /dev/null +++ b/tests/inbox_rejudge.rs @@ -0,0 +1,920 @@ +//! Inbox の件の再判定(`import::ytmusic::rejudge`)。偽の yt-dlp で置いた曲を、応答を切り替えられる +//! 偽のプラグインで判定し直し、タグ・ファイル名・置き場所・サイドカーが初回と同じ形になることを +//! 確かめる。実タイトルや固有名詞は使わない。ffmpeg が無ければ skip + +#![cfg(target_os = "linux")] + +mod common; + +use std::path::Path; +use std::time::{Duration, Instant}; + +use tokio_util::sync::CancellationToken; + +use common::ytmusic::*; +use spindle::domain::relpath::RelPath; +use spindle::domain::tags::{read_audio_file, read_audio_file_with_pictures}; +use spindle::import::sidecar::Sidecar; +use spindle::import::ytmusic::rejudge::{rejudge_item, RejudgeError, RejudgeReport}; +use spindle::jobs::handlers::inbox_rejudge::new_inbox_rejudge_job; +use spindle::jobs::{EnqueueResult, JobState}; + +/// 偽のプラグイン。`fake/mode` の中身で応答を切り替える(BrokenCh は `fake/broken` があれば不正な JSON、 +/// SlowCh は `fake/slow` があれば返さずに眠る)。mode が無ければ KnownCh → a、他 → unmatched。 +/// `fake/hold` があれば `fake/judging` を置いて、hold が消えるまで応答を待つ +const PLUGIN: &str = r#" +IN=$(cat) +FAKE=$(dirname "$0") +if [ -f "$FAKE/hold" ]; then + touch "$FAKE/judging" + while [ -f "$FAKE/hold" ]; do sleep 0.05; done +fi +case "$IN" in + *'"channel":"BrokenCh"'*) if [ -f "$FAKE/broken" ]; then echo 'not json'; exit 0; fi ;; + *'"channel":"SlowCh"'*) if [ -f "$FAKE/slow" ]; then exec sleep 30; fi ;; +esac +mode=$(cat "$FAKE/mode" 2>/dev/null) +if [ -z "$mode" ]; then + case "$IN" in + *'"channel":"KnownCh"'*|*'"channel":"BrokenCh"'*) mode=a ;; + *) mode=unmatched ;; + esac +fi +case "$mode" in + a) echo '{"protocol":1,"ok":true,"track":{"title":"Song One","artists":["Artist A"],"albumartist":"Artist A","album":"Songs of A","category":"Pop","date":"2026","tags":[["ORIGINALARTIST","Someone"]]},"plugin":{"name":"fake","version":"v-a"}}' ;; + b) echo '{"protocol":1,"ok":true,"track":{"title":"Song Two","artists":["Artist A","Guest"],"albumartist":"Artist A","album":"Songs of A","category":"Pop","date":"2026"},"plugin":{"name":"fake","version":"v-b"}}' ;; + c) echo '{"protocol":1,"ok":true,"track":{"title":"Song One","artists":["Artist A"],"albumartist":"Artist A","album":"Other Album","category":"Pop","date":"2026"},"plugin":{"name":"fake","version":"v-c"}}' ;; + nocat) echo '{"protocol":1,"ok":true,"track":{"title":"Song One","artists":["Artist A"],"albumartist":"Artist A","album":"Songs of A","date":"2026"},"plugin":{"name":"fake","version":"v-n"}}' ;; + skip) echo '{"protocol":1,"ok":false,"reason":"skip","message":"配信の告知","plugin":{"name":"fake","version":"v-s"}}' ;; + *) echo '{"protocol":1,"ok":false,"reason":"unmatched","message":"ルールを足してください","plugin":{"name":"fake","version":"v-u"}}' ;; +esac +"#; + +const U1: &str = "https://youtu.be/v1"; +const U2: &str = "https://youtu.be/v2"; +const ALBUM_DIR: &str = "youtube/Artist A/Songs of A"; + +impl Lib { + fn mode(&self, mode: &str) { + std::fs::write(self.fake.join("mode"), mode).unwrap(); + } +} + +fn rel(s: &str) -> RelPath { + RelPath::parse(s).unwrap() +} + +/// `url` を ytdl ジョブで置く(置いた後の inbox ジョブは走らない) +async fn stage(lib: &Lib, url: &str) { + let (id, st) = lib.run(url).await; + assert_eq!(st, JobState::Done, "{:?}", lib.job(id)); +} + +/// 件の行(pending)を作って id を返す +fn pending_item(lib: &Lib, dir: &str) -> i64 { + spindle::db::inbox::insert_item(&lib.conn(), dir, &rel(dir).key(), 1).unwrap() +} + +async fn rejudge(lib: &Lib, item_id: i64) -> Result<RejudgeReport, RejudgeError> { + let (tx, _rx) = tokio::sync::watch::channel((0, 0)); + rejudge_item(&lib.env(), item_id, &CancellationToken::new(), &tx).await +} + +fn tags_of(path: &Path) -> spindle::domain::tags::TagSet { + read_audio_file(std::fs::File::open(path).unwrap(), Some("opus")) + .unwrap() + .tags +} + +fn values(tags: &spindle::domain::tags::TagSet, key: &str) -> Vec<String> { + tags.values(key).map(str::to_owned).collect() +} + +/// ディレクトリの音声ファイル名(名前順) +fn audio_names(lib: &Lib, dir: &str) -> Vec<String> { + let mut v: Vec<String> = std::fs::read_dir(lib.inbox_path(dir)) + .unwrap() + .map(|e| e.unwrap().file_name().into_string().unwrap()) + .filter(|n| n.ends_with(".opus")) + .collect(); + v.sort(); + v +} + +fn sidecar(lib: &Lib, dir: &str) -> Sidecar { + Sidecar::read(&lib.inbox, &rel(dir)).unwrap().unwrap() +} + +#[tokio::test] +async fn ok_to_ok_rewrites_title_artist_and_file_name() { + let lib = lib!(PLUGIN); + lib.video(U1, "v1", "KnownCh", "Song One (Official Video)", true); + lib.start(); + lib.mode("a"); + stage(&lib, U1).await; + let id = pending_item(&lib, ALBUM_DIR); + + lib.mode("b"); + let report = rejudge(&lib, id).await.unwrap(); + assert_eq!((report.total, report.rejudged), (1, 1), "{report:?}"); + assert!(report.failed.is_empty() && report.moved.is_empty()); + + let name = "20260901 Song Two [v1].opus"; + assert_eq!(audio_names(&lib, ALBUM_DIR), vec![name.to_owned()]); + let p = lib.inbox_path(&format!("{ALBUM_DIR}/{name}")); + let (af, pictures) = + read_audio_file_with_pictures(std::fs::File::open(&p).unwrap(), Some("opus")).unwrap(); + assert_eq!(af.tags.first("TITLE"), Some("Song Two")); + assert_eq!(values(&af.tags, "ARTIST"), vec!["Artist A", "Guest"]); + assert_eq!(af.tags.first("ORIGINALARTIST"), None); + assert_eq!( + af.tags.first("SOURCE_URL"), + Some("https://www.youtube.com/watch?v=v1") + ); + assert_eq!(pictures.len(), 1, "PICTURE は触らない"); + let s = sidecar(&lib, ALBUM_DIR); + assert_eq!(s.files.len(), 1); + let e = &s.files[name]; + assert_eq!(e.plugin_version.as_deref(), Some("v-b")); + assert_eq!(e.verdict, "ok"); + assert_eq!(e.plugin_tags, Some(Vec::new())); + assert!(e.rejudge_error.is_none()); + // 仕上げに inbox の走査を投入する + assert_eq!( + lib.count("SELECT count(*) FROM jobs WHERE type = 'inbox' AND state = 'queued'"), + 1 + ); +} + +#[tokio::test] +async fn unmatched_to_ok_moves_into_the_album_directory() { + let lib = lib!(PLUGIN); + lib.video(U1, "v1", "UnknownCh", "Song One (Official Video)", true); + lib.start(); + stage(&lib, U1).await; + let src = "youtube/_unmatched/UnknownCh"; + assert_eq!( + audio_names(&lib, src), + vec!["20260901 Song One (Official Video) [v1].opus"] + ); + let id = pending_item(&lib, src); + + lib.mode("a"); + let report = rejudge(&lib, id).await.unwrap(); + assert_eq!(report.moved.len(), 1, "{report:?}"); + let name = "20260901 Song One [v1].opus"; + assert_eq!( + report.moved[0], + ( + "20260901 Song One (Official Video) [v1].opus".to_owned(), + rel(&format!("{ALBUM_DIR}/{name}")) + ) + ); + assert!(audio_names(&lib, src).is_empty()); + assert_eq!(audio_names(&lib, ALBUM_DIR), vec![name.to_owned()]); + let t = tags_of(&lib.inbox_path(&format!("{ALBUM_DIR}/{name}"))); + assert_eq!(t.first("TITLE"), Some("Song One")); + assert_eq!(t.first("ALBUMARTIST"), Some("Artist A")); + assert_eq!(t.first("ORIGINALARTIST"), Some("Someone")); + assert!(sidecar(&lib, src).files.is_empty()); + let s = sidecar(&lib, ALBUM_DIR); + assert_eq!(s.category.as_deref(), Some("Pop")); + assert_eq!(s.files[name].verdict, "ok"); + assert_eq!( + s.files[name].plugin_tags, + Some(vec!["ORIGINALARTIST".to_owned()]) + ); + // 語彙に足してある + assert_eq!( + lib.count("SELECT count(*) FROM categories WHERE name = 'Pop'"), + 1 + ); +} + +#[tokio::test] +async fn ok_to_unmatched_moves_back_and_clears_plugin_tags() { + let lib = lib!(PLUGIN); + lib.video(U1, "v1", "KnownCh", "Song One (Official Video)", true); + lib.start(); + stage(&lib, U1).await; + let id = pending_item(&lib, ALBUM_DIR); + + lib.mode("unmatched"); + let report = rejudge(&lib, id).await.unwrap(); + assert_eq!(report.moved.len(), 1, "{report:?}"); + let dst = "youtube/_unmatched/KnownCh"; + let name = "20260901 Song One (Official Video) [v1].opus"; + assert_eq!(audio_names(&lib, dst), vec![name.to_owned()]); + assert!(audio_names(&lib, ALBUM_DIR).is_empty()); + let t = tags_of(&lib.inbox_path(&format!("{dst}/{name}"))); + assert_eq!(t.first("TITLE"), Some("Song One (Official Video)")); + for k in ["ARTIST", "ALBUM", "ALBUMARTIST", "DATE", "ORIGINALARTIST"] { + assert_eq!(t.first(k), None, "{k}"); + } + assert!(t.first("SOURCE_URL").is_some()); + let e = &sidecar(&lib, dst).files[name]; + assert_eq!(e.verdict, "unmatched"); + assert_eq!(e.message.as_deref(), Some("ルールを足してください")); + assert_eq!(e.plugin_tags, None); +} + +#[tokio::test] +async fn skip_leaves_tags_untouched() { + let lib = lib!(PLUGIN); + lib.video(U1, "v1", "KnownCh", "Song One (Official Video)", true); + lib.start(); + stage(&lib, U1).await; + let id = pending_item(&lib, ALBUM_DIR); + let name = "20260901 Song One [v1].opus"; + let before = tags_of(&lib.inbox_path(&format!("{ALBUM_DIR}/{name}"))); + + lib.mode("skip"); + let report = rejudge(&lib, id).await.unwrap(); + assert_eq!((report.rejudged, report.moved.len()), (1, 0), "{report:?}"); + assert_eq!(audio_names(&lib, ALBUM_DIR), vec![name.to_owned()]); + let after = tags_of(&lib.inbox_path(&format!("{ALBUM_DIR}/{name}"))); + assert_eq!(before.items(), after.items()); + let e = &sidecar(&lib, ALBUM_DIR).files[name]; + assert_eq!(e.verdict, "skip"); + assert_eq!(e.plugin_version.as_deref(), Some("v-s")); + // 書いた追加タグはまだファイルにあるので、記録も残す(次の再判定で消せる) + assert_eq!(e.plugin_tags, Some(vec!["ORIGINALARTIST".to_owned()])); +} + +#[tokio::test] +async fn subscription_track_keeps_the_subscription_album() { + let lib = lib!(PLUGIN); + lib.video(U1, "v1", "KnownCh", "Song One (Official Video)", true); + let sub = subscribe(&lib, "Sub Artist", "Sub Album", Some("Rock"), false).await; + lib.start(); + let job = match lib + .jobs + .enqueue(spindle::jobs::handlers::ytdl::new_subscription_ytdl_job( + U1, sub, 1, + )) + .await + .unwrap() + { + spindle::jobs::EnqueueResult::Inserted(j) | spindle::jobs::EnqueueResult::Duplicate(j) => j, + }; + assert_eq!(lib.wait(job).await, JobState::Done, "{:?}", lib.job(job)); + let dir = "youtube/Sub Artist/Sub Album"; + assert_eq!(audio_names(&lib, dir), vec!["20260901 Song One [v1].opus"]); + let id = pending_item(&lib, dir); + + lib.mode("unmatched"); + let report = rejudge(&lib, id).await.unwrap(); + assert!( + report.moved.is_empty() && report.failed.is_empty(), + "{report:?}" + ); + let name = "20260901 Song One (Official Video) [v1].opus"; + assert_eq!(audio_names(&lib, dir), vec![name.to_owned()]); + let t = tags_of(&lib.inbox_path(&format!("{dir}/{name}"))); + assert_eq!(t.first("TITLE"), Some("Song One (Official Video)")); + assert_eq!(t.first("ARTIST"), Some("Sub Artist")); + assert_eq!(t.first("ALBUMARTIST"), Some("Sub Artist")); + assert_eq!(t.first("ALBUM"), Some("Sub Album")); + assert_eq!(t.first("DATE"), Some("2026-09-01")); + assert_eq!(t.first("ORIGINALARTIST"), None); + let s = sidecar(&lib, dir); + assert_eq!(s.category.as_deref(), Some("Rock")); + assert_eq!(s.files[name].verdict, "unmatched"); + assert_eq!(s.files[name].subscription_id, Some(sub)); +} + +#[tokio::test] +async fn plugin_fault_fails_that_track_only() { + let lib = lib!(PLUGIN); + lib.video(U1, "v1", "KnownCh", "Song One (Official Video)", true); + lib.video(U2, "v2", "BrokenCh", "Song One (Live)", true); + lib.start(); + lib.mode("a"); + stage(&lib, U1).await; + stage(&lib, U2).await; + let id = pending_item(&lib, ALBUM_DIR); + let broken = "20260901 Song One [v2].opus"; + let before = tags_of(&lib.inbox_path(&format!("{ALBUM_DIR}/{broken}"))); + + std::fs::write(lib.fake.join("broken"), "").unwrap(); + lib.mode("b"); + let report = rejudge(&lib, id).await.unwrap(); + assert_eq!((report.total, report.rejudged), (2, 1), "{report:?}"); + assert_eq!(report.failed.len(), 1); + assert_eq!(report.failed[0].0, broken); + assert_eq!( + audio_names(&lib, ALBUM_DIR), + vec![broken.to_owned(), "20260901 Song Two [v1].opus".to_owned()] + ); + let after = tags_of(&lib.inbox_path(&format!("{ALBUM_DIR}/{broken}"))); + assert_eq!(before.items(), after.items()); + let s = sidecar(&lib, ALBUM_DIR); + assert!(s.files[broken].rejudge_error.is_some()); + assert_eq!(s.files[broken].plugin_version.as_deref(), Some("v-a")); + assert_eq!(s.files["20260901 Song Two [v1].opus"].verdict, "ok"); +} + +#[tokio::test] +async fn missing_item_is_refetched_with_ytdlp() { + let lib = lib!(PLUGIN); + lib.video(U1, "v1", "KnownCh", "Song One (Official Video)", true); + lib.start(); + stage(&lib, U1).await; + let id = pending_item(&lib, ALBUM_DIR); + let mut s = sidecar(&lib, ALBUM_DIR); + for e in s.files.values_mut() { + e.item = None; + } + s.write(&lib.inbox, &rel(ALBUM_DIR)).unwrap(); + std::fs::remove_file(lib.fake.join("calls.log")).unwrap(); + // 取り直しはサイドカーの url(正規形)で引く + let url = "https://www.youtube.com/watch?v=v1"; + lib.video(url, "v1", "KnownCh", "Song One (Official Video)", true); + + lib.mode("b"); + let report = rejudge(&lib, id).await.unwrap(); + assert_eq!(report.rejudged, 1, "{report:?}"); + assert_eq!( + lib.calls(), + vec![format!( + "--dump-single-json --flat-playlist --no-download --no-warnings -- {url}" + )] + ); + let e = &sidecar(&lib, ALBUM_DIR).files["20260901 Song Two [v1].opus"]; + let item = e.item.as_ref().unwrap(); + assert_eq!(item.title, "Song One (Official Video)"); + assert_eq!(item.uploaded_at.as_deref(), Some("2026-09-01")); +} + +/// 出どころ(サイドカーの項の source)を持たないファイルは判定し直さない +#[tokio::test] +async fn files_without_source_are_left_alone() { + let lib = lib!(PLUGIN); + lib.video(U1, "v1", "KnownCh", "Song One (Official Video)", true); + lib.start(); + stage(&lib, U1).await; + let manual = common::make_audio(&lib.inbox_path(ALBUM_DIR), "manual.opus", "opus", 3).unwrap(); + set_tags(&manual, "opus", &[("TITLE", &["Hand Placed"])]); + let before = std::fs::read(&manual).unwrap(); + let id = pending_item(&lib, ALBUM_DIR); + + lib.mode("b"); + let report = rejudge(&lib, id).await.unwrap(); + assert_eq!((report.total, report.rejudged), (1, 1), "{report:?}"); + assert_eq!(std::fs::read(&manual).unwrap(), before); + assert_eq!( + audio_names(&lib, ALBUM_DIR), + vec![ + "20260901 Song Two [v1].opus".to_owned(), + "manual.opus".to_owned() + ] + ); + assert!(!sidecar(&lib, ALBUM_DIR).files.contains_key("manual.opus")); +} + +/// 移動先に同名で別の曲(SOURCE_URL が違う)があれば、その曲は失敗にして元の場所に残す +#[tokio::test] +async fn move_onto_a_different_file_fails_that_track_only() { + let lib = lib!(PLUGIN); + lib.video(U1, "v1", "UnknownCh", "Song One (Official Video)", true); + lib.start(); + stage(&lib, U1).await; + let src = "youtube/_unmatched/UnknownCh"; + let name = "20260901 Song One (Official Video) [v1].opus"; + let id = pending_item(&lib, src); + std::fs::create_dir_all(lib.inbox_path(ALBUM_DIR)).unwrap(); + let other = lib.inbox_path(&format!("{ALBUM_DIR}/20260901 Song One [v1].opus")); + std::fs::copy(lib.inbox_path(&format!("{src}/{name}")), &other).unwrap(); + set_tags( + &other, + "opus", + &[("SOURCE_URL", &["https://example.com/other"])], + ); + let before = tags_of(&lib.inbox_path(&format!("{src}/{name}"))); + + lib.mode("a"); + let report = rejudge(&lib, id).await.unwrap(); + assert_eq!((report.rejudged, report.failed.len()), (0, 1), "{report:?}"); + assert!(report.moved.is_empty()); + assert_eq!(audio_names(&lib, src), vec![name.to_owned()]); + let after = tags_of(&lib.inbox_path(&format!("{src}/{name}"))); + assert_eq!(before.items(), after.items()); + assert_eq!( + tags_of(&other).first("SOURCE_URL"), + Some("https://example.com/other") + ); + let e = &sidecar(&lib, src).files[name]; + assert!(e.rejudge_error.is_some()); + assert_eq!(e.verdict, "unmatched"); + // tmp を残さない + assert!(std::fs::read_dir(lib.inbox_path(ALBUM_DIR)) + .unwrap() + .all(|e| !e.unwrap().file_name().to_string_lossy().starts_with('.'))); +} + +/// 移動先の件が pending でなければ移さず、今の件の中でタグとファイル名だけ直す +#[tokio::test] +async fn move_is_skipped_when_the_target_item_is_not_pending() { + let lib = lib!(PLUGIN); + lib.video(U1, "v1", "UnknownCh", "Song One (Official Video)", true); + lib.start(); + stage(&lib, U1).await; + let src = "youtube/_unmatched/UnknownCh"; + let id = pending_item(&lib, src); + let target = pending_item(&lib, ALBUM_DIR); + lib.conn() + .execute( + "UPDATE inbox_items SET state = 'approved' WHERE id = ?1", + [target], + ) + .unwrap(); + + lib.mode("a"); + let report = rejudge(&lib, id).await.unwrap(); + assert_eq!(report.rejudged, 1, "{report:?}"); + assert!(report.moved.is_empty()); + assert_eq!( + report.not_moved, + vec![( + "20260901 Song One (Official Video) [v1].opus".to_owned(), + "approved".to_owned() + )] + ); + let name = "20260901 Song One [v1].opus"; + assert_eq!(audio_names(&lib, src), vec![name.to_owned()]); + let t = tags_of(&lib.inbox_path(&format!("{src}/{name}"))); + assert_eq!(t.first("TITLE"), Some("Song One")); + assert_eq!(t.first("ARTIST"), Some("Artist A")); + assert_eq!(sidecar(&lib, src).files[name].verdict, "ok"); +} + +/// 移せなかった曲は、移動先の件が pending に戻れば次の再判定で移る(置き場所は実際のディレクトリで見る) +#[tokio::test] +async fn not_moved_track_moves_once_the_target_item_is_pending_again() { + let lib = lib!(PLUGIN); + lib.video(U1, "v1", "UnknownCh", "Song One (Official Video)", true); + lib.start(); + stage(&lib, U1).await; + let src = "youtube/_unmatched/UnknownCh"; + let id = pending_item(&lib, src); + let target = pending_item(&lib, ALBUM_DIR); + lib.conn() + .execute( + "UPDATE inbox_items SET state = 'approved' WHERE id = ?1", + [target], + ) + .unwrap(); + lib.mode("a"); + let report = rejudge(&lib, id).await.unwrap(); + assert_eq!(report.not_moved.len(), 1, "{report:?}"); + let name = "20260901 Song One [v1].opus"; + assert_eq!(sidecar(&lib, src).files[name].verdict, "ok"); + + lib.conn() + .execute( + "UPDATE inbox_items SET state = 'pending' WHERE id = ?1", + [target], + ) + .unwrap(); + let report = rejudge(&lib, id).await.unwrap(); + assert_eq!( + report.moved, + vec![(name.to_owned(), rel(&format!("{ALBUM_DIR}/{name}")))], + "{report:?}" + ); + assert!(audio_names(&lib, src).is_empty()); + assert_eq!(audio_names(&lib, ALBUM_DIR), vec![name.to_owned()]); + assert_eq!(sidecar(&lib, ALBUM_DIR).files[name].verdict, "ok"); +} + +/// 件のディレクトリにある曲は、前回の判定が skip でも、ok で別のアルバムが返ったときに移さない +#[tokio::test] +async fn track_in_an_album_dir_does_not_move_after_skip_then_ok() { + let lib = lib!(PLUGIN); + lib.video(U1, "v1", "KnownCh", "Song One (Official Video)", true); + lib.start(); + stage(&lib, U1).await; + let id = pending_item(&lib, ALBUM_DIR); + let name = "20260901 Song One [v1].opus"; + lib.mode("skip"); + rejudge(&lib, id).await.unwrap(); + assert_eq!(sidecar(&lib, ALBUM_DIR).files[name].verdict, "skip"); + + lib.mode("c"); + let report = rejudge(&lib, id).await.unwrap(); + assert!( + report.moved.is_empty() && report.not_moved.is_empty() && report.failed.is_empty(), + "{report:?}" + ); + assert_eq!(audio_names(&lib, ALBUM_DIR), vec![name.to_owned()]); + assert!(!lib.inbox_path("youtube/Artist A/Other Album").exists()); + let t = tags_of(&lib.inbox_path(&format!("{ALBUM_DIR}/{name}"))); + assert_eq!(t.first("ALBUM"), Some("Other Album")); + let e = &sidecar(&lib, ALBUM_DIR).files[name]; + assert_eq!( + (e.verdict.as_str(), e.plugin_version.as_deref()), + ("ok", Some("v-c")) + ); +} + +/// 新しい名前に置いた後(元のファイルは消えた)・サイドカーの前に落ちた状態から、もう一度で収束する +#[tokio::test] +async fn rerun_after_rename_before_sidecar_converges() { + let lib = lib!(PLUGIN); + lib.video(U1, "v1", "KnownCh", "Song One (Official Video)", true); + lib.start(); + stage(&lib, U1).await; + let id = pending_item(&lib, ALBUM_DIR); + let old = "20260901 Song One [v1].opus"; + let new = "20260901 Song Two [v1].opus"; + std::fs::rename( + lib.inbox_path(&format!("{ALBUM_DIR}/{old}")), + lib.inbox_path(&format!("{ALBUM_DIR}/{new}")), + ) + .unwrap(); + assert!(sidecar(&lib, ALBUM_DIR).files.contains_key(old)); + + lib.mode("b"); + let report = rejudge(&lib, id).await.unwrap(); + assert_eq!((report.total, report.rejudged), (1, 1), "{report:?}"); + assert_eq!(audio_names(&lib, ALBUM_DIR), vec![new.to_owned()]); + let s = sidecar(&lib, ALBUM_DIR); + assert_eq!(s.files.keys().collect::<Vec<_>>(), vec![new]); + assert_eq!(s.files[new].plugin_version.as_deref(), Some("v-b")); + assert_eq!( + tags_of(&lib.inbox_path(&format!("{ALBUM_DIR}/{new}"))).first("TITLE"), + Some("Song Two") + ); +} + +#[tokio::test] +async fn draft_is_cleared() { + let lib = lib!(PLUGIN); + lib.video(U1, "v1", "KnownCh", "Song One (Official Video)", true); + lib.start(); + stage(&lib, U1).await; + let id = pending_item(&lib, ALBUM_DIR); + spindle::db::inbox::set_draft(&lib.conn(), id, &serde_json::json!({"album": "Typed"})).unwrap(); + + lib.mode("b"); + rejudge(&lib, id).await.unwrap(); + assert_eq!( + lib.count(&format!( + "SELECT count(*) FROM inbox_items WHERE id = {id} AND draft IS NULL" + )), + 1 + ); +} + +/// pending でない件は判定し直さない +#[tokio::test] +async fn item_that_is_not_pending_is_refused() { + let lib = lib!(PLUGIN); + lib.video(U1, "v1", "KnownCh", "Song One (Official Video)", true); + lib.start(); + stage(&lib, U1).await; + let id = pending_item(&lib, ALBUM_DIR); + lib.conn() + .execute( + "UPDATE inbox_items SET state = 'approved' WHERE id = ?1", + [id], + ) + .unwrap(); + lib.mode("b"); + assert!(matches!( + rejudge(&lib, id).await, + Err(RejudgeError::NotPending(_)) + )); + assert!(matches!( + rejudge(&lib, 999).await, + Err(RejudgeError::NotPending(_)) + )); + assert_eq!( + audio_names(&lib, ALBUM_DIR), + vec!["20260901 Song One [v1].opus"] + ); +} + +/// ジョブとして走らせると done になり、note に件数が残る +#[tokio::test] +async fn rejudge_job_runs_and_leaves_a_note() { + let lib = lib!(PLUGIN); + lib.video(U1, "v1", "KnownCh", "Song One (Official Video)", true); + lib.start(); + lib.mode("a"); + stage(&lib, U1).await; + let id = pending_item(&lib, ALBUM_DIR); + lib.mode("b"); + let job = match lib.jobs.enqueue(new_inbox_rejudge_job(id)).await.unwrap() { + EnqueueResult::Inserted(j) | EnqueueResult::Duplicate(j) => j, + }; + assert_eq!(lib.wait(job).await, JobState::Done, "{:?}", lib.job(job)); + let note = lib.note(job).unwrap(); + assert!( + note.starts_with("再判定: 1/1 曲、移動 0 曲、失敗 0 曲"), + "{note}" + ); +} + +/// 投入後・実行前に件が approved になったら、ジョブは failed でファイルは変わらない +#[tokio::test] +async fn rejudge_stops_when_the_item_is_no_longer_pending() { + let lib = lib!(PLUGIN); + lib.video(U1, "v1", "KnownCh", "Song One (Official Video)", true); + lib.start(); + stage(&lib, U1).await; + let id = pending_item(&lib, ALBUM_DIR); + lib.conn() + .execute( + "UPDATE inbox_items SET state = 'approved' WHERE id = ?1", + [id], + ) + .unwrap(); + lib.mode("b"); + let job = match lib.jobs.enqueue(new_inbox_rejudge_job(id)).await.unwrap() { + EnqueueResult::Inserted(j) | EnqueueResult::Duplicate(j) => j, + }; + assert_eq!(lib.wait(job).await, JobState::Failed, "{:?}", lib.job(job)); + assert_eq!( + audio_names(&lib, ALBUM_DIR), + vec!["20260901 Song One [v1].opus"] + ); +} + +/// 同じ件の投入は 1 つにまとまる(走行中・待ちの間) +#[tokio::test] +async fn rejudge_is_deduplicated_per_item() { + let lib = lib!(PLUGIN); + // ワーカーを起動しないので、1 つ目は queued のまま残る + let first = lib.jobs.enqueue(new_inbox_rejudge_job(7)).await.unwrap(); + assert!(matches!(first, EnqueueResult::Inserted(_))); + let second = lib.jobs.enqueue(new_inbox_rejudge_job(7)).await.unwrap(); + assert!(matches!(second, EnqueueResult::Duplicate(_))); + let other = lib.jobs.enqueue(new_inbox_rejudge_job(8)).await.unwrap(); + assert!(matches!(other, EnqueueResult::Inserted(_))); +} + +/// 途中でキャンセルされても、書き換えた曲があれば inbox の走査を投入する(行はキャッシュ) +#[tokio::test] +async fn cancel_after_a_rewritten_track_still_enqueues_the_scan() { + let lib = lib!(PLUGIN); + lib.video(U1, "v1", "KnownCh", "Song One (Official Video)", true); + lib.video(U2, "v2", "SlowCh", "Song One (Official Video)", true); + lib.start(); + lib.mode("a"); + stage(&lib, U1).await; + stage(&lib, U2).await; + let id = pending_item(&lib, ALBUM_DIR); + lib.conn() + .execute("DELETE FROM jobs WHERE type = 'inbox'", []) + .unwrap(); + + // 1 曲目(v1)を書いた後、2 曲目(v2)のプラグインが眠っている間にキャンセルする + lib.mode("b"); + std::fs::write(lib.fake.join("slow"), "").unwrap(); + let token = CancellationToken::new(); + let (tx, mut rx) = tokio::sync::watch::channel((0, 0)); + let canceller = { + let token = token.clone(); + tokio::spawn(async move { + while rx.changed().await.is_ok() { + if rx.borrow_and_update().0 == 1 { + token.cancel(); + break; + } + } + }) + }; + let r = rejudge_item(&lib.env(), id, &token, &tx).await; + canceller.await.unwrap(); + assert!( + matches!( + r, + Err(RejudgeError::Download( + spindle::import::ytmusic::downloader::DownloadError::Cancelled + )) + ), + "{r:?}" + ); + assert_eq!( + audio_names(&lib, ALBUM_DIR), + vec!["20260901 Song One [v2].opus", "20260901 Song Two [v1].opus"] + ); + assert_eq!( + lib.count("SELECT count(*) FROM jobs WHERE type = 'inbox' AND state = 'queued'"), + 1 + ); +} + +/// 何も書かずに止まった(件が pending でない)なら走査を投入しない +#[tokio::test] +async fn refusal_without_writes_does_not_enqueue_the_scan() { + let lib = lib!(PLUGIN); + lib.video(U1, "v1", "KnownCh", "Song One (Official Video)", true); + lib.start(); + stage(&lib, U1).await; + let id = pending_item(&lib, ALBUM_DIR); + lib.conn() + .execute("DELETE FROM jobs WHERE type = 'inbox'", []) + .unwrap(); + lib.conn() + .execute( + "UPDATE inbox_items SET state = 'approved' WHERE id = ?1", + [id], + ) + .unwrap(); + assert!(matches!( + rejudge(&lib, id).await, + Err(RejudgeError::NotPending(_)) + )); + assert_eq!( + lib.count("SELECT count(*) FROM jobs WHERE type = 'inbox'"), + 0 + ); +} + +/// ytdl と再判定はサイドカーの鍵を共有する: 鍵を持たれている間、再判定はファイルを置いてもサイドカーを +/// 書かずに待ち、放されてから書く(読んで直して書くが ytdl の upsert と混ざらない) +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rejudge_waits_for_the_shared_sidecar_lock() { + let lib = lib!(PLUGIN); + lib.video(U1, "v1", "KnownCh", "Song One (Official Video)", true); + lib.start(); + lib.mode("a"); + stage(&lib, U1).await; + let id = pending_item(&lib, ALBUM_DIR); + let env = lib.env(); + // ハンドラごとの環境は clone で作る(main.rs)。clone は同じ鍵を指す + assert!(std::sync::Arc::ptr_eq( + &env.sidecar_lock, + &env.clone().sidecar_lock + )); + assert!(std::sync::Arc::ptr_eq( + &env.sidecar_lock, + &lib.env().sidecar_lock + )); + + // 別スレッドで鍵を持つ(ytdl の upsert の最中を模す) + let (held_tx, held_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel::<()>(); + let lock = lib.sidecar_lock.clone(); + let holder = std::thread::spawn(move || { + let _guard = lock.lock().unwrap(); + held_tx.send(()).unwrap(); + let _ = release_rx.recv(); + }); + held_rx.recv().unwrap(); + + lib.mode("b"); + let task = tokio::spawn(async move { + let (tx, _rx) = tokio::sync::watch::channel((0, 0)); + rejudge_item(&env, id, &CancellationToken::new(), &tx).await + }); + let new_name = "20260901 Song Two [v1].opus"; + let deadline = Instant::now() + Duration::from_secs(20); + while audio_names(&lib, ALBUM_DIR) != vec![new_name.to_owned()] { + assert!(Instant::now() < deadline, "ファイルが置かれない"); + tokio::time::sleep(Duration::from_millis(20)).await; + } + tokio::time::sleep(Duration::from_millis(300)).await; + assert!(!task.is_finished(), "鍵を待たずに終わった"); + let s = sidecar(&lib, ALBUM_DIR); + assert!( + !s.files.contains_key(new_name), + "鍵を待たずにサイドカーを書いた" + ); + + release_tx.send(()).unwrap(); + holder.join().unwrap(); + task.await.unwrap().unwrap(); + let s = sidecar(&lib, ALBUM_DIR); + assert_eq!(s.files[new_name].plugin_version.as_deref(), Some("v-b")); +} + +/// 移動で宛先の項を書き、元を消した後・元の項を消す前に落ちた状態から、もう一度で元の項だけを消して +/// 収束する(宛先はそのまま。失敗には数えない) +#[tokio::test] +async fn rerun_after_move_before_source_entry_removal_drops_the_stale_entry() { + let lib = lib!(PLUGIN); + lib.video(U1, "v1", "UnknownCh", "Song One (Official Video)", true); + lib.start(); + stage(&lib, U1).await; + let src = "youtube/_unmatched/UnknownCh"; + let id = pending_item(&lib, src); + let before = sidecar(&lib, src); + + lib.mode("a"); + let report = rejudge(&lib, id).await.unwrap(); + assert_eq!(report.moved.len(), 1, "{report:?}"); + // 元の項を消す前に落ちた状態へ戻す(元のファイルは消えている) + before.write(&lib.inbox, &rel(src)).unwrap(); + assert!(audio_names(&lib, src).is_empty()); + let dest_before = sidecar(&lib, ALBUM_DIR); + lib.conn() + .execute("DELETE FROM jobs WHERE type = 'inbox'", []) + .unwrap(); + + let report = rejudge(&lib, id).await.unwrap(); + assert!(report.failed.is_empty(), "{report:?}"); + assert_eq!((report.total, report.rejudged), (0, 0), "{report:?}"); + assert_eq!( + report.dropped, + vec!["20260901 Song One (Official Video) [v1].opus".to_owned()] + ); + assert!(sidecar(&lib, src).files.is_empty()); + let name = "20260901 Song One [v1].opus"; + assert_eq!(audio_names(&lib, ALBUM_DIR), vec![name.to_owned()]); + assert_eq!(sidecar(&lib, ALBUM_DIR), dest_before); + // サイドカーを書き換えたので走査に見せる + assert_eq!( + lib.count("SELECT count(*) FROM jobs WHERE type = 'inbox' AND state = 'queued'"), + 1 + ); +} + +/// プラグインの応答を待っている間に件が承認されたら、その曲を書かずに止まる +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn approval_during_the_plugin_call_stops_before_writing() { + let lib = lib!(PLUGIN); + lib.video(U1, "v1", "KnownCh", "Song One (Official Video)", true); + lib.start(); + lib.mode("a"); + stage(&lib, U1).await; + let id = pending_item(&lib, ALBUM_DIR); + let name = "20260901 Song One [v1].opus"; + let sidecar_before = sidecar(&lib, ALBUM_DIR); + lib.conn() + .execute("DELETE FROM jobs WHERE type = 'inbox'", []) + .unwrap(); + + lib.mode("b"); + std::fs::write(lib.fake.join("hold"), "").unwrap(); + let env = lib.env(); + let task = tokio::spawn(async move { + let (tx, _rx) = tokio::sync::watch::channel((0, 0)); + rejudge_item(&env, id, &CancellationToken::new(), &tx).await + }); + let deadline = Instant::now() + Duration::from_secs(20); + while !lib.fake.join("judging").exists() { + assert!(Instant::now() < deadline, "プラグインが呼ばれない"); + tokio::time::sleep(Duration::from_millis(20)).await; + } + lib.conn() + .execute( + "UPDATE inbox_items SET state = 'approved' WHERE id = ?1", + [id], + ) + .unwrap(); + std::fs::remove_file(lib.fake.join("hold")).unwrap(); + let r = task.await.unwrap(); + assert!(matches!(r, Err(RejudgeError::NotPending(_))), "{r:?}"); + assert_eq!(audio_names(&lib, ALBUM_DIR), vec![name.to_owned()]); + assert_eq!( + tags_of(&lib.inbox_path(&format!("{ALBUM_DIR}/{name}"))).first("TITLE"), + Some("Song One") + ); + assert_eq!(sidecar(&lib, ALBUM_DIR), sidecar_before); + assert_eq!( + lib.count("SELECT count(*) FROM jobs WHERE type = 'inbox'"), + 0 + ); +} + +/// 判定の category が無くなったら、件の category も消す(Some → None) +#[tokio::test] +async fn category_is_cleared_when_the_judged_track_has_none() { + let lib = lib!(PLUGIN); + lib.video(U1, "v1", "KnownCh", "Song One (Official Video)", true); + lib.start(); + lib.mode("a"); + stage(&lib, U1).await; + assert_eq!(sidecar(&lib, ALBUM_DIR).category.as_deref(), Some("Pop")); + let id = pending_item(&lib, ALBUM_DIR); + + lib.mode("nocat"); + let report = rejudge(&lib, id).await.unwrap(); + assert_eq!(report.rejudged, 1, "{report:?}"); + let s = sidecar(&lib, ALBUM_DIR); + assert_eq!(s.category, None); + assert_eq!( + s.files["20260901 Song One [v1].opus"] + .plugin_version + .as_deref(), + Some("v-n") + ); +} + +/// skip(タグを触らない)では件の category も触らない +#[tokio::test] +async fn skip_keeps_the_item_category() { + let lib = lib!(PLUGIN); + lib.video(U1, "v1", "KnownCh", "Song One (Official Video)", true); + lib.start(); + lib.mode("a"); + stage(&lib, U1).await; + let id = pending_item(&lib, ALBUM_DIR); + + lib.mode("skip"); + rejudge(&lib, id).await.unwrap(); + assert_eq!(sidecar(&lib, ALBUM_DIR).category.as_deref(), Some("Pop")); +} diff --git a/tests/inbox_sidecar.rs b/tests/inbox_sidecar.rs index d0b29021..125db064 100644 --- a/tests/inbox_sidecar.rs +++ b/tests/inbox_sidecar.rs @@ -20,6 +20,11 @@ fn entry(verdict: &str) -> FileEntry { message: (verdict != "ok").then(|| "見つからない".to_owned()), subscription_id: None, position: None, + item: None, + plugin_version: None, + judged_at: None, + plugin_tags: None, + rejudge_error: None, } } @@ -207,3 +212,15 @@ fn rip_entry_check_rejects_broken_shapes() { e.toc = "garbage".into(); assert!(matches!(e.check(), Err(RipEntryError::Toc(_)))); } + +#[test] +fn old_sidecar_without_new_fields_still_parses() { + let json = r#"{"version":1,"files":{"a.opus":{"source":"youtube","verdict":"ok"}}}"#; + let s = Sidecar::parse(json.as_bytes()).unwrap(); + let e = &s.files["a.opus"]; + assert_eq!(e.item, None); + assert_eq!(e.plugin_version, None); + assert_eq!(e.judged_at, None); + assert_eq!(e.plugin_tags, None); + assert_eq!(e.rejudge_error, None); +} diff --git a/tests/jobs.rs b/tests/jobs.rs index 9305287e..1ba5c9a3 100644 --- a/tests/jobs.rs +++ b/tests/jobs.rs @@ -2372,7 +2372,7 @@ fn device_mutex_is_per_device() { #[test] fn device_job_types_round_trip() { - assert_eq!(JobType::ALL.len(), 21); + assert_eq!(JobType::ALL.len(), 22); for (t, s) in [ (JobType::DeviceScan, "device_scan"), (JobType::DeviceSync, "device_sync"), diff --git a/tests/migrations.rs b/tests/migrations.rs index 66e44d76..24939512 100644 --- a/tests/migrations.rs +++ b/tests/migrations.rs @@ -461,3 +461,23 @@ fn playlist_subscriptions_constraints_and_ids_not_reused() { "消した id を再利用しない" ); } + +/// 再判定(D-114): jobs.type が inbox_rejudge を受け付け、列挙にも載る +#[test] +fn inbox_rejudge_job_type_is_accepted_by_the_schema() { + use spindle::db::jobs::JobType; + let conn = open_memory_connection().unwrap(); + assert!(JobType::ALL.contains(&JobType::InboxRejudge)); + assert_eq!(JobType::InboxRejudge.as_str(), "inbox_rejudge"); + conn.execute( + "INSERT INTO jobs (type, payload, created_at) VALUES (?1, '{\"item_id\":1}', 1)", + [JobType::InboxRejudge.as_str()], + ) + .unwrap(); + assert!(conn + .execute( + "INSERT INTO jobs (type, payload, created_at) VALUES ('no_such_type', '{}', 1)", + [], + ) + .is_err()); +} diff --git a/tests/rg_auto_write_schema.rs b/tests/rg_auto_write_schema.rs index bae8e3d5..b1dbf89e 100644 --- a/tests/rg_auto_write_schema.rs +++ b/tests/rg_auto_write_schema.rs @@ -11,7 +11,12 @@ use spindle::db::migrations; fn upgrade_from_0003_keeps_jobs_children_and_adds_rgwrite() { let mut c = Connection::open_in_memory().unwrap(); c.pragma_update(None, "foreign_keys", "ON").unwrap(); - let all = migrations::embedded().unwrap(); + // 0004 の性質を見る試験なので、後続の版(0005〜)は流さない + let all: Vec<_> = migrations::embedded() + .unwrap() + .into_iter() + .filter(|m| m.version <= 4) + .collect(); let upto3: Vec<_> = all.iter().filter(|m| m.version <= 3).cloned().collect(); assert_eq!( migrations::apply_list(&mut c, &upto3).unwrap(), diff --git a/tests/ytmusic_download.rs b/tests/ytmusic_download.rs index 5bc3a008..00234122 100644 --- a/tests/ytmusic_download.rs +++ b/tests/ytmusic_download.rs @@ -6,267 +6,32 @@ mod common; -use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; -use rusqlite::Connection; -use tokio_util::sync::CancellationToken; - -use spindle::db::Db; -use spindle::fsroot::RootDir; +use common::ytmusic::*; use spindle::import::sidecar::Sidecar; -use spindle::import::ytmusic::downloader::{DownloaderEnv, DEDUP_PREFIX}; -use spindle::import::ytmusic::MetadataProvider; -use spindle::jobs::handlers::ytdl::{new_ytdl_job, YtdlHandler}; -use spindle::jobs::{EnqueueResult, JobState, JobType, Jobs, Registry}; - -/// 偽の yt-dlp。`--dump-single-json` なら `dump/<url をキー化した名前>.json` を返し、download なら -/// `-o` のテンプレートに `audio.webm` と `thumb.jpg` を置く。呼び出しは `calls.log` に残す -const FAKE_YTDLP: &str = r#" -FAKE="$1"; shift -printf '%s\n' "$*" >> "$FAKE/calls.log" -url="${@: -1}" -key=$(printf '%s' "$url" | tr -c 'A-Za-z0-9' '_') -if [[ " $* " == *" --dump-single-json "* ]]; then - if [ -f "$FAKE/dump/$key.json" ]; then cat "$FAKE/dump/$key.json"; exit 0; fi - if [ -f "$FAKE/unsupported" ]; then echo "ERROR: Unsupported URL: $url" >&2; exit 1; fi - echo "ERROR: [youtube] $url: Video unavailable" >&2; exit 1 -fi -if [ -f "$FAKE/fail_download" ]; then echo "ERROR: unable to download: network down" >&2; exit 1; fi -tmpl="" -prev="" -for a in "$@"; do - if [ "$prev" = "-o" ]; then tmpl="$a"; fi - prev="$a" -done -id=$(grep -o '"id": *"[^"]*"' "$FAKE/dump/$key.json" | head -1 | sed 's/.*"\([^"]*\)"$/\1/') -out="${tmpl//%(id)s/$id}"; out="${out//%(ext)s/webm}" -cp "$FAKE/audio.webm" "$out" -cp "$FAKE/thumb.jpg" "${out%.webm}.jpg" -"#; +use spindle::import::ytmusic::downloader::DEDUP_PREFIX; +use spindle::jobs::handlers::ytdl::YtdlHandler; +use spindle::jobs::{EnqueueResult, JobState, JobType, Registry}; /// 偽のプラグイン。channel で分岐: KnownCh → ok、SkipCh → skip、BrokenCh → 不正な JSON、他 → unmatched const FAKE_PLUGIN: &str = r#" IN=$(cat) case "$IN" in - *'"channel":"KnownCh"'*) echo '{"protocol":1,"ok":true,"track":{"title":"Song One","artists":["Artist A"],"albumartist":"Artist A","album":"Songs of A","category":"Pop","date":"2026","tags":[["ORIGINALARTIST","Someone"]]}}' ;; + *'"channel":"KnownCh"'*) echo '{"protocol":1,"ok":true,"track":{"title":"Song One","artists":["Artist A"],"albumartist":"Artist A","album":"Songs of A","category":"Pop","date":"2026","tags":[["ORIGINALARTIST","Someone"]]},"plugin":{"name":"fake","version":"v9.9.9"}}' ;; *'"channel":"SkipCh"'*) echo '{"protocol":1,"ok":false,"reason":"skip","message":"配信の告知"}' ;; *'"channel":"BrokenCh"'*) echo 'not json' ;; *) echo '{"protocol":1,"ok":false,"reason":"unmatched","message":"ルールを足してください"}' ;; esac "#; -struct Lib { - dir: tempfile::TempDir, - db_path: PathBuf, - db: Arc<Db>, - jobs: Arc<Jobs>, - inbox: Arc<RootDir>, - fake: PathBuf, - shutdown: CancellationToken, -} - -impl Lib { - fn new() -> Option<Self> { - let dir = tempfile::tempdir().unwrap(); - for d in ["Inbox", "Archive", "tmp", "fake", "fake/dump"] { - std::fs::create_dir(dir.path().join(d)).unwrap(); - } - let fake = dir.path().join("fake"); - common::make_audio(&fake, "audio.webm", "webm", 1)?; - // サムネイル(ffmpeg で 16x16 の JPEG を作る) - let st = std::process::Command::new(common::ffmpeg()?) - .args([ - "-hide_banner", - "-loglevel", - "error", - "-y", - "-f", - "lavfi", - "-i", - ]) - .arg("color=c=red:s=16x16") - .args(["-frames:v", "1"]) - .arg(fake.join("thumb.jpg")) - .status() - .unwrap(); - assert!(st.success()); - std::fs::write(fake.join("ytdlp.sh"), FAKE_YTDLP).unwrap(); - std::fs::write(fake.join("plugin.sh"), FAKE_PLUGIN).unwrap(); - let db_path = dir.path().join("spindle.db"); - let db = Arc::new(Db::open(&db_path).unwrap()); - let jobs = Jobs::new(db.clone()); - let inbox = Arc::new(RootDir::open(&dir.path().join("Inbox")).unwrap()); - Some(Self { - dir, - db_path, - db, - jobs, - inbox, - fake, - shutdown: CancellationToken::new(), - }) - } - - fn env(&self) -> DownloaderEnv { - let plugin = vec![ - "/bin/bash".to_owned(), - self.fake.join("plugin.sh").display().to_string(), - ]; - DownloaderEnv { - db: self.db.clone(), - inbox: self.inbox.clone(), - archive: Arc::new(RootDir::open(&self.dir.path().join("Archive")).unwrap()), - jobs: self.jobs.clone(), - provider: MetadataProvider::new(&plugin, Duration::from_secs(10)).unwrap(), - // 引数配列の先頭にスクリプトと fake ディレクトリを固定する(`ytdlp` は引数配列) - ytdlp: vec![ - "/bin/bash".to_owned(), - self.fake.join("ytdlp.sh").display().to_string(), - self.fake.display().to_string(), - ], - ffmpeg: common::ffmpeg().unwrap_or_else(|| PathBuf::from("ffmpeg")), - tmp_root: self.dir.path().join("tmp").join("ytdl"), - download_timeout: Duration::from_secs(60), - } - } - - fn start(&self) { - let mut reg = Registry::new(); - reg.register(JobType::Ytdl, Arc::new(YtdlHandler::new(self.env()))); - self.jobs.start(reg, self.shutdown.clone()); - } - - /// dump の JSON を置く(動画) - fn video(&self, url: &str, id: &str, uploader: &str, title: &str, webm: bool) { - let formats = if webm { - r#"[{"format_id":"251","ext":"webm","acodec":"opus","vcodec":"none"},{"format_id":"140","ext":"m4a","acodec":"mp4a.40.2","vcodec":"none"}]"# - } else { - r#"[{"format_id":"140","ext":"m4a","acodec":"mp4a.40.2","vcodec":"none"}]"# - }; - let json = format!( - r#"{{"id":"{id}","title":"{title}","uploader":"{uploader}","channel":"{uploader} Channel", - "webpage_url":"https://www.youtube.com/watch?v={id}","upload_date":"20260901", - "duration":123.4,"_type":"video","formats":{formats}}}"# - ); - self.dump(url, &json); - } - - fn dump(&self, url: &str, json: &str) { - let key: String = url - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) - .collect(); - std::fs::write(self.fake.join("dump").join(format!("{key}.json")), json).unwrap(); - } - - fn calls(&self) -> Vec<String> { - std::fs::read_to_string(self.fake.join("calls.log")) - .unwrap_or_default() - .lines() - .map(str::to_owned) - .collect() - } - - async fn run(&self, url: &str) -> (i64, JobState) { - let id = match self.jobs.enqueue(new_ytdl_job(url)).await.unwrap() { - EnqueueResult::Inserted(j) | EnqueueResult::Duplicate(j) => j, - }; - (id, self.wait(id).await) - } - - async fn wait(&self, id: i64) -> JobState { - let deadline = std::time::Instant::now() + Duration::from_secs(120); - while std::time::Instant::now() < deadline { - let (s, attempts): (String, i64) = self - .conn() - .query_row( - "SELECT state, attempts FROM jobs WHERE id = ?1", - [id], - |r| Ok((r.get(0)?, r.get(1)?)), - ) - .unwrap(); - let st: JobState = s.parse().unwrap(); - // 再試行待ち(queued + attempts > 0)も終端として返す - if st.is_terminal() || (st == JobState::Queued && attempts > 0) { - return st; - } - tokio::time::sleep(Duration::from_millis(20)).await; - } - panic!("ytdl ジョブが終わらない"); - } - - fn note(&self, id: i64) -> Option<String> { - self.conn() - .query_row("SELECT note FROM jobs WHERE id = ?1", [id], |r| r.get(0)) - .unwrap() - } - - fn job(&self, id: i64) -> (i64, Option<String>) { - self.conn() - .query_row( - "SELECT attempts, last_error FROM jobs WHERE id = ?1", - [id], - |r| Ok((r.get(0)?, r.get(1)?)), - ) - .unwrap() - } - - fn conn(&self) -> Connection { - Connection::open(&self.db_path).unwrap() - } - - fn inbox_path(&self, rel: &str) -> PathBuf { - self.dir.path().join("Inbox").join(rel) - } - - fn count(&self, sql: &str) -> i64 { - self.conn().query_row(sql, [], |r| r.get(0)).unwrap() - } -} - -impl Drop for Lib { - fn drop(&mut self) { - self.shutdown.cancel(); - } -} - -/// テスト用: ファイルのタグを直接書き換える(`write_tag_changes` を tmp 無しで当てる) -fn set_tags(path: &std::path::Path, ext: &str, tags: &[(&str, &[&str])]) { - let changes: Vec<spindle::domain::tags::TagChange> = tags - .iter() - .map(|(k, vs)| spindle::domain::tags::TagChange { - key: (*k).to_owned(), - values: Some(vs.iter().map(|v| (*v).to_owned()).collect()), - }) - .collect(); - let mut f = std::fs::File::options() - .read(true) - .write(true) - .open(path) - .unwrap(); - spindle::domain::tags::write_tag_changes(&mut f, Some(ext), &changes, None).unwrap(); -} - -macro_rules! lib { - () => { - match Lib::new() { - Some(l) => l, - None => { - eprintln!("ffmpeg が無いので skip"); - return; - } - } - }; -} - const U1: &str = "https://youtu.be/v1"; const U2: &str = "https://youtu.be/v2"; #[tokio::test] async fn ok_video_is_staged_in_inbox_with_tags_sidecar_and_archive() { - let lib = lib!(); + let lib = lib!(FAKE_PLUGIN); lib.video(U1, "v1", "KnownCh", "Song One (Official Video)", true); lib.start(); let (id, st) = lib.run(U1).await; @@ -303,6 +68,14 @@ async fn ok_video_is_staged_in_inbox_with_tags_sidecar_and_archive() { assert_eq!(e.channel.as_deref(), Some("KnownCh")); assert_eq!(e.url.as_deref(), Some("https://www.youtube.com/watch?v=v1")); assert!(e.message.is_none()); + // 再判定の材料(Item・プラグインの版・時刻・追加タグのキー) + let item = e.item.as_ref().unwrap(); + assert_eq!(item.title, "Song One (Official Video)"); + assert_eq!(item.channel, "KnownCh"); + assert_eq!(item.uploaded_at.as_deref(), Some("2026-09-01")); + assert!(e.judged_at.is_some()); + assert_eq!(e.plugin_tags, Some(vec!["ORIGINALARTIST".to_owned()])); + assert_eq!(e.plugin_version.as_deref(), Some("v9.9.9")); // Archive の webm、inbox ジョブの投入、作業領域の後始末 assert!(lib.dir.path().join("Archive/youtube/v1.webm").exists()); assert_eq!( @@ -321,7 +94,7 @@ async fn ok_video_is_staged_in_inbox_with_tags_sidecar_and_archive() { #[tokio::test] async fn unmatched_video_goes_to_the_catch_all_directory_with_the_message() { - let lib = lib!(); + let lib = lib!(FAKE_PLUGIN); lib.video(U1, "v1", "OtherCh", "Some Title", true); lib.start(); let (id, st) = lib.run(U1).await; @@ -343,12 +116,14 @@ async fn unmatched_video_goes_to_the_catch_all_directory_with_the_message() { let e = &s.files["20260901 Some Title [v1].opus"]; assert_eq!(e.verdict, "unmatched"); assert_eq!(e.message.as_deref(), Some("ルールを足してください")); + assert!(e.item.is_some()); + assert_eq!(e.plugin_tags, None); assert!(lib.dir.path().join("Archive/youtube/v1.webm").exists()); } #[tokio::test] async fn skip_is_done_without_downloading() { - let lib = lib!(); + let lib = lib!(FAKE_PLUGIN); lib.video(U1, "v1", "SkipCh", "Announcement", true); lib.start(); let (id, st) = lib.run(U1).await; @@ -371,7 +146,7 @@ async fn skip_is_done_without_downloading() { #[tokio::test] async fn playlist_is_expanded_into_one_job_per_entry() { - let lib = lib!(); + let lib = lib!(FAKE_PLUGIN); let pl = "https://www.youtube.com/playlist?list=PL1"; lib.dump( pl, @@ -445,7 +220,7 @@ async fn playlist_is_expanded_into_one_job_per_entry() { /// 同名のファイルの SOURCE_URL が同じなら自分の成果物として採用し、サイドカーと投入を済ませる #[tokio::test] async fn rerun_after_placing_the_opus_adopts_it_and_finishes() { - let lib = lib!(); + let lib = lib!(FAKE_PLUGIN); lib.video(U1, "v1", "KnownCh", "Song One", true); lib.start(); let (id, st) = lib.run(U1).await; @@ -491,7 +266,7 @@ async fn rerun_after_placing_the_opus_adopts_it_and_finishes() { /// 同じなら、ダウンロードせずにサイドカーと投入だけ済ませる #[tokio::test] async fn rerun_after_the_scan_registered_the_file_completes_without_downloading() { - let lib = lib!(); + let lib = lib!(FAKE_PLUGIN); lib.video(U1, "v1", "KnownCh", "Song One", true); lib.start(); let (id, st) = lib.run(U1).await; @@ -522,7 +297,7 @@ async fn rerun_after_the_scan_registered_the_file_completes_without_downloading( /// SOURCE_URL を読み直す(走査後に差し替えられていれば採用しない) #[tokio::test] async fn adoption_of_a_scanned_file_uses_the_path_key_and_rereads_the_file() { - let lib = lib!(); + let lib = lib!(FAKE_PLUGIN); lib.video(U1, "v1", "KnownCh", "Song One", true); lib.start(); let (id, st) = lib.run(U1).await; @@ -575,7 +350,7 @@ async fn adoption_of_a_scanned_file_uses_the_path_key_and_rereads_the_file() { /// done(note に所在)にして、上部バーの赤丸を本当の失敗だけにする(P4-18) #[tokio::test] async fn already_imported_url_is_done_with_a_note() { - let lib = lib!(); + let lib = lib!(FAKE_PLUGIN); lib.video(U1, "v1", "KnownCh", "One", true); // Library に SOURCE_URL を持つ active なトラック lib.conn() @@ -624,7 +399,7 @@ async fn already_imported_url_is_done_with_a_note() { #[tokio::test] async fn download_failure_is_retried_but_a_missing_webm_format_is_fatal() { - let lib = lib!(); + let lib = lib!(FAKE_PLUGIN); lib.video(U1, "v1", "KnownCh", "One", true); std::fs::write(lib.fake.join("fail_download"), b"").unwrap(); lib.start(); @@ -652,7 +427,7 @@ async fn download_failure_is_retried_but_a_missing_webm_format_is_fatal() { #[tokio::test] async fn plugin_fault_and_unknown_video_are_fatal() { - let lib = lib!(); + let lib = lib!(FAKE_PLUGIN); lib.video(U1, "v1", "BrokenCh", "One", true); lib.start(); let (id, st) = lib.run(U1).await; @@ -812,7 +587,7 @@ fn sweep_tmp_removes_leftover_work_dirs() { #[tokio::test] #[ignore] async fn real_ytdlp_end_to_end() { - let lib = lib!(); + let lib = lib!(FAKE_PLUGIN); let mut env = lib.env(); env.ytdlp = vec!["yt-dlp".to_owned()]; env.download_timeout = Duration::from_secs(300); @@ -853,7 +628,7 @@ async fn real_ytdlp_end_to_end() { /// 失敗ジョブで一覧を埋めない) #[tokio::test] async fn playlist_expansion_skips_videos_already_imported() { - let lib = lib!(); + let lib = lib!(FAKE_PLUGIN); let pl = "https://www.youtube.com/playlist?list=PL2"; lib.dump( pl, @@ -930,31 +705,6 @@ async fn playlist_expansion_skips_videos_already_imported() { // ---------------------------------------------------------------- 購読由来(P4-16、D-78) -/// 購読を登録して id を返す -async fn subscribe( - lib: &Lib, - albumartist: &str, - album: &str, - category: Option<&str>, - align: bool, -) -> i64 { - use spindle::db::subscriptions::{insert, NewSubscription, WriteOutcome}; - let s = NewSubscription { - list_id: "PL1".to_owned(), - url: "https://www.youtube.com/playlist?list=PL1".to_owned(), - albumartist: albumartist.to_owned(), - album: album.to_owned(), - category: category.map(str::to_owned), - align, - enabled: true, - max_enqueue: 50, - }; - match lib.db.write(move |c| insert(c, &s, 1)).await.unwrap() { - WriteOutcome::Ok(id) => id, - other => panic!("{other:?}"), - } -} - async fn run_for(lib: &Lib, url: &str, sub: i64, position: u32) -> (i64, JobState) { use spindle::jobs::handlers::ytdl::new_subscription_ytdl_job; let id = match lib @@ -972,7 +722,7 @@ async fn run_for(lib: &Lib, url: &str, sub: i64, position: u32) -> (i64, JobStat /// align なら TRACKNUMBER = 位置。サイドカーに購読 id と位置 #[tokio::test] async fn subscription_download_uses_the_subscription_target_and_position() { - let lib = lib!(); + let lib = lib!(FAKE_PLUGIN); lib.video(U1, "v1", "KnownCh", "Song One (Official Video)", true); let sub = subscribe(&lib, "Sub Artist", "Sub Album", Some("Rock"), true).await; lib.start(); @@ -1000,7 +750,7 @@ async fn subscription_download_uses_the_subscription_target_and_position() { /// off なら TRACKNUMBER は書かない #[tokio::test] async fn subscription_download_ignores_plugin_skip_and_unmatched() { - let lib = lib!(); + let lib = lib!(FAKE_PLUGIN); lib.video(U1, "v1", "SkipCh", "Announcement", true); lib.video(U2, "v2", "OtherCh", "Some Title", true); let sub = subscribe(&lib, "Sub Artist", "Sub Album", None, false).await; @@ -1042,7 +792,7 @@ async fn subscription_download_ignores_plugin_skip_and_unmatched() { /// 購読が消えていれば通常の ytdl として振る舞う(プラグインの宛先、購読の情報なし) #[tokio::test] async fn subscription_download_falls_back_when_the_subscription_is_gone() { - let lib = lib!(); + let lib = lib!(FAKE_PLUGIN); lib.video(U1, "v1", "KnownCh", "Song One", true); lib.start(); let (id, st) = run_for(&lib, U1, 999, 3).await; @@ -1060,7 +810,7 @@ async fn subscription_download_falls_back_when_the_subscription_is_gone() { /// 束ねた album の category が未推定(NULL)なら購読の category を使う(宛先のディレクトリは category で決まる) #[tokio::test] async fn subscription_download_uses_the_bound_album_but_falls_back_to_its_own_category() { - let lib = lib!(); + let lib = lib!(FAKE_PLUGIN); lib.video(U1, "v1", "KnownCh", "Song One", true); let sub = subscribe(&lib, "Old Artist", "Old Album", Some("Rock"), true).await; lib.conn() @@ -1085,7 +835,7 @@ async fn subscription_download_uses_the_bound_album_but_falls_back_to_its_own_ca /// 購読が消えていれば skip も通常どおり(ダウンロードしない) #[tokio::test] async fn subscription_download_respects_skip_when_the_subscription_is_gone() { - let lib = lib!(); + let lib = lib!(FAKE_PLUGIN); lib.video(U1, "v1", "SkipCh", "Announcement", true); lib.start(); let (id, st) = run_for(&lib, U1, 999, 3).await; diff --git a/tests/ytmusic_metadata.rs b/tests/ytmusic_metadata.rs index d9d5bef7..65d5cbf7 100644 --- a/tests/ytmusic_metadata.rs +++ b/tests/ytmusic_metadata.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; use std::time::Duration; use spindle::db::open_memory_connection; -use spindle::import::ytmusic::{Item, MetadataProvider, Outcome, ProviderError, Track}; +use spindle::import::ytmusic::{Item, MetadataProvider, Outcome, PluginInfo, ProviderError, Track}; use tokio_util::sync::CancellationToken; struct Fake { @@ -67,7 +67,8 @@ async fn round_trip_sends_request_and_reads_track() { .provider(10) .resolve(&item(), &CancellationToken::new()) .await - .unwrap(); + .unwrap() + .outcome; let Outcome::Track(t) = out else { panic!("{out:?}") }; @@ -125,7 +126,8 @@ async fn declined_reasons_are_reported_not_errors() { .provider(10) .resolve(&item(), &CancellationToken::new()) .await - .unwrap_or_else(|e| panic!("{reason}: {e}")); + .unwrap_or_else(|e| panic!("{reason}: {e}")) + .outcome; assert_eq!( out, Outcome::Declined { @@ -265,3 +267,42 @@ fn category_is_added_to_the_vocabulary_when_missing() { tags: vec![], }; } + +#[tokio::test] +async fn plugin_version_is_read_when_present_and_none_otherwise() { + const TRACK: &str = r#""track":{"title":"T","artists":["A"],"albumartist":"A","album":"B"}"#; + for (extra, want) in [ + ( + r#","plugin":{"name":"p","version":"v1.2.3"}"#, + Some(PluginInfo { + name: "p".into(), + version: "v1.2.3".into(), + }), + ), + ("", None), + (r#","plugin":{"version":""}"#, None), + (r#","plugin":"p""#, None), + ] { + let fake = Fake::new(&format!( + "echo '{{\"protocol\":1,\"ok\":true,{TRACK}{extra}}}'" + )); + let r = fake + .provider(10) + .resolve(&item(), &CancellationToken::new()) + .await + .unwrap(); + assert!(matches!(r.outcome, Outcome::Track(_)), "{extra}"); + assert_eq!(r.plugin, want, "{extra}"); + } + // ok: false でも版は読む + let fake = Fake::new( + r#"echo '{"protocol":1,"ok":false,"reason":"skip","message":"m","plugin":{"name":"p","version":"v9"}}'"#, + ); + let r = fake + .provider(10) + .resolve(&item(), &CancellationToken::new()) + .await + .unwrap(); + assert!(r.outcome.is_skip()); + assert_eq!(r.plugin.map(|p| p.version), Some("v9".to_owned())); +} diff --git a/web/src/components/InboxTrackGrid.tsx b/web/src/components/InboxTrackGrid.tsx index 1a6dcd46..3c744f46 100644 --- a/web/src/components/InboxTrackGrid.tsx +++ b/web/src/components/InboxTrackGrid.tsx @@ -15,6 +15,7 @@ import { useMemo, useRef, useState, type DragEvent, type KeyboardEvent, type Mou import type { ArtworkUploadState } from '../hooks/useArtworkUpload' import { editSession } from '../lib/editSession' import { formatDuration } from '../lib/format' +import { formatDateTime } from '../lib/history' import { ARTIST_JOIN, applyPicture, @@ -615,11 +616,13 @@ export function InboxTrackGrid({ /** 判定バッジ。判定できなかったものは行を開くと message(ルールの足し方)と URL が読める */ function VerdictCell({ source }: { source: InboxSource }) { const v = verdictLabel(source) - const badge = <span className={`badge ${v.ok ? 'inbox-verdict-ok' : 'inbox-verdict-ng'}`}>{v.text}</span> - if (v.ok && source.url == null) return badge + const failed = source.rejudge_error != null + const badge = <span className={`badge ${v.ok && !failed ? 'inbox-verdict-ok' : 'inbox-verdict-ng'}`}>{v.text}</span> + if (v.ok && !failed && source.url == null && source.plugin_version == null && source.judged_at == null) return badge return ( <details className="inbox-verdict-details"> <summary>{badge}</summary> + {failed && <pre className="inbox-verdict-message small error">再判定できなかった: {source.rejudge_error}</pre>} {source.message != null && <pre className="inbox-verdict-message small">{source.message}</pre>} {source.url != null && ( <a className="small" href={source.url} target="_blank" rel="noreferrer"> @@ -627,6 +630,8 @@ function VerdictCell({ source }: { source: InboxSource }) { </a> )} {source.channel != null && <div className="muted small">channel: {source.channel}</div>} + <div className="muted small">版: {source.plugin_version ?? '不明'}</div> + {source.judged_at != null && <div className="muted small">判定: {formatDateTime(source.judged_at)}</div>} </details> ) } diff --git a/web/src/components/InboxView.tsx b/web/src/components/InboxView.tsx index a76b4575..8eed40b9 100644 --- a/web/src/components/InboxView.tsx +++ b/web/src/components/InboxView.tsx @@ -10,6 +10,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { apiPost, apiText } from '../api/client' import { useArtworkUpload } from '../hooks/useArtworkUpload' import type { InboxState } from '../hooks/useInbox' +import { useInboxRejudge, type InboxRejudge } from '../hooks/useInboxRejudge' import { useInboxPreview } from '../hooks/useInboxPreview' import { describeLookupError } from '../hooks/useCdLookup' import { @@ -25,6 +26,7 @@ import { logErrorText } from '../lib/records' import { applyCandidate, applyTracklist, + canRejudge, codecSummary, destinationText, discardLabel, @@ -38,6 +40,15 @@ import { itemTitle, nextStage, pictureState, + pluginVersionLabel, + rejudgeBusy, + rejudgeConfirmText, + rejudgeOutcome, + rejudgeWatchAbort, + rejudgeWatchBegin, + rejudgeWatchFinish, + rejudgeWatchSettle, + REJUDGE_SETTLE_TIMEOUT_MS, refreshedDraft, sameTitleCount, stageText, @@ -49,6 +60,7 @@ import { type InboxDraft, type InboxFile, type InboxItem, + type RejudgeWatches, type RipLookup, } from '../lib/inbox' import { parseTracklist } from '../lib/tracklist' @@ -82,6 +94,68 @@ export function InboxView({ // 選んでいた件が消えたら(placed の期限切れ・ディレクトリの消失)最初の件を出す const selected = items.find((i) => i.id === selectedId) ?? items[0] ?? null + // 再判定: 押した件ごとに曲の URL と形を控えておき、ジョブが終わった後に取り直した一覧で押した件が変わったら + // (ジョブの終わりは走査より先に来る)移動先を開く / 移した数を知らせる。変わらないまま 30 秒過ぎたらあきらめる + const rejudgeWatches = useRef<RejudgeWatches>(new Map()) + const [rejudgeTick, setRejudgeTick] = useState(0) + const rejudgeTimers = useRef<Map<number, number>>(new Map()) + const { refresh, items: latestItems, setNotice } = inbox + const clearRejudgeTimer = (id: number) => { + const t = rejudgeTimers.current.get(id) + if (t != null) window.clearTimeout(t) + rejudgeTimers.current.delete(id) + } + const rejudge = useInboxRejudge((finished) => { + refresh() + const { watches, marked } = rejudgeWatchFinish(rejudgeWatches.current, finished, latestItems, Date.now()) + rejudgeWatches.current = watches + if (marked.length === 0) return + setRejudgeTick((t) => t + 1) + for (const id of marked) { + clearRejudgeTimer(id) + rejudgeTimers.current.set( + id, + window.setTimeout(() => { + rejudgeTimers.current.delete(id) + setRejudgeTick((t) => t + 1) + }, REJUDGE_SETTLE_TIMEOUT_MS), + ) + } + }) + useEffect(() => { + const timers = rejudgeTimers.current + return () => { + for (const t of timers.values()) window.clearTimeout(t) + } + }, []) + useEffect(() => { + if (latestItems == null || rejudgeWatches.current.size === 0) return + const { watches, landed } = rejudgeWatchSettle(rejudgeWatches.current, latestItems, Date.now()) + rejudgeWatches.current = watches + for (const { watch, settle } of landed) { + clearRejudgeTimer(watch.id) + if (settle === 'timeout') { + setNotice('再判定した') + continue + } + const { select, notice } = rejudgeOutcome(watch, latestItems) + if (select != null) setSelectedId(select) + setNotice(notice) + } + }, [rejudgeTick, latestItems, setNotice]) + const startRejudge = async (item: InboxItem) => { + // 投入の途中・実行中の二度押しは何もしない(控えも置き換えない) + if (rejudge.isBusy(item.id)) return + const { watches, mine, prior } = rejudgeWatchBegin(rejudgeWatches.current, item) + rejudgeWatches.current = watches + const msg = await rejudge.start(item.id) + if (msg != null) { + rejudgeWatches.current = rejudgeWatchAbort(rejudgeWatches.current, mine, prior) + setNotice(msg) + refresh() + } + } + return ( <section className="cd inbox"> <div className="table-toolbar"> @@ -147,7 +221,14 @@ export function InboxView({ })} </ul> {selected != null && ( - <ItemForm key={selected.id} item={selected} inbox={inbox} onOpenAlbum={onOpenAlbum} /> + <ItemForm + key={selected.id} + item={selected} + inbox={inbox} + rejudge={rejudge} + onRejudge={startRejudge} + onOpenAlbum={onOpenAlbum} + /> )} </div> )} @@ -158,10 +239,14 @@ export function InboxView({ function ItemForm({ item, inbox, + rejudge, + onRejudge, onOpenAlbum, }: { item: InboxItem inbox: InboxState + rejudge: InboxRejudge + onRejudge: (item: InboxItem) => Promise<void> onOpenAlbum: (albumId: number) => void }) { // 初期値は保存済みの下書き(無ければ提案)。編集中は一覧が取り直されても上書きしない。 @@ -199,6 +284,18 @@ function ItemForm({ const dest = destinationText(item, draft) const sync = syncNote(item, draft, inbox.subscriptions, formatDateTime) + const rejudgeProgress = rejudge.running.get(item.id) + // 再判定の間(押してから受理されて取得に載るまでを含む)は承認・却下・下書きに戻すを止める(API も 409 rejudging) + const rejudging = rejudgeBusy(item.id, rejudge.starting, rejudge.running) + const versionLabel = pluginVersionLabel(item.tracks) + const rejudgeNow = () => { + // 未送信の編集 = フォームの今の下書きが、サーバの値から作った下書きと違う + const dirty = JSON.stringify(draft) !== JSON.stringify(fresh) + const ask = rejudgeConfirmText(item, dirty) + if (ask != null && !window.confirm(ask)) return + void onRejudge(item) + } + const approve = async () => { setSubmitError(null) const msg = await inbox.approve(item.id, draftForSubmit(draft)) @@ -232,6 +329,7 @@ function ItemForm({ <span className="badge">{source}</span> <span className={`badge inbox-state-${item.state}`}>{stateLabel(item.state)}</span> {changes > 0 && <span className="badge inbox-changed-badge">変更 {changes} 件</span>} + {versionLabel != null && <span className="badge">{versionLabel}</span>} </div> {item.stages != null && item.stages.length > 0 && ( <ol className="inbox-stages" aria-label="配置後の処理の進み"> @@ -383,22 +481,31 @@ function ItemForm({ <button type="button" className="primary" - disabled={inbox.busy || artwork.busy || problems.length > 0} + disabled={inbox.busy || artwork.busy || problems.length > 0 || rejudging} onClick={() => void approve()} > 承認して配置 </button> )} {(item.state === 'pending' || item.state === 'failed' || item.state === 'approved') && ( - <button type="button" disabled={inbox.busy} onClick={() => void inbox.reject(item.id)}> + <button type="button" disabled={inbox.busy || rejudging} onClick={() => void inbox.reject(item.id)}> 却下 </button> )} {(item.state === 'approved' || item.state === 'rejected' || item.state === 'failed') && ( - <button type="button" disabled={inbox.busy} onClick={() => void inbox.reopen(item.id)}> + <button type="button" disabled={inbox.busy || rejudging} onClick={() => void inbox.reopen(item.id)}> 下書きに戻す </button> )} + {canRejudge(item, inbox.ytmusicEnabled) && ( + <button type="button" disabled={inbox.busy || rejudging} onClick={rejudgeNow}> + {rejudgeProgress != null + ? `再判定中 ${rejudgeProgress.done}/${rejudgeProgress.total}` + : rejudging + ? '再判定中' + : '再判定'} + </button> + )} {item.state === 'rejected' && item.discard_requested_at == null && ( <button type="button" diff --git a/web/src/hooks/useInbox.ts b/web/src/hooks/useInbox.ts index 67ff44b3..68179643 100644 --- a/web/src/hooks/useInbox.ts +++ b/web/src/hooks/useInbox.ts @@ -33,6 +33,8 @@ export type InboxState = { discardRetentionDays: number | null /** 件が参照する購読の直近の同期(承認画面の「同期が番号を空けた経緯」。P4-22)。旧サーバでは空 */ subscriptions: InboxSubscriptionSync[] + /** YouTube 機能(`[ytmusic].enabled`)。無効・旧サーバ・取得前は false(再判定のボタンを出さない) */ + ytmusicEnabled: boolean setNotice: (s: string | null) => void } @@ -56,6 +58,7 @@ export function useInbox(enabled: boolean, onChanged?: () => void): InboxState { const [busy, setBusy] = useState(false) const [discardRetentionDays, setDiscardRetentionDays] = useState<number | null>(null) const [subscriptions, setSubscriptions] = useState<InboxSubscriptionSync[]>([]) + const [ytmusicEnabled, setYtmusicEnabled] = useState(false) const timer = useRef<number | null>(null) const fetchNow = useCallback(() => { @@ -64,12 +67,14 @@ export function useInbox(enabled: boolean, onChanged?: () => void): InboxState { watch?: InboxWatch discard_retention_days?: number subscriptions?: InboxSubscriptionSync[] + ytmusic_enabled?: boolean }>('/api/inbox') .then((r) => { setItems(r.items) setWatch(r.watch ?? null) setDiscardRetentionDays(r.discard_retention_days ?? null) setSubscriptions(r.subscriptions ?? []) + setYtmusicEnabled(r.ytmusic_enabled ?? false) setError(null) setUnavailable(false) }) @@ -202,6 +207,7 @@ export function useInbox(enabled: boolean, onChanged?: () => void): InboxState { undiscard, discardRetentionDays, subscriptions, + ytmusicEnabled, setNotice, } } diff --git a/web/src/hooks/useInboxRejudge.ts b/web/src/hooks/useInboxRejudge.ts new file mode 100644 index 00000000..72da4619 --- /dev/null +++ b/web/src/hooks/useInboxRejudge.ts @@ -0,0 +1,119 @@ +// Inbox の件の再判定(D-114): 投入と進みの監視。ジョブは `inbox_rejudge`(payload.item_id が件)。 +// `GET /api/jobs?type=inbox_rejudge` を 2 秒ごとに見て、走っていた件が終わったら `onDone(終わった件の id)` を +// 呼ぶ(一覧の取り直しと、移動先の件を開く処理は呼び出し側)。取得は順に番号を振り、投入より前に出した取得と +// 追い越された古い取得の結果は捨てる(投入した件がまだ載っていない一覧で「終わった」としない)。押してから、受理後に +// 出した取得が届くまでは「投入の途中」(`starting`)として押せなくする(二度押し。`lib/inbox` の rejudgeStarting*) + +import { useCallback, useEffect, useRef, useState } from 'react' +import { ApiError, apiFetch, apiPost } from '../api/client' +import type { JobList } from '../api/types' +import { + rejudgeBusy, + rejudgeStartingBegin, + rejudgeStartingPolled, + rejudgeStartingSettle, + type RejudgeStarting, +} from '../lib/inbox' + +const POLL_MS = 2000 + +export type RejudgeProgress = { done: number; total: number } + +export type InboxRejudge = { + /** 件の再判定を投入する。失敗した理由(文字列)を返す。成功と、投入の途中・実行中で何もしなかったときは null */ + start: (itemId: number) => Promise<string | null> + /** 待ち / 実行中の件 id → 進み */ + running: Map<number, RejudgeProgress> + /** 投入の途中の件(POST の応答待ちと、受理後の最初の取得待ち) */ + starting: RejudgeStarting + /** 今押せないか(投入の途中か、待ち / 実行中)。描画を待たずに最新の値で答える */ + isBusy: (itemId: number) => boolean +} + +function describe(e: unknown): string { + if (e instanceof ApiError) { + if (e.code === 'state') return '再判定できない状態になっている(一覧を取り直す)' + if (e.code === 'not_found') return '取り込みが無くなっている(一覧を取り直す)' + if (e.code === 'duplicate') return '再判定は既に投入されている' + } + return e instanceof Error ? e.message : String(e) +} + +export function useInboxRejudge(onDone: (finished: number[]) => void): InboxRejudge { + const [running, setRunning] = useState<Map<number, RejudgeProgress>>(new Map()) + const [starting, setStartingState] = useState<RejudgeStarting>(new Map()) + // 同じ描画の中の二度押しも止めるため、判断は ref の最新の値で行い、state は描画用に写す + const startingRef = useRef<RejudgeStarting>(new Map()) + const runningRef = useRef<Map<number, RejudgeProgress>>(new Map()) + const setStarting = useCallback((s: RejudgeStarting) => { + startingRef.current = s + setStartingState(s) + }, []) + const previous = useRef<Set<number>>(new Set()) + // 取得の番号: 最後に出した番号、最後に採った番号、採ってよい最小の番号(投入の後に出したもの) + const issued = useRef(0) + const applied = useRef(0) + const minSeq = useRef(0) + const onDoneRef = useRef(onDone) + useEffect(() => { + onDoneRef.current = onDone + }, [onDone]) + + const poll = useCallback(() => { + const seq = ++issued.current + apiFetch<JobList>('/api/jobs?type=inbox_rejudge') + .then((l) => { + if (seq < minSeq.current || seq <= applied.current) return + applied.current = seq + const now = new Map<number, RejudgeProgress>() + for (const j of l.items) { + if (j.type !== 'inbox_rejudge' || (j.state !== 'queued' && j.state !== 'running')) continue + const id = j.payload?.item_id + if (typeof id === 'number') now.set(id, { done: j.done ?? 0, total: j.total ?? 0 }) + } + const finished = [...previous.current].filter((id) => !now.has(id)) + previous.current = new Set(now.keys()) + runningRef.current = now + setRunning(now) + setStarting(rejudgeStartingPolled(startingRef.current)) + if (finished.length > 0) onDoneRef.current(finished) + }) + .catch(() => { + // 一時的な取得失敗は次の周期で取り直す + }) + }, [setStarting]) + + useEffect(() => { + poll() + const t = window.setInterval(poll, POLL_MS) + return () => window.clearInterval(t) + }, [poll]) + + const isBusy = useCallback( + (itemId: number) => rejudgeBusy(itemId, startingRef.current, runningRef.current), + [], + ) + + const start = useCallback( + async (itemId: number) => { + if (isBusy(itemId)) return null + setStarting(rejudgeStartingBegin(startingRef.current, itemId)) + try { + await apiPost<{ job_id: number }>(`/api/inbox/${itemId}/rejudge`, {}) + } catch (e) { + setStarting(rejudgeStartingSettle(startingRef.current, itemId, false)) + return describe(e) + } + // 次の取得までに終わっても「終わった」と気づけるよう、先に走っている扱いにする。投入の前に出した + // 取得(この件がまだ載っていない)は捨てる + previous.current.add(itemId) + setStarting(rejudgeStartingSettle(startingRef.current, itemId, true)) + minSeq.current = issued.current + 1 + poll() + return null + }, + [poll, isBusy, setStarting], + ) + + return { start, running, starting, isBusy } +} diff --git a/web/src/lib/inbox.test.ts b/web/src/lib/inbox.test.ts index b60386c3..9ee61114 100644 --- a/web/src/lib/inbox.test.ts +++ b/web/src/lib/inbox.test.ts @@ -1,5 +1,21 @@ import { describe, expect, it } from 'vitest' import { + canRejudge, + pluginVersionLabel, + rejudgeConfirmText, + rejudgeLanding, + rejudgeOutcome, + rejudgeSettle, + rejudgeWatchOf, + rejudgeBusy, + rejudgeStartingBegin, + rejudgeStartingSettle, + rejudgeStartingPolled, + rejudgeWatchBegin, + rejudgeWatchAbort, + rejudgeWatchFinish, + rejudgeWatchSettle, + REJUDGE_SETTLE_TIMEOUT_MS, applyPicture, nextStage, stageText, @@ -1010,3 +1026,179 @@ describe('Inbox の段', () => { expect(nextStage({})).toBeNull() }) }) + +describe('再判定', () => { + const src = (over: Partial<NonNullable<InboxFile['source']>> = {}): InboxFile['source'] => ({ + source: 'youtube', + url: 'https://www.youtube.com/watch?v=a', + channel: null, + verdict: 'ok', + message: null, + ...over, + }) + const yt = (rel: string, over: Partial<NonNullable<InboxFile['source']>> = {}): InboxFile => ({ + ...file(rel), + source: src({ url: `https://www.youtube.com/watch?v=${rel}`, ...over }), + }) + + it('canRejudge: YouTube 機能が有効で、pending で source のある曲があるときだけ', () => { + expect(canRejudge(item({ tracks: [yt('d/a')] }), true)).toBe(true) + expect(canRejudge(item({ tracks: [yt('d/a')] }), false)).toBe(false) + expect(canRejudge(item({ state: 'approved', tracks: [yt('d/a')] }), true)).toBe(false) + expect(canRejudge(item(), true)).toBe(false) + }) + + it('pluginVersionLabel', () => { + const v = (x?: string) => yt('d/a', x == null ? {} : { plugin_version: x }) + expect(pluginVersionLabel([v('v0.1.0-rc.2'), v('v0.1.0-rc.2')])).toBe('判定: spindle-ytmusic-meta v0.1.0-rc.2') + expect(pluginVersionLabel([v('v0.1.0-rc.2'), v('v0.1.0-rc.1')])).toBe('判定: v0.1.0-rc.1 ほか') + expect(pluginVersionLabel([v(), v()])).toBe('判定: 版不明') + expect(pluginVersionLabel([file('d/a')])).toBeNull() + }) + + it('rejudgeConfirmText', () => { + const saved = proposal + expect(rejudgeConfirmText({ draft: saved }, false)).toBe('保存済みの補正は消え、判定結果で置き換わります。再判定しますか?') + expect(rejudgeConfirmText({ draft: null }, true)).toBe('未保存の補正は消えます。再判定しますか?') + expect(rejudgeConfirmText({ draft: saved }, true)).toBe('保存済みの補正は消え、判定結果で置き換わります。再判定しますか?') + expect(rejudgeConfirmText({ draft: null }, false)).toBeNull() + }) + + it('rejudgeLanding', () => { + const urls = ['https://www.youtube.com/watch?v=d/a', 'https://www.youtube.com/watch?v=d/b'] + const dest = item({ id: 9, rel_dir: 'x', tracks: [yt('d/a'), yt('d/b')] }) + expect(rejudgeLanding(7, urls, [dest])).toEqual({ target: dest, moved: 2 }) + const part = item({ id: 9, rel_dir: 'x', tracks: [yt('d/a')] }) + const rest = item({ id: 7, tracks: [yt('d/b')] }) + expect(rejudgeLanding(7, urls, [part, rest])).toEqual({ target: null, moved: 1 }) + expect(rejudgeLanding(7, urls, [item({ id: 7, tracks: [yt('d/a'), yt('d/b')] })])).toEqual({ target: null, moved: 0 }) + }) + describe('再判定の着地', () => { + const pressed = item({ id: 7, tracks: [yt('d/a', { judged_at: 100 }), yt('d/b', { judged_at: 100 })] }) + const finishedWith = (items: InboxItem[] | null, at = 1000) => ({ ...rejudgeWatchOf(pressed), finished: { items, at } }) + + it('ジョブが終わるまでは待つ', () => { + expect(rejudgeSettle(rejudgeWatchOf(pressed), [pressed], 1000)).toBe('wait') + }) + + it('終わった時点の一覧のままなら待つ(取り直しを待つ)', () => { + const atFinish = [item({ id: 7, tracks: [yt('d/a', { judged_at: 200 })] })] + expect(rejudgeSettle(finishedWith(atFinish), atFinish, 1000)).toBe('wait') + }) + + it('取り直しても押した件が変わっていなければ待つ(走査の前・古い取得)', () => { + const w = finishedWith([pressed]) + const stale = [item({ ...pressed, tracks: [...pressed.tracks] })] + expect(rejudgeSettle(w, stale, 1000 + 5000)).toBe('wait') + }) + + it('押した件の曲の名前か判定が変わったら着地する', () => { + const w = finishedWith([pressed]) + const renamed = item({ id: 7, tracks: [yt('d/a2', { url: 'https://www.youtube.com/watch?v=d/a', judged_at: 100 }), yt('d/b', { judged_at: 100 })] }) + expect(rejudgeSettle(w, [renamed], 1001)).toBe('land') + const rejudged = item({ id: 7, tracks: [yt('d/a', { judged_at: 200 }), yt('d/b', { judged_at: 200 })] }) + expect(rejudgeSettle(w, [rejudged], 1001)).toBe('land') + }) + + it('押した件が一覧から消えたら着地する', () => { + const w = finishedWith([pressed]) + expect(rejudgeSettle(w, [item({ id: 9, rel_dir: 'x', tracks: [yt('d/a'), yt('d/b')] })], 1001)).toBe('land') + }) + + it('変わらないまま時間が過ぎたらあきらめる', () => { + const w = finishedWith([pressed]) + const stale = [item({ ...pressed })] + expect(rejudgeSettle(w, stale, 1000 + REJUDGE_SETTLE_TIMEOUT_MS - 1)).toBe('wait') + expect(rejudgeSettle(w, stale, 1000 + REJUDGE_SETTLE_TIMEOUT_MS)).toBe('timeout') + }) + + it('rejudgeOutcome: 全曲が移った先を開き、一部なら移した数を知らせる', () => { + const w = rejudgeWatchOf(pressed) + const dest = item({ id: 9, rel_dir: 'x', tracks: [yt('d/a'), yt('d/b')] }) + expect(rejudgeOutcome(w, [dest])).toEqual({ select: 9, notice: '再判定した。2 曲を x へ移した' }) + const part = item({ id: 9, rel_dir: 'x', tracks: [yt('d/a')] }) + const rest = item({ id: 7, tracks: [yt('d/b')] }) + expect(rejudgeOutcome(w, [part, rest])).toEqual({ select: null, notice: '再判定した。1 曲を x へ移した' }) + expect(rejudgeOutcome(w, [pressed])).toEqual({ select: null, notice: '再判定した' }) + }) + }) + + describe('投入の途中(二度押し)', () => { + const none = new Map<number, unknown>() + + it('押した時点から、受理されて最初の取得が届くまで押せない', () => { + let s = rejudgeStartingBegin(new Map(), 7) + expect(rejudgeBusy(7, s, none)).toBe(true) + expect(rejudgeBusy(8, s, none)).toBe(false) + s = rejudgeStartingSettle(s, 7, true) + expect(rejudgeBusy(7, s, none)).toBe(true) + // 応答待ちの件は取得が届いても残す + s = rejudgeStartingBegin(s, 8) + s = rejudgeStartingPolled(s) + expect(rejudgeBusy(7, s, none)).toBe(false) + expect(rejudgeBusy(8, s, none)).toBe(true) + }) + + it('投入に失敗したら押せるように戻す', () => { + const s = rejudgeStartingSettle(rejudgeStartingBegin(new Map(), 7), 7, false) + expect(rejudgeBusy(7, s, none)).toBe(false) + }) + + it('待ち / 実行中の件は押せない', () => { + expect(rejudgeBusy(7, new Map(), new Map([[7, { done: 0, total: 1 }]]))).toBe(true) + }) + + it('元の写しは変えない', () => { + const s0 = new Map<number, 'posting' | 'accepted'>() + rejudgeStartingBegin(s0, 7) + expect(s0.size).toBe(0) + }) + }) + + describe('件ごとの控え', () => { + const a = item({ id: 7, tracks: [yt('d/a', { judged_at: 100 })] }) + const b = item({ id: 8, rel_dir: 'y', tracks: [yt('y/b', { judged_at: 100 })] }) + + it('別の件を押しても前の件の控えは残る', () => { + let w = rejudgeWatchBegin(new Map(), a).watches + w = rejudgeWatchBegin(w, b).watches + expect([...w.keys()].sort()).toEqual([7, 8]) + }) + + it('後から失敗した投入(409 duplicate 等)は、受理済みの控えを消さない', () => { + const first = rejudgeWatchBegin(new Map(), a) + const again = rejudgeWatchBegin(first.watches, a) + expect(again.prior).toBe(first.mine) + const w = rejudgeWatchAbort(again.watches, again.mine, again.prior) + expect(w.get(7)).toBe(first.mine) + // 控えの無かった件の失敗は消す + const solo = rejudgeWatchBegin(new Map(), b) + expect(rejudgeWatchAbort(solo.watches, solo.mine, solo.prior).has(8)).toBe(false) + }) + + it('失敗の前に置き換わっていたら触らない', () => { + const one = rejudgeWatchBegin(new Map(), a) + const two = rejudgeWatchBegin(one.watches, a) + expect(rejudgeWatchAbort(two.watches, one.mine, one.prior).get(7)).toBe(two.mine) + }) + + it('終わった件だけ印を付け、件ごとに着地する', () => { + let w = rejudgeWatchBegin(rejudgeWatchBegin(new Map(), a).watches, b).watches + const atFinish = [a, b] + const f = rejudgeWatchFinish(w, [7], atFinish, 1000) + expect(f.marked).toEqual([7]) + w = f.watches + expect(w.get(7)?.finished).toEqual({ items: atFinish, at: 1000 }) + expect(w.get(8)?.finished).toBeNull() + // 二度目は印を付け直さない + expect(rejudgeWatchFinish(w, [7], [], 2000).marked).toEqual([]) + + const moved = item({ id: 9, rel_dir: 'x', tracks: [yt('d/a')] }) + const s = rejudgeWatchSettle(w, [moved, b], 1001) + expect(s.landed.map((l) => [l.watch.id, l.settle])).toEqual([[7, 'land']]) + expect([...s.watches.keys()]).toEqual([8]) + const t = rejudgeWatchSettle(w, atFinish, 1000 + REJUDGE_SETTLE_TIMEOUT_MS) + expect(t.landed.map((l) => [l.watch.id, l.settle])).toEqual([[7, 'timeout']]) + }) + }) +}) diff --git a/web/src/lib/inbox.ts b/web/src/lib/inbox.ts index 2bc49381..f38dee7a 100644 --- a/web/src/lib/inbox.ts +++ b/web/src/lib/inbox.ts @@ -119,6 +119,12 @@ export type InboxSource = { /** 再生リストの購読から落とした曲の購読 id と再生リスト上の位置(D-78)。それ以外は無い */ subscription_id?: number position?: number + /** 判定したプラグインの版。無ければ null(旧サーバ・旧サイドカーでは無い) */ + plugin_version?: string | null + /** 判定した時刻(UNIX epoch 秒) */ + judged_at?: number | null + /** 直近の再判定がこの曲で失敗した理由 */ + rejudge_error?: string | null } /** 追記先の既存 album(GET /api/inbox の destination。D-70) */ @@ -743,6 +749,213 @@ export function verdictLabel(s: InboxSource): { text: string; ok: boolean } { return s.verdict === 'ok' ? { text: '判定済み', ok: true } : { text: `未判定(${s.verdict})`, ok: false } } +// ---------------------------------------------------------------- 再判定(D-114) + +/** 再判定できる件か(YouTube 機能が有効で、承認前で、ダウンローダの判定がある曲を含む) */ +export function canRejudge(item: Pick<InboxItem, 'state' | 'tracks'>, ytmusicEnabled: boolean): boolean { + return ytmusicEnabled && item.state === 'pending' && item.tracks.some((f) => f.source != null) +} + +/** 見出しの「判定: <プラグインの版>」。判定のある曲が無ければ null。版が混ざれば辞書順で最小の版に「ほか」 */ +export function pluginVersionLabel(tracks: Array<Pick<InboxFile, 'source'>>): string | null { + const sources = tracks.map((f) => f.source).filter((s): s is InboxSource => s != null) + if (sources.length === 0) return null + const versions = sources.map((s) => s.plugin_version ?? null) + const known = [...new Set(versions.filter((v): v is string => v != null))].sort() + if (known.length === 0) return '判定: 版不明' + if (known.length === 1 && versions.every((v) => v != null)) return `判定: spindle-ytmusic-meta ${known[0]}` + return `判定: ${known[0]} ほか` +} + +/** 再判定の確認文。失うものが無ければ null(確認なしで実行してよい)。保存済みの下書きがある方を優先して言う */ +export function rejudgeConfirmText(item: Pick<InboxItem, 'draft'>, dirty: boolean): string | null { + if (item.draft != null) return '保存済みの補正は消え、判定結果で置き換わります。再判定しますか?' + if (dirty) return '未保存の補正は消えます。再判定しますか?' + return null +} + +/** + * 再判定のあと、押した件の曲(`prevUrls` = 押した時点の `source.url`)がどこへ行ったか。 + * 移した数 = 元の件以外に今ある URL の数。元の件が消え、全 URL が別の 1 件に揃っていればそれが移動先 + */ +export function rejudgeLanding( + prevId: number, + prevUrls: string[], + items: InboxItem[], +): { target: InboxItem | null; moved: number } { + const wanted = new Set(prevUrls) + const others = items.filter((i) => i.id !== prevId) + const moved = new Set( + others.flatMap((i) => i.tracks.map((f) => f.source?.url ?? null).filter((u): u is string => u != null && wanted.has(u))), + ).size + if (items.some((i) => i.id === prevId) || wanted.size === 0) return { target: null, moved } + const target = others.find((i) => { + const have = new Set(i.tracks.map((f) => f.source?.url ?? null)) + return prevUrls.every((u) => have.has(u)) + }) + return { target: target ?? null, moved } +} + +/** 再判定を押した件の控え。`finished` はジョブが終わったと気づいた時点の一覧(参照)と時刻 */ +export type RejudgeWatch = { + id: number + /** 押した時点の曲の `source.url` */ + urls: string[] + /** 押した時点の曲の形(`rejudgeShape`) */ + before: string + finished: { items: InboxItem[] | null; at: number } | null +} + +/** ジョブが終わってから、一覧に結果が届くのを待つ上限(過ぎたら移動先を探さずに知らせる) */ +export const REJUDGE_SETTLE_TIMEOUT_MS = 30_000 + +/** 件の曲の形: 名前と判定の記録(判定・版・時刻・失敗)。再判定の結果が一覧に届いたかを見分ける */ +function rejudgeShape(item: Pick<InboxItem, 'tracks'>): string { + return item.tracks + .map((f) => + JSON.stringify([ + f.rel_path, + f.source?.url ?? null, + f.source?.verdict ?? null, + f.source?.plugin_version ?? null, + f.source?.judged_at ?? null, + f.source?.rejudge_error ?? null, + ]), + ) + .sort() + .join('\n') +} + +export function rejudgeWatchOf(item: InboxItem): RejudgeWatch { + return { + id: item.id, + urls: item.tracks.map((f) => f.source?.url ?? null).filter((u): u is string => u != null), + before: rejudgeShape(item), + finished: null, + } +} + +/** + * 再判定の着地を決めてよいか。ジョブの終わりは走査(一覧の作り直し)より先に来るので、終わった後に + * 取り直した一覧で、押した件が消えたか曲の形が押した時点と変わったら `land`。変わらない一覧(走査の前・ + * 古い取得)では待ち、終わってから `REJUDGE_SETTLE_TIMEOUT_MS` 過ぎたら `timeout` + */ +export function rejudgeSettle(w: RejudgeWatch, items: InboxItem[], now: number): 'wait' | 'land' | 'timeout' { + if (w.finished == null) return 'wait' + if (items !== w.finished.items) { + const it = items.find((i) => i.id === w.id) + if (it == null || rejudgeShape(it) !== w.before) return 'land' + } + return now - w.finished.at >= REJUDGE_SETTLE_TIMEOUT_MS ? 'timeout' : 'wait' +} + +/** 着地: 全曲が別の 1 件へ移ったらその件を選び、移した数を知らせる(`rejudgeLanding`) */ +export function rejudgeOutcome(w: RejudgeWatch, items: InboxItem[]): { select: number | null; notice: string } { + const { target, moved } = rejudgeLanding(w.id, w.urls, items) + const where = + target ?? + items.find((i) => i.id !== w.id && i.tracks.some((f) => f.source?.url != null && w.urls.includes(f.source.url))) + return { + select: target?.id ?? null, + notice: moved > 0 && where != null ? `再判定した。${moved} 曲を ${where.rel_dir} へ移した` : '再判定した', + } +} + +/** + * 再判定の投入の途中の件: POST の応答を待つ間は `posting`、受理された後、投入より後に出したジョブの取得が届くまでは + * `accepted`(その取得で待ち / 実行中に載るか、もう終わったと分かる)。この間も押せない(二度押し) + */ +export type RejudgeStarting = ReadonlyMap<number, 'posting' | 'accepted'> + +/** 件 `id` の再判定を押せないか(投入の途中か、待ち / 実行中) */ +export function rejudgeBusy(id: number, starting: RejudgeStarting, running: ReadonlyMap<number, unknown>): boolean { + return starting.has(id) || running.has(id) +} + +/** 押した: POST の応答待ちにする */ +export function rejudgeStartingBegin(starting: RejudgeStarting, id: number): RejudgeStarting { + return new Map(starting).set(id, 'posting') +} + +/** POST の応答: 受理なら最初の取得を待ち、失敗なら外す(また押せる) */ +export function rejudgeStartingSettle(starting: RejudgeStarting, id: number, accepted: boolean): RejudgeStarting { + const next = new Map(starting) + if (accepted) next.set(id, 'accepted') + else next.delete(id) + return next +} + +/** 受理後に出した取得が届いた: 受理済みの件を外す(以後は取得の待ち / 実行中で見る)。応答待ちの件は残す */ +export function rejudgeStartingPolled(starting: RejudgeStarting): RejudgeStarting { + return new Map([...starting].filter(([, s]) => s === 'posting')) +} + +/** 件 id → 再判定を押した件の控え(件ごと。別の件を押しても前の件の着地を失わない) */ +export type RejudgeWatches = ReadonlyMap<number, RejudgeWatch> + +/** + * 押した件の控えを置く。同じ件の控えが既にあれば置き換え、元の控えを `prior` で返す(投入が失敗したら + * `rejudgeWatchAbort` で戻す) + */ +export function rejudgeWatchBegin( + watches: RejudgeWatches, + item: InboxItem, +): { watches: RejudgeWatches; mine: RejudgeWatch; prior: RejudgeWatch | undefined } { + const mine = rejudgeWatchOf(item) + return { watches: new Map(watches).set(item.id, mine), mine, prior: watches.get(item.id) } +} + +/** + * 投入に失敗した: 置いた控え(`mine`)がまだその件の控えなら、元の控え(無ければ外す)に戻す。後から失敗した + * 投入(409 duplicate 等)が、受理済みのジョブの控えを消さないため + */ +export function rejudgeWatchAbort( + watches: RejudgeWatches, + mine: RejudgeWatch, + prior: RejudgeWatch | undefined, +): RejudgeWatches { + if (watches.get(mine.id) !== mine) return watches + const next = new Map(watches) + if (prior != null) next.set(mine.id, prior) + else next.delete(mine.id) + return next +} + +/** ジョブが終わった件(`finished`)の控えに、その時点の一覧と時刻を付ける。`marked` は新しく付けた件 */ +export function rejudgeWatchFinish( + watches: RejudgeWatches, + finished: number[], + items: InboxItem[] | null, + now: number, +): { watches: RejudgeWatches; marked: number[] } { + const next = new Map(watches) + const marked: number[] = [] + for (const id of finished) { + const w = next.get(id) + if (w == null || w.finished != null) continue + next.set(id, { ...w, finished: { items, at: now } }) + marked.push(id) + } + return { watches: next, marked } +} + +/** 控えごとに着地を決め(`rejudgeSettle`)、着地した控えを外す */ +export function rejudgeWatchSettle( + watches: RejudgeWatches, + items: InboxItem[], + now: number, +): { watches: RejudgeWatches; landed: Array<{ watch: RejudgeWatch; settle: 'land' | 'timeout' }> } { + const next = new Map(watches) + const landed: Array<{ watch: RejudgeWatch; settle: 'land' | 'timeout' }> = [] + for (const w of watches.values()) { + const settle = rejudgeSettle(w, items, now) + if (settle === 'wait') continue + next.delete(w.id) + landed.push({ watch: w, settle }) + } + return { watches: next, landed } +} + // ---------------------------------------------------------------- タグの変更(D-86) diff --git a/web/src/lib/jobs.ts b/web/src/lib/jobs.ts index af321f9a..380cc77e 100644 --- a/web/src/lib/jobs.ts +++ b/web/src/lib/jobs.ts @@ -16,6 +16,7 @@ const TYPE_LABEL: Record<string, string> = { flaccheck: 'FLAC 検査', hirescheck: '偽ハイレゾ検出', inbox: 'Inbox 取り込み', + inbox_rejudge: '再判定', ytdl: 'YouTube ダウンロード', playlist_sync: '再生リストの同期', source_hash: '送る元のハッシュ',