diff --git a/AGENTS.md b/AGENTS.md index 9cb4e041..799d9837 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,12 +5,14 @@ spindle の実装作業を始める前に、このファイルを最初に読む ## このプロジェクトは何か TrueNAS 上で動く単一コンテナの音楽ライブラリ管理アプリ。CD リッピング、 -メタデータ一括編集、ReplayGain、プレイリスト、簡易再生。 -**設計は完了している。** 仕様は `docs/SPEC.md`、判断の理由は `docs/DECISIONS.md`、 -着手順は `docs/TASKS.md` にある。 +メタデータ一括編集、ReplayGain、プレイリスト、簡易再生、端末(Android / iPhone)への配信。 +P0〜P5 は実装済みで、v0.6.0 を本番で運用している。ドキュメントの地図は `docs/README.md`。 -読む順序: `docs/TASKS.md` で担当タスクを確認 → 該当する `docs/SPEC.md` の節 → -`docs/DECISIONS.md` で背景を確認。 +- 仕様: `docs/spec/`(章ごと。`SPEC §7.5` は `docs/spec/README.md` の目次で引く) +- 判断の理由: `docs/decisions/`(`D-62` は `docs/decisions/D-062.md`) +- 過去のタスクと受け入れ条件: `docs/development/tasks-history.md`(履歴。新しいタスクは書かない) + +読む順序: 依頼に関係する `docs/spec/` の章・節 → そこから参照される `docs/decisions/` で背景を確認。 ## 絶対に守る不変条件 @@ -47,8 +49,10 @@ TrueNAS 上で動く単一コンテナの音楽ライブラリ管理アプリ。 - **圧縮レベルを揃えるための FLAC 一括再エンコード。** 削減は 1% 未満で、 200GB の書き直しとスナップショット肥大に見合わない。 - **非可逆 → 非可逆の再エンコード。** 世代劣化する。配布ビューは原本を返す。 + - 明示の例外: Apple 向けの `aac` 系統だけは非可逆の原本も AAC に変換する(`lossy_sources`。 + ミュージック.app は Opus を読めない)。D-75。 - **仕様にない判断を黙って入れること。** 迷ったら実装を止めて質問する。 - 決めた場合は `docs/DECISIONS.md` に追記案を出す。 + 決めた場合は `docs/decisions/` に追記案を出す。 - **`db/migrations/*.sql` の既存ファイルの書き換え。** スキーマ変更は 必ず新しい連番ファイルを追加する。 @@ -140,17 +144,22 @@ cd web && npm install && npm run dev # フロント単体(API は SPINDLE cd web && npm run build # 同梱用ビルド(tsc -b + vite) cd web && npx vitest run && npm run lint # 純粋ロジックのテストと oxlint -python3 scripts/preflight.py /mnt/ssd/musics/Opus --dest /mnt/ssd # 移行前チェック -python3 scripts/migrate_plan.py /mnt/ssd/musics --out /tmp/plan # 振り分け一覧 +python3 scripts/preflight.py <旧ライブラリ> --dest <コピー先> # 移行前チェック(docs/admin-guide/migration.md) +python3 scripts/migrate_plan.py <旧ライブラリ> --out /tmp/plan # 振り分け一覧 docker build -f deploy/Dockerfile -t spindle . ``` ## 作業の進め方 -1. `docs/TASKS.md` から次のタスクを 1 つ取る。依存関係を確認する +1. 設計を書き、ユーザの了承を得る。設計書と実装計画は**作業中だけ** `docs/development/` に置く + (`YYYY-MM-DD--design.md` / `YYYY-MM-DD-.md`)。superpowers などのスキルが既定で + 書き出す `docs/superpowers/` には置かない 2. 受け入れ条件を満たすテストを先に書く -3. 実装し、`cargo clippy -- -D warnings` と `cargo test` を通す -4. タスクにチェックを入れ、仕様との差異があれば `docs/SPEC.md` も更新する - -タスクをまたいで実装を先回りしない。P0 の完了条件は「既存ライブラリ全曲が -表に出て、一括編集と巻き戻しができる」ことであり、それ以外は後回しで良い。 +3. 実装し、`cargo clippy -- -D warnings` と `cargo test`(フロントは `npx vitest run` と `npm run lint`)を通す +4. 設計書の要点を `docs/spec/` の該当する章へ、判断とその理由を `docs/decisions/` へ移し + (新しい判断は次の番号の `D-NNN.md` を作り、`docs/decisions/README.md` の一覧に足す)、 + 設計書と計画を `docs/development/` から消す。利用者の操作が変わるなら `docs/user-guide/` も直す +5. レビュー(codex)→ PR → CI。リリースは `vX.Y.Z-rc.N` を本番で確かめてから `vX.Y.Z` + (`docs/admin-guide/operations.md`「リリース候補(RC)を本番で確かめてから出す」)。`CHANGELOG.md` に版の要点を書く + +依頼の範囲を超えて実装を先回りしない。 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..a1550f81 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,158 @@ +# 変更履歴 + +リリースごとの主な変更。形式は [Keep a Changelog](https://keepachangelog.com/ja/1.1.0/) に倣い、版は +[Semantic Versioning](https://semver.org/lang/ja/) に従う(1.0 までは minor で機能を足す)。リリース候補(`-rc.N`)は載せない。 +各版のイメージと差分は [GitHub Releases](https://github.com/AkashiSN/spindle/releases) にある。括弧の D-n は +[設計判断](docs/decisions/README.md)、P-n は [タスクの履歴](docs/development/tasks-history.md) の番号。 + +## [Unreleased] + +### Changed + +- ドキュメントを読者別(`docs/user-guide/` `docs/admin-guide/` `docs/spec/` `docs/decisions/`)に再編し、 + 仕様は章ごと、設計判断は 1 判断 1 ファイルに分けた。README・CONTRIBUTING・CHANGELOG・LICENSE を足した + +## [0.6.0] - 2026-10-19 + +### Added + +- スマホ用の画面 `/m/`。ライブラリを眺める・探す・試し聴きする・プレイリストを作る・YouTube の購読を同期する(D-115) + +## [0.5.0] - 2026-10-18 + +### Added + +- Inbox の YouTube 由来の件を、メタデータプラグインで再判定する。判定したプラグインの版を記録する(D-114) + +### Fixed + +- adb サイドカーの鍵の待ちで tokio のワーカーを塞がない + +## [0.4.4] - 2026-10-17 + +### Added + +- Android の同期の進捗を端末タブに出す(D-109) +- MP3 の写像できないキーを ID3v2 の TXXX に読み書きする(D-110) +- Inbox の承認に種類と除外の列、タグのキーに前方一致の候補(D-113) + +### Changed + +- Derived のジョブは行った処理を結果列に出す(D-111) +- Derived のバッジを O(opus)と A(aac)に分ける(D-112) +- スマートプレイリストのルールの編集中は position のソートを既定にする + +## [0.4.3] - 2026-10-16 + +### Added + +- `spindle-agent sync` の進捗を Mac の端末と端末タブに出す(D-108) +- プレイリストの行に同期の状況を出す + +### Fixed + +- Mac の同期中に「同期が途中です」と強制破棄を出さない + +## [0.4.2] - 2026-10-15 + +### Fixed + +- 選曲の見積もりで、送る元のハッシュが無い曲を推定して容量に足す(D-107) +- SSE の応答に `X-Accel-Buffering: no` を付け、nginx 越しでもイベントが届くようにする + +## [0.4.1] - 2026-10-15 + +### Added + +- リリース候補(`vX.Y.Z-rc.N`)のイメージを出せるようにする + +### Fixed + +- 記録画面をスクロールできるようにする + +## [0.4.0] - 2026-10-15 + +### Added + +- 「記録」画面(編集履歴・退避ファイル・ログ)と、ジョブの結果の表示 + +## [0.3.1] - 2026-10-14 + +### Fixed + +- CTDB の 404 を登録なしとして扱う + +## [0.3.0] - 2026-10-14 + +### Added + +- DB を失った後に、Android の保存先を新しい登録へ引き継ぐ(P5-7) +- DB を失った後に、Mac の保存先を引き継ぐ `spindle-agent pair --adopt`(P5-8) +- 印のついたプレイリストの削除の規則と、終端した同期の計画の GC(P5-6) + +### Fixed + +- GC が `tracks` の最大 id の行を消さない(id の再利用を防ぐ) + +## [0.2.0] - 2026-10-02 + +### Added + +- 端末への配信(D-95)。端末タブで端末ごとに選曲し、Android へは USB の ADB で直接(adb サイドカー)、 + iPhone へは Mac の `spindle-agent` がミュージック.app を経由して同期する(P5-1〜P5-5) +- ReplayGain を解析後に自動でタグへ書き、Derived はその後に作る +- 遡及照合の結果を操作タブとプロパティに出す + +## [0.1.3] - 2026-09-26 + +### Fixed + +- Inbox で配置済みの件が「手置き」「変更 N 件」と出るのを直す + +## [0.1.2] - 2026-09-26 + +### Added + +- CD の取り込みで品番(と JAN)を手で入れて版を識別し、照会とタグに使う(D-94) + +### Fixed + +- compose に `init: true` を足し、取り消しで kill した孫プロセスのゾンビを回収させる + +## [0.1.1] - 2026-09-26 + +### Fixed + +- ALAC のプロセス内デコードの範囲を ffmpeg の edit list の扱いに揃える(D-89) +- リバースプロキシ越しでのログインの CSRF 判定(`trusted_proxies`)を運用手順に書く + +## [0.1.0] - 2026-09-26 + +最初のリリース。P0〜P4 の主要機能。 + +### Added + +- ライブラリの走査と同一性解決(`(dev, inode)` → `audio_md5` → `rel_path`)、論理削除と GC(P0、P1-11) +- foobar2000 風の 3 ペイン UI、トラック一覧の絞り込み・並べ替え、一括タグ編集・リネーム・バッチ単位の巻き戻し(P0) +- ReplayGain の解析と書き込み、ロスレスの FLAC 正規化、FLAC 健全性チェック(P1) +- Derived(Opus / AAC)の生成と追随、Range 再生(P1-9、P1-10) +- 手動・スマートプレイリスト(foobar2000 風の DSL)、m3u8 の書き出し・取り込み(P1-6〜P1-8) +- CD 取り込み: TOC と DiscID、MusicBrainz 照会、吸い出し、AccurateRip / CTDB 照合、遡及照合、Inbox 経由の配置(P2) +- YouTube(ytmusic)統合: 再生リストの購読と同期、タイトル解析、Inbox への取り込み(P3、P4) +- 認証(argon2id、CSRF 検査)、DB の自動バックアップ、GHCR へのイメージ配布(P0、P4-12) + +[Unreleased]: https://github.com/AkashiSN/spindle/compare/v0.6.0...HEAD +[0.6.0]: https://github.com/AkashiSN/spindle/compare/v0.5.0...v0.6.0 +[0.5.0]: https://github.com/AkashiSN/spindle/compare/v0.4.4...v0.5.0 +[0.4.4]: https://github.com/AkashiSN/spindle/compare/v0.4.3...v0.4.4 +[0.4.3]: https://github.com/AkashiSN/spindle/compare/v0.4.2...v0.4.3 +[0.4.2]: https://github.com/AkashiSN/spindle/compare/v0.4.1...v0.4.2 +[0.4.1]: https://github.com/AkashiSN/spindle/compare/v0.4.0...v0.4.1 +[0.4.0]: https://github.com/AkashiSN/spindle/compare/v0.3.1...v0.4.0 +[0.3.1]: https://github.com/AkashiSN/spindle/compare/v0.3.0...v0.3.1 +[0.3.0]: https://github.com/AkashiSN/spindle/compare/v0.2.0...v0.3.0 +[0.2.0]: https://github.com/AkashiSN/spindle/compare/v0.1.3...v0.2.0 +[0.1.3]: https://github.com/AkashiSN/spindle/compare/v0.1.2...v0.1.3 +[0.1.2]: https://github.com/AkashiSN/spindle/compare/v0.1.1...v0.1.2 +[0.1.1]: https://github.com/AkashiSN/spindle/compare/v0.1.0...v0.1.1 +[0.1.0]: https://github.com/AkashiSN/spindle/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..c5904bd6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,56 @@ +# 開発への参加 + +spindle は作者が自宅の TrueNAS で使うために作っている個人プロジェクトです。Issue や PR は歓迎しますが、 +設計の方針(下記の不変条件)に合わないものは取り込めないことがあります。大きな変更は、先に Issue で相談してください。 + +## 最初に読むもの + +- [AGENTS.md](AGENTS.md): 実装の**不変条件・禁止事項・コーディング規約・よくある実装ミス**。人も AI エージェントもここから読む +- [docs/spec/](docs/spec/README.md): 仕様。変更する機能の章を読む +- [docs/decisions/](docs/decisions/README.md): 設計判断とその理由。記録された判断を覆すときは、理由を添えて先に相談する + +## 開発環境 + +- Rust(stable)、Node.js(`web/` の SPA) +- 外部バイナリ `ffmpeg` `flac` `opusenc` `cd-paranoia` `cdrdao` `yt-dlp` `adb`。Docker イメージには同梱される。 + ローカルで動かすときは PATH に置くか `config.toml` の `[bin]` で指す。無いと該当するテストは skip する +- 本番と同じ ffmpeg で試すなら CI と同じ `rust:1-bookworm` のコンテナを使う + +```bash +cargo build && cargo test # バックエンド +cargo clippy -- -D warnings && cargo fmt --check + +cd web && npm install && npm run dev # フロント単体(API は SPINDLE_BACKEND へ中継。既定 127.0.0.1:8080) +cd web && npm run build # 同梱用ビルド(tsc -b + vite) +cd web && npx vitest run && npm run lint # 純粋ロジックのテストと oxlint + +docker build -f deploy/Dockerfile -t spindle . +``` + +CD ドライブとネットワークを要するテストは `#[ignore]` 付きで、`cargo test -- --ignored` で個別に走らせる。 +Mac 用の `spindle-agent` は `agent/`([agent/README.md](agent/README.md))。 + +## 変更の進め方 + +1. **設計**: 振る舞いが変わる変更は、先に設計を書く。設計書と実装計画は作業中だけ + [docs/development/](docs/development/README.md) に置く +2. **テストを先に**: 受け入れ条件をテストにしてから実装する。同一性解決・パス生成・DSL パース・ + AccurateRip CRC のように、壊れても画面上は正常に見える部分は単体テスト必須 +3. **ドキュメント**: 実装が済んだら、設計書の要点を [docs/spec/](docs/spec/README.md) へ、判断と理由を + [docs/decisions/](docs/decisions/README.md)(新しい番号の `D-NNN.md`)へ移し、設計書と計画は消す。 + 画面の操作が変わるなら [docs/user-guide/](docs/user-guide/README.md) も直す +4. **PR**: `cargo fmt` / `cargo clippy -- -D warnings` / `cargo test` / フロントのテストと lint を通す。 + CI([.github/workflows/ci.yml](.github/workflows/ci.yml))が同じものを走らせる +5. **リリース**: `main` のコミットに `vX.Y.Z-rc.N` を打って本番で確かめ、問題なければ同じコミットに `vX.Y.Z` を打つ。 + CI がイメージと GitHub Release を作る。[CHANGELOG.md](CHANGELOG.md) に版の要点を書く + +スキーマ変更は `db/migrations/` に新しい連番ファイルを足す。既存のマイグレーションは書き換えない。 + +## ドキュメントの置き場所 + +[docs/README.md](docs/README.md) が地図。使う人向けは `docs/user-guide/`、動かす人向けは `docs/admin-guide/`、 +作る人向けは `docs/spec/` と `docs/decisions/`。文章は日本語で書く。 + +## ライセンス + +コントリビューションは [MIT License](LICENSE) で提供されるものとします。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..474ee43d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 AkashiSN + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 635dcfec..fbde71e1 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,60 @@ # spindle -TrueNAS 上で動作する単一コンテナの音楽ライブラリ管理アプリ。 -CD リッピング(AccurateRip / CTDB 照合付き)、メタデータ一括編集、ReplayGain、 -プレイリスト管理、簡易再生を提供する。Windows 版 foobar2000 の実用機能を代替する。 +TrueNAS 上で動く、自宅向けの音楽ライブラリ管理アプリ。Windows 版 foobar2000 でやっていたことを、 +ブラウザから NAS の上で行えるようにする。本体は 1 コンテナで、Android へ同期するなら adb のサイドカーを足す。 -- 規模: 300GB / 1〜6 万トラック / 単一ユーザ / LAN 内のみ -- 言語: Rust(バックエンド)+ React/TypeScript(フロント) -- 配布: 単一バイナリ(SPA を `rust-embed` で同梱)+ Docker イメージ +- **CD の取り込み**: 吸い出した音声を AccurateRip / CTDB と照合し、正確に読めたことを確かめてから FLAC で保管する。 + 照合ログの無い既存の FLAC も後から照合できる(遡及照合) +- **メタデータの一括編集**: 数千曲のタグ編集・リネームをプレビューしてから適用し、**バッチ単位で巻き戻せる** +- **ReplayGain**: EBU R128 で解析してタグに書く(Opus は R128 形式) +- **プレイリスト**: 手動のものと、foobar2000 風のクエリで書くスマートプレイリスト。m3u8 で書き出す +- **配布用の変換**: 原本から Opus / AAC の配布用ファイルを作り、タグの変更や移動に追随させる +- **YouTube の取り込み**: 再生リストを購読し、新しい曲を承認キュー(Inbox)経由でライブラリに入れる +- **端末への同期**: 端末ごとに選んだ曲とプレイリストを、Android へは USB の ADB で直接、iPhone へは Mac の + ミュージック.app を経由して同期する +- **再生**: ブラウザで試し聴きする。外出先向けにスマホ用の画面(`/m/`)もある -## ドキュメント - -| 読む人 | ファイル | 内容 | -|---|---|---| -| 使う人 | `docs/USERGUIDE.md` | **画面の操作手順。** ログインから一括編集・巻き戻し・取り込み(Inbox / CD / YouTube)まで | -| 使う人 | `docs/OPERATIONS.md` | 運用手順。イメージの更新、バックアップと復元、正規化 | -| 使う人 | `docs/MIGRATION.md` | 旧ライブラリからの移行手順 | -| 使う人 | `docs/DSL.md` | スマートプレイリストの文法 | -| 作る人 | `CLAUDE.md` | **実装時に最初に読む。** 不変条件・規約・禁止事項 | -| 作る人 | `docs/SPEC.md` | 全体仕様。データモデル、パイプライン、API、UI、デプロイ | -| 作る人 | `docs/DECISIONS.md` | 設計判断とその理由。却下案も記録 | -| 作る人 | `docs/TASKS.md` | 実装タスクと受け入れ条件、進捗 | -| 作る人 | `db/migrations/*.sql` | スキーマ(連番で前進のみ。既存ファイルは書き換えない) | -| 作る人 | `scripts/preflight.py` | 移行前チェック(標準ライブラリのみ) | -| 作る人 | `scripts/migrate_plan.py` | 移行の振り分け計画。`rsync --files-from` の一覧を生成(標準ライブラリのみ) | +## 設計の考え方 -## 現在の状態 +- **ファイルが正、DB はキャッシュ。** DB を消してもファイルから作り直せる。SMB 越しに foobar2000 などが + ファイルを書き換えても、spindle はファイルの内容を採る +- **パスは識別子ではない。** リネームは日常の操作なので、曲の同一性は inode と音声のハッシュで追う +- **ライブラリへの破壊的な操作は巻き戻せる。** タグ書き込み・リネーム・削除は旧値を記録してから行う。削除は論理削除で、 + 実際に消すのは猶予期間(既定 7 日)の後(消した後は戻せない)。端末上の写しは Library から作り直せる複製なので対象外 +- **非可逆から非可逆へ変換しない。** 配布用のファイルは可逆の原本から作り、非可逆の原本はそのまま配る。例外は + Opus を読めない Apple の機器向けの AAC で、これだけは非可逆の原本からも変換する -P0(ライブラリ基盤)〜 P3(ytmusic 統合)は完了し、P4 の改善を進めている。CD の実ドライブ制御と -吸い出し(P2-1 / P2-5)だけが未実装。実機ではリハーサル環境(9,098 トラック)で稼働中で、 -`ssd/musics` が引き続き正、リリース時に再移行する。詳細と受け入れ条件は `docs/TASKS.md`。 +設計の背景と却下した案は [設計判断の記録](docs/decisions/README.md) に 100 件以上残してある。 -## 起動 +## 現状 -イメージは GHCR にある: `ghcr.io/akashisn/spindle:latest`(最新のリリース `vX.Y.Z`。`X.Y` も可)と -`edge`(`main` の最新。開発用で、DB の互換は前進のマイグレーション以外は約束しない)。 -`deploy/compose.yaml` を TrueNAS のカスタムアプリ(compose)として写す。作り方・更新・戻し方は -`docs/OPERATIONS.md`「イメージの更新」。動いている版は `GET /health` の `version`(`spindle --version` -と同じ)と `ytdlp` で確認できる。 +v0.6.0(2026-10)。作者の自宅の TrueNAS で、約 9,000 曲のライブラリを実際に運用している。 -`SPINDLE_CONFIG`(既定 `/data/config.toml`)で設定を指す。設定の例は `deploy/config.example.toml`。 -初回起動では環境変数 `SPINDLE_INITIAL_PASSWORD` を読んで argon2id で DB に保存し、以後は無視する。 -環境変数も DB もパスワードが無いとロックモード(`/health` が `{"status":"locked"}`、 -それ以外は 503)で起動する。**初期化が済んだら compose から `SPINDLE_INITIAL_PASSWORD` の -行を消してよい**(平文を残さないため。消しても DB のパスワードで起動する)。 +- 想定: 単一ユーザ、LAN 内(外からはリバースプロキシ越し)、300 GB・1〜6 万曲 +- 言語: Rust(バックエンド)+ React / TypeScript(フロント)。SPA を同梱した単一バイナリと Docker イメージで配る +- 非対応: マルチユーザ、Subsonic API などの配信サーバ、ギャップレスや DSP などの高度な再生、DSD -起動後の使い方は `docs/USERGUIDE.md`。 +変更の履歴は [CHANGELOG.md](CHANGELOG.md)。 -## 開発 +## 動かす ```bash -cargo build && cargo test # バックエンド -cargo clippy -- -D warnings && cargo fmt --check +docker pull ghcr.io/akashisn/spindle:latest +``` -cd web && npm install && npm run dev # フロント単体(API は SPINDLE_BACKEND へ中継。既定 127.0.0.1:8080) -cd web && npm run build # 同梱用ビルド(tsc -b + vite) -cd web && npx vitest run && npm run lint # 純粋ロジックのテストと oxlint +[`deploy/compose.yaml`](deploy/compose.yaml) を TrueNAS のカスタムアプリとして登録して起動する。データセットの作り方、 +初期パスワード、設定は [導入](docs/admin-guide/install.md) にある。起動後の使い方は [操作手順](docs/user-guide/README.md)。 -docker build -f deploy/Dockerfile -t spindle . -``` +## ドキュメント + +| 読む人 | 文書 | +|---|---| +| 使う人 | [操作手順](docs/user-guide/README.md)、[スマートプレイリストの文法](docs/user-guide/dsl.md)、[Mac 用 spindle-agent](agent/README.md) | +| 動かす人 | [導入](docs/admin-guide/install.md)、[運用手順](docs/admin-guide/operations.md)、[移行手順](docs/admin-guide/migration.md) | +| 作る人 | [仕様](docs/spec/README.md)、[設計判断](docs/decisions/README.md)、[開発への参加](CONTRIBUTING.md)、[AGENTS.md](AGENTS.md) | + +全体の地図は [docs/README.md](docs/README.md)。 + +## ライセンス -外部バイナリ(`ffmpeg` `flac` `opusenc` `cd-paranoia` `cdrdao` `yt-dlp`)はイメージに同梱される。 -ローカルで動かすときは PATH に置くか `config.toml` の `[bin]` で指す。CD ドライブとネットワークを -要するテストは `#[ignore]` 付きで、`cargo test -- --ignored` で個別に走らせる。 +[MIT](LICENSE) diff --git a/agent/src/adopt.rs b/agent/src/adopt.rs index d1da25a6..0a73b760 100644 --- a/agent/src/adopt.rs +++ b/agent/src/adopt.rs @@ -1,5 +1,5 @@ //! `pair --adopt`: サーバの DB 全損のあと、この Mac の保存先を新しい登録へ引き継ぐ -//! (docs/superpowers/specs/2026-10-13-p5-8-agent-adopt-design.md 2)。 +//! (SPEC §7.11「iPhone」の「引き継ぎ」、D-104)。 //! pair で行の uuid を前の登録へ差し替え、`adopt_pending` を保存してから `adopt` で id を付け替える。 //! その間で落ちたら、次の `sync`(または `pair --adopt` のやり直し)が `finish` で続きを行う。 //! 付け替えで外れた曲・プレイリストは state から外すだけで、ファイルもミュージック.app の項目も消さない diff --git a/agent/src/batch.rs b/agent/src/batch.rs index 9c6b22b0..5e6328ee 100644 --- a/agent/src/batch.rs +++ b/agent/src/batch.rs @@ -1,4 +1,4 @@ -//! パス変更(移動・更新 + 移動)のバッチ(仕様 ⑥「パス変更は ADB と同じバッチ」、③「例外: 始まった +//! パス変更(移動・更新 + 移動)のバッチ(SPEC §7.11 iPhone「パス変更は ADB と同じバッチ」、③「例外: 始まった //! パス変更のバッチ」)。本体の `device::sync` の `path_batch` / `abort_batch` / `batch_members` と //! `device::recover` の `complete_batch` / `vacate` / `place` の移植。ジャーナルの代わりに state.json の //! `pending_batches`(1 回の書き込みに全メンバーと相)を、端末の `mv` の代わりにローカルの rename と @@ -405,7 +405,7 @@ fn place( } /// 前回の実行が途中で止まったバッチを先頭から片付ける(`recover::recover` が `pending_ops` より先に呼ぶ。 -/// バッチの途中は track の location が `.moving/` を指すので、仕様 ⑥「回復の順序」)。 +/// バッチの途中は track の location が `.moving/` を指すので、SPEC §7.11 iPhone「回復の順序」)。 /// sealed / prepared は破棄(縮めない)、vacating 以降は完遂する pub fn recover_batches(cx: &mut Ctx<'_, M, S>) -> Result<()> { let batches = cx.state.pending_batches.clone(); diff --git a/agent/src/exec.rs b/agent/src/exec.rs index 9acaf08f..d4f816f1 100644 --- a/agent/src/exec.rs +++ b/agent/src/exec.rs @@ -1,4 +1,4 @@ -//! 確定した計画の実行(仕様 ⑥「反映」、D-100)。順序は 削除 → パス変更 → 更新 → 追加 → プレイリスト。 +//! 確定した計画の実行(SPEC §7.11 iPhone「反映」、D-100)。順序は 削除 → パス変更 → 更新 → 追加 → プレイリスト。 //! 各操作は副作用の前に `pending_ops` へ記録し、終われば state の行の更新と pending からの除去を //! 1 回の保存で行う。途中で落ちたものは `recover::recover` が片付ける diff --git a/agent/src/lib.rs b/agent/src/lib.rs index 6075ded8..6de27587 100644 --- a/agent/src/lib.rs +++ b/agent/src/lib.rs @@ -1,5 +1,5 @@ //! spindle-agent: spindle の端末(iPhone)の写しを Mac のミュージック.app へ反映する -//! (docs/superpowers/specs/2026-09-29-device-delivery-design.md ⑥、D-95・D-99・D-100)。 +//! (SPEC §7.11「iPhone(Mac の spindle-agent)」、D-95・D-99・D-100)。 //! 外界は `Music`(ミュージック.app)・`Server`(spindle の `/api/agent/*`)・`Secrets`(トークン)の //! trait に閉じ込め、ローカルの root と state.json は本物のファイルシステムで扱う diff --git a/agent/src/music.rs b/agent/src/music.rs index bb7b99a5..fc2b6494 100644 --- a/agent/src/music.rs +++ b/agent/src/music.rs @@ -1,4 +1,4 @@ -//! ミュージック.app の操作(仕様 ⑥)。本物は `osascript` で JXA を実行する(P5-4c)。 +//! ミュージック.app の操作(SPEC §7.11 iPhone)。本物は `osascript` で JXA を実行する(P5-4c)。 //! 実装の契約: 外部プロセスはタイムアウトと終了コードの検査を伴い、stderr をログに出す。 //! 名前の比較は `canonical_key`。persistent ID は 16 進の文字列で扱う diff --git a/agent/src/pair.rs b/agent/src/pair.rs index 51031731..9234768c 100644 --- a/agent/src/pair.rs +++ b/agent/src/pair.rs @@ -1,4 +1,4 @@ -//! pair と冪等な初期化(仕様 ⑥「保存先の所有権」)。 +//! pair と冪等な初期化(SPEC §7.11 iPhone「保存先の所有権」)。 //! 副作用(marker・フォルダの作成・改名)の前後のどこで落ちても、`setup.phase` と //! nonce を手掛かりに続きから再開できる。`sync` の冒頭も `continue_setup` を通る diff --git a/agent/src/plan.rs b/agent/src/plan.rs index f81319b8..3b9a5640 100644 --- a/agent/src/plan.rs +++ b/agent/src/plan.rs @@ -1,4 +1,4 @@ -//! 確定した計画の再開の照合(仕様 ③「開始済みの計画の再開」)。本体の `device::plan` の移植。 +//! 確定した計画の再開の照合(SPEC §7.11「開始済みの計画の再開」)。本体の `device::plan` の移植。 //! 直すときは両方を直す。 //! 実行するのは計画の部分集合だけ: 今の状態で満たされている操作は飛ばし、今の差分にも同じ操作・ //! 同じトークン・同じパスで残っているものだけを実行する。計画に無い操作(とくに新しい削除)は diff --git a/agent/src/playlist.rs b/agent/src/playlist.rs index d8a3fa7f..7eae277e 100644 --- a/agent/src/playlist.rs +++ b/agent/src/playlist.rs @@ -1,4 +1,4 @@ -//! プレイリストの反映(仕様 ⑥「プレイリスト」、D-100 判断 11・15)。spindle フォルダの直下だけを扱う。 +//! プレイリストの反映(SPEC §7.11 iPhone「プレイリスト」、D-100 判断 11・15)。spindle フォルダの直下だけを扱う。 //! 追加・更新は一時プレイリスト `.tmp-` を作って中身を入れ、旧を消してから改名する //! (入れ替え)。削除を全部先に、それから追加・更新。途中で落ちたものは `recover` が片付ける diff --git a/agent/src/progress.rs b/agent/src/progress.rs index 2554cf1e..9a1edfd1 100644 --- a/agent/src/progress.rs +++ b/agent/src/progress.rs @@ -1,4 +1,4 @@ -//! sync の進捗の数え方と書式(docs/superpowers/specs/2026-10-15-agent-sync-progress-design.md)。 +//! sync の進捗の数え方と書式(SPEC §7.11「iPhone」の「進捗」、D-108)。 //! 外界(標準エラー・時計)は `ProgressSink` と `Clock` に閉じ込め、ここは純粋に数えて整形するだけ use agent_proto::{ProgressPhase, ProgressRequest, PROGRESS_CURRENT_MAX}; diff --git a/agent/src/recover.rs b/agent/src/recover.rs index 3db3dfd2..0081507e 100644 --- a/agent/src/recover.rs +++ b/agent/src/recover.rs @@ -1,4 +1,4 @@ -//! 回復(仕様 ⑥「回復」、D-100)。前回の実行が途中で止まった `pending_ops` を先頭から 1 件ずつ片付ける。 +//! 回復(SPEC §7.11 iPhone「回復」、D-100)。前回の実行が途中で止まった `pending_ops` を先頭から 1 件ずつ片付ける。 //! 片付けと pending からの除去は 1 回の保存で行う。ここで扱うのは曲の追加・更新・削除とプレイリスト //! (パス変更のバッチは先に `batch::recover_batches` が片付ける) @@ -20,7 +20,7 @@ use crate::state::{Candidate, OpPhase, PendingKind, PendingOp}; use crate::{Error, Result}; pub fn recover(cx: &mut Ctx<'_, M, S>) -> Result<()> { - // バッチの途中は track の location が `.moving/` を指すので、先に片付ける(仕様 ⑥「回復の順序」) + // バッチの途中は track の location が `.moving/` を指すので、先に片付ける(SPEC §7.11 iPhone「回復の順序」) crate::batch::recover_batches(cx)?; remove_orphaned_moving(cx)?; let ops = cx.state.pending_ops.clone(); diff --git a/agent/src/rediscover.rs b/agent/src/rediscover.rs index 9f9fa622..8e5a0a16 100644 --- a/agent/src/rediscover.rs +++ b/agent/src/rediscover.rs @@ -1,4 +1,4 @@ -//! 再発見(仕様 ⑥「再発見」、D-100 判断 2〜4)。反映の前に、ミュージック.app の file track のうち +//! 再発見(SPEC §7.11 iPhone「再発見」、D-100 判断 2〜4)。反映の前に、ミュージック.app の file track のうち //! `location` が root の下を指すものを列挙して state と突き合わせる。 //! - 同じファイルを指す track が複数ある → どれも採らずに止める //! - state の曲の track が手で消された → state から外し、自分のファイルを片付ける(判断 4) diff --git a/agent/src/state.rs b/agent/src/state.rs index 95dc5bec..de6a2597 100644 --- a/agent/src/state.rs +++ b/agent/src/state.rs @@ -1,4 +1,4 @@ -//! `state.json`(仕様 ⑥「ミュージック.app への反映」)。エージェントの実状態の正本。 +//! `state.json`(SPEC §7.11 iPhone「ミュージック.app への反映」)。エージェントの実状態の正本。 //! 書き込みは同じディレクトリの tmp → fsync → rename → 親ディレクトリの fsync。1 つの遷移で //! 変わるもの(tracks の更新・バッチの相・pending_* からの除去)は同じ 1 回の書き込みに含める diff --git a/agent/src/sync.rs b/agent/src/sync.rs index f8e160d2..cc8b43fb 100644 --- a/agent/src/sync.rs +++ b/agent/src/sync.rs @@ -1,4 +1,4 @@ -//! `sync` の段取り(仕様 ⑥「回復の順序」、D-100 判断 5)と `pair` / `resolve` / `abandon` / `status`。 +//! `sync` の段取り(SPEC §7.11 iPhone「回復の順序」、D-100 判断 5)と `pair` / `resolve` / `abandon` / `status`。 //! 各コマンドは `/lock` を取り、state.json を読んで `Ctx` を作ってからエンジンを呼ぶ use std::ffi::{OsStr, OsString}; diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md deleted file mode 100644 index be770f44..00000000 --- a/docs/DECISIONS.md +++ /dev/null @@ -1,4992 +0,0 @@ -# 設計判断の記録 - -実装中に「なぜこうなっているのか」を調べ直さなくて済むように、 -判断とその理由、および却下した案を残す。 -**ここに記録された判断を実装時に覆さないこと。** 覆すべき理由が見つかった場合は -実装を止めて確認する。 - ---- - -## D-1 ライブラリ構造は役割別(フォーマット別ではない) - -**決定**: `Library`(正)/ `Derived`(生成物)/ `Archive`(生データ)/ `Inbox`(一時) - -**理由**: 旧構成の `Opus/` と `Original/` は実質「配布用」と「保管用」の -役割分担だった。CD の FLAC は保管物であると同時に再生対象でもあるため、 -フォーマットで切るとどちらに置くべきか決まらない。 - -**却下**: 同一ディレクトリにフォーマット混在(他プレイヤーから重複に見える)。 -CD イメージを `Archive` に二重保持(300GB の重複。`disc.cue` + `disc.toc` + -per-track FLAC があればイメージを再構成でき、検証可能性は失われない)。 - ---- - -## D-2 ファイルが正、DB はキャッシュ - -**決定**: DB を消してもファイルから再構築できること。外部変更はファイルが勝つ。 - -**理由**: SMB 経由で foobar2000 や他プレイヤーが同じファイルを読み書きする。 -DB だけを真実にすると、外部から触られた瞬間に破綻する。 - -**例外**: プレイリスト、編集履歴、検証結果、ジョブ履歴は DB にしか存在しない。 -個別にバックアップする。 - ---- - -## D-3 同一性は inode と audio_md5 で解決する - -**決定**: `(dev, inode)` → `audio_md5` → `rel_path` の優先順位。 - -**理由**: リネームとタグ編集が日常操作であり、パスもバイト列も変わる。 -ZFS では rename とタグ書き換えで inode が不変。FLAC の STREAMINFO には -非圧縮音声の MD5 があり、タグ変更で不変かつデコード不要で読める。 - -**既知の限界**: 非可逆音源には強い識別子がないため `(size, mtime, path)` の -ヒューリスティックに落ちる。これは割り切る。 - -**補足**: どの識別子も実体の一意 ID ではない。各段での候補の検証規則は D-30、 -重複音声の扱いは D-29、hardlink は D-26。 - ---- - -## D-4 音声とタグを別に版管理する - -**決定**: `audio_version` と `tag_version` を分ける。 - -**理由**: これがないと、数千件の一括タグ編集のたびに Derived の再エンコードが -走り数時間失われる。タグだけの変更は Derived 側もタグ上書きで済む。 - ---- - -## D-5 タグ書き込みは tmp + rename - -**決定**: 一時ファイルに書いて fsync してから rename する。 - -**理由**: 電源断でファイルが壊れないことを優先する。 - -**代償**: inode が変わるため、書き込み成功時に DB の `(dev, inode)` を -必ず更新する必要がある。忘れると次回スキャンで全件が新規トラックになる。 - ---- - -## D-6 Category と GENRE タグは別フィールド - -**決定**: パス決定には統制語彙の `category` を使い、`GENRE` タグとは分離する。 - -**理由**: (a) GENRE は多値を取り得るがパスは単一値 (b) MusicBrainz 由来の -ジャンル文字列は表記揺れが激しくディレクトリ名が乱立する -(c) ジャンルは後から変わり、パス直結だと数千ファイルが動く。 - -語彙にマッチしないものは `_Unsorted/` に置き、取り込みを止めない。 - ---- - -## D-7 アルバムフォルダ名に年を入れない - -**決定**: `{album}` のみ。衝突時のみ `{album} ({year})` へ降格。 - -**注意**: DATE / ORIGINALDATE タグは必ず保持する(パスに出さないだけ)。 -配置前に MusicBrainz Release ID または DiscID で同一リリース判定を行う(→ D-67 追記 3 で DiscID を外した。 -DiscID は 1 枚ごとの値で、複数枚組のリリースの鍵にならない)。 -**同名 ≠ 同一リリース**であり、パス衝突をマージ扱いにしてはならない。 - ---- - -## D-8 Derived は可逆のみ、配布ビューで解決 - -**決定**: `delivery_path = 可逆 ? Derived : Library`(`delivery` ビュー)。 - -**理由**: 非可逆音源を再エンコードすると世代劣化する。Derived にコピーすると -二重管理になる。ビューで解決すれば m3u8 生成側は Derived の有無を意識しない。 - -**例外**: 容量逼迫時のみトラック単位で `force_transcode` を許可。 -ただし元が 256kbps 以上の場合のみ。 - -**追記(2026-09-20。P4-8)**: Apple 向けの `aac` 系統(D-75)だけは非可逆原本(opus / ogg / mp3)も -AAC へ変換する。ミュージック.app が Opus を読めず、配布ビューで原本へ倒しても Apple 側で再生できない -ため、世代劣化を承知で全曲を揃える。`opus` 系統と配布ビュー(Android / Web)はこの決定のまま。 -原本が AAC でも同じ経路で再エンコードする(焼き込みのため)。 - ---- - -## D-9 Opus 128kbps VBR - -**決定**: Derived は Opus 128k VBR。 - -**理由**: 事実上透過で、可逆 200GB 分でも約 25GB。96k はポータブルでは十分だが -有線イヤホンで差が出る境界。160k 以上は体感差がほぼなく容量だけ増える。 - -**補足**: Derived は再生成可能なので、この決定は低リスクであり後から変えられる。 - -**追記(2026-09-20。P4-7)**: `opus` 系統の既定を **256 kbps** に上げる(`--vbr --music` は当初から)。 -可逆 237 GB(7,570 本)で 29 GB → 約 58 GB。あわせて系統ごとの設定 `[encode.derived.]` に -改め、**行の `audio_profile`(codec / bitrate / サンプルレート規則 / 焼き込み方式の版を並べた文字列)が -設定と食い違えば再エンコード**の判定を足す(これが無いと設定を変えても音声版が同じ既存の Derived は -作り直されない。D-75)。既存の 128k(`opus:128:v1`)はこの判定で全件作り直す。 - ---- - -## D-10 WAV は FLAC へ正規化する - -**決定**: 取り込み時に可逆変換し、PCM MD5 の一致を確認してから元 WAV を -`Archive/` へ退避する。物理削除は GC ジョブ(既定 30 日後、`archived_files` 台帳を -根拠に)のみが行う。 - -**理由**: WAV は RIFF INFO / ID3 のどちらを使うかがソフトごとに異なり、 -ReplayGain タグの互換性も低い。**音声情報は**可逆変換なので失われない。 - -**意図して捨てるもの**: PCM MD5 が保証するのは音声サンプルの同一性だけで、 -RIFF INFO / ID3 / 未知チャンク / コンテナのバイト列は FLAC から再生成できない。 -保持期間後にこれらを不可逆に捨てることを受け入れる。WAV の付随メタデータに -価値があるライブラリなら `[gc]` の保持期間を延ばすか、GC を止めて無期限保持にする。 - -**却下**: MD5 一致確認後の即時削除(初版の案)。「ユーザデータの物理削除は GC のみ」 -という禁止事項、および「破壊的操作はバッチ単位で巻き戻せる」不変条件と矛盾する。 -情報が失われないのは事実だが、例外を 1 つ作ると原則の検査が実装ごとの判断になる。 -代償は一時的な容量 2 倍(GC までの 30 日間)で、設計レビュー(2026-09-15)で指摘。 - ---- - -## D-11 FLAC の圧縮レベル統一はしない - -**決定**: `flac -8` は新規生成物(CD リップ、WAV 正規化)にのみ適用。 - -**理由**: `flac -8` は圧縮率だけの違いでデコード結果は完全に同一。 -削減は 0.5〜1.5% に対し、200GB の書き直しと、ZFS スナップショットが旧ブロックを -掴むことによる実効使用量の倍増が発生する。 - -**ただし**: STREAMINFO の MD5 が未設定の FLAC は再エンコードで補填する。 -未設定だと同一性解決の第 2 手段が使えず、遡及照合も不可能。 - ---- - -## D-12 CD は全ディスクを 1 本の PCM として吸う - -**決定**: トラック単位ではなく `cd-paranoia '1-' -` で一括取得し、 -オフセット適用後に分割する。 - -**理由**: 読み取りオフセット補正がトラック境界をまたぐため、 -トラック単位で吸うと補正が正しく適用できない。 - ---- - -## D-13 CTDB を主、AccurateRip を補助 - -**決定**: 照合は CTDB を優先する。 - -**理由**: CTDB は公開 HTTP API があり、確認だけでなくパリティによる修復データも -返る。AccurateRip の DB 利用は本来 Illustrate 社の許諾前提というグレーさがある。 - -**重要**: 不一致は「不良」を意味しない。ドライブオフセット差、ギャップ処理差、 -隠しトラック、データトラックの存在で普通に外れる。`mismatch` は「要確認」として -扱い、警告色で表示しない。 - ---- - -## D-14 検証状態と可逆性は別軸 - -**決定**: `source_type` / `lossless` / `verification` の 3 属性は直交。 - -**理由**: ハイレゾや配信音源はそもそも TOC が存在せず照合対象外。 -これは `unverifiable` であって「未検証」ではない。1 つのフラグで表すと -いつまでも気持ち悪いリストが残る。 - ---- - -## D-15 Subsonic API 互換は実装しない - -**決定**: Android へは Derived + m3u8 のファイル同期を継続。 - -**理由**: 実装すれば既存クライアントが使えるが、Subsonic の認証は -パスワードの salt+md5 を送る方式で、argon2 ハッシュのみを保存する設計と -両立しない(別クレデンシャル保管が必要になる)。 - ---- - -## D-16 スマートプレイリストは DSL → AST → SQL - -**決定**: foobar 風文法を pest でパースし、AST(JSON) を DB に保存。 -実行時にパラメータ化 SQL へ変換する。 - -**却下**: 生 SQL を保存する案(スキーマ変更で全ルールが壊れ、 -インジェクション面も抱える)。UI の条件ビルダのみの案(foobar に慣れた -ユーザには遅く、複雑な条件が組めない)。 - -`rule_source`(原文)と `rule_ast` を両方保存するのは、AST から DSL を -逆生成すると整形が変わりユーザの記述意図が失われるため。 - ---- - -## D-17 認証は LAN 限定でも入れる - -**決定**: 単一パスワード + argon2id + セッション Cookie。 - -**理由**: 攻撃者対策というより事故対策。この API は 300GB のライブラリを -リネーム・削除・タグ上書きできる。認証がないと、別タブのスクリプト、 -家族の端末、うっかり有効にしたポートフォワードがそのまま破壊的操作に届く。 - ---- - -## D-18 SMB は insensitive + formD - -**決定**: `casesensitivity=insensitive`, `normalization=formD`。 - -**理由**: (a) Windows の foobar2000 が `Cover.jpg` を、spindle が `cover.jpg` を -作る事故を ZFS 層で潰す。spindle は自身の生成パスの一意性は保証できるが、 -他クライアントが作るファイルは制御できない -(b) 日本語の濁点には NFC / NFD の 2 表現があり、macOS 由来のパスは NFD に -なりがち。正規化なしだと「見た目が同じで別ファイル」が発生し、目視で気づけない。 - ---- - -## D-19 移行は新規データセット作成 + rsync - -**決定**: 既存データセットの `zfs rename` による流用はしない。 - -**理由**: `casesensitivity` と `normalization` は**作成時のみ指定可能**で -後から変更できない。rename で流用すると Library だけ `sensitive` のまま残る。 - -**代償**: 300GB の実コピーと、一時的に 2 倍の空き容量が必要。 -`zfs send/recv` も不可(作成時プロパティが継承されてしまう)。 - -**注意**: NFSv4 ACL は rsync で引き継げない(`rsync -A` は POSIX ACL)。 -コピー後に TrueNAS の ACL エディタで適用し直す。 - ---- - -## D-20 インポートは Inbox + 承認キュー - -**決定**: スキャン対象ディレクトリを増やす方式は採らない。 - -**理由**: 外部ディレクトリを直接スキャン対象にすると、そのツリーのパス規約と -タグ品質がそのまま Library に混入する。承認キューがないと `_Unsorted` が -際限なく育つ。 - ---- - -## D-21 メタデータは MusicBrainz + 手入力の一級市民化 - -**決定**: 照会結果ゼロでも CD 取り込みウィザードが完走できることを必須要件とする。 -トラックリスト貼り付け(テキストを行解析して割り付け)を実装する。 - -**理由**: 同人・VTuber・インディーズの国内盤は MusicBrainz 未登録が常態。 -プロバイダを増やすより、手入力経路の品質を上げる方が効果が大きい。 - ---- - -## D-22 マルチチャンネルは切り捨て - -**決定**: 2ch 以外は Derived 対象外、album RG の集計からも除外。 -トラック単位でステレオダウンミックスをオプトイン可能。 - -**理由**: 5.1 が混ざると album gain の集計でも Opus 変換でも例外処理になる。 -DSD / SACD ISO は非対応。 - ---- - -## D-23 ジョブの dedup は未完了の間だけ効かせる - -**決定**: `jobs.dedup_key` は `queued` / `running` の行に対する partial unique index。 -列 UNIQUE にしない。バックオフの待ち時間は `run_after` 列に永続化する。 - -**理由**: 列 UNIQUE だと `done` / `failed` / `cancelled` 後に同じキーを永久に投入できない。 -`scan` / `inbox` の固定キーは一生 1 回しか作れず、同じ `tag_version` の手動再試行も不能。 -バックオフをメモリだけで持つと、再起動直後に失敗ジョブが一斉再実行される。 - -**却下**: 完了時に `dedup_key` を NULL に書き換える案。履歴から「どのキーで走ったか」が -消え、二重実行の事後調査ができなくなる。 - ---- - -## D-24 一括編集は DB 先行更新 + pending 記録、スキャナは pending を巻き戻さない - -**決定**: 編集バッチは 1 トランザクションで「旧値・新値・事前条件(dev / inode / mtime / -rel_path)を `edit_ops` / `edits` に記録 → DB 更新 → track 単位の tagwrite ジョブ投入」を -行い、ファイル反映は非同期。**ファイル反映の単位はトラック(op)**で、同一トラックの -全差分を 1 回の tmp+rename で書く。DB の値は確定値ではなく**書き込み意図の -オーバーレイ**と定義し、UI に pending を表示する。 -`pending` の op があるトラックは、スキャナがその op の所有する論理フィールドを -再評価しない(物理的な所在は追随する)。**pending 中の再編集は 409 で拒否**する。 -巻き戻しは終端状態のバッチに限り、実際に applied になった op(既存の逆バッチで戻し済みの -ものを除く)だけを反転する。**結果は op 単位**で、1 フィールドでも現在値が元バッチの新値と -違えば op 全体を conflict にする(フィールド単位の部分適用はしない)。元バッチの -`reverted_at` は逆バッチが対象を全件 applied にしたときだけ立てる。 -op が applied 以外の終端になるときは、同じトランザクションで DB 値をファイルの現在値へ -戻す(overlay の解消。次回スキャン任せにしない)。事前条件には `ctime_ns` と `tag_hash` -を含める。 - -**理由**: 「ファイルが正」と「DB を先に更新して UI へ即時反映」は、反映待ちの窓で -衝突する。この窓でスキャンが走ると編集が DB から巻き戻され、後続の tagwrite と -食い違う(設計レビューで指摘)。pending を一級の状態として持てば、スキャナ・tagwrite・ -巻き戻し・起動時リカバリが同じ表を見て判断でき、クラッシュ点ごとの結果が決まる。 - -**却下**: ファイル書き込み後に DB を確定する案。6 万件の一括編集で UI が数分間 -古いまま残り、その間の再編集が前の編集を見ない状態で行われる。 - -**却下(P0 では)**: pending 中の再編集を許し後続バッチが先行 intent を統合する案 -(`superseded`)。先行バッチの集計・スキャナ抑止の解除・リカバリの定義が必要で、 -P0 の価値に対して複雑すぎる。`edit_ops.result` に値だけ予約し、必要になったら足す。 -409 の代償は「反映中の数分間はそのトラックを触れない」ことで、UI がバッジで示す。 - -**却下**: フィールド単位で tmp+rename する案。1 件目で inode が変わり 2 件目以降の -事前条件が必ず外れる(再レビューで指摘)。 - ---- - -## D-25 配布ビューは版が一致する Derived だけを返す - -**決定**: `delivery` は `derived_files.src_audio_version = tracks.audio_version` の -ときだけ Derived を返し、音声版が古ければ Library 原本へフォールバックする。 -タグ版だけ古い場合は Derived を返し `stale_tags = 1` を付ける。 - -**理由**: 存在だけで採用すると、音声差し替え(修復・再リップ)後の再エンコード待ちの間、 -古い音声を正常品として配ってしまう。タグ版まで一致を要求すると、一括タグ編集のたびに -全曲が一時的に可逆原本の配布へ落ちて Android 同期が肥大する。 - ---- - -## D-26 hardlink は移行ブロッカー、Library 内では作らない - -**決定**: `preflight.py` は `nlink > 1` のファイルを「解消必須」に分類する。 -移行後に Library 内で見つかった場合(`nlink > 1`、または同じ走査で同一 inode を複数パスが -持つ場合)は **inode 段も md5 段も飛ばし `rel_path_key` だけで解決**し、警告バッジを出す。 -spindle 自身は hardlink を作らない。 - -**理由**: hardlink は「1 トラック = 1 ファイル」と `(dev, inode)` 同一性の両方を壊す。 -同じ inode を 2 パスが共有すると、片方へのタグ書き込みが他方を黙って書き換える。 - -**却下**: 各パスを別トラックとして登録する案。二重書き込みのリスクを受け入れる -ことになり、原則 4(巻き戻し)が破れる。 - ---- - -## D-27 trusted_cidrs は route allowlist だけ認証をスキップする - -**決定**: `trusted_cidrs` からの接続は **route allowlist**(`/api/stream/:id`、 -`/api/artwork/:hash`、`/api/tracks/:id` の限定フィールド、`/api/playlists/:id/export`)だけ -認証なしで通す。一覧・検索・SSE・履歴・ジョブ・設定はメソッドが GET でもセッション必須。 -変更系は CIDR 内でもセッション Cookie を要求し、`Origin` があれば完全一致、無ければ -`Sec-Fetch-Site` で判定する(`Host` は使わない)。接続元の判定は socket のアドレスで、 -`X-Forwarded-*` は `trusted_proxies` からのものだけ採用する。 - -**理由**: Cookie なしで破壊 API が通る経路があると、SameSite も CORS も CSRF の防御に -ならない(別サイトの JS から LAN IP への単純 POST は送信され、応答が読めないだけ)。 -一方、他プレイヤーや curl からのストリーム参照にセッションを要求すると運用が面倒。 -読み取りだけ許すのが両立点。 - -**却下**: 設定項目の削除(ストリーム参照に毎回セッションが要る)。 -全 API で維持 + カスタムヘッダ必須(curl から使うたびにヘッダを付ける手間、実装増)。 -メソッドベース(全 GET を開ける): 履歴・ジョブのエラー文・rel_path・設定が LAN 全体に -見える。他プレイヤーの用途に SSE や履歴は要らない。 -`Origin || Host` の論理 OR: クロスサイト POST でも Host は送信先になるので防御にならない。 - ---- - -## D-28 初期パスワードは環境変数で与える - -**決定**: `SPINDLE_INITIAL_PASSWORD` を、DB にパスワードが無い初回起動時だけ読み、 -argon2id でハッシュして保存する。以後は無視する。環境変数も DB も無ければ -ロックモード(`/health` 以外 503)で起動し、先着で設定できる画面は置かない。 - -**理由**: 「最初にアクセスした人がパスワードを決める」画面は、起動直後の窓が無防備になる。 -LAN 内・単一ユーザでもコンテナの再作成のたびにその窓が開くので、明示的に与える方が -事故が少ない。 - -**代償**: compose.yaml に平文が残り得る。設定後に行を消す運用を README に書く。 -サンプルは `${SPINDLE_INITIAL_PASSWORD:?...}` 形式にし、既知の既定値(`change-me` 等)を -書かない。既定値があると、そのまま起動した利用者が既知パスワードで初期化される。 -ロックモードでは SPA の配信も止める(セットアップ画面が無いので出しても操作不能)。 - -**却下**: 起動ログに one-time token を出す案(`docker logs` を見る手順が要り、 -TrueNAS の UI からだと一手間)。 - ---- - -## D-29 同一音声の重複は自動マージしない - -**決定**: `audio_md5` が一致する active トラックが複数あっても別トラックとして扱う。 -スキャンで「移動」と判定するのは候補がちょうど 1 行で、かつ移動元が消えている場合だけ。 -重複は `duplicate_groups` ビューで UI に見せ、統合するかはユーザが決める。 - -**理由**: `audio_md5` は音声内容の識別子であってトラック実体の ID ではない。 -コピー元が残っている・ベスト盤に同一マスターが収録されている・無音トラック、で -普通に衝突する。自動マージすると片方のタグ・プレイリスト所属が黙って消える。 - ---- - -## D-30 同一性の各段で候補を検証する - -**決定**: 走査は **4 相**(inventory 固定 → 候補生成 → 決定的 claim → 単一トランザクションで -commit)で、判定は inventory 全体が揃ってから行う。(1) inode 段は inventory 内でその inode を -持つパスが 1 つだけ、`nlink = 1`、未 claim、`size` か `mtime_ns` の一致を要求し、両方違えば -`audio_md5` の一致を要求する。(2) audio_md5 段は候補がちょうど 1 行で、その旧 `rel_path_key` -が inventory に無いことを要求する。(3) rel_path_key 段は未 claim を要求する。取り合いは -`rel_path_key` 昇順で決着する。claim は `scan_runs.id`(`tracks.seen_run_id`)で管理し、 -`missing_since` は run が `completed` になった finalize でだけ立てる。DB の path / key 更新は -予約済み一時 key 経由の 2 段階で行う(swap / 循環で UNIQUE を踏まない)。 - -**却下**: 走査しながら逐次判定する方式。コピー先を先に訪問すると、未訪問のコピー元を -「消失」と誤認して移動扱いにし、その後コピー元を新規登録する。並列走査では結果が順序依存。 -`scan_generation` = 開始時刻を `seen_at` に兼用する案(epoch 秒では同秒の再実行と区別不能)。 - -**理由**: inode は削除後に再利用され、別ファイルが同じ inode を得る。並列走査では -2 パスが同じ行を取り合う。検証なしの一致採用は「見た目は正常だが別の曲に紐づく」 -最悪の壊れ方をする。 - -**変更検出**: 最速パスの比較に `ctime_ns` を含める(mtime を保存する上書きの検出)。 -版は `tag_hash` と音声フィンガープリントの**実差分**があるときだけ進める。音声 -フィンガープリントは可逆が `audio_md5`、非可逆が**エンコード済みパケット列の SHA-256** -(`audio_fp`、demux のみ)。非可逆の `size` 変化を音声の変化とみなす案は却下: タグ書き換えで -コンテナサイズは普通に変わり、不変条件 3 に反する。spindle 自身の tagwrite / rename / -RG 書き込み / MD5 補填は音声不変が既知なので再計算せず `audio_version` を据え置く。 -ZFS rollback は ctime も戻すので検出できず、`deep scan`(既定 30 日、手動可)で吸収する。 - ---- - -## D-31 パスは canonical key で比較し、dirfd 基準で開く - -**決定**: `rel_path` / `rel_dir` に対して `casefold(NFD(...))` の canonical key 列を持ち -UNIQUE にする。ファイルは root の dirfd から `openat2(RESOLVE_BENEATH | -RESOLVE_NO_SYMLINKS)` で開き、symlink は辿らない。一時ファイルは対象と同じ -ディレクトリに `O_EXCL` で作る。外部コマンドは引数配列 + `--`(非対応なら `./` 前置)。 - -**理由**: ZFS は `insensitive` + `formD`(D-18)だが SQLite の UNIQUE は BINARY 比較で、 -同じファイルを指す 2 つの文字列を別と見る。canonicalize → prefix 比較は symlink の -差し替えに負ける。先頭 `-` のファイル名は外部ツールにオプションとして解釈される。 - -**限界**: `casefold(NFD())` は spindle 側の保守的な同値規則で、OpenZFS の `u8_textprep` -と同一の保証はない(ß、トルコ語 I、合字、Unicode 版差)。DB の key は事前判定に留め、 -最終判定は `O_EXCL` / `RENAME_NOREPLACE` の失敗で行う。`openat2` が無い環境は起動時に -落とす(フォールバックを設けない。非 Linux 開発機は結合テストを skip)。 - -**却下**: SQLite の `COLLATE NOCASE`(ASCII しか畳まない)。全角・半角の同一視 -(NFKC の領域で、ZFS の formD とも別物。同一視しない)。 - ---- - -## D-32 album の同一性は構成トラックと MBID / DiscID で引く - -**決定**: ディレクトリ rename 後の album は、`mb_release_id` / `discid` が**ちょうど 1 件** -(→ D-67 追記 3 で `discid` の列ごと外し、`mb_release_id` だけになった) -一致し旧 rel_dir が消えているもの → 構成トラックの過半数が属していた album(旧 rel_dir が -消えているもの)の順で既存行を引き当て、`rel_dir` を書き換えて id を維持する。候補が -複数なら自動では寄せない。分割は移った側が新規、統合は最多の album が id を維持し、 -構成 0 になった album は `albums.missing_since` を立てて行を残す(削除すると -`album_verifications` が CASCADE で消える)。`rel_dir` の更新もトラックと同じ 2 段階。 - -**理由**: album を `rel_dir` で作り直すと `album_verifications` / `artwork_id` / -プレイリストのアルバム参照が失われる。「パスは識別子ではない」は album にも適用する。 - ---- - -## D-33 一括編集の対象はプレビュー時のスナップショットに固定する - -**決定**: `POST /api/tracks/batch/preview` でサーバが selection を解決し、対象 `track_id` と -各行の `tag_version` / 事前条件を短期保存して `selection_token`(TTL 15 分)を返す。 -`PATCH /api/tracks/batch` はこの token の集合だけを対象にする。スナップショット後に -`tag_version` が変わった行は conflict になる。 - -**理由**: フィルタ形の selection を preview と apply で別々に評価すると、その間のスキャンで -増えた(ユーザが一度も見ていない)行まで破壊的操作の対象になる。「プレビューで見たもの -だけに効く」は一括編集の基本保証。ID を HTTP で列挙しない要件とは両立する。 - -**却下**: apply 時にフィルタ式 + `scan_run_id` を再送して一致を要求する案。スキャンが -走るたびに 409 になり、6 万件の適用中に必ず踏む。 - -## D-34 起動時に設定を厳格に検証し、不正なら起動しない - -**決定**: `config.toml` は未知キーをエラーにし、値の範囲(`flac_compression` 0..=8、 -`gc.retention_days` ≥ 1、`reference_lufs` は有限の負数、`flac_recompress_all = false` 固定、 -`musicbrainz.rate_limit_per_sec = 1` 固定など)を起動時に検証する。`[paths]` の各ルートは -絶対パスで、**互いに同じでも入れ子でもなく**(Derived が Library の下にあるとスキャナが -Derived を原本として拾う)、**ディレクトリとして存在**しなければならない。同一性は字面に -加えて `(dev, inode)` でも見る(symlink / bind mount の別名を弾く)。入れ子は字面に加えて -symlink 解決後(`canonicalize`)のパスでも見る。いずれかを満たさなければプロセスは起動せず、 -理由をログに出して終了する。 -待ち受けアドレスは `[server].listen`(既定 `0.0.0.0:8080`、省略可)で持つ。 - -保証の範囲: 検出できるのは「無い」「同じ」「symlink 解決後まで含む入れ子」まで。 -**bind mount で Library の子ディレクトリを別パスに見せた入れ子**は `canonicalize` にも -`(dev, inode)` にも現れないので検出しない(`/proc/self/mountinfo` の解析が要り、compose の -volumes でそれを書く事故は考えにくい)。**空の別ディレクトリが誤ってマウントされている** -ケースも区別できない(それはスキャナ側の "全曲 missing" 閾値の話で、P0-6 以降で扱う)。 - -**理由**: マウント忘れで空の `/library` を走査すると、次の finalize で全曲に `missing_since` -が立つ。復旧は可能(再発見で復活する)だが、GC の猶予やバッジの意味が壊れる。typo で -「設定したつもり」になるのも同種の事故で、黙って既定値に倒すより起動時に落ちる方が安い。 -listen をハードコードしないのは、開発機で複数インスタンスを並べるため。ポート公開は -compose 側の責務なので、既定値はコンテナ内で使う値に固定する。 - -**却下**: 存在しないルートを自動作成する案。ホスト側のマウント漏れを隠してしまう。 -`[paths]` の存在確認を warn に留める案。全曲 missing の後始末の方が高くつく。 - -## D-35 認証まわりの固定値と境界 - -**決定**: ログインのレート制限は同一 IP から 15 分に 10 回で、超えると 429(設定には -出さない)。枠は argon2id の検証に入る**前に原子的に予約**する(並行送信で上限を超えさせない)。 -表はハード上限 1024 IP で、超えたら期限切れを掃除し、それでも一杯なら最古の窓を追い出す。 -`X-Forwarded-For` は trusted proxy から受け取ったチェーンを右から辿り、trusted proxy の hop を -飛ばした最初の IP をクライアントとする(それより左は自己申告)。壊れた値はクライアント不明。期限切れセッションの掃除は起動時とログイン成功時に行い、周期タスクは置かない。 -SPA の静的配信(`/api` 以外の GET)はセッション不要で通す(ログイン画面を出すため)。 -`POST /api/auth/login` も CSRF 検証の対象にする。`GET /api/auth/session` はセッションが -無ければ 401(SPA はこれでログイン画面へ切り替える)。`OPTIONS` は変更系でも SPA でも -ないのでセッション必須扱いになり、結果として CORS プリフライトは常に失敗する。 - -**理由**: 単一ユーザ・LAN 内なので制限値を調整する場面が無く、設定項目を増やすだけ損。 -セッションはログインでしか増えないので、ログイン時の掃除で溜まらない。curl からログイン -したい場合は `Sec-Fetch-Site: none` を付ければ通る(trusted_cidrs の用途は stream 参照で、 -ログインは想定しない)。 - -**却下**: 周期的な掃除タスク(P0-4 のジョブ基盤に載せる方が筋だが、それまで待つ理由が -ない程度の量しか溜まらない)。 - ---- - -## D-36 ジョブ基盤の固定値と境界 - -**決定**: 仕様(SPEC §8)が定めていない値と境界を次のとおり固定する。設定には出さない。 - -- バックオフは失敗 n 回目で `10 × 2^(n-1)` 秒、上限 1 時間。`attempts` は**失敗のたびに** - 進める(claim 時ではない)。`attempts >= max_attempts` で `failed`。既定の `max_attempts` - はスキーマの DEFAULT と同じ 5 -- 手動 `retry` は `failed` / `cancelled` だけを対象にし、`attempts` を 0、`run_after` / - `cancel_requested_at` / 進捗を NULL に戻す。`last_error` は前回の理由として残す。同じ - `dedup_key` の未完了ジョブがあれば 409 `duplicate`。`done` / `queued` / `running` の retry は - 409 `not_retryable` -- `cancel` は `queued` なら即 `cancelled`(ハンドラを経ない)、`running` なら - `cancel_requested_at` を立ててプロセス内の CancellationToken も倒す。終端は 409 - `not_cancellable`。どちらも 202 で返す(実際の遷移は SSE で観測する) -- **cancel 要求は再実行より優先する。** `cancel_requested_at` が立った行は、 - (a) 起動時リカバリで `running` / `queued` とも `cancelled` に送る、 - (b) ワーカーが claim の前に `queued` を掃除して `cancelled` に送る(バックオフ中の cancel)、 - (c) ハンドラが token を見ずに失敗 / 再キューを返しても `cancelled` にする(試行回数は数えない)。 - 例外は**完了**で、ハンドラが `Done` を返せば cancel が後から来ていても `done`(仕事は済んで - いる。API は 202 を返しているが、最終状態は SSE / 一覧で分かる) -- 進捗は SSE には毎回流し、DB には 250ms に 1 回(および `done >= total` のとき)だけ書く。 - 未書き込みの最後の値は**終端遷移と同じトランザクション**で書く。DB の `cancel_requested_at` - は永続化のたびに読み、立っていれば token を倒す(API 経由でない要求も拾う) -- **版付きジョブ(tagwrite / transcode)は基盤が payload の `track_id` をロックしてから、同じ - トランザクションで stale 判定する。** ロック待ちの間に版が進んだジョブを実行しないための - 順序。同じトランザクションで先にトラックの存在を見て、**無ければロックを取らずに no-op - `done`**(`track_locks` は `tracks` への FK なので、消えたトラックはロックできない)。 - ロックが取れなければ試行回数を数えずに再キュー、版が古ければロックを返して no-op `done`。 - ハンドラは既にロックを持った状態で始まり、追加のロックを取った後は `JobContext::is_stale()` - で再確認できる。版付き種別の payload に整数の `track_id` と版が無いものは**投入時に拒否**し、 - DB に直接入った不正行は再試行せずに `failed` にする(ゲートを迂回して実行しない) -- `track_locks` が取れずに `Outcome::Requeue` で戻したジョブは `run_after = now + 1` 秒で - 再度対象になる。試行回数は数えない。ロックはジョブの終端遷移と同じトランザクションで - 全解放する -- ハンドラの panic はそのジョブの失敗(バックオフ対象)に変換し、ワーカーは止めない。 - 終端遷移の書き込み自体が失敗(DB 障害)したときも `running` に固着させず、失敗として - 記録を試み、それも駄目なら次回起動のリカバリに任せる(**追記 2026-09-20**: 記録の前に - バックオフ付きで再試行し、駄目なら稼働中の回収に任せる。D-76) -- **停止は共有の CancellationToken 1 つで行う。** シグナルハンドラがそれを倒し、HTTP サーバの - graceful shutdown・ワーカー・SSE ストリームが同時に止まる(SSE を閉じないと axum が接続の - 終了を待ち続け、コンテナの stop timeout で SIGKILL される)。ワーカーはループの先頭と - 各 claim の直前で token を見て新規 claim を止め、claim の DB 往復中に停止が来た分は起動せずに - `queued` へ戻す。実行中タスクは**破棄**する。DB 上は `running` のまま残り、次回起動のリカバリで `queued` に - 戻る(ジョブは冪等なのでこれで良い) -- `GET /api/jobs` は未完了を先頭に最大 1000 件で、一覧と `summary` は同じ読み取り - スナップショットで取る。SSE の keep-alive は 15 秒間隔、broadcast の容量は 1024。 - **購読者が遅れて取りこぼしたときは、読み飛ばした後続イベントより前に `event: resync` を - 流す。** クライアントは resync を受けたら一覧を再取得する。接続直後の取りこぼしを避けるため、 - クライアントは**SSE を開いてから**一覧を取得する(逆順だと開く前のイベントを失う) -- 外部プロセスは `process_group(0)` で自分のグループのリーダーとして起動し、キャンセル時は - グループへ SIGTERM → **`killpg(pgid, 0)` でグループが空になったかを見て** 2 秒待ち、残っていれば - グループへ SIGKILL。leader の終了だけでは判定しない(TERM を無視する孫が残る)。待つ間も - leader を `try_wait` で回収し続ける(zombie はグループの一員として数えられ、消えたことに - ならない)。グループに 1 つでもプロセスが残る限りその pgid は再利用されない(pgid として - 参照中の pid は割り当てられない)ので、`killpg` での判定と送信は leader 回収後も安全。 - wait を経ずに drop された(panic / 停止時の abort)ときはグループへ SIGKILL。tmp は `TempGuard`(drop で削除)で持ち、 - キャンセル・失敗・panic のどの経路でも残さない - -**理由**: 単一ユーザ・LAN 内で調整する場面が無く、設定項目を増やすだけ損。バックオフの -基数を 10 秒にしたのは NAS の一時的な I/O 詰まりを跨ぐには十分で、UI の「失敗」表示が -1 時間以上遅れることもないため。進捗の間引きは 1 万件スキャンで進捗のたびに WAL へ -書かないため。停止時に実行中を待たないのは、コンテナの stop timeout(既定 10 秒)内に -数分単位のジョブが終わる保証が無く、待っても SIGKILL されるだけだから。cancel を再実行より -優先するのは、ユーザが止めたジョブがバックオフ後や再起動後に勝手に動き出すのが破壊的 -操作(tagwrite / rename)では取り返しがつかないため。 - -**却下**: claim 時に `attempts` を進める案(プロセス kill の繰り返しで `failed` に達する -ジョブが出るが、その状況は電源断であってジョブの失敗ではない)。停止時に実行中ジョブへ -キャンセル要求を出す案(`cancelled` は「ユーザが止めた」の意味であり、再起動で再開すべき -ものと区別がつかなくなる)。Lagged を SSE の切断で伝える案(再接続のたびにイベントを -失う窓ができ、resync 1 行を流す方が単純で確実)。 - ---- - -## D-37 パス安全層・同一性解決・フィンガープリントの固定値と境界 - -**決定**: 仕様(SPEC §5 / §6)が定めていない値と境界を次のとおり固定する。設定には出さない。 - -- **canonical key は `NFD → full casefold → NFD`。** casefold の結果は NFD とは限らない - (U+1F88 → U+1F00 U+03B9 のように分解が必要な文字がある)ので末尾で NFD を再適用し、 - `key(key(x)) == key(x)` を保つ。casefold は Unicode の full case folding(`caseless`)で、 - `ß` と `ss`、`fi` と `fi` は同じ key になる。これは spindle 側の**保守的な**同値規則で、ZFS が - 別名とみなす組を同名と見ることはあっても逆はない想定。差の実測は `tests/zfs_corpus.rs` - (`#[ignore]`)で対象 NAS 上で行い、結果をここに追記する -- 相対パスの要素は、`/` 区切り・先頭 `/` なし・`.` / `..` なし・NUL / `\` なしに加え、 - SMB / exFAT 禁止文字 `< > : " | ? *` と制御文字を含まず、末尾がドット・スペースでなく、 - Windows 予約名(CON PRN AUX NUL COM1–9 LPT1–9。**拡張子付きも予約**、大小文字無視)でなく、 - 255 バイト以下。パス全体の長さ上限(240 UTF-16 単位)は検証ではなく**生成側**(P0-11)で - 切り詰める。先頭 `-` のファイル名は正当で、外部コマンドへ渡す側で無害化する -- root は `O_DIRECTORY` で開いた dirfd を保持し、以後は `openat2(RESOLVE_BENEATH | - RESOLVE_NO_SYMLINKS)` だけで解決する。**`O_NOFOLLOW` は付けない**(`O_PATH` と組むと末尾の - symlink 自体が開けてしまい、親ディレクトリの解決で symlink を通す穴になる。全段の拒否は - `RESOLVE_NO_SYMLINKS` の ELOOP に任せる)。stat は親を `O_PATH` で開いてから - `statat(AT_SYMLINK_NOFOLLOW)`、rename / unlink も親 dirfd 基準。tmp は - `.spindle-tmp-<16 hex>` を `O_EXCL` で作り、EEXIST なら 8 回まで引き直す。 - 起動時に全 root(library / derived / archive / inbox / playlists)を開き、 - `openat2` が ENOSYS ならそこで落ちる。非 Linux では `RootDir` は空実装で `open` が常に - `Openat2Unsupported` を返す(crate はビルドでき、実ファイルの結合テストは - `#![cfg(target_os = "linux")]` で Linux の CI だけが走らせる) -- 外部コマンドの既定タイムアウトは 600 秒(エンコード等は呼び出し側で伸ばす)。stderr は - **読みながら**末尾 16 KiB だけを保持し(全量を溜めない。長時間の ffmpeg でもメモリは有界)、 - 失敗時は warn、成功時は debug でログに出す。leader が終了した時点でプロセスグループに - 子孫が残っていれば(leader が fork して先に exit した)グループごと掃除してから返す。 - 放置すると継承した stdout / stderr パイプの EOF が来ず呼び出し側が固まるか、孫が漏れる。`--` 方式では最初のパスの前に - `--` を 1 度だけ置くので**オプションはパスより前に並べる**。`./` 方式は相対パスで先頭 `-` の - ものだけ前置し、絶対パスは触らない。タイムアウトはキャンセルと同じ経路(プロセスグループ - SIGTERM → 2 秒 → SIGKILL、D-36)で止め、エラー型で区別する -- 同一性解決は純関数(DB を触らない)で、inode 段 → md5 段 → path 段の順に**各段を全エントリ - について** `rel_path_key` 昇順で回す。エントリごとに 3 段を回す方式は、rename 後の旧パスに - 別ファイルが置かれたとき、先に来た旧パスの path 一致が後のエントリの inode 一致から行を - 奪うので採らない。DB 側で同じ `(dev, inode)` を複数行が持つ(過去の hardlink)場合は曖昧 - として inode 段を飛ばす。同じ md5 の新パスが複数あり旧パスが消えている場合は key 昇順の - 先頭だけが移動、残りは新規(duplicate_groups に出る)。`changed` は - `(dev, inode, size, mtime_ns, ctime_ns)` のいずれかの差で、md5 段の採用は常に `changed` -- `decoded_pcm_md5`(ALAC / WAV)は FLAC エンコーダが STREAMINFO に書くのと同じ流儀 - (チャンネルインターリーブ、リトルエンディアン、bps を 8 の倍数に切り上げたバイト数)で - 取るので、同じ PCM の WAV / ALAC / FLAC は同じ `audio_md5` になる(WAV → FLAC 正規化と - ALAC からの移行で同一性が保たれる)。ALAC の bit depth は demuxer から来ないので magic - cookie(`extra_data`、`frma` / `alac` atom 前置あり)の 6 バイト目から読む -- **非可逆の `audio_fp` は Opus を含めて symphonia の demuxer で取る。** symphonia が非対応 - なのは Opus の**デコード**であり、Ogg の Opus パケットは取り出せる。ffmpeg 経由にすると - 外部プロセスと ffmpeg の版差(remux 時のヘッダ生成)に結果が依存する。ハッシュはパケットの - データをそのまま連結した SHA-256(長さ前置なし。demuxer の packetization が変わっても - バイト列が同じなら同じ値)。MP3 / Opus / AAC(MP4) / Vorbis でタグ書き換え後に不変であることを - テストで固定する -- `tag_hash` はキーを大文字化、値を NFC、キー順に整列(同一キーの多値は入力順を保持)した - `(key, value)` 列を、それぞれ 8 バイト LE の長さ前置で連結した SHA-256。埋め込み画像は - `PICTURE` キーに `:<画像バイト列の SHA-256 hex>` として入れる(カバー差し替えも - `tag_version` を進め、Derived のタグ追随対象になる) - -**理由**: 設定に出しても調整する場面が無い。key の規則を保守的にするのは、DB の UNIQUE が -FS より緩い(別と見て同名を作ろうとする)方が、FS より厳しい(同じと見て正当な名前を拒む) -より危険なため(前者は `O_EXCL` / `RENAME_NOREPLACE` が最終防衛線になるが、黙って上書きに -近い挙動に見える)。PCM MD5 を FLAC と揃えるのは、正規化・移行で `audio_md5` が変わると -Derived の再エンコードと重複検出の両方が空回りするため。 - -**却下**: `char::to_lowercase` による簡易 casefold(`ß` などが畳まれず、ZFS の挙動との差が -増える方向)。SQLite の `COLLATE NOCASE`(D-31)。パケット列のハッシュに長さを前置する案 -(packetization 差で値が変わる)。`bits_per_sample` が無い ALAC をバッファ幅(32 ビット)で -ハッシュする案(FLAC と一致しなくなる)。 - ---- - -## D-38 スキャナの固定値と境界 - -**決定**: 仕様(SPEC §7.1 / §6)が定めていない値と境界を次のとおり固定する。設定には出さない。 - -- **スキャンの起動経路**は `POST /api/scan`(`{"kind": "incremental" | "deep"}`、dedup key は固定の - `scan`)と、**起動時に incremental を 1 件投入**する(停止中の外部変更を拾う。既に queued / - running なら dedup で何もしない)。incremental は、完了した deep scan が - `[scan].deep_interval_days` より古い(または一度も無い)とき deep に**昇格**する。`0` なら - 昇格しない。deep は全既存行を「変更あり」として扱い、`tag_hash` と音声フィンガープリントを - 再計算する。**実差分があれば版を進める**(deep は stat に見えない変更 — ZFS rollback など — - を拾うためのもので、「再計算だから据え置く」ではない) -- 走査対象は拡張子で決める(flac / opus / m4a・mp4・aac / mp3 / wav / ogg・oga / wv / ape / - aiff・aif)。`.` で始まるファイルは黙って飛ばす(macOS の `._x`)。symlink と、SMB / exFAT - 制約に反する名前(`RelPath` が拒否するもの、UTF-8 でない名前)は**対象外として一覧に出す**。 - 対象外のディレクトリは配下ごと飛ばす。walk の途中でディレクトリを読めなければ run は - `failed`(`completed` は「root を開けて walk がエラーなく完了」なので、途中欠落を missing に - しない)。音声として読めないファイル(壊れた FLAC 等)は `errors` に数え、既存行なら物理属性 - だけ更新し、新規なら登録しない。run 自体は `completed` にする -- **`.spindle-tmp-*` の回収**は mtime が 1 時間より古いものだけ(並走中の tagwrite の tmp を - 消さないための猶予) -- **`artist_display`** は多値 ARTIST を `", "` で連結(foobar2000 の `%artist%` と同じ見え方)。 - `albumartist` キャッシュ列は ALBUMARTIST が無ければ ARTIST の先頭値で代用する。`track_no` / - `disc_no` は先頭の整数(`"3/12"` → 3) -- **`category_id` の推定**(album 単位): 先頭ディレクトリ名が `categories.name` と canonical key で - 一致すればそれ → 構成トラックの GENRE を `genre_category_map` で引く → どちらも無ければ NULL - (`_Unsorted` への配置はパス生成側 P0-11 の判断)。album のメタデータ(albumartist / album / - date / original_date / mb_release_id / disc_count)は構成トラックの**最頻値**(`discid` は D-67 追記 3 で外した) - (同数なら文字列順で先)。`edition` は P0-6 では設定しない -- **album 照合の候補順**は MBID が 1 件一致(DiscID は D-67 追記 3 で外した)→ 構成トラックの過半数が直前まで属していた - album → そのディレクトリに既にある album → 新規。既存 album を別のディレクトリへ寄せてよいのは - 「旧 rel_dir が inventory に無い」か「**旧 rel_dir の現在の構成の過半数が別の album に属して - いる**」ときで、後者が 2 ディレクトリの swap を id 維持で解く鍵になる(仕様の「旧 rel_dir が - inventory に無ければ」の字義どおりだと swap は両方新規になる)。ディレクトリを別の album に - 明け渡した album が誰にも claim されなかったときは `rel_dir` を予約 key(`\0displaced:`)へ - 退避して UNIQUE を避け、構成 0 なので finalize で missing になる。変更のないディレクトリ - (最速パスだけ)は照合を省く -- パスの 2 段階更新の一時 key は `\0track:` / `\0album:`(NUL は `RelPath` が拒否するので - 実 key と衝突しない)。外部 rename の**宛先 key を今回 claim されなかった別の行**(missing 行を - 含む)が占有していれば、同じトランザクションでその行の key を `\0vacated:` へ明け渡させる。 - 行は残り、finalize で missing になる(SPEC §7.1)。inventory 内で canonical key が重複する - ファイル(case-sensitive な FS で大小文字だけ違う 2 ファイル)は後から見つかった方を対象外 - (`DuplicateKey`)にして UNIQUE を踏まない -- **pending op は commit トランザクションの中で読み直す。** Phase 2 のスナップショットは長い - Phase 3 の間に編集バッチ(DB 先行更新 + pending)に追い越され得るので、スナップショットの - 値で判断すると編集意図をファイル値で巻き戻す(D-24 違反)。さらに **rel_path と物理属性が - スナップショットから変わっている行(tagwrite の tmp + rename や rename ジョブが Phase 3 中に - 完了した)は、今回の inventory と読み取りが古いので何も適用せず `seen` だけ更新する** - (`overtaken` として報告。次回スキャンで整合する)。追い越された行は album 照合の - ディレクトリ集計にも入れない(所在も所属も DB の現在値が正しい)。scan は track_locks を - 取らないので tagwrite / rename と並走する前提 -- pending の rename op があるトラックの `rel_path` は op が所有する(overlay で先に変わっていて - よい)。外部移動の判定は DB の `rel_path` ではなく **`edit_ops.expected_rel_path`(記録時点の - 物理パス)** と inventory のパスを key で比べ、違えば `skipped_conflict`。同じなら物理属性と - タグ(rename op はタグを所有しない)は通常どおり追随する -- 音声の変化は同種のフィンガープリントの差に加え、**種類が変わった**(同じパスで ALAC ⇔ AAC - など可逆 ⇔ 非可逆の差し替え)ときも `audio_version` を進める。旧値が片方しか無いので同種比較 - では見えない -- root 直下へ移ったトラックは `album_id` / `album` を NULL にする(album = ディレクトリ。root は - album を持たない) -- pending の `tags` op があるトラックはタグ・キャッシュ列・`tag_hash`・`tag_version` を触らない。 - pending の `rename` op があるトラックで外部 rename を検出したら `rel_path` を据え置き、op を - `skipped_conflict`(error に新パス)にする。物理属性はどちらも追随する(D-24) -- Phase 3(タグ読込 + フィンガープリント)の並列度は CPU コア数(`available_parallelism`)。 - 進捗は Phase 3 の件数で報告し、DB への永続化は `JobContext::progress` の間引きに任せる -- **性能の参照値**(`tests/scanner.rs::perf_10k_synthetic_tree`、`--release`、1 万件 = FLAC 8 割 / - Opus 2 割 / アルバム 12 曲、2 回目は warm cache): 2026-09-16、TrueNAS ホスト(AMD Ryzen 5 7600、 - 12 論理コア、`/tmp` = tmpfs、DB も tmpfs)で **1 回目 2.87 秒(new=10000)、2 回目 0.15 秒 - (unchanged=10000)**。ZFS 上の実ライブラリでは 1 回目はタグ読込の I/O が支配的になるが、 - 2 回目は stat だけなので同程度に収まる想定 - -**理由**: 起動時スキャンは「DB はキャッシュ」の原則の実装で、停止中の外部変更を UI を開く前に -拾う。tmp の猶予 1 時間は tagwrite が 1 ファイルに要する時間の上限を大きく超える値で、短すぎて -書き込み中の tmp を消す事故を避ける。swap の扱いを字義より広げたのは、「パスは識別子ではない」 -を album にも適用する D-32 の意図に沿うため。 - -**却下**: ffmpeg で全ファイルの音声属性を取る案(lofty の properties で足りる。外部プロセス -1 万回は遅い)。`.spindle-tmp-*` を無条件に回収する案(並走する tagwrite の tmp を消す)。 - -## D-39 トラック一覧 API の固定値と境界 - -**決定**: 仕様(SPEC §9 / §12)が定めていない値と境界を次のとおり固定する。設定には出さない。 - -- **フィルタ式は URL エンコードした JSON 1 文字列**で、キーはホワイトリスト(未知キーは 400): - `category`(`categories.name`)/ `albumartist`(`tracks.albumartist` 完全一致)/ `album_id` / - `playlist_id` / `flags`(`unverified` `duplicate` `missing` `no_rg` `pending` `conflict` `hardlink`。 - 複数は AND)/ `q`(検索語)。サイドバーの 3 区画(ツリー・プレイリスト・固定フィルタ)と - 検索ボックスをそのまま表せる。`selection.filter` も同じ文字列。P1-7 のスマートプレイリスト - DSL は同じ JSON に `dsl` キーとして足す(DSL を一覧のフィルタ式に採用して P1-7 を前倒しする案は - 「タスクをまたいで先回りしない」に反し、プレイリスト所属も表せないので却下) -- `unverified` は `verification = 'not_attempted'` だけ。`unverifiable`(TOC が無い音源)と - `mismatch` は別の意味を持つバッジなので混ぜない(SPEC §6) -- **ソートはホワイトリスト**(`album`(既定。albumartist, album_id, disc_no, track_no)/ `title` / - `artist` / `album_title` / `albumartist` / `date` / `duration` / `codec` / `rel_path` / `id`。 - `-` 前置で降順)。**キーセットページング**で、カーソルは「発行時のソート `s` / 最終行のソートキー値 - `k` / `id`」の JSON オブジェクトを base64url にした不透明文字列。ソート列は NULL を許すが行値比較は - NULL で不定になるので、 - ソート式は `coalesce(col, '' | 0 | -1)` に畳み、同じ式の**式索引**を `0001_init.sql`(旧 `0002_tracks_sort_indexes.sql`。D-88 で統合) - に持つ(NULL は昇順で先頭、降順で末尾)。SQLite は式索引に行値比較の範囲最適化を掛けないため、 - 述語は「先頭キーの `>=` / `<=`(索引の入口)AND 全キー + id の行値比較(絞り込み)」の 2 段にする。 - **カーソルは発行時のソート(向き込み)を持ち**、デコード時にキー数と値の型(TEXT / INTEGER)を - ソートキーの型並びと照合する。型が違う値をバインドすると SQLite の storage class 順序で比較が - 常に真になり先頭ページが再掲されるため、キー数だけの照合では足りない。**リクエストの `sort` と - 発行時のソートが違う(向き違いを含む)カーソルは `Query::from_params` で 400**、DB 層に直接 - 届いた不一致は空ページ(二重防御。エラーにしない)。`limit` は 1..=1000、既定 100 -- **バッジ列は行ごとの索引検索で引く**: pending は `edit_ops` の partial UNIQUE を LEFT JOIN、 - 最新 op は `max(id)` の相関サブクエリを LEFT JOIN、重複は `idx_tracks_md5` への相関 EXISTS。 - `duplicate_groups` ビューを LEFT JOIN すると毎回 GROUP BY の実体化と自動索引(temp B-tree)が - 走るので使わない(SPEC §9 の「LEFT JOIN で引く」は「1 クエリで返す」の意味に読み替える)。 - `total` は同じ読み取りトランザクションで `count(*)` を取る -- フィルタ `pending` / `conflict` は行ごとの相関サブクエリではなく **`edit_ops` 側から集合を作って - `IN` で引く**(該当行は少数で、全行を歩くと 6 万回の索引検索になる。計測で 124ms → 0.2ms)。 - `duplicate` は相関 EXISTS のまま(ビューの `IN` は 2 倍遅い) -- **フィルタ付きソートの temp B-tree は許容する。** 索引の無い組み合わせ(`category` で絞って - `album` 順など)では planner が絞り込み側の索引を選んで ORDER BY を temp B-tree で解く。 - 絞り込んだ集合のソートなので 100ms 以内に収まる(下の計測)。無フィルタの全ソートキーと - バッジ用 JOIN、`total` に temp B-tree が出ないことはテストで固定する -- 検索は **3 文字以上(文字数。バイト数ではない)で FTS5 trigram**、未満は 4 列(title / - artist_display / album / albumartist)の `LIKE ... ESCAPE '\'`。FTS には入力全体を 1 フレーズ - (`"` は `""`)として渡し、演算子・構文文字を解釈させない。`GET /api/search?q=` は一覧と同じ - レスポンス形(`filter.q` を差し替えるだけ。表の集合を差し替える SPEC §12.1 の作りに合わせる) -- **`selection_token` の保存はプロセス内メモリ**(TTL 15 分、上限 16 件で最古を追い出す)。 - DB に置かないのは、再起動で消えてよい短期の状態で、apply 側が 409 `preview_stale` で preview から - やり直せるから。token は 32 バイトの乱数(base64url)で、**乱数源が読めなければ発行しない** - (時刻などで代用しない)。apply は `take`(期限確認と削除を同じロックの中で行う)で token を - 原子的に消費し、同じ token の並行 apply は 1 つだけ通る。スナップショットは `track_id` / - `tag_version` / `audio_version` / 事前条件(dev / inode / size / mtime_ns / ctime_ns / tag_hash / - rel_path)と preview 時の `ops` を持つ。`ids` 形の解決は `json_each` で 1 パラメータに畳む - (数万個のプレースホルダを並べない) -- `GET /api/tracks/:id` を trusted_cidrs から**セッション無し**で引いたときの限定フィールドは - id / title / artist_display / album / albumartist / track_no / disc_no / date / duration_ms / - codec / lossless。パス・編集状態(pending / conflict)・重複・missing・検証結果は含めない(D-27) -- `GET /api/albums` はページングしない(数千件。ツリーの構築に全件を使う)。`track_count` / - `duration_ms` は active なトラックだけを数える -- SSE `library` は **scan ジョブの完了時に 1 回**流す。変更行(新規・更新・移動・復活・明け渡し・ - missing 確定・pending rename の conflict 化・読めなかったファイルの物理属性更新)が 200 件以下なら - `ids`、超えたら `bulk`、0 件なら流さない。基準は「表の表示値(バッジ含む)が変わった行」。 - commit 後に流すのでクライアントが再取得すれば新しい値が見える -- **性能の参照値**(`tests/tracks_perf.rs`、`--release`、6 万件 / 5,000 album / 履歴 10 万 op / - 重複 5% / pending 50 件、warm cache、5 回の中央値): 2026-09-16、TrueNAS ホスト(AMD Ryzen 5 7600、 - 12 論理コア、DB は tmpfs)で **既定ソート 1 ページ目 0.75ms、title 昇順 1.2ms、599 ページ目 - 1.2ms、`category` 絞り込み 12ms、`duplicate` 32ms、`conflict` 19ms、`no_rg` + `-date` 21ms、 - FTS で全行に当たる語 53ms(最悪)、LIKE 13ms**。フィルタ形 selection の全件解決は 27ms - (ID 列挙なし)。すべて `total` とバッジ列込み - -**理由**: 一覧は P0-8 の表が 6 万行を仮想スクロールで流す土台なので、ページ取得の遅さがそのまま -体感になる。キーセットにしたのは OFFSET が深いページで線形に遅くなるため(599 ページ目でも -1.2ms)。フィルタ形式を JSON にしたのはパーサを持たずにホワイトリストで SQL を閉じられ、 -サイドバーの操作と 1 対 1 に対応するから。 - -**却下**: OFFSET ページング。`duplicate_groups` ビューの LEFT JOIN(実体化のコストが毎回かかる)。 -token を DB に保存する案(短期状態に永続化は過剰。P0-9 の `edit_batches` が apply 後の正)。 - -## D-40 表 UI の固定値と境界 - -**決定**: 仕様(SPEC §12)が定めていない値と境界を次のとおり固定する。設定には出さない。 - -- **行の読み込みはカーソル順に積む。** `GET /api/tracks` はキーセットなので N ページ目を単独では - 取れない。1 ページ 500 件で先頭から順に取り、仮想スクロールが未読込の index を描くときにその - index まで続けて取る(末尾へのジャンプは全ページを順に読む。6 万件 = 120 リクエストで 0.8 秒)。 - 未読込の行は骨組みで描く。TanStack Table には**表示中の窓の行だけ**を渡す(6 万件の Row - オブジェクトをページのたびに作り直さない)。仮想化の count は `total` -- **無効化(SSE `library` / `batch` / `resync`)は「表示に必要な件数まで先頭から取り直し、揃ってから - 差し替える」。** 1 ページずつ差し替えると途中で行数が縮んでスクロール位置が飛ぶ。表示範囲より - 後ろの行は捨て、スクロールで再び要求されたら読む。`library` の `ids` は読み込み済みの行に含まれる - id があるときだけ、`bulk` は無条件。`batch` も取り直す(反映待ち / conflict バッジが変わる)。 - ソート・フィルタの変更は積んだ行を全部捨てて最初から -- **一覧は SSE を開いてから取る**(D-36)。`onopen` を初回取得のトリガにする。**再接続の `onopen` - では表示範囲・ジョブ要約・アルバムを全部取り直す**(サーバの SSE は broadcast の購読で event id - による再送が無く、切断中の job / batch / library は `resync` としても届かない)。SSE の `onerror` - ではセッションを確かめる(`GET /api/auth/session`、5 秒に 1 回)。401 で閉じられた SSE は - `apiFetch` の 401 境界を通らないので、これがログイン画面へ戻る経路になる -- **表の集合 = サイドバーの scope + 上部ナビの検索語 `q`。** ツリー(category / albumartist / - album_id)とプレイリストは scope を**置き換え**、固定フィルタ(`flags`)は**トグルで AND**(ツリーの - 絞り込みと組み合わせられる)。検索語は 250ms 遅らせて反映する。どれを変えても同じ表コンポーネント - の filter が変わるだけ -- **filter 形の選択を持ったまま表示フィルタを変えた後は、Ctrl / Shift による除外・解除を無視する** - (素のクリックで ids 形に置き換えるのは常に効く。2026-09-29 の改訂で表示フィルタを変えると選択を - 解除するようになったので、今はこの状態に入らない。lib/selection の防御としては残す)。表示中の行が選択集合に入っているかを client で - 判定できないので、除外 id を増やすと「total − 除外数」の件数が合わなくなる。Ctrl+A の時点で - 1 ページ目が届いておらず total が無かったときは、同じフィルタの total が届いた描画で一度だけ確定する -- **選択は immutable な値として表と別に持つ**(`lib/selection.ts`)。ids 形(クリック / Shift 範囲 / - Ctrl トグル)と filter 形(Ctrl+A。選択した時点の filter 文字列 + 除外 id)。Shift の範囲は表示中の - 行順で解決し、anchor がソート・フィルタで表示から消えていれば素のクリック扱い。filter 形での Ctrl - クリックは除外、Shift は除外の解除、素のクリックは ids 形に戻す。ソート・SSE では選択は変えない。 - **表の集合(scope・固定フィルタ・検索語・ルール編集中の DSL)が変わったら選択を解除する** - (2026-09-29 改訂。以前は表示フィルタでも選択を保っていたが、左のツリーで移動した後も前の表示の - 「選択 1 件」が右パネルに残り、プロパティ・一括編集・操作が見えていない行に効くのが分かりにくかった。 - 右パネルの対象は表示中の表の行だけにする) -- **filter 形の件数は「Ctrl+A 時点のサーバの total − 除外数」**、「うち反映待ち」は選択時のフィルタに - `pending` を足して `GET /api/tracks?limit=1` の `total` で数える(除外した行の反映待ちは読み込み済み - で判定できる分だけ差し引く)。集合は immutable でも中の行の pending はバッチの進行で変わるので、 - 選択時に加えて **batch / library(ids の該当有無に関わらず)/ resync / SSE 再接続**のたびに数え直し、 - 応答は世代で守る(`lib/pendingCount.ts`)。ハイライトは**表示フィルタが選択時のフィルタと同じときだけ**出す - (違うときは表示中の行が集合に入っているかを client では判定できない。件数は右パネルで見せる) -- 列の表示・順・幅は TanStack Table の状態を localStorage(`spindle:columns.*`)に持つ。 - 右パネルの折りたたみ・幅(220〜640px)・タブ、も同様。読めない・壊れている値は既定に倒す。 - `rel_path` 列は既定で非表示。`#` 列はトラック番号(2 枚目以降のディスクだけ `2-03` と前置) -- **反映待ちの行**(`pending_batch_id` あり)は `aria-disabled` + グレー背景、missing の行は - 打ち消し線。編集 UI 自体は P0-10 なので「編集不可」はまだ見え方だけ -- 401 はどの API でもログイン画面へ戻す(`GET /api/auth/session` で初回判定)。ログイン以外の画面 - (アルバム / CD / ジョブ / 履歴 / 設定)は骨格のプレースホルダ -- 純粋ロジック(filter の正規化、選択、ページローダ、バッジの条件、書式)は vitest で固定する - (`cd web && npx vitest run`)。React コンポーネントの単体テストは持たない(ブラウザで確認する) -- **性能の参照値**(2026-09-16、TrueNAS ホスト AMD Ryzen 5 7600、headless Chromium via agent-browser、 - 6 万件の合成行 + 実ファイル 9 件、release ビルドの同梱 SPA、1280×577): 6 行/フレームの連続 - スクロール 5 秒で **60.2 fps、フレーム間隔 p95 16.7ms、20ms 超のフレーム 0**、30 行/フレームでも - 60.3 fps / 0 落ち。末尾へのジャンプ(120 ページ順読み)0.8 秒。ソート変更後・Ctrl+A・除外・ - 表示フィルタ変更後に選択が維持されること(当時の仕様。2026-09-29 に解除へ改訂)、スキャンで行が増えたときに SSE でリロードなしに - `total` が 60,009 → 60,010 になることを同じセッションで確認した。Chrome 安定版の DevTools - Performance での再計測は実機の UI 確認時に行う - -**理由**: 6 万行を表に出す土台なので、行データの持ち方(順に積む・窓だけ渡す)とスクロール位置を -保つ無効化が体感を決める。選択を表と別の immutable な値にしたのは、Ctrl+A の集合を ID で -持たない要件(SPEC §12.2)と「ソート・SSE では選択が変わらない」を同じ型で満たすため。表の集合が -変わったときの解除は `Selection` の値の側ではなく、App がその値を `NO_SELECTION` に置き換えて行う -(2026-09-29 改訂。以前は「表示を変えても選択は変わらない」も同じ型で満たしていた)。 - -**却下**: react-query 等のデータ取得ライブラリ(キーセット + 順読みの都合を吸収できず、依存が -増えるだけ)。TanStack Table の rowSelection(ID 列挙前提で filter 形を表せない)。無効化で表示中の -ページだけをそのカーソルで取り直す案(行が増減するとページ境界がずれて重複・欠落する)。 - ---- - -## D-41 編集履歴の記録機構の固定値と境界 - -**決定**: 仕様(SPEC §7.5 / §8)が定めていない値と境界を次のとおり固定する。設定には出さない。 - -- **外部 rename の追随と ctime。** Linux の rename は対象 inode の ctime を進めるので、事前条件に - `ctime_ns` を含めたままでは「rel_path のみ不一致(外部 rename)は tags op なら追随」(SPEC §7.5)が - 成立しない。**DB の `rel_path` が記録時点の `expected_rel_path` と違う(= スキャナが `(dev, inode)` で - 外部 rename を追随した)ときに限り、`ctime_ns` だけの不一致は rename によるものとみなして続行する。** - dev / inode / size / mtime_ns / tag_hash のどれかも違えば conflict。rel_path が同じなら ctime の - 不一致は従来どおり conflict(`touch -r` を伴う in-place 更新の検出)。宛先 key の占有はスキャナが - 移動を記録する時点で解決済み(`rel_path_key` UNIQUE)なので、tagwrite 側では再判定しない -- **事前条件の不一致でも、ファイルの全フィールドが op の新値と一致すれば `applied` として確定する。** - クラッシュ前に rename まで済んでいた op の確定(SPEC §7.5 リカバリ)を、起動時の特別処理ではなく - 通常の `apply_op` の経路で行う。再投入されたジョブが同じ判定を通るので、リカバリの手順は - 「pending の op に track ジョブを再投入する」だけになる -- **overlay の解消はファイルの現在値が原則、読めないときだけ記録値。** ファイルが消えている・ - 壊れていて読めない op は `skipped_conflict` にし、`edits.old_value` と事前条件の物理属性 - (dev / inode / size / mtime_ns / ctime_ns / tag_hash)へ戻す。物理属性を記録時点へ戻すのは、その後 - ファイルが外部で変わっていれば次回スキャンが差分として拾い直せるようにするため(`rel_path` は - 戻さない。所在は常にスキャナが追随する) -- **キャンセルは書き込みロックの外でファイルを読む。** 1 つ目のトランザクションで子ジョブに - cancel 要求を立て(queued は即 `cancelled`)、pending の op のうちジョブが `running` でないものを - 集める。その後 op ごとにファイルを開いて現在値を読み、`failed`(error = `cancelled`)に閉じて - overlay を解消する。running のジョブはそのまま完了し、そのジョブが集計する。queued のジョブは - 1 つ目のトランザクションで `cancelled` になっているので、その後に claim されることはない -- **バッチの `cancelled` は「error = 'cancelled' の failed op が 1 件以上ある」で判定する。** cancel を - 要求しても全 op が既に進行中で完了した場合は `applied` / `partial` になる(キャンセルは何もしていない) -- **tagwrite ジョブはファイル操作を始める前だけ cancel を見る。** 始めた後は完了まで走り、`Done` を - 返す(進行中の op は完了を待つ。SPEC §7.5)。手を付ける前に cancel 要求があれば op を `failed` - (`cancelled`)に閉じて `Cancelled` -- **最終試行の失敗では op を `failed` に閉じる。** バックオフの上限(`max_attempts`)に達する失敗を - ハンドラが検知し、overlay を解消してから `Failed` を返す。op を pending のまま残すと再起動まで - そのトラックを編集できず、再起動後は同じ失敗を繰り返す -- **起動時リカバリは `jobs::recovery::run` の後・ワーカー起動の前。** prepared / applying のバッチの - pending op について、対応するジョブが `cancelled` なら op を閉じ、ジョブが無い・`done`・`failed` なら - 現在の `tag_version` で track ジョブを再投入する(dedup key `tagwrite::` で - 二重にはならない)。pending が無いのに開いているバッチは集計する -- **prepare は値が変わらないトラックを op にしない。** 全フィールドが現在値と同じトラックは - `unchanged` に数えるだけで `tag_version` も動かさない。全件が unchanged ならバッチを作らず - `NoChanges`。同じトラックが入力に 2 回あれば拒否する。値はキー大文字化・NFC 正規化してから - 比較・記録する(`edits.new_value` と反映後の読み戻しが同じ表記になる) -- **書いた内容は同じ FD から読み戻して `tag_hash` を確定し、編集キーが意図どおりでなければ反映 - しない。** lofty が書いた結果をスキャナと同じ関数で読み、編集した全キーが `edits.new_value` と - 一致することを確かめてから rename する。一致しなければ(その形式で表現できない値)tmp を捨てて - op を `failed` に閉じる(再試行しても直らない)。hash と fstat(rename 後)を DB に書く。 - rename は ctime を進めるので rename の後に fstat する。rename 後に親ディレクトリを fsync する -- **rename の直前に宛先を開き直して stat を再確認する。** 事前条件の確認から rename までの窓 - (コピー + タグ書き込み。大きいファイルでは秒単位)で外部が書き換えていれば、tmp を捨てて - `skipped_conflict`(overlay はその時点のファイルから解消)。dev / inode / size / mtime_ns / - ctime_ns が確認時点の FD と同じなら内容も同じ(書けば ctime が進む)。再確認と rename の間の - 窓は排他ロックを持たない以上は残り(不変条件 5)、そこはスキャンが調停する -- **tmp には元ファイルの mode / 所有者 / xattr を写す。** mode(`fchmod`)は必須で、失敗したら - 反映しない。所有者(`fchown`)と xattr(`user.*` / `system.*` を含む全部。TrueNAS SCALE の - NFSv4 ACL は xattr として見える)は best-effort で、権限や FS の都合で写せなければ警告して続行する - (タグ編集のためにアクセス制御を変えない。それでも写せない属性は運用でディレクトリ継承に任せる) -- **外部の実体を採用する経路では音声属性とフィンガープリントも確定する。** 事前条件不一致で - `applied`(全フィールドが新値)/ `skipped_conflict` になるとき、および overlay 解消・cancel で - ファイルを読むときは、スキャナと同じ規則(`import::scanner::read_fingerprint` / - `audio_changed`)で `audio_md5` / `audio_fp` を比べ、変わっていれば `audio_version` を進め、 - codec / 音声属性も置き換える。物理属性を新ファイルに揃えると次回スキャンには「変更なし」と - 見えるので、ここで確定しないと音声の差し替えを deep scan まで拾えない。自分の tagwrite の - 結果(音声不変が既知。SPEC §6)では計算しない -- **generic 形式(ID3 / APE 等)は全タグブロックに同じ変更を当てる。** 読み側は複数ブロック - (ID3v2 + ID3v1 など)を **primary 優先で集約し、副ブロックからは primary に無いキーだけを - 補う**(連結すると同値が多値として二重になる。P0-6 の読み側もこの規則に揃えた)。 - 書き側が primary だけ書くと副ブロックの旧値が残るので、全ブロックに当てる -- **generic Tag に写像できないキーは書かない。** MP4 / ID3 / APE 等では lofty の `ItemKey` に - 写像できるキー(Vorbis Comment 名)だけを書き、写像できないキーは警告して落とす(読み側も - 名前を持たない項目を落とす。P0-6) -- **`tracks.album`(albums の複製)と album メタは prepare では更新しない。** ALBUM タグの編集は - `track_tags` とキャッシュ列 `title / artist_display / albumartist / track_no / disc_no / date` に - 反映され、album 側は tagwrite 後の次回スキャン(物理属性が変わるので dirty group になる)で - 再計算される。UI からの編集(P0-10)で即時性が要るなら、そこで album メタの再計算を足す - -**理由**: 事前条件は「ユーザが見たものと同じファイルか」を保証するための道具であり、rename が -進める ctime を conflict にすると日常操作(フォルダ整理中の一括編集)が全部 conflict になる。 -タグ内容は同じ FD の `tag_hash` で確認しているので、rename 追随時に ctime を緩めても書き込みの安全性は -落ちない。overlay の解消をファイル読みで行うのは「ファイルが正」を守るためで、記録値に戻すのは -読めないときの次善策にとどめる。キャンセルで 6 万ファイルを書き込みロックの中で読まない。 - -**却下**: 起動時に rename 済み op を専用の手順で確定する案(`apply_op` の経路と二重になり、 -片方だけ直す事故が起きる)。キャンセルで op を記録値へ戻す案(pending 中の外部変更を DB が -見失い、物理属性が一致したままだと次回スキャンも拾わない)。pending のまま失敗し続ける op を -残す案(そのトラックが永久に 409 になる)。 - ---- - -## D-42 タグ一括編集の固定値と境界 - -**決定**: 仕様(SPEC §9 / §12.3)が定めていない値と境界を次のとおり固定する。設定には出さない。 - -- **操作の JSON 形**は SPEC §9 のとおり(`set` / `ref` / `replace` / `number` / `delete`)。キーは - 大文字化・値は NFC に正規化してから評価する。`PICTURE` は対象外(アートワークは P1-3)。 - 操作は 1〜64 件。`set` の空(空文字・空配列)は削除と同義。`ref` の `%key%` は同じトラックの - **先頭値**で展開し、無ければ空(結果が空なら削除)。`replace` は各値に全一致置換で `$1` が使え、 - 空になった値は落ちる。正規表現は `fancy-regex`(先読み・後方参照可)でバックトラック上限 - 100,000。`number` は `start + 位置`、`pad` 桁で 0 埋め(0〜6。範囲外は 400)。 - **`set_rows`**(2026-09-21、P4-14)は `rows: { "": 値 }` で行ごとに違う固定値を書く。 - 値の規則は `set` と同じ、`rows` に無い行は触らない(preview で「変更なし」)、1〜10,000 行。 - 一括編集の UI には出さず、スクリプトからの補填(`scripts/backfill_source_url.py` の `SOURCE_URL`)に - 使う。行ごとに別バッチにすると履歴が汚れ巻き戻しも 1 件ずつになるので、1 op で済ませる -- **評価は preview と apply で同じ純粋関数**(`domain::tagops::apply_ops`)を、DB の `track_tags` - に対して行う(ファイルは読まない。DB はファイルのキャッシュで、同じ `tag_version` なら同じ - タグ集合)。apply は snapshot の各行に対して**再評価**し、現在値との差分だけを `edits` にする。 - preview 後に `tag_version` が進んだ行は評価せず `skipped_conflict` の op として記録だけする - (`affected` に含む。DB も版も触らない)。**op の事前条件は preview 時の snapshot の値** - (dev / inode / size / mtime_ns / ctime_ns / tag_hash / rel_path。D-33)で、apply 時の DB 値では - ない。preview の後にタグはそのままで音声だけ差し替えられた(`tag_version` は据え置き)ファイルは - tagwrite の事前条件確認が conflict にし、外部 rename の追随は D-41 の規則(rel_path 差 + ctime - のみ)に乗る -- **preview は selection の解決・pending・各行のタグを 1 つの読み取りトランザクション**で読む - (WAL のスナップショット)。途中でスキャンが commit しても、token の `tag_version` と表示した - 差分の世代が行ごとに混ざらない -- **連番の位置は「解決した selection を要求の `sort` で並べたときの位置」**で、反映待ちの行も - 位置を消費する(preview と apply で番号がずれない)。`sort` は一覧と同じ文字列で、省略時は - 既定ソート。preview の `items` は値が変わる行だけ返す(6 万件全部の差分を運ばない。変更なしは - 件数だけ)。反映待ちの行は評価せず `pending_excluded` に数える -- **apply は token を処理の間だけ占有する**(`SelectionStore::claim`)。同じ token の並行 apply は - 占有中なので `preview_stale`。409(`pending` / `preview_stale`(ops 不一致)/ `no_changes`)では - `release` して同じ token でやり直せるようにし、201 で `finish`(消費)する。pending の事前確認と - Editor の再確認(同じトランザクション)の間に別バッチが入っても、Editor の `Pending` を同じ - 409 に変換して token を残す。ops の照合は正規化後の canonical JSON の完全一致。token 不明・ - 期限切れは `preview_stale` -- **`no_changes`**: snapshot 全行が変更なし(または反映待ちで除外)のときは 409。バッチは作らない -- **Derived の追随**は applied になった op のトランザクションで、`derived_files` の - `src_tag_version` が現在の `tag_version` と違うときだけ `transcode` ジョブ - (payload `{ track_id, audio_version, tag_version, kind: "retag" }`、dedup key - `transcode::`)を投入する。P0 では `derived_files` が空なので何も - 起きない。ハンドラと `kind` の解釈は P1-10 が決める(`retag` = タグ上書きだけ、既定 = 再エンコード) -- **UI**: 操作リストは `useBatchEdit` が持ち、選択を変えても残る。プレビュー結果は - 「選択・操作・ソート」の組(`previewKey`)に紐づき、どれかが変わると古くなる(表の差分は消え、 - [適用] はプレビューし直すまで押せない)。差分は表の列 → タグキーの写像(Title / Artist / - Album / AlbumArtist / Date / #)があるセルにだけ 旧→新 で重ね、選択集合にあって変更の無い行は - 薄く描く。`pending` の 409 は「M 件を除外して適用 / 待つ」で、この確認もプレビューの key に - 紐づく(選択・操作・ソートを変えたら消える)。`preview_stale` はプレビューのやり直しを促す。**インライン編集**はダブルクリックしたセルの列を `set` 1 件の操作にして - 同じ preview → apply を続けて呼ぶ(ユーザにはプレビューを見せない)。`#` 列は `2-03` 形式で - DISCNUMBER + TRACKNUMBER、それ以外は TRACKNUMBER だけ。反映待ちの行はダブルクリックしても - 開かない。Artist 列の多値は `, ` で連結表示されているので、インライン編集では 1 値になる - (多値は一括編集の `set` に配列で渡す。UI の入力は 1 値) -- **`tracks.album`(albums の複製)は編集で変わらない**(D-41)。ALBUM を編集した行の Album 列は - 次回スキャン後に追随する - -**理由**: 評価をサーバの純粋関数に閉じることで、preview に見せた値と apply で書く値が一致し、 -クライアントは表示だけを持てばよい。再評価にしたのは、preview 結果(数万行 × 変更)を token に -抱えて apply まで持ち歩くより、`tag_version` が同じなら同じ結果になる性質を使う方が単純で、 -版が進んだ行の検出(conflict)も同じ比較で済むため。連番が反映待ちの行を飛ばさないのは、 -preview で見た番号と apply の番号を一致させるため(飛ばすと「除外して適用」で全部ずれる)。 - -**却下**: クライアントで操作を評価して新値を送る案(正規表現の方言が JS と Rust で違い、 -preview と apply の一致を保証できない)。preview の全行差分を返す案(6 万行で数十 MB)。 -409 で token を消費する案(「除外して適用」のたびに preview からやり直しになる)。 - ---- - -## D-43 パス生成と一括リネームの固定値と境界 - -**決定**: 仕様(SPEC §5 / §7.5 / §8 / §9)が定めていない値と境界を次のとおり固定する。設定には -出さない(テンプレートは `[layout]` の 3 本だけ。リクエストでは変えられない)。 - -- **テンプレート**(`domain::pathgen::Template`): プレースホルダは `category` / `albumartist` / - `artist` / `album` / `title` / `disc` / `track` / `year` / `edition`。`{track:02}` のように `:0N` - (N ≥ 1)で 0 埋め幅を指定できるのは整数フィールド(`disc` / `track`)だけ。`{track:2}` や - `{title:02}` は書式エラー。未知の名前・閉じていない `{` とともに設定の読み込み時に落とす(fail-fast)。拡張子はテンプレートに含めない(形式はファイルが決める)。 - ytmusic の `///. ` は `{category}/{albumartist}/{album}/{track}. {title}` - で再現できる -- **テンプレートの選択**: album に category が無ければ `unsorted`、album が複数ディスク - (`albums.disc_count > 1`、または active な構成トラックの `disc_no` の最大が 2 以上)なら - `multi_disc`、それ以外は `single_disc`。`{category}` は `albums.category_id` の名前 -- **値のフォールバック**: `albumartist` → `artist_display` → `Unknown Artist`、`album` → - `Unknown Album`、`title` → 現在のファイル名(拡張子なし)、`track` → 0、`disc` → 1、 - `year` は album の `date`(無ければトラックの `date`)の先頭 4 桁が数字のときだけ -- **置換テーブル**は ytmusic の `FILENAME_REPLACE_TITLE` + `EXTRA` を全要素に適用する(ytmusic は - albumartist に `:` と `*` だけ当てていたが、表を 1 本にする。SPEC §5 の表のとおり)。表に無い - SMB / exFAT 禁止文字は同じ流儀で全角にする(`/`→`/` `\`→`\` `|`→`|` `"`→`"`)。 - 制御文字は落とす。末尾のドット・スペースは削る。Windows の予約名は `_` を後置する - (`CON` → `CON_`、`con.txt` → `con_.txt`)。空になった要素は `_`。タグ値そのものは変えない -- **切り詰め**: 各要素 255 バイト、パス全体 240 UTF-16 単位。要素はその要素の末尾を、全体は - ファイル名の stem を `…`(U+2026)付きで切る。拡張子は保つ。省略記号の前の末尾スペース・ - ドットは削る。切り詰めた結果に元の文字が 1 つも残らない(ディレクトリだけで上限を超える、拡張子が - 長すぎる、先頭が多バイト / サロゲートで予算に入らない)ときは生成エラー(計画では conflict)に - する。上限を満たさないパスは返さない -- **衝突降格の単位はリリース**: `mb:<MUSICBRAINZ_ALBUMID>` → `album:<album_id>`(`disc:<DISCID>` は D-67 追記 3 で外した)の - 順で決めるキーが同じなら同一リリース。同じ宛先ディレクトリに 2 つ以上のリリースが来たら、 - そのディレクトリに**既にいる**リリース(選択外の占有者、または選択内で現在そこにいるもの)が - 1 つだけでそれと同じなら合流(降格なし)、それ以外は `{album} ({year})` → `{album} ({edition})` に - 降格する。年 / edition が無い、または降格しても衝突するものは `conflict`(マージにはしない。D-7) -- **ファイル名の衝突**は `rel_path_key` で判定する(選択内の重複、選択外の active 行の占有)。 - 選択内の現在 key は占有とみなさない(swap / 循環が通る)。missing 行が宛先 key を持っていれば - prepare で `\0vacated:<id>` へ明け渡させ(スキャナと同じ規則)、ファイルが残っていれば - `RENAME_NOREPLACE` が最終判定になる。自分自身の大小文字だけの変更は許可する -- **バッチ 1 つに `rename` ジョブ 1 つ**(payload `{ batch_id }`、dedup key - `rename:batch:<batch_id>`、並列 1)。SPEC §8 の「track 単位」を rename では採らない: 2 phase は - バッチ全体の順序を要し、track ジョブに分けると phase の境界を DB で管理する必要が出る。 - ジョブは開始時にバッチの全トラックをロックする(取れなければ再キュー) -- **DB の overlay は prepare の時点**で `rel_path` を新値にする(2 段階更新。D-24 と同じ - 「DB 先行更新 + pending」)。`expected_rel_path` が記録時点の物理パス -- **一時名は `spindle-rename-<op_id>.<ext>`**(source と同じディレクトリ、隠しファイルにしない)。 - `.spindle-tmp-*` はスキャナが 1 時間で回収するので使えず、隠すとスキャナが対象外にして - トラックを missing にする。スキャナは pending の rename op があるトラックの所在が - source / 一時名 / 最終名(DB の `rel_path`)のいずれかなら衝突にしない(`edit::in_progress_keys`)。 - どれでもなければ op を `skipped_conflict` にし、**同じトランザクションで `rel_path` を実在パスへ - 追随させる**(rename ジョブは pending の op しか見ないので、overlay の最終名を残すと次回スキャンまで - DB が実在と食い違う) -- **phase 1 の事前条件は stat だけ**(dev / inode / size / mtime_ns / ctime_ns)。タグは読まない - (内容は変えないので `tag_hash` の再計算は不要。in-place のタグ書き換えは ctime で見える)。 - preview 時の `tag_hash` が apply 時の DB 値と違う行は prepare で `skipped_conflict` にする - (計画の前提が崩れている。D-33) -- **op の終端は commit で一括**(1 トランザクション)。各 op の所在(最終名 / 一時名 / source / - 不明)をファイルから決め、`rel_path` を所在へ揃え(2 段階更新)、物理属性を追随する。所在が - 不明(ファイルが無い。一時名が外部に消された等)なら記録時点の `expected_rel_path` と物理属性へ - 戻す(次回スキャンが inode で追随するか missing にする)。ただしその key を別の行(swap の相手の - 実在パスを含む)が持っていれば戻せないので `\0vacated:<id>` のままにする(所在が分かっている行が - 正。UNIQUE を踏んで commit が永久に失敗する経路を作らない)。順序は **所在不明の行を先に予約 key へ - 退避 → 所在の分かった行を実在パスへ(バッチ外の占有者は明け渡し)→ 所在不明の行を空いていれば - 記録時点のパスへ**(所在不明の行の overlay が、相手の戻り先と重なることがある)。phase 2 で宛先が取られていた op は - source へ戻して `skipped_conflict`、戻せなければ一時名のまま `skipped_conflict`(DB の `rel_path` - は一時名。警告を出す)。rename の直後は毎回親ディレクトリを fsync する(phase 1 / 巻き戻し / - phase 2 のどこで電源断しても rename が永続化されている) -- **cancel は phase 2 に入るまで**: phase 1 の各 op の前と phase 1 完了直後に確認し、退避済みを - source へ戻して全 op を `failed('cancelled')` に閉じる。phase 2 に入ったら最後まで進める - (一部だけ戻すと swap / 循環が解けない)。ジョブが走っていないバッチの cancel / リカバリ / - 最終失敗は `close_rename_ops` がバッチの pending を一度に閉じる(rename op はパスが互いに - 絡むので op 単位に閉じない) -- **クラッシュ復旧は所在の再判定**: 再投入されたジョブが op ごとに最終名 → 一時名 → source の - 順に inode で所在を判定し、続きを行う。起動時の特別処理は無い(D-41 と同じ) -- **album は coordinator が追随する**(「ディレクトリ = album」を DB でも保つ): overlay と - overlay 解消の両方で、宛先ディレクトリに album 行があれば合流(missing なら復活)、無ければ - ある album の active な構成トラック全部が同じ宛先へ動くとき(album 全体の移動)は `rel_dir` を - 書き換えて id を維持(D-32)、それ以外は新規 album(メタは構成トラックのキャッシュ列の最頻値、 - category は先頭ディレクトリ名が語彙に一致すればそれ、無ければ旧 album から引き継ぐ)。宛先の - album 行が missing でこちらが album 全体の移動なら、その行を `\0displaced:<id>` へ退かせて - id を維持する。構成 0 になった旧 album は `missing_since`。スキャナは変更のないディレクトリの - 照合を省く(D-38)ので、ここで揃えないと album が古いまま残る -- **同梱ファイル(cover.jpg / disc.cue / rip.log 等)は動かさない。** rename op はトラック 1 本の - パスだけを所有し、巻き戻しの対象もそれだけ。album 全体を動かした後の旧ディレクトリに残る - 同梱ファイルの扱い(追随・GC)は未決(残課題)。埋め込み統一(D-49 / D-60)で Library の同梱 - ファイルは実データで png 1 本しか無く、Library に `rip.log` / `disc.cue` を置き始める P2-8 で決める - → D-67 で決めた: rename ジョブが commit 後に、album 全体の移動で active な行が無くなった旧ディレクトリの - 既知の名前のファイルを宛先へ移し、空なら rmdir する(op としては記録しない) -- **API**(SPEC §9): preview は選択を固定して token を返し、行ごとに `old` / `new`(衝突なら - null と `reason`)。apply は token の集合で計画を取り直し、`affected` / `conflict` を返す。 - 409 の規則は tags と同じ(`pending` / `preview_stale` / `no_changes`) - -**理由**: 2 phase をバッチ単位のジョブにすると、phase の境界がプロセス内の制御フローだけで済み、 -クラッシュ復旧は「所在をファイルから判定する」1 本になる。一時名を可視にするのは、スキャンが -リネームと並走する前提(不変条件 5)で pending 中のトラックを missing にしないため。album を -coordinator で追随させるのは、リネーム直後の一覧(category / albumartist / album のツリー)が -次回 deep scan まで古いままになるのを避けるため。 - -**却下**: track 単位の rename ジョブ(phase 境界の管理が DB に要る)。`.spindle-tmp-*` を一時名に -使う案(スキャナが回収する)。phase 2 の途中で cancel を効かせる案(swap の片側だけ戻せない)。 -同名の album をマージする案(D-7)。 - -**追記(2026-09-25。ファイル名の書式)**: `[layout]` の既定(`config.example.toml` / SPEC)を `{track:02}. {title}` -(番号の後にピリオド)にする。multi_disc は `{disc}-{track:02}. {title}`。理由: 旧パイプラインから移した Library の -約 7,500 曲が `01. タイトル` で、購読の番号揃えや Inbox の配置が付ける `165 タイトル`(ピリオド無し)と同じ -ディレクトリで混ざった(リハーサルの明透・理芽)。既存の多数派に合わせ、混ざった分はリネームで揃える。購読の -番号揃えの改名判定(ファイル名の先頭の番号)は数字の続く部分だけを読むので両方の形で同じ番号になる -(`leading_number` の単体試験)。テストが自前で与える layout はそのまま(書式に依存しない)。 - -**追記(2026-09-25。edition の供給源と、片方だけが edition を持つ衝突)**: `{album} ({edition})` の降格は -`albums.edition` を使うが、それまでスキャナがこの列に値を入れる処理が無く、全 album で NULL(edition の段は一度も -効いていなかった)。実機の IM@S CM Solo の 2 枚(通常版 4 曲と、`Hi-Res/` サブフォルダの 1 曲。ALBUM / ALBUMARTIST / -DATE が同じ)がリネームで「降格に必要な値が無い: edition」になって見つかった。 -- **edition = 構成トラックの `EDITION` タグの最頻値**(大文字正規化済みのキー。他の album 値と同じ決め方)。 - スキャナの album の再計算、Inbox の配置(下書きの `tags` の変更を当てた後の値)で入れる。リネームで album 行を - 作り直すときは旧 album の値を引き継ぐ。変化の無い既存の album は、edition が NULL で構成トラックに `EDITION` が - あれば次のスキャンで埋める(NULL のときだけ。`idx_track_tags_key` で EDITION を持つトラックだけを読む。 - **missing の確定の後**に行う: この run で消えた曲もその前は active なので、消えた曲の値を残さない)。Inbox の配置が - 既存の同一リリースの album を採用した(追記・合流・missing の復活)ときも、edition が NULL なら今回の曲の値で埋める。 - 同数のときは文字列の小さい方(スキャナ・配置・埋め戻しで同じ。下書きの順には依らない) -- **最後の段で edition を持たないリリースは元の `{album}` のまま**(ユーザの判断)。通常版 + Hi-Res 版は通常版が - `IM@S CM Solo - 034 速水奏`、Hi-Res が `IM@S CM Solo - 034 速水奏 (Hi-Res)` になる。edition の無いリリースが - 2 つ以上なら元の名前どうしで再び衝突し、edition 同士が同じでも衝突するので、どちらも conflict(マージにはしない)。 - 年の段は従来どおり(年が違えばそこで分かれる)。既にいるリリースが 1 つだけでそれと同じなら合流する規則も - 従来どおりで、宛先に通常版が既にいる場合は新しく来た方が年の段で分かれる(edition の段まで行かない) -- **却下**: 両方に EDITION を付けて規則を変えない案(通常版に「CD」等の値を付けるだけの手間が増え、既存の - フォルダ名も変わる) - ---- - -## D-44 巻き戻しの固定値と境界 - -**決定**: 仕様(SPEC §7.5「巻き戻し」/ §9 / §12.4)が定めていない値と境界を次のとおり固定する。 - -- **逆バッチは通常のバッチ**で、`reverts_batch_id` だけが違う。tags は `prepare_tags_tx`、rename は - `prepare_rename_tx` に乗る(DB 先行更新 + tagwrite / rename ジョブ、事前条件は記録時点の DB 値、 - pending の 409、キャンセル、リカバリはすべて同じ経路)。`delete` は `missing_since` を戻すだけの - DB 操作なので、同じトランザクションで op を applied にして即終端にする(構成トラックが戻った album の - `missing_since` も外す)。`archive`(ロスレス正規化)は `prepare_normalize_tx` に乗る(D-46) -- **対象集合はトラック単位**で引く: 元バッチの `applied` op − 逆バッチ群(`reverts_batch_id` = 元)で - `applied` になった op の `track_id`。1 バッチ 1 トラック 1 op なので op と track は 1:1 -- **現在値の比較は DB の値**(`track_tags` / `rel_path` / `missing_since`)で行う。DB はファイルの - キャッシュで、スキャン済みの外部変更はここで conflict になり、未スキャンの外部変更は tagwrite / - rename の事前条件確認(tag_hash / stat)が conflict にする。どちらも op 単位の `skipped_conflict` -- **計画時点の conflict にも edits を残す**(戻そうとした変更 = 現在値 → 元の値)。履歴画面が - 「何を戻そうとして、現在値が何だったか」を出せる。preview 後に版が進んだ行(tags の - `expected_tag_version` 不一致)は評価しないので edits は空 -- **`reverted_at` は `aggregate_batch` が立てる**: 逆バッチが終端になった時点で、元バッチの applied op が - 逆バッチ群で全件 applied になっていれば元バッチに `reverted_at`。partial なら立てず、再 revert は - 残りだけが対象。やり直し(redo)= 逆バッチの revert で、同じ規則で逆バッチに `reverted_at` が立つ。 - 元バッチの `reverted_at` は redo でも消さない(「#39 で戻し済み」の事実は変わらない。さらに戻すなら - redo バッチを revert する) -- **一覧の `reverted_by`** は `reverted_at` が立っているときだけ、applied op を持つ逆バッチの最大 id。 - 一覧は新しい順で 1000 件まで(履歴はユーザ操作 1 回 = 1 行) -- **`description` は省略可**で、無ければ UI が「(#N の巻き戻し)」と表示する(DB には入れない) -- **UI**(`HistoryView`): 一覧は画面を開いたときに取り、開いている間は SSE `batch` で取り直す - (250ms で間引く)。開いている行の詳細も一緒に取り直す。[巻き戻す] は終端かつ applied の op があり - `reverted_at` が無いときだけ。逆バッチの行は「巻き戻す (=やり直し)」。[キャンセル] は - prepared / applying のみ。conflict の op は「ファイルを再読込した現在値」として `current` を出す - -**理由**: 巻き戻しを既存のバッチ機構にそのまま乗せることで、pending / conflict / cancel / リカバリの -規則が 1 本になる。対象集合をトラック単位で引くのは、部分的に戻った後の再 revert(残りだけ)と -redo を同じ式で扱うため。 - -**却下**: 逆バッチを専用の状態機械にする案(同じ規則を二重に持つ)。元バッチの `reverted_at` を redo で -消す案(履歴の事実が書き換わり「#N で戻し済み」の表示が揺れる)。 - ---- - -## D-45 移行の振り分けと Library のロスレス形式(実機合わせ) - -**決定**: - -- **実機**: プールは `tank` ではなく `ssd`(NVMe、928G)と `hdd`(43.6T)。旧ライブラリは - `ssd/musics`(342G、既に `insensitive` + `formD` + `utf8only=on`)で、最上位は `Opus/` - `Original/` `AAC/`。`Playlists/` は `Opus/Playlists` と `Original/Playlists` の中にある -- **`Original/` は「生データ」ではなく大半がロスレス master**。内訳は ALAC 7,572(うち 24/96 が 4)、 - webm 1,512(YouTube 生データ)、mp3 14、AIFF 2(同名の ALAC あり)。`Opus/` はその 1:1 の - 非可逆版。`AAC/` は iTunes 用に FDK-AAC 256k + ReplayGain 適用済みで音声を書き換えた配布物 -- **振り分け**(`scripts/migrate_plan.py` が一覧を生成し、`docs/MIGRATION.md` の rsync が使う): - - | Original の原本 | Library(master) | Derived | Archive | 捨てる | - |---|---|---|---|---| - | ALAC | `.m4a` そのまま | 再生成(旧 `.opus` は移さない) | — | — | - | webm | 対応する `Opus/*.opus`(remux) | — | `.webm` | — | - | mp3 | `.mp3`(非可逆原本) | — | — | 対応する `.opus`(非可逆→非可逆) | - | AIFF + 同名 ALAC | ALAC(整数 PCM) | — | `.aiff` | — | - | `AAC/`、`.exclude` / `.noimage`、`.fpl` / `gen.sh` | — | — | — | 移さない | - - Library の音声は 9,098 本。Hi-Res の 24/96(`Album/Hi-Res/01.m4a`)とアルバム直下の 16/44 は - `audio_md5` が違う別トラックとして両方 Library に入れる(整理は表 UI で後から)。 - プレイリストは `Opus/Playlists/*.m3u8`(28 本)だけを `Playlists/m3u8/` へ -- **Library のロスレスは FLAC に統一する。** 移行時は ALAC のまま取り込み、変換は spindle の - ジョブで行う: P1-4 の WAV → FLAC 正規化を「ロスレス → FLAC 正規化」(WAV / ALAC / AIFF)に - 広げ、PCM MD5 照合 → 一致時のみ元ファイルを `Archive/` へ退避 → 台帳 → 30 日後 GC の同じ機構に - 乗せる。`audio_md5` は同じ PCM なら形式に依らず一致する(D-37)ので同一性は保たれ、 - `audio_version` は上げない(Derived の再生成は起きない) -- **Apple 向け(ALAC / AAC)は Derived に置かず、将来のエクスポート・同期プロファイルで - オンザフライ変換する。** D-9(Derived は Opus のみ)は変えない -- **データセットの置き場**: `ssd/media/{Library,Derived,Inbox}` と `ssd/apps/spindle` は SSD、 - **`Archive` は `hdd/media/Archive`**(`/mnt/hdd/media/Archive`) -- **DSD**(現状 0 本): 買ったら原本 `.dsf` / `.dff` は Archive、Library には PCM 変換した FLAC - (24/176.4 または 24/88.2)を master として置く。DSD → PCM は不可逆なので Archive の原本は - 消さない。FLAC は PCM 専用で DSD を格納できない - -**理由**: - -- SPEC の master の定義は「手元にある最高品質 = Library の実体」。`Original/` を丸ごと Archive に - 送る旧手順では 7,572 曲のロスレスが再生も編集もできない場所に埋もれ、非可逆の Opus が master に - なる。D-1 の役割別構成をそのまま適用すれば、ロスレスが Library、生データ(webm)が Archive、 - Opus は Derived になる -- FLAC を選ぶのは、ブラウザ再生が全種で直送できる(ALAC は Safari 以外で毎回 Opus 変換、§11)、 - Vorbis comment で複数値タグが素直(旧 `AAC/Readme.md` の「`; ` を `、` に置換」は MP4 ilst の - 制約の回避策だった)、STREAMINFO の MD5 で `flac -t` の健全性検査ができる(P1-5)、CD リップ - (P2)と WAV 正規化(P1-4)も FLAC なので Library のロスレスが 1 形式になる、から -- 移行前に ffmpeg で一括変換しないのは、ilst → Vorbis comment のタグ移し替え(複数値・ディスク - 番号・コンピレーション・アートワーク)が不完全で 7,572 曲の検品ができないこと、変換後は - 旧ツリーとの rsync checksum 照合が効かなくなること。ジョブなら lofty でタグを移し、失敗すれば - 戻せる -- Archive を hdd に置くのは、FLAC 化のとき 220G の ALAC が Archive へ退避され、SSD だけだと - 一時的に 520G を超えて空き(435G)に収まらないため。Archive は追記のみで速度が要らない -- 旧 `Opus/` を Derived に流用しないのは、旧パスがテンプレート(`01 Title` 形式)と一致せず、 - `derived_files` に紐づかない孤児として GC 対象になるだけだから。再生成は CPU 時間だけで済む - -**却下**: ALAC のまま Library に残す(Safari 以外の再生が全件変換、複数値タグの制約が続く)。 -`AAC/` を Archive に置く(音声を書き換えた非可逆で原本性が無い)。`Original/Playlists` の fpl を -移す(foobar 専用。P1-6 の取り込みでパスは書き換えるので m3u8 だけで足りる)。 - -## D-46 ロスレス正規化の固定値と境界 - -**決定**: 仕様(SPEC §7.4 / §8 / §9、D-10 / D-45)が定めていない値と境界を次のとおり固定する。 - -- **起動は selection API**(`POST /api/normalize/preview` → `/apply`。rename と同型で - `selection_token` を使う)。スキャンは WAV / ALAC / AIFF を見つけても自動投入しない。移行で - 取り込んだ ALAC 7,572 本(220G)の変換は数時間の CPU と Archive への実コピーを伴うので、 - いつ・どの範囲を変換するかはユーザが決める。Inbox 取り込み(P2-10)の後続ジョブとしての - 自動投入はそのときに足す。`[normalize].wav_to_flac = false` なら API は 409 `normalize_disabled` -- **編集バッチの機構に乗せる**: `edit_batches` + `edit_ops(kind='archive')` + `edits` - (`rel_path` 旧→新、`codec` 旧→新)。宛先は拡張子を `.flac` に置き換えた同じパス。 - ジョブは track 単位の `normalize`(dedup `normalize:<track_id>:<op_id>`、並列 2。op ごとに一意に - しないと、巻き戻し直後の新しい op がまだ `running` の前のジョブに相乗りして実行されない)で、 - ハンドラが自分で - `track_locks` を取る。pending の op があるトラックは再編集できない(409)ので、変換中にタグ編集や - リネームが割り込むことはない -- **DB は先行更新しない**(tags / rename の overlay と違う)。変換が終わるまで表は元ファイルの実体 - (ALAC / WAV)を示す。エンコードが失敗すれば何も変わらない。コーデックを先に FLAC と見せると - 失敗時に戻す overlay 解消が要り、表示も嘘になる -- **照合は独立した 2 つのデコーダ**: 元の PCM MD5 は symphonia(`decoded_pcm_md5`、スキャンと同じ - 計算)、エンコード側は ffmpeg でデコードした WAV を `flac -8 --verify` に通した STREAMINFO の - MD5。両者が一致するときだけ置く。どちらかが元ファイルを読み違えれば不一致になり、op は - `failed`(元ファイルは無傷、生成物は捨てる)。ALAC を ffmpeg に渡すときは `/dev/stdin` に - root から開いた FD を繋ぐ(MP4 は moov が末尾にあると pipe では読めない。パス文字列は渡さない) -- **タグは lofty で写す**: 読み側が Vorbis 名へ揃えた項目と画像の実体を、生成した FLAC の - VorbisComments と PICTURE ブロックにそのまま書く。写した結果の `tag_hash` が元と違うときだけ - `tag_version` を進める(MP4 ilst で表現していたキーが落ちた等。同値なら進めない)。 - `audio_md5` は同じ PCM なので変わらず、`audio_version` も据え置く -- **破壊フェーズは 3 段で、unlink の直前まで元ファイルを照合し続ける**(排他ロックは使わない。 - 不変条件 5)。(1) 宛先の FLAC を置き、元ファイルを同じディレクトリの一時名 - `spindle-normalize-<op_id>.<ext>` へ `RENAME_NOREPLACE` で退避する。rename した実体の inode が - 自分の FD と違えば(stat と rename の間に差し替えられた)戻して conflict。以後に元パスへ現れる - 外部のファイル(tmp + rename)は自分の inode と分離され、触らない。(2) Archive へ実コピー - (別プールなので rename できない)。コピーしながら取った SHA-256 が最初の値と違えば(同じ inode - への in-place 更新)conflict、書いた tmp を読み戻して一致しなければ I/O 失敗として再試行。 - rename の前に **元ファイルのコンテナ全体の SHA-256 を `edits(key='source_sha256')` に記録**する。 - (3) unlink の直前に同じ FD の stat とバイト列、一時名にあるのがその FD の実体(dev / inode) - であること、宛先にあるのが期待した音声(MD5)であることをもう一度照合し、外れていれば - conflict。**unlink が不可逆な確定点**で、その後の失敗(dir の fsync 等)では宛先も Archive も - 消さず再試行に回す(反映済み経路で確定する)。 - 確定点では同じ blocking 関数の中で一時名の実体(dev / inode)を再確認してから unlink する。 - conflict では元を元パスへ戻し、自分が置いた宛先と Archive の未確定コピーを消す(ユーザデータは - 元の inode に無傷で残っている)。消すのは**今そのパスにあるのが自分の置いた実体で、置いてから - 変わっていない(置いた直後の stat と dev / inode / size / mtime / ctime が一致し、内容の SHA-256 も - 書いたときと一致する。カーネルの時刻は粗い粒度なので stat だけでは同じ tick の変更を見落とす) - ときだけ**で、 - 外部が差し替え・上書きしていれば触らない(パスは識別子ではない)。消すときも unlink をパスに - 対して直接は行わず、同じディレクトリの隔離名(`.spindle-undo-…`。隠しファイルだが - `.spindle-tmp-` ではないので、スキャナの取り残し回収の対象に**ならない**)へ原子的に rename して - パスから切り離し、外した実体(dev / inode・size / mtime・内容)を照合する。一致した自分の生成物 - だけを `.spindle-tmp-` の名前へ移してから unlink する(消す前に落ちても 1 時間後に回収される)。 - 照合に外れれば元の場所へ戻す(塞がっていれば回収パス。どちらも失敗すれば隔離名のまま残り、 - 自動では消えない)。一時名の実体が差し替えられて - いれば、開いている FD の内容を元パスへ、元パスも塞がっていれば同じディレクトリの回収パス - `spindle-recovery-<op_id>-<random>.<ext>` へ書き出す(所在は op の error に残る)。元をどこにも - 残せなかったときは、同じ音声を持つ自分の生成物(宛先の FLAC・Archive のコピー)を 1 つも消さず、 - 実在する複製だけを op の error に記す。I/O 失敗(再試行)では生成物 - だけ消し、元は一時名に残す(戻す rename は ctime を進めて次の試行の事前条件が外れるため。 - 一時名にある元は「rename による ctime の差」として許容する)。op を閉じるとき(最終失敗・ - キャンセル)に、一時名にあるのが記録した inode なら元パスへ戻す。 - 台帳 `archived_files` は `rel_path`(Archive 相対 = 元の Library 相対)で UNIQUE、 - `eligible_after = now + [gc].retention_days` -- **巻き戻しは同じ op 種別で向きを逆にする**(`rel_path` 新→旧、`codec` 新→旧。D-44 の - 「未実装」を解消)。元ファイルは Archive から Library へ**コピー**で戻し、Archive の実体は消さない - (Archive は追記のみ。台帳は `restored`)。Library にあった FLAC は Archive へ move し、台帳に - `reason='restore'` の行を足す(GC の対象。マイグレーション 0003 で CHECK を広げた)。 - やり直し(逆バッチの revert)は WAV を再び Archive へ move するが、同じパスの行が既にあるので - 作り直さず `held` に戻して期限を更新する。復元元が GC 済み(`deleted`)や台帳に無い op は - `skipped_conflict` -- **冪等性の判定はファイルの実体**: 再投入されたジョブは Library/<旧>(無ければ一時名)の有無、 - Library/<新> の音声 MD5(期待値と一致するときだけ自分の成果物)、Archive/<旧> のバイト列(元と - 一致するときだけ退避済み)から続きを行う。<旧> も一時名も無く、<新> の音声 MD5 が DB と一致し、 - Archive/<旧> の SHA-256 が記録した `source_sha256` と一致するときだけ「反映済み」として DB を - 確定する。記録が無い・一致しないときは何も消さず `skipped_conflict`(宛先も Archive の実体も - 残す)。宛先に別のファイルがあれば `skipped_conflict`、Archive の同じパスに別のファイルがあれば - **Library を触る前に** `failed`。置いた宛先の読み直しに失敗したときは terminal にせず再試行 - (反映済み経路で確定する) -- **スキャナは作業中のパスを避ける**: pending の archive op の元(`expected_rel_path`)・一時名・ - 宛先(`edits.rel_path` の新値)の key にあるエントリは新規登録も移動も missing 判定もせず、 - seen だけにする。DB を先行更新しないので、ジョブが宛先を置いてから DB を確定するまでの窓で - スキャンが宛先を「新規トラック」に登録すると、確定時に `rel_path` の UNIQUE を踏む。 - inventory を取った後・commit の前にジョブが DB を確定した(op はもう pending でない)場合は、 - `Identity::New` の挿入直前に `rel_path_key` の占有を読み直し、占有されていれば挿入せず - seen だけにする(走査全体を UNIQUE 違反で失敗させない) -- **cancel は Library を触る前まで**(変換中の ffmpeg / flac は子プロセスごと止め、tmp を消す)。 - 配置を始めたら最後まで進める。外部の変更は事前条件(stat + tag_hash)で `skipped_conflict`、 - 変換の間に元ファイルが更新されていれば配置の直前に同じ FD の fstat で検出する -- **UI は未着手**(rename と同じ。curl で起動できる)。設定画面の「退避ファイルの一覧と復元」 - (SPEC §12.6)は台帳 API と合わせて後で載せる - -**理由**: 破壊的操作(Library からファイルを外す)は必ずバッチとして巻き戻せる必要があり -(不変条件 4)、既存の編集バッチの機構に乗せれば pending / conflict / cancel / リカバリ / 履歴 / -巻き戻しの規則が 1 本になる。DB を先行更新しないのは、変換の成否がファイルを読むまで分からず、 -失敗が普通に起きる(MD5 不一致、ビット深度非対応)ため。2 つのデコーダで照合するのは、 -「可逆変換だから情報は失われない」を実装が検査できる形にするため。 - -**却下**: スキャンで自動投入(220G の変換が起動直後に勝手に走る)。ffmpeg だけで FLAC まで作る -(`flac -8` の参照エンコーダを外すうえ、照合が単一デコーダの自己一致になる)。元ファイルを -Archive から move で戻す(Archive の追記のみの原則に例外が増える)。DB を先行更新して codec=flac -を先に見せる(失敗時の overlay 解消が要る)。 - ---- - -## D-47 ReplayGain 解析の起動と固定値(実装合わせ) - -**決定**(P1-1): - -- **起動は `POST /api/rg { selection }`**(selection は rename / normalize と同じ `ids` / `filter` - の 2 形)。含まれる active なトラックの `album_id` ごとに `rg` ジョブ(dedup `rg:album:<id>`)を - 投入し、`album_id` を持たないトラックは track 単位(`rg:track:<id>`)。既に queued / running の - album は `duplicates` に数えて投入しない。preview 段階は無い(DB にしか書かない)。全曲は - `filter = {"flags":["no_rg"]}` で指定する。UI の起動ボタンは未着手(curl で起動できる) -- **album の集計はデコード結果が 2ch のトラックだけ**(SPEC §6 / D-22 のとおり mono も除外)。 - 判定は DB の `channels` 列ではなく実際にデコードしたチャンネル数(ファイルが正)。集計に - 入らないトラックは track の値だけ持ち、`rg_album_gain` / `rg_album_peak` は NULL。track 単位の - ジョブも album の値を持たない -- **開いた FD を DB の行と照合してから解析する**: root から開いた FD の fstat(dev / inode / - size / mtime / ctime)が行と一致しなければ失敗(パスは識別子ではない。同名で差し替えられて - いれば別トラックの音声をこの行の値として保存してしまう)。デコード後にも同じ FD を fstat し直し、 - 解析中の in-place 更新を検出する。外部のタグ書き換えでも size / mtime は動くので、その場合は - 再スキャンで行が更新された後の再試行で通る -- **album は all-or-nothing**: 構成トラックが 1 本でもデコードできなければジョブを失敗にし、 - 何も書かない。一部だけ書くと album gain が揃わないまま次の再解析まで残る。書き込みの - トランザクション内で構成(active な構成トラックの id と stat・rel_path)を読み直し、解析した - 行の集合と違えば(scanner が missing にした・別 album へ移した・新しいトラックが加わった・ - 同じ行を別実体へ追随させた)何も書かずに失敗する。 - 失敗したジョブは `last_error` に該当パスを持ち、`/api/jobs/:id/retry` で再投入できる -- **無音(絶対ゲート -70 LUFS 以下)は gain 0 dB**(補正なし)。積分ラウドネスが `-inf` になる - ので `reference - lufs` が定義できない。peak は測定値のまま -- **デコードは symphonia を優先し、持たない形式だけ ffmpeg**(Opus は demux まで symphonia、 - PCM は ffmpeg の `f32le` を stdout のチャンクで受ける。WavPack / APE は lofty の属性 + ffmpeg)。 - ffmpeg の stdout はメモリに溜めない(`ExternalCommand::stdout_channel`。受け手が消えたら子を - グループごと止める。SIGPIPE を無視する子でもタイムアウトまで待たない) -- **`rg_scanned_at` だけ更新**し、`audio_version` / `tag_version` は動かさない(`rg_written_at` - は値が変われば NULL。D-48)。走査中に missing になった行は書かない - -**理由**: rg ジョブは SPEC §8 で album_id 単位・CPU コア数並列と決まっているが、起動の入口が -§9 に無かった。selection で受ければ UI の選択・フィルタとそのまま繋がり、移行直後の「全曲」も -`no_rg` フラグで表現できる。2ch 判定をデコード結果で行うのは、`channels` 列が lofty 由来の -キャッシュであり、不変条件 1 に従うと解析時に読んだ実体を採用すべきため。 - -**却下**: scan 完了時の自動投入(移行直後に 9,098 曲の解析が勝手に始まる。normalize と同じ理由)。 -失敗トラックを飛ばして album を書く(嘘の album gain が残る)。ffmpeg で全部デコードする -(外部プロセス起動と f32 のパイプが 9,000 回。symphonia で済む形式は済ませる)。 - -**追記(P1-13)**: 外部で音声が差し替わって `audio_version` が進んだ行は、スキャナ(Phase 4 の -`apply_content`)と tagwrite の overlay 解消(`sync_track_to_file`)の両方で解析値を捨てる -(`db::replaygain::reset_analysis`: `rg_*` と `rg_scanned_at` / `rg_written_at` を NULL)。古い値を -Derived や再生に使わないため。album の他のトラックの `rg_album_*` は次の album 解析で揃う。 - -**追記 2(2026-09-25、P4-22。ユーザの決定)**: 値を捨てた行は**自動で解析し直す** -(`db::replaygain::reset_and_reanalyze`。上の 2 経路が同じトランザクションで rg ジョブを積む)。 -投入単位は `POST /api/rg` と同じ規則(`scopes_of`: album gain が on の album は album 単位、それ以外は track -単位。D-74)で、dedup も同じなので Inbox の配置や手動の投入と二重にならない(同じ album の複数トラックが -同時に差し替わっても album のジョブは 1 本)。解析は DB に値を入れ、タグへの書き込みは従来どおり -`[replaygain].write_tags` に従う。aac の Derived は RG が揃うまで作らない(D-75)ので、値を捨てるだけだと -その行の aac が止まったままになっていた(リハーサルで D-89 により ALAC 7 本の `audio_md5` が変わったとき、 -手で `POST /api/rg` した)。ジョブの組み立て(`new_album_job` / `new_track_job`)はスキャナ・編集(DB 層) -からも使うので `db::replaygain` に移し、`jobs::handlers::rg` から再公開する。 - -投入量: 対象は「音声が変わった」と判定された行だけ(外部の差し替え・deep scan での `audio_md5` の変化)で、 -新規トラックや RG が未解析の行には積まない(冒頭の却下の理由どおり、移行直後の全曲解析は人が起動する)。 -deep scan で数千行の音声が変わったとき(デコーダの修正等)は track 単位で数千本積むが、scan 完了時の -transcode の自動投入(`enqueue_all_stale`)と同じ扱いで、rg の並列度は CPU コア数・終端のジョブ行は -`[gc].jobs_done_days` で消える。**却下**: scan 完了時に `rg_scanned_at IS NULL` の行をまとめて積む -(未解析の新規トラック・移行直後の全曲まで拾ってしまい、上の「自動投入しない」と矛盾する)。 - -## D-48 ReplayGain のタグ書き込みは通常の編集バッチに乗せる(実装合わせ) - -**決定**(P1-2): - -- **`POST /api/rg/write { selection, description?, skip_pending? }`** で、selection の解析済み - トラックについて DB の `rg_*` を形式ごとのタグに変換した **tags op の編集バッチ**を記録する - (`Editor::prepare_rg_write`)。旧値の記録・DB 先行更新(overlay)・track 単位の tagwrite・ - 事前条件・巻き戻し・キャンセル・リカバリはタグ編集と共通で、専用のジョブ種別も op 種別も持たない。 - preview 段階は無い(値は DB から一意に決まり、ユーザが選ぶものが無い)。反映待ちがあれば 409 - `pending`(`skip_pending` で除外)、書く行も一致済みの行も無ければ 409 `no_changes`、 - `[replaygain].write_tags = false` なら 409 `rg_write_disabled` -- **書くキーは形式ごとに固定し、値の無いキーは消す。** Opus は `R128_TRACK_GAIN` / - `R128_ALBUM_GAIN`(Q7.8、-23 LUFS 基準。SPEC §6 の式)だけを書き、`REPLAYGAIN_*` 4 キーは - 消す(RFC 7845 §5.2.1 は Opus に `REPLAYGAIN_*` を使わないとしている。両方あると基準の違う - 値をプレイヤーが拾う)。他形式は `REPLAYGAIN_TRACK_GAIN`(`+0.00 dB`、小数 2 桁、-18 LUFS - 基準)/ `REPLAYGAIN_TRACK_PEAK`(線形の true peak、小数 6 桁。1.0 を超えうる)/ - `REPLAYGAIN_ALBUM_GAIN` / `REPLAYGAIN_ALBUM_PEAK` を書き、`R128_*` は触らない(Opus 専用で - generic Tag に写像できない)。album の値を持たないトラック(album 無し・2ch 以外)は album - のキーを消す。MP4 / MP3 / APE 等へは lofty の写像(`----:com.apple.iTunes:replaygain_*`、 - `TXXX:REPLAYGAIN_*`)で書く。`OpusHead` の output gain は触らない(SPEC §6) -- **`rg_written_at` はフラグではなく「ファイルの RG タグが解析値と一致していると確認した時刻」。** - 書き込み op の applied に限らず、DB をファイルの現在値へ揃える経路(`sync_track_to_file`: - applied の追随、overlay の解消、外部変更の採用)のすべてで `file_matches` を判定し直し、 - 一致すれば `now`、一致しなければ NULL にする。DB のタグが既に変換結果と一致している行 - (再解析で同じ値になった、前回の書き込みが済んでいる)は op にせず `rg_written_at` だけ立てる - (DB のタグはファイルのキャッシュなので、pending が無ければファイルも一致している)。 - 巻き戻しやユーザの手編集で RG のキーが解析値と違う値になれば自動的に NULL に戻る。 - **再解析(`db::replaygain::store`)で値が 1 つでも変わった行も NULL にする**(時刻が秒単位 - なので、確認と再解析が同じ秒に起きると `rg_written_at < rg_scanned_at` では検出できない)。 - 値が全て同じで確認が有効なら確認時刻を `now` へ進める(同じ値の再解析で未書き込みに落とさない) -- 一覧のフィルタに **`rg_unwritten`**(`rg_scanned_at IS NOT NULL AND (rg_written_at IS NULL OR - rg_written_at < rg_scanned_at)`)を足す。移行直後の「解析済み全曲を書く」は - `filter = {"flags":["rg_unwritten"]}` で表現する。UI の起動ボタンは未着手 - -**理由**: 不変条件 4(タグ書き込みは旧値を記録してから)と 3(タグだけの変更で `audio_version` を -上げない)は編集バッチがそのまま満たす。専用ジョブにすると事前条件・overlay・巻き戻しを二重に -持つことになる。`rg_written_at` を op の属性(書き込みバッチかどうかのフラグ)にすると、巻き戻しや -手編集でファイルの RG タグが変わっても「書き込み済み」のまま残り、UI の半透明表示が嘘になる。 -ファイルの内容から判定すれば不変条件 1(ファイルが正)と整合する。 - -**却下**: 値だけ書いて他のキーを残す(Opus に `REPLAYGAIN_*` と `R128_*` が併存する、mono に -なった曲の古い album gain が残る)。書き込み専用の op 種別 / ジョブ種別。preview → apply の -2 段階(選ぶものが無い)。 - -**追記(P1-13)**: スキャナは外部のタグ変更を取り込んだ行(`tag_hash` が変わった。pending の tags op -がある行は overlay を守るので除く)で `sync_written_at` を呼び、外部ツールが RG タグを消せば -`rg_written_at` を NULL に、解析値と一致する値を書けば `now` にする(`Scanner::with_replaygain_reference` -で基準を受ける)。`tag_version` が進むので Derived の追随(P1-10)が RG タグを `R128_*` へ変換して -埋めるのは P1-10 側の責務。 - -## D-49 アートワークの解決とキャッシュ(読み側。実装合わせ) - -**決定**(P1-3 の読み側): - -- **album のアートワークは同梱カバー画像 → 最初のトラックの埋め込み画像の順。** 同梱画像は - `cover` / `folder` / `front` × `jpg` / `jpeg` / `png` / `webp` を名前 → 拡張子の優先順で 1 つ - (大文字小文字は区別しない。ZFS insensitive と同じ)。埋め込みは構成トラックを - `disc_no` / `track_no` / `rel_path` 順に見て最初に見つかる 1 枚(front cover を優先、無ければ - ファイル内の最初)。形式と寸法はバイト列のヘッダ(`imagesize`)で判別する -- **原画像は元の形式のまま、サムネイルだけ WebP。** `artwork` 表は画像の SHA-256 で一意化し、 - 原画像を `<data>/thumbs/<hex>/orig.<ext>` へコピーする(同じ画像は 1 回だけ。Library には - 何も書かない)。`thumbnail` ジョブ(dedup `thumbnail:<artwork_id>`、並列 4)が ffmpeg で - `256.webp` / `768.webp`(長辺、拡大なし、quality 82)を tmp + rename で作る -- **解決はスキャンの Phase 5(commit の後)で、予約された album だけ。** Phase 4 は行が変わった - トラックの**現在の album と直前まで属していた album**(分割・移動で構成を失った側)の - `artwork_resolved_at` を同じトランザクションで NULL にして再解決を予約する。予約は DB に残るので、 - Phase 5 が cancel・失敗・プロセス停止で終わっても次のスキャンで続きを行う(ジョブの冪等性)。 - Phase 5 の対象はこの予約に加えて、同梱画像の有無・stat(`albums.cover_inode / cover_size / - cover_mtime_ns / cover_ctime_ns`。トラックではないので最速パスでは拾えない。マイグレーション - 0004)が前回と違う album と、参照中の原画像がキャッシュに無い・`artwork.bytes` と長さが違う album - (thumbnail ジョブも原画像の欠損で参照 album を予約する)。同じ長さの破損は incremental では - 見えないが、`put_original` が既存ファイルの SHA-256 を照合して不一致なら置き直すので deep で直る。 - deep は Phase 4 の同じトランザクションで active な album 全件を予約する(commit 直後・Phase 5 の - 前に止まっても残る)。Phase 5 も候補を全件、解決を始める前に予約し(`artwork_resolved_at = NULL`)、 - 成功した album だけが予約を消す。決められなかった album(deep だけを理由に対象になったものを含む)と、cancel・ - DB エラー・panic・プロセス停止で処理できなかった album は予約のまま残り、次の incremental で続きになる。マイグレーション直後の既存 album と、アートワーク無しで - 走ったスキャナの結果も NULL なので次のスキャンで解決する。画像の読み取りは並列、DB 更新は - 1 トランザクション -- **決められない album は状態を動かさない。** キャッシュへ置けない、同梱画像の I/O 失敗、読んで - いる間に同梱画像が変わった(読み前後の stat 不一致)、構成トラックを 1 本でも開けない・タグを - 読めない(先頭が読めなければ「画像なし」も「後続が最初」も確定できない)ときは、旧画像も - `artwork_resolved_at` も据え置いて次のスキャンでやり直す。同梱画像が画像として認識できない - (magic だけ一致して寸法が読めない・0・16,384 px 超)ときだけは埋め込みへ倒し、stat は記録する - (変わるまで読み直さない)。同梱画像は 32 MiB を上限とし(それ以上は画像とみなさない)、読み取りも - 上限 + 1 で打ち切る -- **Phase 4 の commit 後の Phase 5 は run の状態を戻さない。** missing の確定と `scan_runs = completed` - は commit 済みなので、Phase 5 の cancel は「続きは次回」、失敗は `ScanReport.artwork_error` - (警告ログ)にとどめ、scan ジョブは done にする。予約が残っているので取りこぼさない -- **`GET /api/artwork/:hash?size=`** は `size` があれば WebP、無ければ原画像を元の MIME で返す。 - サムネイルが未生成なら原画像へ倒す。倒した応答は `no-cache`(ETag で再検証。後で WebP に - 置き換わる)、それ以外は `immutable`。`GET /api/albums` に `artwork_hash`(hex)を足す -- UI のアルバムグリッドは missing の album を出さず、クリックで一覧を `album_id` に絞る - -**理由**: foobar2000 や SMB クライアントは同梱の cover.jpg を見るので、それを優先すると spindle と -他のプレイヤーで同じ絵になる。ハッシュアドレスにすると同じ画像を持つ数千 album でも原画像と -サムネイルは 1 組で済み、URL が不変なのでブラウザキャッシュを最大にできる。原画像を Library から -都度読む案は、埋め込み画像の取り出しに毎回 lofty のパースが要り、cover.jpg の差し替え後に旧画像を -出せない(将来の巻き戻しに使えない)。ffmpeg は既に必須の外部バイナリで libwebp を内蔵する。 - -**却下**: 埋め込み優先(ディレクトリ単位で他のクライアントが見る絵と食い違う)。`image` クレート -(WebP は可逆のみでサムネイルが大きい)。`webp` クレート(Docker ビルドに C 依存が増える)。 -スキャンのたびに全 album の同梱画像をハッシュする(9,000 album × 数百 KB を毎回読む)。 -サムネイル未生成時の 404(グリッドに壊れた画像が出る)。 - -**却下(追加)**: Phase 5 の対象を「この run で行が変わったトラックの現在の album」から都度計算する -(構成を失った旧 album が対象から漏れ、Phase 5 が途中で止まると次回 incremental では track が -unchanged なので二度と再試行されない)。Phase 5 の cancel / 失敗で run を cancelled / failed に -上書きする(missing_since が立っているのに run が failed という矛盾。SPEC §7.1)。 - -**未決**: 書き側は D-60(埋め込み統一。cover ファイルは書かず、抽出も作らない)。 -参照が無くなった `artwork` 行と `thumbs/` の回収は GC(P1-11)。スキャナが外部の埋め込み画像の -変更を検出するのは tag_hash 経由(`PICTURE` 疑似キー)なので、画像だけ差し替えられた場合も -Phase 5 に入る。 - -## D-50 同一性解決の audio_md5 は要求される分だけ並列で計算する(実装合わせ) - -**決定**(P1-0): - -- `identity::resolve` が md5 を要求する条件を絞る。段 1(inode 一致で `size` も `mtime` も違う)は - **行が `audio_md5` を持つときだけ**要求する(持たなければ照合できない)。段 2 は**移動候補** - (`audio_md5` を持ち、段 1 で claim されず、旧 `rel_path_key` が inventory に無い行)が 1 つも無ければ - md5 を要求しない(計算しても照合相手が無い)。初回スキャン(既存行なし)と移動の無い増分スキャンでは - 要求が 0 件になる -- 要求されうるエントリの集合を `identity::md5_requests`(md5 が無い前提で `resolve` を走らせて要求を - 記録する。実際の `resolve` の要求を含む)で先に求め、スキャナが Phase 3 と同じ並列度で計算してから - キャッシュを引くコールバックで `resolve` を呼ぶ。結果は Phase 3 のフィンガープリント計算にも渡す - (同じファイルを二度デコードしない) -- 進捗コールバックに相を持たせる(`Progress = Fn(ScanPhase, done, total)`。`Md5` は要求があるときだけ、 - `Read` は Phase 3)。ジョブの進捗(`done / total`)は相を区別せず最新値を出す -- ログの既定フィルタ(`RUST_LOG` 未設定時)で `lofty` と `symphonia*` を `error` に落とす。ファイルごとの - WARN(非対応のメタデータ・軽微な破損)は初回 deep scan で数千行になり、読めなかった結果は spindle 側の - ログと `ScanReport.errors` で分かる - -**理由**: P0-14 のリハーサル(ALAC 7,572 本)で初回 deep scan が 75 分かかった主因は、Phase 2 が全エントリの -ALAC / WAV / AIFF を**直列に**フルデコードしていたこと。しかも初回は既存行が無いので、その md5 は何とも -照合されずに捨てられていた(Phase 3 が並列に計算し直す)。要求を絞ると初回・通常の増分で Phase 2 の -デコードが消え、移動があるときだけ未決エントリ分を並列に計算する。判定の結果は変えない -(`md5_requests` は `resolve` の要求の上位集合なので、`resolve` が引くキャッシュに欠けは無い)。 - -**却下**: Phase 2 の md5 を丸ごと Phase 3 へ回す(段 2 の移動判定に md5 が要るので、判定を Phase 3 の -後に遅らせる大改造になる)。段 2 の要求を移動候補の md5 と一致しうるエントリに更に絞る(md5 を計算する -前に一致は分からない。候補の有無で 0 件にできれば十分)。 - -**計測**(2026-09-17、リハーサル環境 9,098 トラック / ALAC 7,570 本、12 コア): 空 DB からの初回 -deep scan 3,583 秒 → **552 秒**。既存 DB への deep scan 554 秒(Phase 2 の要求 0 件、Phase 3 が -並列にフルデコード)。変更なしの増分は 1 秒未満。 - -**未決**: deep scan は Phase 3 で全エントリのフィンガープリントを計算し直すので、可逆のフルデコードは -残る(deep の定義そのもの。並列度分だけ短縮)。P1-4 の正規化で ALAC が FLAC になれば、deep でも -STREAMINFO の MD5 を読むだけになる。 - -## D-51 Derived の生成と追随はトラック単位の `transcode` ジョブが現在値に揃える - -**決定**(P1-10): - -- **`transcode` ジョブ(payload `{track_id, audio_version, tag_version}`、dedup - `transcode:<track_id>:<audio_version>`、並列 cpus-1、基盤が `audio_version` で stale 判定して track - lock を取る。SPEC §8)は「そのトラックの Derived を現在の状態に揃える」ハンドラ。** D-42 の - payload `kind` は解釈しない(廃止)。ハンドラは毎回現在値から必要な処理を判定する - (`domain::derived::plan`): 非可逆 / missing / 1ch・2ch 以外(チャンネル数不明を含む。属性を読めなかった - ファイルはマルチチャンネルかもしれない)は no-op(Derived を作らない。D-8 / D-22)。 - `derived_files` が無い・`src_audio_version` が違えば再エンコード。音声版が一致していて `rel_path` - が期待値(Library の `rel_path` の拡張子を `.opus` に替えたもの)と違えば Derived を rename - (元が無ければ再エンコード)。`src_tag_version` / `src_artwork_id` / `src_rg_scanned_at` のどれかが - 違えばタグ・画像だけ上書き(Derived が無ければ再エンコード)。全部一致なら no-op。 - Library の FD は開いた直後とエンコード後に fstat して DB の行と照合し、違えば失敗(再スキャン - 後の再試行で通る。パスは識別子ではない) -- **投入契機は 4 つ**で、すべて `db::derived::enqueue_if_stale`(上の判定に当たるときだけ投入)に - 集約する。(1) **scan ジョブの完了時**に対象を 1 クエリで取り出して一括投入 - (`enqueue_all_stale`。初回は可逆全曲、以後は差分。外部の移動・音声差し替え・カバー差し替え・ - 実行中に dedup で落ちた分もここで拾う)。(2) tagwrite が applied になったトランザクション - (D-42 の `enqueue_derived_retag` を置き換え)。(3) rename が applied になったトランザクション。 - (4) RG 解析値の保存と同じトランザクション。**タグ版に乗らない 2 つの世代を `derived_files` に - 持つ**(マイグレーション 0005): `src_artwork_id`(埋めた画像。NULL = 画像なし)と - `src_rg_scanned_at`(埋めた `R128_*` の元になった `tracks.rg_scanned_at`。NULL = 未解析で書いた)。 - これが無いと RG の解析やカバーの差し替えが Derived に届くのは次のタグ編集まで。`src_artwork_id` に - 記録するのは album の `artwork_id` ではなく**実際に埋めた**画像の id(原画像がキャッシュに無くて - 画像なしで書いたら NULL。album の id を書くと、スキャンが原画像を復旧しても同じ SHA-256 → 同じ id - なので不一致が出ず、画像なしのまま固定される)。`rg_scanned_at` は値が変わる再解析では前回より - 必ず大きくする(同じ秒でも世代が進む。`db::replaygain::store`) -- **エンコードは `media::encode::OpusEncoder`(`FlacEncoder` と同型)。** Library の FD を - `/dev/stdin` で ffmpeg に渡して `<data>/tmp` の WAV へ(ビット深度に合う `pcm_s*le`、無ければ - `pcm_s24le`)→ `opusenc --bitrate <derived_bitrate> --vbr --music --discard-comments - --discard-pictures` → lofty で Vorbis Comment と画像を書く → Derived の宛先ディレクトリの tmp - (`create_tmp`)へコピー → `replace_file`。`derived_files` の upsert はファイル配置後 -- **タグは Library ファイルの `TransferTags` をそのまま写し、RG だけ DB の解析値から `R128_*` へ変換して - 差し替える**(`REPLAYGAIN_*` は消す。SPEC §7.6「再解析しない」。解析前なら RG タグは書かない)。 - **画像はトラック自身の `artwork_id`、無ければ album の `artwork_id` の `768.webp`**(D-61 で改訂。 - P1-3 のキャッシュ。無ければ thumbnail ジョブと同じ変換で作る。原画像も無ければ画像なしで作り、 - 次のスキャンが原画像を復旧したときに `src_artwork_id` の不一致で書き直される)を `image/webp` の - front cover 1 枚として埋める -- **Derived の削除は行わない。** missing は可逆(SMB 切断)なので Derived を残し、 - `retention_days` 超の回収と孤児ファイルの回収は GC(P1-11)。期待パスを**別トラックの** - `derived_files` 行が占有しているとき(A を消して B を A のパスへ移した: B は inode で追随し、A は - key を明け渡して missing になるが、A の Derived の行は残る)は、占有側が missing ならその行を消して - 上書きする(Derived は再生成物でユーザデータではない)。占有側が生きていれば failed(バックオフで - 再試行。A が生きたまま別のパスへ移り、その跡地に B が来た場合で、A の transcode が先に Derived を - 動かせば次の試行で通る。占有側のファイルは占有側のロックを持たないので触らない)。**占有の確定は - 物理的な書き込みの前に 1 トランザクションで行う**(`claim_path`: 読んで missing なら行を消す、まで - 同じトランザクション)。scanner は track lock を取らないので、確認と書き込みの間に A が別のパスで - 復活しうる。先に行を消しておけば、復活した A は「Derived 無し」として自分の期待パスに作り直すだけで、 - B が置いたファイルを A のパスへ動かすことはない。**期待パスの canonical key は `derived_path_locks` - (マイグレーション 0005)で排他予約してから触る**(encode / move / retag の全経路。別のジョブが - 持っていれば試行回数を数えずに再キュー、終了時に解放。持ち主が `running` でなくなれば無効、 - 起動時リカバリで全件消す)。`track_locks` は track 単位なので、`x.flac` と `x.wav` のように別の - Library パスが同じ期待パスに写る 2 本の並走や、先に走っている retag が後から同じパスを置き換える - ことを防げない。予約があれば、並走は直列化されて負けた方が「生きているトラックが持っている」で - 失敗し(勝者のファイルは上書きされない)、retag 中の宛先を別のトラックが claim することもない -- **Library の swap / 循環 rename は自己退避で解く。** 期待パスを**生きている**トラックが持っていて、 - それがその相手の期待パスでもない(相手も追随待ち)ときは、自分の Derived を同じディレクトリの - 一時名 `<名前>.moving-<track_id>` へ退避して行のパスもそこへ向け(音声版が古くても退避する。次の - 試行の Encode が置き換えて退避ファイルを消す。実体が無ければ行を消して key を明け渡し、次の試行は - Encode)、相手の transcode が queued / running なら再キュー(試行回数を数えない)、いなければ失敗 - (バックオフ。次の scan で両方投入される)。 - 相手はそれで自分の元パスへ移れ、自分は次の試行で相手が空けたパスへ移る(rename バッチの 2 段階と - 同じ考え方。相手のファイルは相手のロックを持たないので触らない)。相手の期待パスでもあるとき - (`x.flac` / `x.wav`)だけが本当の衝突で、失敗にする。退避名は起動時回収の対象外(行が指している)で、 - クラッシュで行が追随しなかった退避ファイルは GC が孤児として回収する -- **SIGKILL / 電源断で残った作業ファイルは起動時に回収する**(`transcode::sweep_tmp`。ワーカー起動前)。 - `<data>/tmp` の `spindle-transcode-*` / `spindle-normalize-*` と Derived の `.spindle-tmp-*`。単一 - インスタンスで起動前は何も走っていないので年齢を見ずに消す。Derived は scan の対象外なので - scanner の猶予付き回収は効かない -- `delivery` ビューは変えない(D-25) - -**理由**: ハンドラが「現在値に揃える」1 種類だと、retag / move / 再エンコードの区別を投入側が -正しく判定する必要がなく、投入が重複しても(dedup は `audio_version` 単位)最初の 1 本で全部 -片づく。冪等性(電源断・再投入)も同じ判定で済む。scan 完了時の一括投入にしたのは、旧 `Opus/` を -捨てた移行直後は Derived が空で(D-45)、手動の起動を待つ理由がないから。画像を album の WebP -サムネイルにするのは、Android 側に cover.jpg のミラーが無く埋め込みが要る一方、原画像(数百 KB〜 -数 MB)を 7,500 本に埋めると Derived が数 GB 太るため。長辺 768 の WebP は数十 KB で足りる。 - -**却下**: `kind = "retag"` / `"move"` / `"encode"` を投入側で決める(判定が 2 か所になり、投入後に -状態が変わると誤る)。Derived を 1 本の同期ジョブで全曲処理する(並列度と stale 判定を自前で持つ -ことになり、SPEC §8 の `transcode` と二重になる)。トラック自身の埋め込み画像を写す(同梱 -cover.jpg 優先の D-49 と食い違い、原寸のまま太る)。missing になった時点で Derived を消す -(SMB 切断のたびに再エンコードが走る)。手動 API のみ(移行直後の 7,500 本を誰も投入しない)。 - -**計測**(2026-09-17、リハーサル環境 ALAC 7,570 本、12 コア、`transcode` 並列 11): 起動時スキャンの -完了で 7,570 件を一括投入し、39 分で全件完了(failed 0)。Derived 30 GB。投入自体は 1 トランザクションで -0.14 秒。エンコード中のロードアベレージは 13 前後で、他のコンテナと同居する NAS では並列度を設定で -落とせるようにする余地がある(SPEC §8 の cpus-1 固定のまま)。 - -**追記(2026-09-20。P4-7 / P4-8)**: D-75 が次を上書きする。`transcode` の処理単位は -`(track_id, variant)` で dedup キーは `transcode:<track_id>:<variant>:<audio_version>`(SPEC §8)、 -`derived_files` は系統ごとに 1 行、`aac` 系統は非可逆も対象(D-8 追記)で -RG の解析世代の差分は Retag でなく Encode、`delivery` は `opus` 系統に固定、`has_derived` は `opus` の -行の有無。判定の入力に `audio_profile` / `tag_profile`(設定の世代)が加わる。それ以外(判定を -ハンドラが現在値から下す、退避経路、投入経路)はこの決定のまま。 - -**閉じた未決**(2026-09-18、リハーサル環境 9,098 トラックの実データで需要を確認。出てきたら再開): -- マルチチャンネルのダウンミックス(SPEC §7.6): 全トラックが 2ch。DSD / SACD も非対応方針で、 - マルチチャンネルが入る経路が無い。作るなら `tracks` に列を足し、対象判定と `-ac 2`、RG 集計の扱いを決める -- 非可逆の `force_transcode`(SPEC §7.6、D-15): 非可逆は Opus 1,512 / MP3 14 / AAC 2。Opus は変換の - 意味が無く、残りは 16 本で容量逼迫の解決にならない -- Derived 側の `cover.jpg` ミラー: 全件に WebP を埋め込み済み(トラック単位の画像は D-61)。Library は - D-49 で同梱ファイルを書かない方針なので、Derived にだけ書くと方針が割れる。埋め込みを読まない - Android プレイヤーが出てきたら再検討 - -**未決**: `transcode` の並列度の設定化。進捗表示(ジョブ画面の件数で代替)。transcode が**実行中**に -scan が同じトラックの版を進めた場合、その scan の一括投入は dedup で落ちる(ハンドラが記録するのは -読み始めた時点の版なので不一致は残り、次の scan で投入される)。 - -## D-52 再生は原本か Derived を Range で直送し、オンザフライ変換は Derived が無いときだけ - -**決定**(P1-9): - -- **`GET /api/stream/:id`** は Library の原本を Range 対応で返す(単一範囲、206 / 416、 - `Accept-Ranges: bytes`、HEAD、`ETag` は `(inode, size, mtime_ns)`、`Content-Type` は codec から)。 - ファイルは `RootDir::open_file`(dirfd 基準)で開き、**開いた FD の fstat が DB の行と一致する** - ことを確かめてから返す(パスは識別子ではない。同名で差し替えられていれば 409 `stale`。次の - スキャンで直る)。missing は 404 -- **`?transcode=opus`** は `delivery` ビューが Derived を指す(音声版が一致)なら **Derived の Opus を - 同じ Range 対応で直送**する。SPEC §11 の「変換結果はキャッシュしない(Derived と役割が重複)」は、 - Derived がそのキャッシュだという意味。Derived が無い・音声版が古いときだけ ffmpeg を stdout - パイプで起動して Ogg/Opus(libopus 128k)を chunked で返す。このときは Range に応えず、 - `?start=<秒>` を `-ss` に渡してその位置から返す。子プロセスはクライアントの切断(本文の drop)で - group ごと kill する。上限はトラック長 + 猶予(60 秒)で、本文のストリームが `Sleep` も poll する - ので stdout が止まったままでも期限で起きて打ち切る(本文はエラーで終わり、子は kill)。stdout の - EOF 後は終了コードを検査し、非ゼロなら本文をエラーで終える(200 の途中で切れる形になるが成功と - 区別できる)。malformed な Range(数値でない・桁あふれ・逆順・複数範囲)は無視して 200、構文は - 正しいが範囲外のときだけ 416。`If-None-Match` は `*` と弱比較(`W/`)と並記を扱い、304 にも - `ETag` / `Cache-Control` を付ける -- **クライアント能力は再生時に URL で選ぶ。** 起動時に `canPlayType()` で codec ごとの可否を - 持ち、再生できない codec なら `?transcode=opus`。サーバへ能力を通知して保存する形(SPEC §11)は - 取らない(状態を持たないほうが単純で、同じ結果になる)。**可逆はブラウザが再生できても既定で - Derived の Opus を使う**(帯域。設定「可逆は原本を再生」を localStorage に持ち、原本を選ぶと - Range 直送) -- **RG はクライアントで掛ける。** `GET /api/tracks` の行に `rg { track_gain, track_peak, album_gain, - album_peak }`(内部表現 -18 LUFS 基準の dB、未解析なら null)を足し、UI は Web Audio の - `GainNode` で `10^(gain/20)` を掛ける。peak でクリップしないよう `min(10^(gain/20), 1/peak)`。 - モードは off / track / album の 3 択(foobar2000 と同じ。localStorage `player.rgMode`)。album は - `album_gain` / `album_peak` を使い、`album_gain` が無い(単独曲・未集計)行は track に倒す - (foobar の "album, fallback to track")。peak も同様に `album_peak` → `track_peak` -- **UI**: 表の行先頭に ▶(ホバーで表示)を置き、その行から**表示中の順序で連続再生**する。 - 下部バー左に ▶ / ‖、シーク、経過 / 総時間、音量、RG モード(off / track / album)、曲名。`transcode=opus` を要求しても - Derived の直送か ffmpeg の chunked かはクライアントには応答からしか分からないので、`loadedmetadata` - で duration が有限なら `<audio>` のネイティブシーク(Range)、無限 / NaN なら chunked として - `start=` で読み直して表示時刻をオフセットする。metadata 前のシークは保留して確定後に適用する。 - 次曲は表の現在の順序で決め、末尾で未読なら読んでから続け、フィルタ・ソートの変更で現在曲が表から - 消えたらその曲で止まる。ユーザの play / stop は自動継続の待ちを必ず打ち切る - -**理由**: P1-10 で可逆全曲の Derived が揃うので、再生の大半は静的ファイルの Range 配信で済み、 -ffmpeg の起動はライブラリに加わった直後の短い期間に限られる。ffmpeg の chunked 出力はシークが -できない(`-ss` の再起動で代替)ので、Range が効く Derived を優先する。RG をクライアントで掛けるのは、 -サーバ側でゲインを掛けると原本の直送ができなくなり、Derived にも `R128_*` が入っていてブラウザは -それを読まないため。 - -**却下**: 変換結果のサーバ側キャッシュ(Derived と二重)。サーバへの能力通知(セッションに状態を -持つ理由が無い)。`<audio>` の `volume` で RG(0..1 なので正のゲインを掛けられない)。 - -**閉じた未決**(2026-09-18): Safari 向けの AAC 変換(`transcode=aac`)。Safari 18.4(2025-03。 -iOS / iPadOS / macOS)以降は Ogg Opus をネイティブ再生でき、FLAC / ALAC はもともと再生できる。 -実機の Safari で Derived の Opus と Opus 原本の再生を確認したので作らない。Opus 不可のブラウザが -必要になったら ffmpeg の aac エンコーダ → ADTS で足せる。 -ハイレゾのサンプルレート変換。可逆は既定で Derived の Opus(48 kHz)を -再生するので、96 kHz(実データで 64 本)がそのまま流れるのは「原本」を選んだときだけで、ブラウザは -そのまま再生できる。原本を選んだ人にリサンプルを掛けるのは逆なので作らない。SPEC §11 の -「クライアント設定で変換可」は既定の Derived 再生がその役割。 - -## D-53 手動プレイリストと m3u8 の書き出し・取り込みの固定値と境界 - -**決定**(P1-6。仕様 SPEC §9 / §10 / §12 が定めていない値と境界): - -- **1 プレイリストに同じトラックは 1 回だけ。** 追加・取り込みで既にある・入力内で重複する - トラックは黙って飛ばし、件数(`skipped` / `duplicates`)だけ返す。`playlist_items` の主キーは - `(playlist_id, position)` で重複を許す形だが、表の行 = トラックの同一性(選択・一括編集・ - `sort=position` の 1:1 JOIN)を崩さないために使わない。`position` は 0 から連続で、追加・除外・ - 移動のたびに全行を振り直す(高々数千件) -- **並びは表と共通。** 項目一覧の API は作らず、`GET /api/tracks?filter={"playlist_id":N}&sort=position` - で取る。`position` は `filter.playlist_id` と組でだけ有効(単独は 400)。`playlist_items` を JOIN して - `(playlist_id, position)` の主キー順で読むので temp B-tree は出ない。UI はプレイリストに入ったら - `position` 昇順、出たら既定ソートへ戻す。並べ替え(行のドラッグ)は `position` 昇順のときだけ -- **移動は「集合を `before` の直前(null なら末尾)へ」。** 移動する側は現在の相対順を保つ。 - `before` が集合の中・プレイリストに無いときは 400 -- **書き出し先は `Playlists/<profile>/<name>.m3u8`。** foobar / android / internal の 3 プロファイルが - 同名で衝突しないよう、プロファイル名のディレクトリに分ける。`Playlists/<profile>/` は `Library/` と - 同じ深さなので相対パスは SPEC §10 どおり `../../Library/…` / `../../Derived/…`。旧ライブラリから - 移した `Playlists/m3u8/` は取り込み元として残す(書き出しはそこへ戻さない)。書き出しは tmp + rename、 - `playlist_exports (playlist_id, profile_id)` に `out_path` と時刻を記録する(改名後の書き出しは - 新しい名前のファイルで、古いファイルは消さない。GC の対象にもしない) -- **プレイリスト名はそのままファイル名になる**ので、`RelPath` の 1 要素と同じ規則(`/` `\` NUL 不可、 - SMB / exFAT の禁止文字・予約名・末尾ドット)で名前単体と `<name>.m3u8` の両方を検証し(長さの - 255 バイトは拡張子込み)、前後の空白は落とす。**一意性は `name_key`(`canonical_key` = casefold + - NFD。マイグレーション 0006)で判定する。** ZFS の insensitive + formD では `Foo.m3u8` と `foo.m3u8`、 - NFC と NFD の同名が同じ実体なので、`name` の BINARY UNIQUE だけでは別プレイリストの書き出しが - 上書きし合う。違反は 409 `duplicate`。既存行の backfill はマイグレーション実行時に登録する SQL 関数 - `spindle_canonical_key()`(Rust の `canonical_key` そのもの。`lower()` は ASCII しか畳まない)で行い、 - 同じ key の行が既にあれば id 最小の 1 本を残して後続を空いている `<name> (n)`(n = 2, 3, …。他の - 全行の key と二次衝突しないもの、`.m3u8` 込みで 255 バイトに収まるよう名前側を削る)に改名する - (消さない。SQL では書けないので `db::migrations::post_sql(6)` が同じトランザクションで行い、 - UNIQUE INDEX もそこで作る) -- **m3u8 は `#EXTM3U` + `#EXTINF:<秒>,<Artist - Title>` 付き、UTF-8 BOM なし、LF。** missing の - トラックは書かず件数(`skipped_missing`)で返す。`delivery` プロファイルは `delivery` ビューの - `path`(音声版が一致する Derived、無ければ原本) -- **`GET /api/playlists/:id/export?profile=` は本文を返し、`POST` が書く。** GET は trusted CIDR の - allowlist(D-27)に入っているので、他プレイヤーが curl で取れる。POST と import は Playlists root が - 無ければ 503 -- **取り込みは Playlists root 下のファイルから**(`GET /api/playlists/import` が `.m3u8` / `.m3u` を - 深さ 4 まで列挙、`POST { path, name? }` で 1 本を手動プレイリストにする)。行の解決は - `\` → `/` にしてから、絶対パス(`/…`、UNC `//host/share/…`、ドライブレター `C:/…`)なら - `/Library/` `/Derived/` のうち文字列中で最も早く現れるものの直後から取り、**無ければ root の外として - 解決しない**(偶然同じ - rel_path があっても誤一致させない)。相対なら先頭の `./` `../` を剥がし先頭の root 名を落とす。 - root 相対にしてから `rel_path_key` の完全一致 → 拡張子を除いた stem の一致(旧 `Opus/` の `.opus` 行が - 今の `.m4a` / `.flac` に当たる)。stem の候補が複数なら active を優先し、**その中でまだ複数なら曖昧と - して解決しない**(同じ stem の `.flac` と `.m4a` が両方 active のような状態でどちらかを黙って選ばない)。 - 解決できない行は応答の `unresolved` に返すだけで DB には残さない。名前の既定はファイル名の stem -- **自動再書き出し(ライブラリ変更をトリガにデバウンス)は P1-7 で**(スマートプレイリストが - 前提。手動は明示の書き出しだけ)。`auto_export` 列は PATCH で切り替えられるが今は何も駆動しない - -**理由**: 旧ライブラリの `Playlists/m3u8/` 28 本は foobar 由来で、`../Anime/…/1.01. Crow Song.opus` の -ように旧 `Opus/` 相対かつ拡張子が今の Library と違う。パスの完全一致だけでは 1 本も当たらないので -stem で当てる。プロファイル別のディレクトリにするのは、SPEC の `Playlists/m3u8/` 1 段だと 3 つの -出力先が同名で上書きし合うため。 - -**却下**: 同じトラックの複数回収録(表の行の同一性が崩れる)。項目一覧の専用 API(表の集合は -サイドバーの scope で差し替えるだけという SPEC §12.1 の原則に反する)。取り込みを画面からの -ファイルアップロードにする(ファイルは既に Playlists root にある。SMB を開かずに済む)。 - -## D-54 スマートプレイリストは評価結果を materialize し、常駐タスクが再評価・再書き出しする - -**決定**(P1-7。仕様 SPEC §9 / §10、docs/DSL.md、D-16 / D-39 が定めていない値と境界): - -- **評価結果は `playlist_items` に書く(materialize)。** ルール(`rule_ast`)が正で、項目はその - キャッシュ。ORDER BY / LIMIT を適用した並びをそのまま `position` にするので、表の - `playlist_id` scope・`sort=position`・m3u8 の書き出しは手動プレイリストと同じ経路で動き、 - LIMIT / ORDER BY と表のキーセットページングが衝突しない。smart への項目の追加・除外・移動は - 409 `smart`、manual へのルール差し替え・再評価は 409 `manual` -- **`added`(初回スキャン日時)は `tracks.added_at`**(マイグレーション 0007。既存行は `seen_at` で - backfill。スキャナが INSERT 時に設定し、復活でも変えない) -- **プレビューは id 無しの `POST /api/playlists/preview { rule }`**(構文・型の検証と評価件数、AST)。 - SPEC §9 の `POST /api/playlists/:id/preview` は「保存前のルールを試す」用途に合わないので - 置き換える。表でのプレビューは D-39 どおり `filter.dsl`(WHERE だけ。ORDER BY / LIMIT は無視し、 - `Filter::parse` で構文・型を検証して 400)。`POST /api/playlists/:id/refresh` で手動再評価 -- **自動再評価・再書き出しはプロセス内の常駐タスク**(`playlist::autoexport`)。`jobs` 表の - `type` CHECK が固定でジョブ種別を足せず、ルールが正なので永続化する理由も無い。`library` - イベント・終端の `batch` イベント・完了した job を合図に `[export].autoexport_debounce_sec` - だけ待ち(その間の合図はまとめる)、全 smart を再評価して並びが変わったものだけ書き直し、 - `auto_export = 1` で書き出し記録のあるプレイリスト(手動も)を記録済みプロファイルへ書き直す。 - 起動時にも 1 回走る。`auto_export = 0` は再書き出しの対象外(再評価はする)。項目が書き換わった - プレイリストは SSE `playlist { playlist_ids }` で知らせ、UI は一覧の件数と(表示中なら)表を取り直す - (UI は元の `library` イベントを即時に処理済みで、デバウンス後の書き換えを知らないため) -- **プレイリストへの書き込み API は 1 トランザクション。** 複合 PATCH(name / auto_export / rule)は - どれかが通らなければ何も変えず、ルールの差し替え・作成は評価と materialize まで含めて原子的 - (評価の実行時失敗のうち `regexp()` 由来 — バックトラック上限など。SQLite は UDF のエラーを文字列で - しか返さないので接頭辞 `regexp: ` で見分ける — は 400 `rule_failed` で巻き戻す。それ以外の SQLite - 障害は 500 のまま)。 - `rewrite_items` は SAVEPOINT で囲み、外側のトランザクションの有無によらず原子的 -- **キーワードには語境界がある**(`ISLAND` を `IS` + `LAND` と読まない。`)AND` や `IS"x"` の記号の - 隣接は通る)。`LIMIT` は 1..=i64::MAX、`added` の日付は暦日として妥当な 1..=9999 年だけ -- **SQL 生成の固定値**: `IS` は `COLLATE NOCASE`、`HAS` は `LIKE`(メタ文字はエスケープ。どちらも - ASCII の大小だけ畳む。日本語の casefold まではしない)、`MATCHES` は全コネクションに登録した - `regexp(pattern, text)`(fancy-regex。スレッドごとにコンパイル済みをキャッシュ)、`GREATER` / - `LESS` は数値フィールドと `date`(辞書順)/ `added`(`YYYY-MM-DD` は UTC 0 時か epoch 秒)だけ、 - `duration` は秒(`IS` は丸めた秒)。真偽(`lossless` `has_derived` `missing`)は `IS true / false` - だけ。任意タグは `EXISTS (track_tags …)` で多値のどれかが一致すれば成立。`NOT` は `coalesce(…, 0)` - で NULL を偽に固定。ORDER BY は NULL を末尾、文字列は NOCASE、`t.id` でタイブレーク -- キャッシュ列以外のフィールド名は `track_tags` のキーとして大文字化して引く。存在しないタグ名は - 単に一致しない(エラーにしない。foobar と同じ) - -**理由**: スマートを仮想(毎回 SQL に展開)にすると、表のキーセットページングと LIMIT / ORDER BY -が両立せず、書き出しも別経路になる。materialize なら P1-6 の機構をそのまま使え、評価コストも -ライブラリ変更のたびに 1 回で済む(9,098 曲で数十 ms)。 - -**却下**: 仮想スマート(上記)。ジョブとしての再評価(CHECK 制約の変更に表の作り直しが要る)。 -`IS` を canonical key で比較(タグの値に key 列が無く、全行の関数評価になる)。 - -`HAS` の語境界は foobar 実機と突き合わせ済み(部分一致で一致。D-55)。foobar クエリへの変換は D-55。 - -## D-55 foobar クエリ変換は変換できない項を落として notes に出し、プロファイルは固定 - -**決定**(P1-8。仕様 SPEC §10「エクスポート形式」「foobar クエリへの変換」、docs/DSL.md が定めて -いない値と境界): - -- **変換は AST → `{ query, sort, notes }` の純粋関数**(`playlist::fb2k`)。`GET /api/playlists/:id/fb2k_query` - が保存済みの `rule_ast` を変換して返す(smart のみ。manual は 409 `manual`)。SPEC の `.txt` 出力は - 作らず、UI のダイアログからクリップボードへコピーする -- **写像表**: `albumartist` → `%album artist%`、技術情報は生の値を返す `%__…%`(`%__codec%` - `%__samplerate%` `%__bitrate%` `%__channels%` `%__bitspersample%`。`%channels%` は mono / stereo の - 表示文字列になるので比較・ソートに使えない)、`duration` → 特殊フィールド `%length_seconds%` - (技術情報でないので `PRESENT` / `MISSING` は変換不能)、その他の標準タグ・任意タグは同名。 - spindle 固有(`verification` `category` `source_type` `lossless` `added` `has_derived` `missing`)と - `MATCHES` は変換不能 -- **変換不能な項は落とし、`notes` に原文(spindle の記法)と理由を残す。** 落ちて空になった `AND` / - `OR` と、子が落ちた `NOT` も落とす。全部落ちたら `query` は空で、その旨も `notes` に出す。 - `OR` の 1 項が落ちると結果は狭まるが、黙って別の条件に置き換えるよりは `notes` で見せて手で - 直してもらう方が安全 -- **演算子**: `PRESENT` / `MISSING` は foobar の後置形、`date` の `GREATER` / `LESS` は `AFTER` / - `BEFORE`。値は空白・括弧・`"` を含むか予約語と同じなら二重引用符で囲む。`"` を含む値は foobar 側で - エスケープできないので `notes` に警告。複合式の子は常に括弧で囲み、foobar 側の優先順位に頼らない -- **`ORDER BY` は `sort`(ソートパターン)へ分離。** ソートパターンは title-format の出力を文字列で - 比べるので、spindle が数値順にするフィールド(`tracknumber` `discnumber` `samplerate` `bitrate` - `channels` `bitdepth` `duration`)は `$num(…,10)` でゼロ埋めする(比較には付けない)。降順・ - `random`・`LIMIT` は表せないので `notes` -- **`delivery` の書き出しは `stale_tags`(タグ追随待ちの Derived)を件数で返す**(`POST …/export` の - 応答。自動再書き出しはログ)。追随ジョブの完了を待つ方式は取らない(書き出しがジョブ待ちで止まる - 方が運用上困る。追随が終われば次の合図で書き直される) -- **エクスポートプロファイルは 3 つ固定で CRUD の API は持たない。** 実運用で変えたいのは foobar の - UNC prefix だけなので `[export].fb2k_prefix` を正とし、起動時に `export_profiles` の `foobar` 行の - `path_prefix` を揃える(`db::playlists::sync_foobar_prefix`)。`.pls` も作らない(`format` の CHECK に - 列挙だけ残す) - -**理由**: Autoplaylist はファイルで渡せないので、ユーザが貼れる 2 本の文字列と「何が落ちたか」が -あれば足りる。技術フィールドまで写すのは、`%codec% IS flac` のような条件がライブラリの整理で -普通に出てくるため。 - -**却下**: 変換不能な項を `%path% HAS` などへ近似する(`category` はディレクトリ名と一致するが -部分一致で誤ヒットする)。プロファイル CRUD(3 つ以外の出力先が無い)。`.txt` の書き出し -(Playlists root に置いても foobar はそこから読まない)。 - -**実機との突き合わせ**(2026-09-19、Windows の foobar2000 で確認。変換器の変更は不要): -- `%title% HAS ab` は **部分一致**(`xaby` に当たる)。spindle の `LIKE %v%` と同じ -- 値の二重引用符は挙動を変えない(`HAS "ab"` は `HAS ab` と同じ)。**単一引用符は文字通りの値になり - 一致しなくなる**ので、変換器は値を囲むのに `"` だけを使う(`'` で囲まない) -- `%__bitspersample% MISSING` / `PRESENT` は技術情報フィールドにも効く(非可逆 / 可逆の件数と一致) -- `%codec% IS flac` も期待どおり - -## D-56 GC は 5 区分を 1 本のジョブで回収し、dry-run は同じ計画関数を API が同期で返す - -**決定**(P1-11。仕様 SPEC §6「論理削除」/ §8 / §13 `[gc]`、D-45 / D-46 / D-49 / D-51 / D-53 が -GC に委ねた事項の固定値と境界): - -- **物理削除を行う唯一の経路は `gc` ジョブ**(並列 1、dedup key `gc`、payload `{}`)。対象は 5 区分: - | 区分 | 対象 | 判定 | 物理削除 | - |---|---|---|---| - | A | missing トラック | `missing_since <= now - retention` **かつ Library に実体が無い**(`stat` で再確認。あれば次のスキャンが復活させるので飛ばす。symlink 等 NotFound 以外のエラーも飛ばす) | 行のみ。CASCADE で `playlist_items` / `track_tags` / `derived_files` / RG / verifications | - | B | missing アルバム | `missing_since <= now - retention` かつ構成トラック 0(A の削除後に判定) | 行のみ | - | C | Archive の退避ファイル | `archived_files.state='held'` かつ `eligible_after <= now` | `Archive/<rel_path>` を unlink → `state='deleted'`。実体が既に無ければ warn して `deleted`。unlink がそれ以外で失敗したら `held` のまま次回 | - | D | Derived の孤児 | `Derived/` 配下の実体で `derived_files` に `rel_path_key` が無いもの(A で行が消えた Derived もここで拾う)。`.spindle-tmp-*` と **mtime が 24 時間以内**の実体は除外 | unlink。空になったディレクトリも消す(root は残す) | - | E | アートワーク孤児 | `artwork` 行: `albums.artwork_id` から参照されず、`edits` の `PICTURE` 値(旧 / 新)にも現れない(D-60。巻き戻しに要る)。`thumbs/<hex>/` の mtime が 24 時間以内なら行も残す(アップロード直後・参照前を守る)。`thumbs/<hex>/`: 行の無い hex(**mtime が 24 時間以内**の dir は除外。スキャン Phase 5 が原画像を置いてから行を入れるまでの間を守る) | 行を DELETE、dir を再帰削除 | - 触らないもの: Archive 内の台帳に無いファイル、`Playlists/` の古い書き出し(D-53)、Library の - 同梱ファイル(D-43 の残課題)、`data/tmp`(起動時回収の領分) -- **判定(`gc::plan`)と実行(`gc::execute`)を分け、dry-run は `GET /api/gc/preview` が `plan` を同期で - 呼んで返す**(区分ごとの件数・バイト数と先頭 50 件のパス)。ジョブの結果を返す列が `jobs` に - 無く、「ジョブとしての dry-run」は結果をログでしか見られない。`POST /api/gc` はジョブの投入 - (未完了があれば 409 `duplicate`) -- **scan と GC は同じ名前付き排他 `library`(`job_mutexes`。マイグレーション 0009)を取り、取れた - 側だけが走る。** 取れなければ `Outcome::Requeue`(1 秒後に再試行)。check-then-requeue を両側に - 置くだけでは同時に claim されたとき両方が譲り合い続けるので、勝者を DB で一意に決める。排他は - `track_locks` と同じくジョブの終端で自動的に解放され、持ち主が running でなくなれば奪える。 - 起動時リカバリで全件消す。A の行削除がスキャン Phase 4 の commit と、E(dir) の削除が Phase 5 の - 原画像の配置と競合しないため -- **実行順と原子性**: A → B → E(行) を 1 トランザクション(削除の SQL でも `missing_since` / - 参照の無さを再確認する)→ C → D → E(dir)。ファイル削除は 1 件ずつ、失敗はログして続行し、 - 区分ごとの件数・バイト数・失敗数を `info!` で出す(**削除件数のログは必須**)。24 時間の猶予は - in-flight の書き込みと手作業で置いた実体の保護。Derived のサブディレクトリが読めなければその下 - だけ飛ばして続ける(root が読めなければ計画できず失敗) -- **計画と実行の間に状態が変わりうるので、物理削除の直前に 1 件ずつ再確認する**(計画は数秒〜 - 数分前の読み取り。その間に巻き戻し・redo・スキャンの復活・transcode の claim・Phase 5 が動く): - - C: 行がまだ `held` で `eligible_after <= now`、`rel_path` が計画時と同じことを確認し、その台帳の - トラック行が残っていれば**トラックのロック**(`track_locks`)を GC のジョブで取ってから unlink - する(巻き戻しの normalize ジョブと同時に走らない。取れなければ今回は飛ばす)。`deleted` への - 遷移は `UPDATE … WHERE state = 'held'` の CAS。クラッシュで実体だけ消えて `held` が残った行は - 次回 NotFound として `deleted` に進む - - D: その key を指す `derived_files` 行が無いことを確認して **transcode と同じ排他予約** - (`derived_path_locks`。`track_id` は NULL。マイグレーション 0008 で NULL 可にした)を GC の - ジョブで取ってから `stat` し直し、一覧時と inode / mtime が同じときだけ unlink、件ごとに解放する。 - transcode は予約無しに宛先へ書かない(D-51)ので、GC が持っている間は置き換えられない。running - なジョブの予約があれば飛ばし、終わったジョブの残骸は奪う(`lock_path` の規則) - - E(dir): その hex の `artwork` 行が無く(E(行) が参照の出現で skip されたとき・スキャンが行を - 入れたとき)、dir の mtime がまだ猶予を過ぎていることを確認してから消す -- **冪等**: 途中で落ちても次回が続きを拾う(行が消えて実体が残った Derived は D、`held` のまま実体が - 無い Archive は C で `deleted` に進む)。キャンセルは区分の境界と件ごとの進捗で見る -- **起動契機は backup と同じスケジューラ**(起動時と 10 分ごとに「最後の終端 `gc` から 24 時間」で - due 判定)+ 手動 `POST /api/gc`。間隔は定数(`[gc]` に設定を足さない。`retention_days` が 30 日 - なので 1 日 1 回で足りる) -- **UI は作らない**(SPEC §12.6 の設定画面は骨格のみ。API とジョブで受け入れを満たす) - -**理由**: 編集履歴(`edit_ops` / `archived_files` の `track_id`)は意図的に FK にしていないので、 -行を消しても巻き戻しの根拠は残る。Derived は transcode が物理書き込みの前に `derived_files` の -行で期待パスを占有する(D-51)ので「行の無い実体」= 孤児と判定できる。 - -**却下**: ジョブとしての dry-run(結果の置き場が無い)。Archive の台帳に無いファイルの回収 -(Archive は追記のみでスキャン対象外。何が置かれたか分からないものは消さない)。区分ごとに別 -ジョブ(順序に依存がある: A → D、E 行 → E dir)。 - -**未決**: 設定画面からの起動(SPEC §12.6)。Library の同梱ファイルの回収(D-43)。 - -**追記(2026-09-25。保持期間の既定)**: `[gc].retention_days` の既定(`config.example.toml` / SPEC)を 30 → **7 日** -にする(ユーザの判断で保持を短くする)。対象は retention に従う GC の区分すべて(`missing_since` の行、Archive へ -退避した原本、Derived の孤児、D-90 の破棄待ちの Inbox の取り込み)。影響: 正規化で退避した原本を「退避ファイル」から -戻せる期間と、missing の行・破棄待ちを取り消せる猶予も 7 日になる。コードに既定値は無く(設定の必須キー)、 -テストが自前で与える値はそのまま。1 日 1 回の定期実行はそのままで足りる - -## D-57 FLAC 健全性チェックは検査結果を版付きで記録し、MD5 の補填は別タスクにする - -**決定**(P1-5。仕様 SPEC §7.9 / §8 / §13 `[normalize]` が定めていない値と境界): - -- **`flaccheck` ジョブは読むだけ。** payload `{ track_id, audio_version }`(版付き。dedup key - `flaccheck:<id>:<ver>`。ワーカーの stale ゲートで古い版は no-op、`track_locks` で同じトラックの - 書き手と直列化)、並列 CPU コア数。対象は `codec = 'flac'` で active。それ以外は no-op で `done` -- **手順**: root で開いた FD を `fstat` して DB の `(dev, inode)` と照合(不一致は書かずに `done`。 - 次のスキャンで版が進めば再投入される)→ STREAMINFO の MD5 を読む(`media::fingerprint`。全ゼロは - 未設定)→ `flac -t -`(FD を stdin で渡す。タイムアウトは長さに比例、終了コード検査、stderr は - ログ)→ 1 UPDATE で記録(`WHERE audio_version = ?` で版を再確認) -- **結果は `tracks` の列**(マイグレーション 0010): `flac_check`(`ok` / `md5_missing` / - `decode_error`)、`flac_checked_at`、`flac_check_version`(検査時の `audio_version`。現在値と違えば - 結果は古い。スキャナは触らない)、`flac_check_error`(stderr の末尾)。判定は `flac -t` の終了 - コード ≠ 0 → `decode_error`、0 で MD5 全ゼロ → `md5_missing`、0 で MD5 あり → `ok` -- **起動契機**: `POST /api/flaccheck { selection }`(RG と同型。selection の active な FLAC を - track 単位で投入)と、`[normalize].flac_verify_on_import = true` ならスキャン完了時に - 「結果が無い・版が古い」FLAC を全件投入(Derived の追随と同じ場所) -- **一覧**: 行に `flac_check { status, checked_at, stale }`。固定フィルタ `flac_unchecked`(FLAC で - 結果が無いか古い)と `flac_error`(`decode_error`)。UI はバッジとサイドバーのフィルタだけで、 - 起動ボタンは RG と同じく未着手 -- **MD5 の補填(`flac_fix_missing_md5`)は P1-5b に切り出す。** 方式は再エンコードではなく - **STREAMINFO の MD5 16 バイトだけを tmp + rename で書き換える**(デコードして PCM MD5 を計算し - 埋める。圧縮も音声も変わらず `audio_version` 据え置き、inode / mtime は追随)。編集バッチに - 乗せ(`edit_ops.kind` に `md5` を足すマイグレーションが要る)、旧値 = 全ゼロを `edits` に残して - 巻き戻せるようにする。今は実データに FLAC が無く、今後の FLAC は自前の `flac -8 --verify` と - CD リップで MD5 が付くので急がない - -**理由**: SPEC の「再エンコードして補填」は MD5 を付けることが目的で、`flac -8` の再圧縮は禁止事項 -(圧縮レベルを揃える一括再エンコード)と紙一重。16 バイトの書き換えで目的を満たせる。検査は -読むだけなので編集履歴に乗せず、版で古さを表す。 - -**却下**: 検査結果を履歴表にする(一覧のフィルタとバッジには列が素直)。`flac -t` の stderr から -MD5 未設定を読む(版差で文言が変わる。STREAMINFO を直接読めばよい)。normalize の archive op を -FLAC → FLAC に広げて補填する(2,000 行の壊れやすい経路を同じパスへの置き換えに拡張するより、 -専用の小さな op の方が安全)。 - -**未決**: P1-5b(補填)。UI の起動導線(RG と一緒に一括編集パネルへ)。 - -## D-58 UI は foobar2000 の配置に寄せ、ツリーの表示形式はパターンで定義し、絞り込みは album_ids で行う - -**決定**(P1-12。SPEC §12 が定めていない配置・形式と、起動導線の置き場): - -- **配置は foobar2000(macOS、DUI)のユーザのレイアウトに寄せる。** ヘッダ(画面切替・検索・ジョブ要約) - → プレイヤーバー(曲名 / ◀◀ ▶ ▶▶ / シーク / 音量 / RG)→ 本体は左右分割。左はツリー + プレイリスト + - 固定フィルタ、その下にアルバムアート(選択行のアルバム、無ければ再生中)。右は上にプロパティ領域 - (タブ: プロパティ / 一括編集 / 操作)、下に表。左右・上下の境界はドラッグで可変、localStorage に - 永続化。配色は現状のライトのまま(ダークは別タスク。CSS 変数は `:root` に集約済み)。 - 表の既定列は `Playing | Artist/album | # | Title / artist | Duration` -- **ツリーの表示形式は foobar の Album List と同じくパターン文字列で定義する。** `|` が階層、 - `%field%` が差し込み、`[ … ]` は中のフィールドが全部空なら省く(角括弧は条件記号で表示しない。 - foobar の title formatting と同じ)。フィールドはアルバム単位の値(`category` `albumartist` `album` - `date` `year` `original_date` `edition` `disc_count` `rel_dir` `folder`)に限る(`GET /api/albums` の - 行から組む。トラックのタグは無い)。`%folder%` だけは特別で、パターン全体がそれだけのときに - `rel_dir` の階層をそのまま展開する(深さはアルバムごとに違う。他のフィールドや文字と混ぜると - 構文エラー)。Library 直下(`rel_dir` が空)のアルバムは「(なし)」の 1 ノードに載せる(消すと - All Music の件数と合わない)。組み込み 5 形式はプリセット: by folder structure `%folder%`(既定。 - リハーサル DB はカテゴリ未推定で by category が 1 本になるため)/ by category - `%category%|%albumartist%|%album%` / by artist `%albumartist%|%album%` / by album - `%album% — %albumartist%` / by year `%year%|%album%`。ユーザ定義は名前 + パターンで localStorage に - 保存。空になった階層は「(なし)」で末尾、並びは日本語 collator、件数はトラック数の合計 -- **ノードの絞り込みは配下の album id の集合。** `GET /api/tracks` の filter に `album_ids`(配列)を - 足し、SQL は `t.album_id IN (SELECT value FROM json_each(?))` で 1 パラメータにバインドする(件数の - 上限なし。ホワイトリスト経由)。組み込み形式もこの経路に統一し、ルートは filter 無し。selection の - filter 形にもそのまま乗る -- **プロパティタブは Selection Properties の再現。** Metadata(標準タグ + 任意タグ全部)と Location / - General(ファイル・技術情報・RG・検証・FLAC 検査・Derived)。複数選択は共通値、異なれば - `<複数の値>`(先頭 50 件で判定)。Metadata の値のダブルクリック編集は選択全体への 1 op の一括編集 - (インライン編集と同じ preview → apply)。`GET /api/tracks/:id` のセッション向け応答に `detail` - (tags / size / mtime / sample_rate / bit_depth / channels / bitrate / audio_md5 / original_codec / - added_at)を足す。CIDR 経由の限定フィールドは変えない -- **起動導線は操作タブ。** リネーム / 正規化(preview の old → new と衝突理由を一覧 → 適用。pending の - 409 は既存の 2 択)、RG 解析 / RG 書き込み / FLAC 検査(投入件数・重複・skip を通知)、 - プレイリストへ追加。既存 API のみ。ジョブの完了は行の値(RG / FLAC 検査 / Derived)を変えるが - `library` イベントは scan だけが流す(SPEC §9)ので、クライアントは `job` の done / failed で - 表示ページとプロパティの詳細を取り直す(一括 transcode で数千回にならないよう 3 秒に 1 回に - 間引く) -- **ジョブ画面(SPEC §12.5)と設定画面(§12.6)を骨格から実装する。** ジョブ画面は種別ごとの並列度 - (`GET /api/jobs` に `concurrency` を足す)と待ち行列の表、一覧(実行中・待ち / 失敗・取り消し / - すべて、種別で絞る)、`edit_batch_id` から履歴画面のそのバッチを開く。プロパティ領域は一覧のとき - だけ出す(ジョブ / 履歴 / 設定では表の代わりに画面が入るので選択の意味が無い)。設定画面の - `GET /api/config` は `Config` が保持する原文(`source`)をそのまま返し、画面からは書き換えない - (変更はファイルを編集して再起動)。`GET /api/archive` は台帳に `batch_id`(退避した op のバッチ)を - 添え、保持中の行は「バッチ #n を巻き戻す」で履歴画面のそのバッチを開く(復元の API は別に作らない。 - SPEC §7.4 のとおり巻き戻しが復元)。 設定は `GET /api/config` - (読み込んだ `config.toml` の原文。秘密は config に無い)、再スキャン / deep scan、GC の preview → - 実行、`GET /api/archive`(`archived_files` の一覧。復元は対応バッチの履歴から巻き戻し) - -**理由**: ユーザが日常的に使っている foobar の配置に寄せれば、移行後の操作の学び直しが要らない。 -ツリーの形式をパターンにすると組み込みとユーザ定義が同じコードで済み、絞り込みを album_ids に -統一すると形式ごとのフィルタの写像が要らない。 - -**却下**: ノードごとに `category` / `albumartist` の固定フィルタへ写す(合成階層に写せない)。 -ツリーのパターンにトラックのタグを許す(アルバム単位で持っていない。必要なら `GET /api/albums` の -拡張で足す)。ダークテーマ(別タスク)。 - -**未決**: `by genre`(albums にジャンルが無い)。ダークテーマ。 - -**追記**(2026-09-20、上部の圧縮): 本体の上に 4 本(上部ナビ / プレイヤー / 右パネルのタブ / 「選択 N 件」) -あって狭かったので 2 本にした。上部ナビとプレイヤーを 1 本の上部バーに統合し、ジョブ要約のテキスト -(実行中 · 反映待ち · 失敗)は「ジョブ」ボタンの件数バッジ + 失敗の赤点 + ツールチップに畳む(画面切替の -「ジョブ」と同じ内容を 2 か所に出していた)。たまにしか開かない履歴 / 設定とログアウトは ☰ メニューへ。 -検索ボックスは上部バーから左サイドバーの先頭(スクロールしない)へ(foobar2000 の「ツリーの下」は -サイドバー全体がスクロールする spindle では隠れるので先頭にした)。右パネルは「選択 N 件」をタブ行の -右端に寄せ、フィルタ形の選択の説明はツールチップに移した。 - -**追記**(2026-09-20、P4-6): アルバム画面は `GET /api/albums` の全件を出していてツリーの選択・プレイリスト・ -検索語を見ていなかった。プレイリストは album の情報だけでは絞れない(中のトラックが属する album が要る)ので、 -クライアントで絞るのではなく `GET /api/albums?filter=` にトラック一覧と同じ JSON フィルタを受け、「一致する -active なトラックを 1 本以上持つ album」を返す。アルバム画面は「いまのトラック一覧と同じ絞り込みのアルバム」に -なる(検索語でも絞られる)。WHERE の生成はトラック一覧と共有し、列名はホワイトリスト・値はバインドのまま。 -実装で決めた点: `filter` が空・`{}` は全件(active なトラックの有無で絞らない。フィルタ無しと同じ)、指定が -あれば `a.id IN (SELECT t.album_id FROM tracks t WHERE t.missing_since IS NULL AND (<filter_where>))`(`missing` -フラグと組み合わせると空になるが、「表に出るトラックの album」の定義に従う)。web はツリー用の全件と画面用の -絞り込みを別に持ち(`useAlbums(enabled, filterParam, wantFiltered)`)、絞り込みはアルバム画面を表示中で -`filterParam` が空でないときだけ取る(ツリーのクリックごとに数千件を取り直さない)。応答は最新の要求だけを -採用し、結果は「どのフィルタに対する完了か」(成功 / 失敗とも)を持つ。現在のフィルタの完了が無い間(応答待ち) -と失敗時は前のフィルタの結果を出さず全件を出し、失敗は全件の成否とは別に見せる。同じフィルタの取り直し -(library / job イベント)の間は前回の結果を出し、届いたら差し替える。 - -**追記(2026-09-21。P4-10 ダークテーマ)**: 配色をライト / ダークで切り替える。直書きの色はすべて -`index.css` の `:root` の変数に集約し(バッジは色相ごとに `--badge-<hue>-bg` / `-fg` の対、影・モーダル -背景・行の状態色・欠落・スケルトンも変数)、ダークは `:root[data-theme='dark']` の 1 ブロックで同じ変数を -全部差し替える(`color-scheme` も切り替え、フォーム部品とスクロールバーを追随させる)。セレクタ側は変数しか -見ないことを `web/src/lib/theme.test.ts` が固定する(直書きの色が 2 ブロック以外に無い、両ブロックの変数 -集合が一致)。設定は `'light' / 'dark' / 'system'`(既定 `system` = OS の `prefers-color-scheme` に追随し -変化も購読)で localStorage の `spindle:theme` に置き(`lib/theme.ts`、`hooks/useTheme.ts`)、解決結果を -`<html data-theme>` に書く。初回描画の白飛びを防ぐため `index.html` のインラインスクリプトが React より先に -同じ規則で `data-theme` を付ける。切り替えは設定画面の「表示」節(ラジオ 3 つ)。上部バーには置かない -(☰ は履歴 / 設定 / ログアウトで足りる)。`@media (prefers-color-scheme)` を CSS に書かない(保存値が OS より -優先で、判定は JS 側の 1 か所に置く)。ライトの値は従来と同じだが 2 箇所だけ揃えた: 行の再生ボタン -(`.td-sel .play-row`)は未定義変数のフォールバック `#888` だったのを `--muted`(#6b7280)に、編集セルの -不正値の枠は単発の `#d33` を `--error` に。影の 0.15 も `--shadow`(0.12)に統一。ダークの淡色(欠落行・ -未検証バッジ・欠落バッジの文字)は本文 13px / バッジ 11px で、選択行(`--accent-bg`)や反映待ち + 選択 -(`--pending-selected-bg`)の背景に載っても 4.5:1 を切らないよう #c4c9d1 / #9ca3af にし、反映待ち + 選択の -背景は `--muted` の文字が 4.5:1 を保つ #1c3358 にした(codex のレビューで計測。欠落 / 反映待ちの行は -選択できるので、状態の組み合わせで確認する)。 - -## D-59 MD5 の補填は md5 op の編集バッチにし、tagwrite ジョブで反映する - -**決定**(P1-5b。D-57 が「別タスク」とした補填の具体化): - -- **op は `kind = 'md5'`、edits は `key = 'audio_md5'`**(値は 32 桁の hex。全ゼロ = 未設定)。 - `old_value` は記録時の STREAMINFO の値(補填では全ゼロ)、`new_value` は記録時 null で、反映時に - デコードして計算した値を書き戻す。巻き戻しは逆向きの md5 op(old = 計算値、new = 全ゼロ。 - `new_value` があれば計算せずそれを書く)。DB は先行更新しない(overlay 無し。archive op と同じ) -- **反映は STREAMINFO の 16 バイトだけを tmp + rename で書き換える。** 補填は 2 段階: デコードして - 計算した値をまず `edits.new_value` へ耐久化し(別トランザクション)、それから書く。rename 後・ - DB 確定前にプロセスが落ちても、再実行で「事前条件は外れているがファイルは新値」と分かり applied に - 確定できる(冪等・巻き戻し可)。事前条件は実体(dev / inode / size / mtime / ctime)と記録時の - MD5 値。どちらか外れていて新値でもなければ `skipped_conflict`(外部で補填・差し替え済み。 - ファイルが正で、DB を現在値に揃える)。FLAC として読めない・デコードできないは - `failed`(再試行しても直らない)。同じトランザクションで `audio_md5`(全ゼロなら NULL)と - `flac_check`(値あり → `ok`、全ゼロ → `md5_missing`)を揃え、`audio_version` / `tag_version` は - 据え置く(音声もタグも変わらない) -- **ジョブは tagwrite を再利用する。** 契約(そのトラックの pending op をファイルへ反映する。 - `track_locks`・stale ゲート・再試行・最終失敗で op を閉じる)が同じ。`Editor::apply_op` が - op の kind で分岐する。新しい job type を足すと `jobs` 表(4 表から参照)も作り直しになる。 - payload は `{ track_id, op_id, batch_id, unversioned: true }` で dedup key は - `tagwrite:<id>:md5:<op_id>`。`unversioned` は jobs 層の明示的な契約(`db::jobs::UNVERSIONED_KEY`)で、 - 版付き種別でも stale 判定をせず `track_locks` だけ取る(md5 op は tags overlay を持たず版も進めない - ので、補填が queued の間に外部のタグ変更で `tag_version` が進んでも捨ててはいけない)。key を op ごとに - するのは、tags と同じ key だと元ジョブが running のうちに巻き戻したとき逆 op のジョブが Duplicate に - なって走らないため -- **マイグレーション 0011 は `edit_ops` を作り直す。** SQLite は CHECK を ALTER できず、`edit_ops` は - `edits`(CASCADE)と `archived_files`(SET NULL)から参照されているので、FK を切らずに DROP すると - 履歴が消える。runner に「FK を切って適用する版」(`FOREIGN_KEYS_OFF`)を持たせ、トランザクションの - 外で `foreign_keys=OFF` → 適用 → commit 前に `foreign_key_check` → `ON` に戻す(SQLite の 12 手順) -- **API は `POST /api/md5fill { selection, description?, skip_pending? }`**(RG 書き込みと同型。preview は - 無い)。対象は selection の active な FLAC で `flac_check = 'md5_missing'`。`[normalize].flac_fix_missing_md5 = false` - なら 409 `md5_fill_disabled`。UI は操作タブの「MD5 を補填」 - -**理由**: 補填は既存の編集バッチの性質(事前条件 → tmp + rename → DB 追随、巻き戻し)をそのまま -使えるので、専用の小さな op にするのが最も安全。 - -**却下**: `kind = 'tags'` に特別なキーで載せる(履歴の種別が嘘になる)。新 job type `md5fill` -(`jobs` 表の作り直しを伴う)。反映時に `flac -t` で再検査する(全部デコードして計算した時点で -検査と同じことをしている)。 - - -## D-60 アートワークの書き側は埋め込み統一。差し替えは `PICTURE` の tags op で、cover ファイルは書かない - -**決定**(P1-3 書き側。D-49 の「未決」の具体化): - -- **spindle は同梱の cover ファイルを書かない。画像はトラックへの埋め込みに統一する。** ライブラリは - 既に全 album が埋め込みで(リハーサル環境 721 album 全部が `origin='embedded'`、同梱画像 0)、 - album で共通の画像と、トラックごとに画像が違う album(神椿系)が混在しているため、埋め込みに - 統一している。cover ファイルを併用すると正が 2 つになり(cover を置いた後の埋め込みが古いまま - 残る、トラックごとに画像が違う album に誰かが cover.jpg を置くと D-49 の規則でそれが勝つ)、 - 読み側の規則も書き側の機構も 2 系統になる。読み側の同梱画像優先(D-49)は外部が置いたものを拾う - ためだけに残す。埋め込み → cover ファイルの「抽出」は作らない -- **差し替えは `kind='tags'` の op で、キー `PICTURE` を変更する。** 値は `tag_hash` と同じ - `<mime>:<sha256hex>` の配列(無しは null)。`track_tags` は既にこの形で `PICTURE` を持つ - (スキャナが `TagSet` ごと保存する)ので、旧値・overlay・`tag_version`・差分・書き戻し確認 - (`file_matches_new_values`)・巻き戻し(`revert_tags`)は既存のまま効く。新しい op 種別も - マイグレーションも要らない。ユーザの JSON タグ操作(`tagops::parse_ops`)は `PICTURE` を拒否した - ままで、画像 op は `Editor::prepare_picture(description, track_ids, sha256)` だけが作る - (`prepare_tags_with` に「`PICTURE` を `[<mime>:<hex>]` に置く」評価器を渡す) -- **差し替えは「全画像を捨てて、上げた 1 枚を front cover として入れる」。** 裏ジャケ等の別種別を - 残す案は、lofty の画像種別が形式ごとに揺れる(MP4 は種別を持たない)ので確実に動かず、 - ライブラリの実態も 1 枚運用なので採らない。既に同じ 1 枚だけを持つ行は差分なし(unchanged) -- **画像の実体は `ArtworkStore`(`thumbs/<hex>/orig.<ext>`)と `artwork` 行で持つ。** アップロード - (`POST /api/artwork/upload`。生のバイト列、`Content-Type: image/*`、上限 32 MiB = `MAX_COVER_BYTES`。 - 形式と寸法はヘッダで判別し JPEG / PNG / WebP 以外は 400)が新画像を置き、`thumbnail` ジョブを - 投入する(UI のプレビューと album の解決に使う)。**tagwrite は書く前に、捨てる旧画像を同じ - store へ退避する**(`stage_tags` が `PICTURE` の変更を含む op で `read_parts` により実体ごと読む。 - 退避できなければ `failed` で何も書かない。`artwork` 行は `Staged::Written` で持ち帰り、finish_op - と同じトランザクションで upsert)。新画像のバイト列が store に無ければ `failed`(「画像が - キャッシュに無い」)。`AlreadyMatches`(外部が先に同じ画像を書いていた)は退避対象が無いので、 - その op の巻き戻しは「画像がキャッシュに無い」で failed になり得る(正直にそう記録する) -- **`write_tag_changes` は `pictures: Option<Vec<Picture>>` を取り、`Some` なら形式ごとに全画像を - 除去して挿入する**(FLAC は PICTURE ブロック、Opus / Vorbis は VorbisComments、MP4 / MP3 等は - generic Tag)。`None` なら画像に触らない(既存のタグ編集はこれ) -- **GC 区分 E は `edits` が参照する画像を回収しない。** `key='PICTURE'` の旧 / 新値に現れる hex の - `artwork` 行と `thumbs/<hex>/` は、履歴が残る限り巻き戻しに要る(画像は数百 KB なので履歴と同じ - 寿命で良い)。加えて、アップロード直後・参照前の行が消えないよう、E(行) にも dir と同じ - **24 時間の猶予**を付ける(`thumbs/<hex>/` の mtime が猶予内なら行も消さない)。既にある画像の - 再アップロードは `put_original` が dir を touch して猶予を数え直し、E(行) は計画の後・削除の直前にも - 書き込みコネクションを持ったまま猶予を再確認する(アップロードは touch → 同じ writer で upsert の - 順なので、touch が先なら残り、削除が先なら upsert が行を作り直す) -- **applied のとき、そのトラックの album を `mark_unresolved` して増分スキャンを投入する**(dedup。 - 既に走っていれば予約が残り次回で拾う)。Phase 5 が D-49 の規則で album の絵を解決し直す。Derived は - `tag_version` が進むので既存の追随で再タグが走り、album の絵が変われば `src_artwork_id` の不一致で - もう 1 回走る(二重だが正しい)。トラック自身の画像を Derived に埋める・再生画面に出すのは - P1-3c(`tracks.artwork_id`、D-51 の改訂) -- **API は `POST /api/artwork/embed { selection, sha256, description?, skip_pending? }`**(RG 書き込み・ - MD5 補填と同型。preview は無い)。対象は selection の active 全行(形式は問わない)。`sha256` の - `artwork` 行か原画像が無ければ 404 `artwork_not_found`。UI は操作タブの「アートワーク」節 - (ファイル選択 → アップロード → 256px のプレビュー → 「選択 N 件の埋め込み画像を差し替え」)。 - 履歴画面の `PICTURE` 値はサムネイルで出す - -**理由**: 埋め込み統一なら書き側は既存のタグ編集バッチに 1 キー足すだけで、事前条件・overlay・ -巻き戻し・リカバリ・stale ゲートの全部が流用できる。cover ファイルの実利は「差し替えが軽い -(FLAC を書き直さない)」と「Explorer のフォルダサムネイル」だけで、差し替えは日常操作ではない -(たまに 1 album 数百 MB の書き直しなら許容。CLAUDE.md が禁じるのは全件再エンコードの規模)。 - -**却下**: cover ファイルの op(`kind='cover'`)を album の先頭トラックを「担い手」にして記録する -(ディレクトリ単位の巻き戻し、cover と埋め込みの二重管理、GC との調停が丸ごと増える)。 -`edit_ops` に `album_id` を足して `track_id` を nullable にする(`edit_ops` の作り直しに加え、 -ロック・pending 一意性・履歴画面・リカバリの全部が「トラック or album」の分岐を持つ)。 -`kind='picture'` の専用 op(`tags` op と同じ事前条件・overlay・巻き戻しを二重に持つことになる。 -md5 op が専用なのは overlay も版も持たないから)。front cover だけ置き換えて他の種別を残す(上記)。 -古い画像を退避せず巻き戻し不可にする(不変条件 4)。 - -**未決**: P1-3c(トラック単位のアートワーク)。アップロード画像の再エンコード(大きすぎる画像を -縮めて埋める)は要望があれば。 - -## D-61 トラックは自身の埋め込み画像を持ち、Derived と再生表示はそれを優先する - -**決定**(P1-3c。D-60 の未決。トラックごとに画像が違う album 向け): - -- **`tracks.artwork_id`(マイグレーション 0012。`artwork` への FK、`ON DELETE SET NULL`)はそのトラック - 自身の埋め込み画像**(`pick_embedded`: front cover → 無ければ最初の 1 枚)。無ければ NULL。埋めるのは - ファイルの画像を実体ごと読む 2 か所: スキャナ Phase 3(`read_entry` が `read_audio_file_with_pictures` - で読み、選んだ 1 枚を `ArtworkStore` へ置く。既に同じ長さの原画像があれば stat だけで済ませる)と - tagwrite(`stage_tags` の書き戻し確認と外部変更の読み取り。store は `Editor::with_artwork`)。 - `TrackContent.picture` は `Unread` / `Absent` / `Found` / `Failed` の 4 値で、`insert_track` / - `update_content` が同じトランザクションで反映する: `Unread`(読んでいない・store が無い)は何も - 触らない、`Absent` は `artwork_id = NULL`、`Found` は `artwork` 行を upsert して `artwork_id` に - する(どちらも `artwork_dirty = 0`)、`Failed`(キャッシュへ置けない)は `artwork_id` を据え置いて - `artwork_dirty = 1`(次項)。normalize の経路は `Unread`(画像は変わらない) -- **キャッシュへ置けない(store の I/O 失敗)は「画像なし」と区別し、`tracks.artwork_dirty`(0012)で - 再試行する。** `register_track_picture` は `Err` を返し、`TrackContent.picture` は `Failed`: `artwork_id` - は据え置き、`artwork_dirty = 1` を立てる。スキャナは Phase 2 で `artwork_dirty` の行を(物理属性が - 同じでも)読み直す対象に含め、`Found` / `Absent` を記録できたら 0 に戻す。物理属性に依らない印なのは、 - deep で変更なしの行を検証して失敗した場合や、tagwrite が conflict / cancel で外部の画像を読んで - 物理属性を現在値に揃えた場合は、stat では再試行の契機が残らないため。`Absent` に潰すと DB が NULL の - まま固定される -- **deep scan はトラックの画像もハッシュを照合して置き直す。** 増分は同じ長さの原画像があれば stat だけ - (同じ画像を持つ数千トラックで実体を読み直さない)だが、同じ長さの破損は album の絵に採用されない - 画像(同梱 cover のある album の 2 曲目以降)だと Phase 5 の `put_original` に届かないので、deep で - `register_track_picture(verify = true)` が直す -- **tagwrite で画像が変わったときの追随は `sync_track_to_file` に集約する。** ファイルの現在値を DB に - 揃えた直後に `artwork_id` の前後を比べ、変わっていれば album の再解決を予約して増分スキャンを投入し、 - サムネイルが無ければ thumbnail ジョブを投入する。Applied / AlreadyMatches / Conflict / Failed / cancel - (`close_op`)の全経路が通る(外部の画像変更を conflict で読んだときも物理属性を現在値に揃えるので、 - ここで予約しないと次の増分スキャンには「変更なし」と見えて album の絵が古いまま固定される)。 - Phase 4 が pending の tags op のために `update_content` を飛ばした行も、その op の反映・conflict・ - cancel のいずれかでここを通る -- **既存行は deep scan 1 回で埋まる。** 変更なしの増分は最速パスでファイルを読まないので NULL のまま。 - NULL の間は Derived も表示も album の絵へ倒れるので壊れない。専用のバックフィルジョブは作らない - (リハーサル環境はリリース時に再移行する) -- **album の絵(D-49)は変えない。** グリッドと album 単位の表示は同梱画像 → 最初のトラックの埋め込み - のまま。Phase 5 が `tracks.artwork_id` でファイルの再読みを省く簡略化は別の改善 -- **Derived に埋める画像は `COALESCE(tracks.artwork_id, albums.artwork_id)`**(D-51 の「album の - `artwork_id`」を改訂)。`load_target` / `enqueue_all_stale` のクエリだけ変わり、`plan` と - `src_artwork_id`(実際に埋めた id)の意味はそのまま。album 共通の album は同じハッシュ → 同じ id - なので再タグは走らない -- **GC 区分 E の「参照されている」に `tracks.artwork_id` を足す。** 忘れると FK SET NULL で消えた - `artwork_id` が増分では復旧しない -- **API / UI**: `TrackRow` と `GET /api/tracks/:id` に `artwork_hash`(トラック自身。無ければ null)。 - 左下のアートワークは選択行(先頭)または再生中トラックの自身の画像 → 無ければ album の画像。 - `AlbumGrid` は変えない - -**理由**: 埋め込み統一(D-60)のライブラリでは、トラックの絵はそのトラックの埋め込みが正。album 単位の -解決(D-49)だけだと、トラックごとに画像が違う album で全曲が 1 曲目の絵になる(再生表示と Derived)。 -スキャナはもともと Phase 3 で画像を含むタグを読んでいるので、選んだ 1 枚をハッシュアドレスの store へ -置くだけで済み、同じ画像を持つ数千トラックでも実体は 1 つ。 - -**却下**: Phase 5 でトラックごとに画像を解決し直す(Phase 5 は album 単位の予約で回っており、トラック -全件を再読みすることになる)。`track_tags` の `PICTURE` 値から `artwork_id` を導く(値はハッシュだけで -実体が store に無いことがあり、front cover の種別も持たない)。バックフィルジョブ(deep scan で足りる)。 -Derived で album の絵を優先する(トラックごとに違う album で目的を果たせない)。 - -**未決**: Phase 5 の簡略化(上記)。 - -## D-62 dev 番号は識別子の一部として永続化しない(dev の付け替えは「変更なし」) - -**決定**(2026-09-19。TrueNAS 実機のホスト再起動で起動時スキャンが全件を読み直した事故から): - -- **同一性解決の inode 段に「dev の付け替え」を足す。** 同じ `(dev, inode)` の候補が無いとき、別の - dev に同じ inode を持つ行が**ちょうど 1 つ**あり、`size` / `mtime_ns` / `ctime_ns` が**すべて**一致 - すれば同じ実体とみなす。`changed = false`(タグもフィンガープリントも読み直さない)で、commit の - 最速パスが `dev` だけを現在値へ直す(`update_physical`)。属性が 1 つでも違えば採らない(inode 再利用 - と区別できないので、次の段 = md5 / path へ落ちて読み直す)。候補が複数なら曖昧として採らない -- **編集の事前条件と「行と FD の照合」から `dev` を外す。** `edit_ops.expected_dev` は記録するが、 - tags / rename / md5 op の照合、rename と normalize の所在判定(`same_inode`)、flaccheck / RG / - 再生(`/api/stream`)の `matches` は inode / size / mtime_ns / ctime_ns(op はさらに `tag_hash` / - MD5 値)で判定する。列とスナップショットの `dev` は残す(inode 段の最速パスの索引 `(dev, inode)` - はそのまま) - -**理由**: Linux の `st_dev` はマウント時に割り当てる番号で、ZFS データセットはホスト再起動で変わる -(実機で `ssd/media` が 79 → 80)。SPEC は `(dev, inode)` を安定したものとして書いていたため、 -再起動後の起動時スキャンで全行が inode 段を外れ、path 段で `physically_changed = true` となって -9,098 件全部のタグ読みと可逆デコード(`lossless_md5`)が走った。データは壊れない(`audio_md5` は -一致するので版は動かない)が、再起動のたびに CPU 全コアで約 10 分・ライブラリ全読み(約 200 GB)を -払う。編集の事前条件も同じ前提だったので、バッチ準備と反映の間に再起動が入ると全 op が -`skipped_conflict` になるはずだった。dev が守るのは「別ファイルシステムの同じ inode 番号」だけで、 -Library の root は 1 データセットであり、size / mtime / ctime(op は tag_hash も)を併せれば -取り違えの余地は実質無い。 - -**却下**: 起動時に dev の対応表を作って DB を一括更新する(root 配下に複数データセットがあると -対応が一意に決まらない。行ごとの照合で十分)。`dev` 列を落とす(inode 段の索引として有用で、hardlink -判定にも使う。走査中は安定している)。dev 不一致を「変更あり」のまま md5 だけで確認する(可逆の -デコードが要り、避けたいコストそのもの)。 - -**未決**: なし。 - -## D-63 遡及照合はオフセットを探し、verify.log は Library に置かない - -**決定**(2026-09-19。P2-9): - -- **オフセットは最初から探す。** AccurateRip / CTDB に登録されている CRC は他人のドライブで - 吸ったもので、読み取りオフセットの補正が違えば同じ盤でも全サンプルが数十個ずれる。CUETools と - 同じ ±(5×588−1) サンプルの範囲で探し、「一致したトラック数 → 一致した信頼度の和 → 0 に近い」の - 順に 1 つ選んで `album_verifications.detected_offset` に残す。吸い出しのオフセットはディスクで - 1 つなので、トラックごとに別のオフセットは採らない。探索のために 1 オフセットごとに - デコードし直すのではなく、1 回流す間にトラック境界 ±2939 の近傍で累積和(AccurateRip v1 は - Σ(n × 語) で位置に線形)と CRC32 の途中状態(zlib の combine で部分列の CRC が出る)を記録し、 - 任意のオフセットの CRC を O(1) で出す(`cd/crctable.rs`)。AccurateRip v2 は線形でないので - オフセット 0 だけ比べる(CUETools も同じ)。CTDB の照合はパリティではなく trackcrcs の総当たり - (CUETools のフォールバック経路と同じ。パリティは P2-7 の修復で扱う) -- **verify.log は `data/verify/<album_id>.log`。** SPEC §5 はアルバムディレクトリに置く前提だったが、 - Library に同梱ファイルを置き始めると一括リネーム後の旧ディレクトリへの追随(D-43 の残課題、 - P2-8 で決める)が先に問題になる。アプリのデータ領域に album_id で置き、 - `album_verifications.log_path` で参照する。アルバムの全ディスクぶんを毎回書き直す -- **AccurateRip の ID は CUETools / dBpoweramp 式を既定にする。** Enhanced CD(末尾がデータ - トラック)で id1 / id2 は音声トラックだけを足すがリードアウトは実際の値、FreeDB ID はデータ - トラックも数える。データトラックを落として −11400 で切る libdiscid 式の ID も DB に別キーとして - 存在する(Hybrid Theory JP 盤で両方を実サーバで確認)が、エントリ数は CUETools 式のほうが多い。 - 必要なら `Toc::audio_session()` から作れる -- 照会に失敗したらジョブを失敗させて再試行し、何も記録しない。「照会できなかった」を - `not_found` や `mismatch` に混ぜない -- **記録は 1 トランザクションで、ジョブ単位に冪等。** 照合を始めたときの `audio_version` と - 記録直前の fstat(root からパスで開き直す)の両方が一致するときだけ書き、ディスク × 手法の行と - トラックの行と `tracks.verification` を 1 トランザクションで書く。verify.log の rename も - トランザクションの中(rename に失敗すれば DB も巻き戻す)。commit の後・done の前に落ちて - 起動時リカバリで同じジョブが再実行されても、`album_verifications.job_id`(`(job_id, disc_no, method)` - で一意)で既存行を見つけて何もしない(rename は commit の前なのでログも確定済み。読み直して - 別の結果で上書きしない) - -**理由**: 遡及照合の目的は「既存 FLAC の格付け」で、オフセット 0 だけでは補正なしのリップや -プレス違いが全部 `mismatch`(要確認)になり、格付けとして役に立たない。CUETools が実用になって -いるのは探索があるからで、累積和を使えば探索のコストはデコード 1 回 + 数百万回の整数演算で済む。 - -**却下**: オフセット 0 だけで始めて実機の一致率を見てから決める(探索の実装コストが小さく、 -先送りする理由が無かった)。verify.log をアルバムディレクトリに置く(P2-8 の同梱ファイルの決定を -待つ。決まったらそちらへ寄せてもよい)。AccurateRip の crc450 でオフセットを検出する -(CUETools の手法だが、累積和があれば全オフセットの完全な CRC を直接比べられる。crc450 は -計算だけ残してある)。 - -**未決**: ~~verify.log の置き場は P2-8 で同梱ファイルの扱いが決まったら見直す~~(D-67 で `data/verify` のままに確定)。 - -## D-64 MusicBrainz の候補は「リリース × medium」、TOC の入力源は差し替え可能にする - -**決定**(2026-09-19。P2-3): - -- **照会は DiscID → TOC の 2 段。** `ws/2/discid/<DiscID>` を引き、404 なら同じエンドポイントに - `?toc=`(MusicBrainz 形式の TOC)を付けて fuzzy に引く。MB 側がプレス違い・登録漏れの DiscID に - 対して TOC の近いリリースを返す。fuzzy の結果は `exact = false` として区別する -- **候補の単位はリリース × medium。** リリースの media のうち、自分の DiscID を `discs` に持つ - medium は exact、そうでなければ音声トラック数が同じ medium を近似の候補にする(複数枚組で - トラック数が同じディスクが複数あれば、それぞれが候補になる)。exact を先に並べ、MB の順は保つ。 - fuzzy の応答にも自分の DiscID を持つ medium が混ざることがあり、それは exact -- **`inc=recordings artist-credits labels release-groups isrcs`。** トラックのタイトル・アーティスト - 表記(joinphrase で繋ぐ。トラック固有が無ければリリースのもの)・長さ・recording / track の id・ - ISRC、リリースの日付・国・レーベルとカタログ番号・バーコード・注記・release-group id を候補に - 持たせる。タグの写像(P2-8 の配置で書く)はこの候補から決める -- **UA 必須・1 req/s はクライアント内で守る。** 直前の要求時刻を持ち、間隔が空くまで待つ - (ロックを持ったまま待つので並行する照会も直列)。503(負荷制限)は 1 度だけ間隔ぶん待って - 再試行し、それでも 503 なら API は 503 `musicbrainz_unavailable`(一時的。届かない・壊れた応答の - 502 `lookup_failed` とは分ける)。両方の要求に `cdstubs=no` を付ける(未登録 DiscID に CD stub が - あると 200 で別の形が返り、404 → fuzzy に進めない)。`[musicbrainz].url` で照会先を差し替えられる - (テストと自前ミラー) -- **TOC の入力源は差し替え可能にし、ドライブが無い間は貼り付け。** `POST /api/cd/lookup { toc }` は - TOC 文字列(CTDB 形式か MusicBrainz 形式。`Toc::parse`)を受け、ドライブからの TOC - (P2-1 / P2-2 の `GET /api/cd/status`)も同じ文字列で渡す。CD 画面は今は貼り付け欄 - (`cdrecord -toc` の出力も LBA を拾って受け付ける)を入力源にし、P2-1 で検出に差し替える。 - 貼り付けはデバッグ用に残す - -**理由**: 候補をリリース単位にすると複数枚組でどの medium かが決まらず、トラック対応も -確認できない。DiscID だけで引くとプレス違いの盤(同じ内容で TOC が数セクタ違う)が 0 件になり、 -D-21 の「手入力」に落ちる場面が増える。TOC の入力源を API の境界で文字列に揃えておけば、 -ドライブの実装(P2-1)を待たずに照会と候補選択の全経路をテストでき、届いた後は検出を差し替える -だけで済む。 - -**却下**: libdiscid の FFI(TOC からの整数演算で足りる。P2-2)。CD stub(MB の cdstubs)を -候補に含める(品質が低く、手入力経路があるので不要)。cover art の取得(P2-8 の配置で -Cover Art Archive を引くか決める)。 - -**未決**: fuzzy の候補が多いときの並べ方(今は MB の順)。手入力と候補の「補正」は D-65 で決めた。 - -**追記(2026-09-22。P2-3 拡張。DiscID 以外の識別経路)**: 実盤(嵐「Five」の初回盤)が MusicBrainz に -登録されているのに DiscID もトラック長も未登録で、TOC の fuzzy では原理的に出なかった。識別を 5 経路にする: -(1) DiscID(exact)、(2) ユーザが貼ったリリース URL / MBID(`ws/2/release/<id>`。どんな盤でも当てられる保険)、 -(3) ディスクの Q サブチャネルから読んだ ISRC(`ws/2/recording?query=isrc:A OR isrc:B…` を 1 回 → 一致数の -多いリリースから上限 5 件を取得)、(4) MCN = JAN / UPC(`ws/2/release?query=barcode:` → 上限 3 件)、 -(5) TOC の fuzzy。ISRC / MCN はドライブが TOC と同じ回に SG_IO の READ SUB-CHANNEL で読み -(`GET /api/cd/status` の `isrcs` / `mcn`)、UI が照会に添える。DiscID で当たれば (3)(4) は引かない((2) だけ -足す)。同じリリース × medium は 1 件に束ね、出てきた経路を `matched_by`(discid > release > isrc > -barcode > toc)で持って、その順に並べる。fuzzy の応答に混ざった DiscID 持ち(上記)は discid 経路として扱う。 -指定リリースが読めない・トラック数の合う medium が無いときは `notes` で返し、照会全体は失敗にしない。 -候補を選んだ後(DiscID 未登録のとき)は libdiscid と同じ形の登録 URL(`cdtoc/attach?id=&tracks=&toc=`)を -UI に出し、登録は本人がブラウザで行う。上の「未決」の並べ方はこれで閉じる(経路の強さ、同じ経路の中は -MB の順)。 - -**追記 2(2026-09-22。接続の経路と診断)**: この回線の **IPv4 から MetaBrainz のインフラ全体 -(musicbrainz.org / beta.musicbrainz.org / coverartarchive.org、443 と 80)へ繋がらない**ことが分かった。 -TCP は受理されるが TLS ハンドシェイクの途中で切られる(`unexpected eof`。TLS 1.2 を強制しても、鍵交換の -曲線を絞っても同じ)。同じ回線から github.com の IPv4 は通り、MetaBrainz の **IPv6 は通る**(TrueNAS ホスト・ -開発コンテナ・spindle のコンテナすべてで確認。Docker の bridge は ULA + NAT66 で外へ出られる)。レート制限 -(503 + JSON)とは別物で、先方のエッジがこの公開 IPv4 を落としている。対処: -- `[musicbrainz].address_family = "auto" | "ipv6" | "ipv4"`(既定 auto)。`Auto` 以外なら reqwest の DNS 解決を - その族に絞る(`cd::select_addrs` / `FamilyResolver`)。reqwest に族を選ぶ設定は無いのでリゾルバを差し替える。 - 指定した族のアドレスが 1 つも無ければ、黙ってもう一方へ倒さず「IPv6 のアドレスが無い」と分かる失敗にする - (塞がっている族へ落ちると原因の分からない TLS エラーになるだけで、1 回の解決で片方しか返らない事故も - 見えなくなる。接続の失敗は下の張り直しで 1 度やり直すので、一過性の解決漏れはそこで復帰する) -- 応答が返る前に落ちた要求(TLS の失敗・idle なコネクションの再利用・一過性の切断)は **1 度だけ張り直す**。 - タイムアウトは対象外(待ち直しても同じで、時間が倍になるだけ) -- ログと API の本文は `cd::error_chain` で原因の末端まで出す。reqwest の `Display` は - 「error sending request for url (…)」で止まり、TLS の失敗・タイムアウト・接続断の区別が消えていた -- **照会結果は入力(TOC + ISRC + MCN + 指定リリース)ごとに 10 分覚える**(追記 3。`MusicBrainzClient` の中。 - 8 件まで、古いものから捨てる)。1 枚の照会で最大 13 本の要求が飛ぶので、画面を開き直すたびに引き直すと - 1 req/s の規約に対して重い。失敗は覚えない(押し直せばまた引きにいく)。画面の「MusicBrainz に照会」 - ボタンは `refresh: true` で捨てて引き直す(DiscID を登録した直後などに効く)。ディスク検出からの自動照会は - 付けない -- MusicBrainz の `ws/2` は読み取りに認証が不要で、**OAuth トークンを取ってもレート上限も IP 遮断も変わらない** - (トークンは投稿・ユーザデータ用)。MetaBrainz アカウントが効くのはローカルミラー(Live Data Feed の - レプリケーショントークン)を立てる場合で、そのときは `[musicbrainz].url` を差し替える - -**追記 4**(2026-09-23。P4-20): 経路を**段階にして打ち切る**。`discid` → `ids`(ISRC 検索 + -バーコード検索)→ `toc`(TOC 近似)の順で、上の段で候補が 1 件でも残れば下の段は引かない。 -候補の数はトラック数で絞ったあとで数える(ISRC が当たっても曲数の合う medium が無ければ 0 件で、 -次の段に進む)。**DiscID が 200 でも候補が 0 件なら次の段へ落とす**(登録済みの DiscID でも medium の -トラック数が合わなければ候補にならず、そこで止めると行き止まりになる)。ただし `exact` は真のままに -して、DiscID の登録を勧めない。ユーザが貼ったリリース指定は段に関係なく常に足し、`toc` に進むかの -判定では「残った候補」に数える(ただし `ids` は引く。複数経路を束ねる D-64 本体の動きを壊さない)。 - -**`ids` の候補は常に TOC 近似より強い、と決め打ちしている。** ISRC は録音単位なので、1 曲だけ一致する -コンピレーションでも曲数さえ合えば候補に残り、TOC 近似を止める。正解を自動で取り逃すことはあり得るが、 -逃げ道は「さらに広げて探す」(`can_widen`)で残す。一致した ISRC の数などの確度を持って比べる設計は -採らない(値を経路ごとに持ち回る仕組みが要り、割に合わない)。 - -理由: 実機(嵐「Five」)で、ISRC 経路に正解があるのに TOC 近似が全く別の盤を大量に返し、 -候補一覧が読めなくなっていた。TOC 近似は「トラック長が近い」だけの弱い一致で、DiscID が未登録で -ISRC も無い盤のための最後の手段。応答の `stage` と `can_widen` で画面に段を見せ、「さらに広げて -探す」(`widen`)で明示的に下の段まで引ける。`widen` はキャッシュの鍵に入れる(広げた結果が -普通の照会に返らないように)。 - ---- - -## D-65 手入力は「候補を土台に編集する 1 つのフォーム」、貼り付けの行解析は決め打ち - -**決定**(P2-4、D-21 の具体化): - -- **候補も手入力も同じフォーム(`DiscDraft`)に収束させ、確定で `DiscMetadata` にする。** 候補を - 選ぶとフォームに写り(アルバム・アルバムアーティスト・日付・レーベル / カタログ番号 / バーコード・ - ディスク番号 / 枚数・各トラックのタイトル / アーティスト、MusicBrainz の release / recording / track - id と ISRC)、そこから直せる。候補ゼロ件なら空のフォームに直行し、候補がどれも違えば「候補を使わず - 手入力」で空にする。どちらも同じ `DiscMetadata`(`source: musicbrainz | manual`)なので、吸い出し - (P2-5)と配置(P2-8)の入力は 1 経路。候補を写し直すと編集中の内容は消える(画面で断る) -- **行は TOC の音声トラックと 1:1。** 番号と長さは TOC から取り、編集できない。候補のトラックは - 位置順に当て、足りなければ空行、余れば捨てる。`POST /api/cd/lookup` の応答に `tracks: [{ number, - length_ms }]` を足し、候補が無くても行数と長さが出る -- **確定の条件はアルバム名・アルバムアーティスト・各トラックのタイトル。** 日付は `YYYY[-MM[-DD]]` - か空。トラックのアーティストが空ならアルバムアーティストで埋める。タイトルの分からない盤のために - 「空のタイトルを `Track NN` で埋める」ボタンを置く(黙って埋めない) - → **P4-20 で覆した**(下の追記を見ること) -- **貼り付けの行解析は web(TypeScript)に置き、規則は決め打ち。** 貼るたびに往復せず即座にプレビュー - でき、間違いはフォームで直す前提なので、推測して当てに行かない。規則: 行頭の番号(`1.` `01` `1)` - `[01]` `Track 1:` `M-1` `#1`、全角。1..=99 だけで、年や `100 Years` は番号にしない)、行末の時間 - (`4:32` `(3:05)` `1:02:03`。秒は 00..59)、アーティストの区切り(`/` と `/` > `|` と `|` > - ` - ` ` – ` ` — `。既定は「タイトル / アーティスト」で、チェック 1 つで逆順。区切りは**アーティスト側の - 端**で切り、タイトル内の ` - Remix - ` を残す。端にぶら下がった区切り(`Title/`、`A - B -`)は - 貼り付けの残骸として落とし、空白無しの `-Intro-` は残す)、タブがあれば表の - 貼り付け(数字だけの列は番号、時間の列は長さ、残りがタイトル・アーティスト)、空行と `Disc 1` / - `トラックリスト` / `収録曲` の見出しは飛ばす。番号の無い行は前の行 + 1。**番号で行に対応付ける**ので、 - 貼り付けの行数が TOC と違っても写せるところは写し、行数の違い・TOC に無い番号・未設定の行を警告に - 出す。アーティストの無い行は既存の値を保つ(候補の表記を貼り付けで消さない)。番号と時間の判定は - 全角の数字・コロンも受けるが、タイトルの全角は正規化しない -- **画面の遷移は純粋な reducer(`web/src/lib/cdState.ts`)で、段が上から消える。** toc → result → - selected / draft → confirmed の順に段があり、上の段が変わると下は全部消える(TOC の編集と「結果を消す」は - 結果と貼り付けの本文ごと、照会のやり直しと候補の選び直しは選択より下。同じディスクの照会のやり直しでは - 貼り付けの本文を保つ)。確定後は候補の選び直しと手入力を受け付けず、「編集に戻る」で確定だけ外す。 - hook(`useCdLookup`)は非同期の照会と dispatch だけにして、遷移は vitest で固定する -- **確定画面はタグ名で見せ、写像を関数(`albumTags` / `trackTags`)で固定する。** P2-8 の配置はこの - 写像で書く。MusicBrainz の id は recording が `MUSICBRAINZ_TRACKID`、リリース内の track が - `MUSICBRAINZ_RELEASETRACKID`(Picard の写像。取り違えやすい)。写像は `[キー, 値の列]` で、ISRC のような - 多値は Vorbis コメントの同じキーの反復として持つ(`;` で 1 値に繋がない)。`TRACKTOTAL` と - `MUSICBRAINZ_DISCID` は TOC から吸い出し時に付ける - -**理由**: 同人・VTuber・インディーズの国内盤は MusicBrainz にあっても誤字や表記揺れがあり、 -「候補は選ぶだけ、手入力は最初から全部」だと誤字 1 つで全部書き直しになる。候補と手入力を別の形に -すると P2-8 のタグ写像が 2 経路になり、MusicBrainz の id を持ち越すかどうかも経路ごとに決めることになる。 -貼り付けの解析は「そこそこ当たって、外れたらすぐ直せる」ことが価値で、通販ページの形は無限にあるので -規則を増やして当てに行くより、プレビューとフォームで直す方が速い。 - -**却下**: 解析を Rust の API にする(貼るたびに往復。純粋関数なので vitest で固定化できる)。 -空のタイトルを確定時に黙って `Track NN` にする(黙った既定はタグに残る。ボタンで明示させる) -→ **P4-20 で覆した**。プレースホルダで埋まる値が常に見えているので「黙って」ではない。 -アーティストの区切りを「最初の出現」で固定する(`Title - Remix - Artist` が壊れる)。全角の英数字・ -記号をタイトルごと正規化する(`第2章` や `!` が変わる。番号と時間の判定だけ全角を受ける)。 -画面の遷移を hook の useState に散らす(テストできず、TOC 編集で確定が残るような取りこぼしが見えない)。 - -**未決**: 貼り付けの行数が TOC と違うときに「上から順に当てる」選択肢(今は番号で対応付けるだけ)。 -複数枚組の貼り付け(`Disc 2` 以降を別のディスクとして扱う。今は見出しを飛ばして番号の重複を警告)。 - - -**追記**(2026-09-23。P4-20。上の「確定の条件」と「遷移」を覆す): - -- **空のタイトルは確定を止めない。** 表では `Track NN` を**プレースホルダとして見せ**(値は空)、 - 取り込みの直前に空のままなら `Track NN` を採る(`defaultTitle` / `finalizeDraft`)。 - 「空のタイトルを `Track NN` で埋める」ボタンは要らなくなったので消した。 - 確定の条件はアルバム名・アルバムアーティスト・日付の形・ディスク番号だけ -- **「確定」の段そのものを廃止した。** 表を直接編集して「取り込む」1 発にしたので、`confirmed` / - `draftErrors` と `confirm` / `unconfirm` / `fill_titles` は無い。「編集に戻る」も無い -- **フォームは TOC が読めた時点で必ず存在する**(表は照会の前から出る)。消えるのは別のディスクに - 替わったときだけで、照会の開始と失敗では消さない -- **CD 画面からは直せない**(2026-09-23 追記。ユーザ要望)。候補を選ぶ / 写す範囲を変える / - 「どれも違う」の 3 つだけで、補正とトラックリスト貼り付けは **Inbox の承認画面**に移す - (取り込んだものは Inbox を通る。D-67 追記)。入力欄が 2 か所にあると、どちらで直すのか - 分からなくなる。貼り付けは P2-10 で Inbox の承認画面へ移した(下の追記 2) - -理由: 分からないトラックに既定の名前が「最初から見えている」方が、ボタンを押させるより分かりやすい -(ユーザ要望)。画面が「表を直して取り込む」1 本になり、確定を挟む理由が無くなった。黙って埋めるのを -避けたかった元の意図は、プレースホルダで**埋まる値が常に見えている**ことで満たしている。 - -**追記 2**(2026-09-23。P2-10。貼り付けを Inbox の承認画面へ移した): - -- **写し先は Inbox の下書き(`InboxDraft`)の、選んだ 1 枚のディスクの行。** Inbox の件は TOC ではなく - ファイルなので、対応付けは行の `track_no`(ファイルの並びではない)。件に複数のディスクがあるときだけ - 「写す先」を選ばせる(上の未決の「`Disc 2` 以降を別のディスクとして扱う」は決めず、1 回の貼り付けは - 1 枚ぶん)。同じ番号の行が複数ある(下書きの番号が重複している)ときは、どれに写すか決められないので - 写さずに警告する -- **アーティストを貼った行は多値の「そのまま保つ」(D-70)を外す。** 保ったままだと貼った値が - 書かれず、画面も結合表示のまま変わらない。貼った以上は 1 値で書く意図とみなす。アーティストの無い行は - 従来どおり既存の値(と保つ設定)を保つ -- CD 画面側の `lib/cd.ts` の `applyTracklist` は消した(Inbox 版は `lib/inbox.ts`) - ---- - -## D-66 CTDB の修復は CUETools の定義どおりに、CRC が合うときだけ適用する - -**決定**(P2-7): - -- **符号は CUETools `CDRepair` そのもの。** 16 bit 語を stride = 11760 語(10 セクタ)の行に並べ、 - 列ごとに GF(2^16)(生成多項式 0x1100B)の Reed-Solomon。データ行は先頭 1 行(leadin)と末尾 - 1 行 + 端数(leadout)を除いた K = 2N/stride − 2 行で、CTDB のディスク CRC の範囲と一致する。 - シンドローム S_i(c) = Σ_r d_r·α^{i(K−r)} を直接 Horner で取る(CUETools は LFSR でパリティを作って - から変換するが、パリティファイルが持つのはシンドロームなので、変換を挟まない方が短い) -- **パリティファイルは面順のシンドローム。** `hasparity` の URL に npar 面 × 11760 語(LE)。XML の - `syndrome` 属性は列 0 で、旧い `parity` 属性(列 0 のパリティ 8 語)は `Parity2Syndrome` の式で - 同じ形にする。取得は Range で先頭 npar 面だけ(CUETools は 4 → 8 → 16 と広げる。npar 8 で 188 KB)。 - 列 0 が属性と合わなければ拒む -- **オフセットは列 0 だけで探す**(`syndrome` 属性があれば通信なしで決まる)。ずらした列は隣の列に - leadin / leadout の 1 語を足し引きして出る(`GetSyndrome` と同じ)。探索範囲は ±2939(CrcTable と - 同じ。CUETools は ±5879)。完全一致を優先し、無ければ列 0 の誤りが npar/2 未満で復号できる - うち最少のもの -- **直した後のディスク CRC がエントリの値に一致するときだけ計画を採用する。** 誤りパターンの - CRC への寄与は線形なので、計画の段階で `crc32_combine` により適用前に求まる(CUETools と同じ)。 - RS が「直せた」と言っても CRC が合わなければ誤訂正として捨てる。適用は 2 回目の走査で語を XOR - し、そこでもデータ行の CRC を取って返す -- **計画と適用を分ける。** 吸い出し(P2-5)は tmp の PCM を 2 回読む: 1 回目で CRC 表とシンドローム表、 - 2 回目で適用。ディスク全体(最大 800 MB)を載せない。修復の対象は自分の吸い出し結果だけで、 - 既存 FLAC(§7.3 の遡及照合)は直さない(音声の書き換えは版管理と巻き戻しの対象で、別の判断が要る) - -**理由**: 傷は 1 セクタ(1176 語)単位で来て、1176 の列に 1 語ずつ散るので、列あたり npar/2 個 -(4 か 8)の訂正能力で数セクタぶんが直る。これが CTDB を主にする理由(D-13)そのもの。CRC の -ゲートを外すと、能力を超えた誤りを RS が別の符号語に「直して」しまったものが通る。 - -**却下**: パリティを LFSR で作って変換する(CUETools 互換の内部表現だが、ファイルにあるのは -シンドローム)。オフセット探索に CRC 表を使う(誤りがあると CRC は一致しないので探せない)。 -遡及照合の FLAC を直す(版管理と巻き戻しの範囲で別途決める)。 - -**未決**: オフセット探索を ±5879 に広げる(CrcTable の範囲も一緒に広げる必要がある)。 -80 分 3〜5 秒のシンドローム計算を SIMD で速める(吸い出しは 10 分かかるので今は要らない)。 -CTDB への提出(自分のシンドロームを面順にした `DbSyndromes::from_table` は用意してある)。 - ---- - -## D-67 CD の配置は rip ジョブが直接登録し、同梱ファイルは album 全体の移動に追随する - -**決定**(2026-09-19。P2-8。D-43 / D-63 / D-64 / D-65 の残課題を閉じる): - -- **配置は `cd::place::place_disc` の 1 関数で、rip ジョブ(P2-5)の最終段として呼ぶ。** 入力は - `Toc`、`DiscMetadata`(D-65 の確定フォームと同じ形 + `category`)、`TrackLayout`、オフセット適用済みの - tmp の PCM(s16le / 2ch / 44.1k)、`RipReport`(ドライブ・オフセットと出所・試行回数・トラックごとの - 再読み / C2 数・AccurateRip / CTDB の `MethodResult`・修復の適用数。P2-5 が埋める)。`POST /api/cd/rip` - と吸い出しは P2-5 で、P2-8 はテストから `place_disc` を直接呼んで固定する -- **エンコードは raw PCM を `flac -8 --verify` に直接渡す**(ffmpeg を挟まない。正規化の `FlacEncoder` - はデコードが要るので経路が違う)。トラックごとに PCM の MD5 を取り、STREAMINFO の MD5 と一致する - ときだけ成果物にする。タグは D-65 の写像(`album_tags` / `track_tags` を Rust に移植)+ `TRACKTOTAL` - (TOC の音声トラック数)+ `MUSICBRAINZ_DISCID` を lofty で書く。`category` はタグに書かない(パス専用。 - SPEC §5) -- **パスは `pathgen::plan` を再利用する**(`[layout]`、category 無しなら `unsorted`、`disc_count > 1` なら - `multi_disc`。降格と衝突は D-43 の規則のまま)。リリースキーは `mb:<release_id>` → 無ければ、宛先 - ディレクトリに albumartist と album が一致する複数枚組(`disc_count > 1` か構成トラックの `disc_no` の - 最大が 2 以上)の album があり、入力も複数枚組で、その `disc_no` のトラックがまだ無ければ**その album に - 合流**(複数枚組の手入力で 2 枚目が `({year})` に降格しない)→ それ以外は `disc:<DiscID>` の新規リリース - (Library へ直接置いていたときの規則。D-67 追記 2 で Inbox 経由になり、追記 3 で `disc:` のキーは無くなった)。 - `release_id` があるときは合流を探さない(別リリースなら降格し、降格先が album になる。合流先を持ったまま - 降格すると「ディレクトリ = album」が壊れる)。計画はエンコードの前に一度(衝突を早く知る)と、 - `library` の排他を取った後にもう一度行い、後者を確定にする(エンコードの間に scan / rename が album を - 動かし得る)。rename は `library` の排他を取らない(track_locks だけ。D-38 の並走前提)ので、登録の - トランザクション(writer で直列)でも全経路を再検証する: 合流先は `rel_dir_key` が計画の宛先と一致し - active であること、宛先の既存 album はリリースキーが計画と同じであること(違えば衝突。missing の - album は同じキーなら復活、違えば `\0displaced:` へ退かせて新規)、同じパスの行は `audio_md5` が - 一致すること。検証に失敗したら、この呼び出しで**新しく置いた**ファイルだけを消し(既存の成果物と - 他人のファイルは触らない)、空になったディレクトリを消してから Conflict にする -- **配置は `job_mutexes` の `library` を取ってから**(scan / gc と同じ。取れなければ Requeue)。 - トラックと同梱ファイルを tmp → fsync → `RENAME_NOREPLACE` → dir fsync で置き、1 トランザクションで - `albums`(無ければ作成。合流なら触らない)/ `tracks`(スキャナの `track_content` / `insert_track`。 - `source_type = 'cd_rip'`、`verification`)/ `track_tags` / `album_verifications`(`source = 'rip'`、 - 手法ごと、`log_path` は Library 相対の rip.log)/ `track_verifications` を書く。`tracks.verification` - の写像は verify ジョブと同じ(CTDB 一致 → `verified_ctdb` → AR 一致 → `verified_ar` → 候補あり不一致 → - `mismatch` → 候補なし → `not_attempted`)。後続は `rg`(album)と `transcode`(`derived::enqueue_if_stale`)。 - カバー画像は無いので thumbnail は出ない(Cover Art Archive は引かない。`/api/artwork/embed` で後から - 埋め込む) -- **冪等性は MD5 で判定する。** 宛先に既にファイルがあれば STREAMINFO の MD5 が自分の PCM の MD5 と - 一致するときだけ自分の成果物とみなして配置を飛ばす。DB に同じ `rel_path` の行が既にあり `audio_md5` - も一致すれば(配置の後に落ちてスキャナが先に拾った)挿入せずその行を採用し、出自と検証だけ書く。 - どちらでもなければ conflict で失敗し、Library は触らない -- **同梱ファイルの名前は 1 枚なら `disc.cue` / `disc.toc` / `rip.log`、複数枚組は `disc<N>.cue` / - `disc<N>.toc` / `rip<N>.log`**(N = `disc_no`、0 埋めなし)。形式: - - `rip.log`: 先頭行 `spindle rip log v1`(スキャナの判定キー)。ドライブ / デバイス / 読み取り - オフセットと出所 / エンコーダ / 各種 DiscID と TOC 文字列 / アルバム情報 / トラック表(LBA・長さ・ - 再読み・C2・CRC32・ARv1・ARv2・CTDB CRC・照合結果・ファイル名)/ 手法ごとの結論と修復 / 総合結果 - - `disc.cue`: EAC 流の複数ファイル cue(`REM DISCID` / `REM DATE` / `CATALOG`(バーコードが 13 桁の - とき)/ `TITLE` / `PERFORMER`、トラックごとに `FILE "…" WAVE` / `TRACK NN AUDIO` / `TITLE` / - `PERFORMER` / `ISRC` / `INDEX 01 00:00:00`)。ギャップは前トラック末尾に付く形(INDEX 00 は TOC から - 分からない)。データトラックは `REM` で記す - - `disc.toc`: `Toc` から cdrdao 構文(`CD_DA` / `CATALOG` / `CD_TEXT` / `TRACK AUDIO` + - `FILE "…" 0 MM:SS:FF`)で生成。実ドライブの `cdrdao read-toc`(P2-2)も `Toc` にしてから同じ形で - 書く(形式を 1 つに) -- **同梱ファイルは album 全体の移動に追随する(D-43 の残課題)。** rename ジョブは phase 2 の後の - commit トランザクションの中(`tx.commit()` の前)で、applied の op の旧ディレクトリのうち宛先が 1 つで - active な行が残らないものについて、**既知の名前**(`cover` / `folder` / `front` × 画像拡張子、 - `disc*.cue` / `disc*.toc` / `rip*.log`)の通常ファイルを新ディレクトリへ `RENAME_NOREPLACE` で移し - (衝突は残して警告)、旧ディレクトリが空なら `rmdir`(`ENOTEMPTY` は無視)。commit の前に動かすのは、 - トラックの実体は phase 2 で既に宛先にあって commit はそれを記録するだけなので、途中で落ちても再実行が - 同じ判定に至り(旧ディレクトリに同梱ファイルはもう無い)、commit の後に落ちて追随だけが永久に残る窓を - 作らないため。終端を観測した側は追随済みの状態を見る。`edits` には記録しない。巻き戻しは逆向きの - album 全体の移動になるので同じ経路で戻る。一部だけの巻き戻しでは動かない(トラックが残る側に - 付いていく)。rename op がトラックのパスだけを所有する原則(D-43)は変えない -- **verify.log は `data/verify` のまま**(D-63 の未決を閉じる)。Library に置く同梱ファイルは rip 由来の - 3 つだけ -- **スキャナは spindle の rip.log から `source_type = 'cd_rip'` を復元する。** ディレクトリの inventory で - `rip.log` / `rip<N>.log` を見つけたら先頭行だけ読み、`spindle rip log v1` なら印を付け、**新規挿入する - 行**だけ `cd_rip` にする(既存行は触らない。`verification` は復元せず遡及照合 §7.3 で付け直す)。 - 不変条件 1(DB はキャッシュ)を出自についても保つ -- **`category` は確定フォームで選ぶ。** `GET /api/categories` / `POST /api/categories { name }`(SPEC §9 に - あって未実装だった)を足し、`DiscDraft` / `DiscMetadata` に `category`(語彙の名前。null なら - `_Unsorted`)を持たせる - -**理由**: スキャナ経由の登録(置いてから incremental scan)だと出自と検証結果を書く相手の行が -いつ現れるか分からず、競合を避けるにはどのみち `library` の排他が要る。直接登録なら配置と登録が -1 つのジョブに閉じ、冪等性の判定も MD5 の 1 規則で済む。同梱ファイルの追随を rename ジョブに置く -のは、album 全体の移動を既に coordinator が判定していて、そこでしか「ディレクトリが空になる」 -ことが分からないため。出自を rip.log から復元するのは、DB を消しても `cd_rip` の絞り込みが戻る -ように(検証は照会し直せば戻るが、出自は照会では戻らない)。 - -**却下**: 置いてからスキャンで拾わせる(上記)。同梱ファイルの移動を op として記録する -(rename の 2 phase に絡み、巻き戻しの対象が増える割に、ユーザが編集した値ではない)。同梱 -ファイルを残して GC に回収させる(旧ディレクトリに cover.jpg だけが 30 日残る)。verify.log を -アルバムディレクトリへ移す(既存ログの移行が要り、Library に置く理由が無い)。Cover Art Archive の -取得(同人 / 国内盤は無いことが多く、埋め込みは後からできる)。`SOURCEMEDIA=CD` のようなタグで出自を -持つ(外部ツールが付けた同名タグと区別できない。rip.log は spindle の署名付き)。 - -**未決**: 同梱ファイルの追随が衝突で残ったときの回収(今は警告だけ)。cdrdao の `read-toc` が持つ -ISRC / pre-emphasis / CD-TEXT を `Toc` に持たせて disc.toc に書く(P2-2 のドライブ実装で決める)。 - -**追記**(2026-09-23。ユーザ要望。**実装は P2-5**): **吸い出したものは Library へ直接置かず、Inbox を -通す。** CD 画面からは編集を外し、値を直す場所を Inbox の承認画面に一本化する。 - -- 吸い出した FLAC は `[paths].inbox` の下に 1 ディレクトリ = 1 枚で置く(複数枚組はディスクごと) -- **MusicBrainz から写した内容と検証結果はサイドカー `spindle-inbox.json` に書く**(D-70 が - ytmusic 用に作った仕組みをそのまま使う)。これで承認画面に候補の値が最初から入り、打ち直しが要らない。 - タグにも書くが、`category` はタグに書かない(パス専用。SPEC §5)ので、サイドカーが唯一の伝え方になる -- `rip.log` / `disc.cue` / `disc.toc` も件のディレクトリに置き、承認して配置するときに Library へ運ぶ - (既存の同梱ファイルの追随と同じ扱い) -- **AccurateRip / CTDB の照合結果は配置時に登録する。** `album_verifications` / `track_verifications` / - `tracks.verification` は `tracks` の行が要るので、Inbox に居る間は登録できない。サイドカーに - `RipReport` を入れておき、Inbox の配置がそれを読んで `source = 'rip'` で入れる -- `source_type = 'cd_rip'` も配置時に付ける(スキャナの rip.log 判定は残す。外から置かれた rip にも効く) - -**理由**: ユーザの導線は「入力(CD / YouTube)→ Inbox → ライブラリ」で、CD だけ Library へ直行するのは -一貫しない。また CD 画面と承認画面の両方に入力欄があると、どちらで直すのか分からなくなる。承認を -挟むことで、取り込んだ直後に名前を直してから Library に入る。 - -**この追記で `place_disc` は作り直しになる**(宛先が Library から Inbox へ、登録が `tracks` から -`inbox_items` + サイドカーへ変わる)。既存の `tests/cd_place.rs` も書き換わる。P2-5 でまとめて行う。 - -**リップ開始の入力は空の名前を許す**(codex 指摘)。CD 画面から編集を外したので「どの候補も違う」 -盤は名前の無いまま取り込むことになるが、いまの `DiscMetadata::validate`(`src/cd/metadata.rs`)は -`EmptyAlbum` / `EmptyAlbumArtist` を返し、web の `validateDraft` もアルバム名とアルバムアーティストを -必須にしている。このまま P2-5 で既存型を配線すると、その経路は必ず開始を拒まれる。 - -- リップの開始に渡すのは「**名前は空でもよい**」別の契約にする(`DiscMetadata` の検証は Library へ - 置くときのもの。Inbox の承認で `InboxDraft` の検証が必須を担保するので、二重に持たない) -- 名前が空なら Inbox の件のディレクトリ名は DiscID など盤の識別子から作る(`_Unsorted` へ落とすのは - 承認時の判断) -- web の `validateDraft`(`lib/cd.ts`)は CD 画面から使われなくなった。P2-5 で開始の前提を決めるときに - 消すか、空名を許す形に直す -- 受け入れ条件: **候補ゼロ件・アルバム名もアーティストも空のまま Inbox まで完走する** - -**サイドカーは `RipReport` の各トラックをファイルに明示的に結びつける**(codex 指摘)。既存の -`cd::place::disc_record` は `RipReport` の配列順と `track_ids` の配列順を zip しているが、Inbox では -ユーザが `disc_no` / `track_no` / タイトルを直せるので、走査順や下書き順を暗黙に信じてはいけない。 - -- サイドカーは**ファイルの basename(不変の鍵)でトラックへ対応させる**。配置時に、件の全ファイルと - 1 対 1 で対応すること・CRC の件数・`disc_no` が揃うことを確かめてから登録する -- `source_type = 'cd_rip'` / `album_verifications`(`source = 'rip'`)/ `track_verifications` / - `tracks.verification` は、Inbox の登録(`register_item`)と**同じトランザクション**で書く -- `album_verifications.log_path` は移動後の `rel_path`(Library 相対)にする -- CD は `album_gain = true` で提案する(SPEC 7.2 / D-74。いまは配置時に立てていた) -- サイドカー v1 は `category` / `files` しか持たず、`RipReport` も serde 型ではない。**形を足すのは - P2-5 の明示タスク**(テストも) - -**追記 2**(2026-09-23。P2-5 の Inbox 側の実装で決めたこと): - -- サイドカーは v1 のまま `rip`(`RipEntry`)を足す。無い件ではキーを出さず、旧い読み手は未知のキーを無視する - ので版を上げない。`RipReport` / `MethodResult` はそのまま serde 型にした(列挙は snake_case)。Inbox に - 置かれたまま版をまたぎ得るので、キーの綴りはテストで固定する。モジュールは `import/ytmusic/sidecar.rs` - から `import/sidecar.rs` へ移した(CD と共有するため) -- **検証記録の `disc_no` は下書きの値**(承認でディスク番号を直せば、記録もそのディスク番号に付く)。1 件 = 1 枚 - なので、下書きのディスク番号が揃っていなければ配置しない -- **Inbox 経由の記録は `job_id` を NULL にする**(`record_album` の `job_id` を `Option` に)。1 回の inbox ジョブが - 複数の件を置くので、ジョブ id は `(job_id, disc_no, method)` の一意性の鍵にならない(2 枚の CD を同じ回に - 置くと 2 件目が「記録済み」で落ちる)。代わりに**全トラックに `source = 'rip'` の記録が既にあれば書かない** - (`all_have_rip_records`)。登録の commit の後・Inbox を消す前に落ちると、残った音声で走査が件を `pending` - に戻し、再承認で同じファイルと行を採用して登録へ戻ってくるため(codex 指摘)。同じ音声の盤を吸い出し直して - 同じ行に置いた場合も書かない(Library は変わらず、最初の記録が残る。照合し直すなら遡及照合) -- **サイドカーは読んだ FD の同一性(inode / size / mtime / ctime)を登録の直前と Inbox を消す前に照合する** - (codex 指摘)。登録前に変わっていれば置いたファイルを片付けて `Changed`(件は `pending`)、登録後に変わって - いれば消さずに残す。外から差し替えられた記録を古いまま登録しない・新しいものを消さない(ファイルが正) -- rip.log が宛先の別内容と衝突して移せなかったときは `log_path` を NULL にする(存在しないパスを指さない) -- **Inbox への置き方**(`place_disc`): Inbox 直下の隠しディレクトリ `.spindle-rip-<DiscID>` で組み立て、 - ディレクトリごと `CD/<albumartist - album> [<DiscID>]` へ `RENAME_NOREPLACE` する。走査は `.` で始まる - ディレクトリを見ないので、ファイルやサイドカーが揃う前の盤が件になって承認されることがない(1 本ずつ - 置くと、途中の件を承認すると検証記録の無い `download` として Library に入る)。名前が空なら - `CD/[<DiscID>]`: **MusicBrainz の DiscID は `.` で始まり得る**(base64 の変形で `.` `_` `-` を含む)ので - 括弧で包む(素のままだと隠しディレクトリになり、走査に見えない。テストの TOC で実際に起きた)。 - トラックのファイル名は `NN.flac`(TOC の番号)。サイドカーとの対応付けの鍵なので、承認で変わる値 - (タイトル)を入れない。Inbox の行は DB に直接書かず(D-68)、公開後に `inbox` ジョブを投入して走査に - 出させる -- **Inbox 経由で置いた CD の album に `albums.discid` は入れない**(ユーザ判断)。入れると追記先の判定 - (`destination` は DiscID を持つ album を除く)から外れ、MusicBrainz に無い複数枚組の 2 枚目が 1 枚目に - 合流できなくなる。既存曲が大量にあり、album の同一性の規則を今は動かさない。~~**未決**: スキャナは - `MUSICBRAINZ_DISCID` から `discid` を復元するので、DB を作り直した後だけ値が付く食い違いが残る~~ - → 下の追記 3 で `albums.discid` ごと外して解消 - -**追記 3**(2026-09-23。ユーザ判断。上の未決を閉じる): **`albums.discid` を落とし、DiscID を album の -同一性に使わない。** リリースの同一性は `mb_release_id`(`MUSICBRAINZ_ALBUMID`)→ album 行の 2 段にする。 - -- **DiscID は 1 枚ごとの値で、album の列には収まらない。** 複数枚組では album に DiscID が複数あり、スキャナは - 最頻値(どの 1 枚かは決まらない)を入れていた。しかも Inbox 経由の CD は NULL で、DB を作り直すと値が付き、 - 追記先の判定・リリースキー(`disc:`)・album の照合(D-32)が作り直しの前後で変わっていた。リリースの MBID は - 全ディスクで 1 つなので、MusicBrainz にある複数枚組は MBID で自然に合流する -- マイグレーション `0024` で索引 `idx_albums_discid` と列を落とす。リリースキーは `mb:` → `album:<id>` - (`placement::release_key` / `edit::rename` の `release_of`)、album の照合は MBID が 1 件一致 → 構成 - トラックの過半数。稼働 DB(721 album)は `discid` も `mb_release_id` も 0 件なので、失われる値は無い -- **DiscID の置き場所**: トラックのタグ `MUSICBRAINZ_DISCID`(1 枚の識別。吸い出しで付く)、 - `album_verifications`(ディスク単位の行)、rip.log / disc.cue。アルバム単位で DiscID を引く機能が要るように - なったら、そのときに 1 枚ごとの表(`album_discs(album_id, disc_no, discid)`)を足す -- **CD とそれ以外は混ぜない。** `discid` を落とすと、MBID の無い CD の album と YouTube などの album が - どちらも「MB キーの無い album」になり、Inbox の追記先(D-70)で互いに混ざる(D-7 の「同名 ≠ 同一 - リリース」に反する)。トラックのタグ `MUSICBRAINZ_DISCID` を **CD から来た印**にし(ファイルにあるので - 作り直しでも同じ判定)、`import::inbox::destination` は CD の件を CD の album にだけ、しかも album に - まだ無い `disc_no` のときだけ採用する(MBID の無い複数枚組の 2 枚目を 1 枚目に合流させる。同じ番号は - 同名の別の盤なので降格)。CD でない件は CD でない album にだけ採用する。MBID のある件は従来どおり - 追記先を持たず、配置の `mb:` キーで合流 / 降格が決まる -- **同じ判定を 3 か所で通す**(codex 指摘): (1) 追記先(`destination`)、(2) 再実行の「自分の成果物」 - (`self_album`。同じ音声の行が CD でない album にあっても、CD の件をそこへ合流させない)、(3) 登録の - トランザクション(計画の後にタグ編集や別の配置で album が変わり得るので引き直す。MB キーの album は対象外)。 - 「自分の成果物」は同じ音声の行がある album を**全部**候補に集め、件に MBID があればそのリリースの album、 - 無ければ MB キーの無い album で件を入れられるものが**ちょうど 1 つ**のときだけ採る(D-29。音声の一致だけで - 別の album を自分とみなさない。件に MBID が無いのに `mb:` の album を採らない)。 - 判定は `fits_plain_album`: CD かどうかは album の**全行**で見る(同じ音声の行を自分の成果物として除くと、 - 他の album の行まで除いてしまう)、ディスク番号の重なりは自分の成果物の行を除いて見る(再実行) -- **購読の束ね先**(P4-16): 束ねてある album が CD の album になっていたら同期を止める(Inbox はそこへ - ダウンロードを追記しないので、揃えだけが進んで配置先と食い違う)。稼働 DB に CD の album は 0 件なので、 - 既存の束ねの移行は要らない - -**却下**: 両方の経路で `discid` を書き、合流は別の規則で救う(1 枚目だけの時点では 1 枚ものに見えるので、 -2 枚目が来たときに `discid` を NULL に戻す更新が要り、規則が 3 か所に増える)。1 枚ごとの表を今作る(読む -機能が無い。遡及照合は STREAMINFO から TOC を作り直すので DiscID を使わない)。 - -**関連**: Inbox 経由の件への MusicBrainz 照会は、YouTube の件には行わない(曲の身元は `SOURCE_URL`、 -D-70)。候補ゼロ・「どれも違う」で取り込んだ CD の引き直しだけを TASKS P4-21 に積む - ---- - -## D-68 Inbox は 1 ディレクトリ = 1 件の承認キューにし、配置は CD と同じ経路で登録する - -**決定**(2026-09-19。P2-10。D-20 の具体化): - -- **件 = 音声ファイルのあるディレクトリ。** `inbox` ジョブ(並列 1・固定キー)が Inbox を歩いて - `inbox_items`(`rel_dir_key` で一意。状態 `pending` → `approved` → `placing` → `placed` | `failed`、 - `rejected`)と `inbox_files`(stat・コーデック・表示用タグ列・全タグ JSON)に写す。**正は Inbox の - ファイルで行はキャッシュ**: stat が変わったファイルだけタグを読み直し、ディレクトリが消えれば行も消す - (`placed` は 24 時間残す)。`approved` の件でファイルが変わっていたら `pending` に戻す。検出は - `[inbox].poll_interval_secs`(既定 60、0 で自動なし)の周期投入と `POST /api/inbox/scan` -- **補正はアルバム単位とトラック単位の両方**(category / albumartist / album / date と、各トラックの - disc_no / track_no / title / artist)。下書き(`proposal`)はタグから作る(最頻値、category は GENRE → - `genre_category_map`)。承認の検証は CD の確定(D-65)と同じ厳しさ: album / albumartist / 各 title が - 空でない、`(disc_no, track_no)` は 1 以上で重複なし。不足のまま Library に入れない(D-20) -- **補正はファイルのタグに書く。** Library へコピーする tmp に、補正で変わるキーだけ - `write_tag_changes` で書いてから置く。ファイルが正のまま再スキャンしても DB と一致する。Inbox の原本は - move で消えるので、この書き込みは編集バッチ(巻き戻し)の対象にしない(Library のデータの書き換えでは - なく、Library に入る前の整形) -- **配置は CD の配置(D-67)と同じ経路。** `pathgen::plan`(category 無しは `unsorted`、`disc_no` の - 最大 ≥ 2 なら `multi_disc`、リリースキーは MUSICBRAINZ_ALBUMID の最頻値があれば `mb:`、無ければ件ごとの - 新規キー)→ `library` の排他 → tmp + `RENAME_NOREPLACE` → 1 トランザクション登録(`source_type = - 'download'`、宛先 album のリリースキー再検証、同パス行の MD5 検証)→ 失敗時はこの呼び出しで置いた - ファイルだけ片付ける。共通部分(1 ファイルの配置・後始末・リリースキー・album の検索 / 作成・行の - 登録)は `src/import/placement.rs` に置き、`cd::place` と `import::inbox` が使う。コピーに使う FD を - fstat で承認時の行と照合し、コピーの後にも同じ FD を照合し、置いたファイルの音声の指紋が承認時に読んだ - ものと一致することを確かめる(外れたら `Changed` → `pending` に戻して再承認。コピー = 検証済みの複製)。 - 既知の同梱ファイル(cover 画像 / cue / toc / log)も移し、空になった Inbox のディレクトリを消す -- **状態遷移は CAS、`placing` はジョブの先頭で回復する。** `approve` / `reject` / `reopen` と worker の - `approved → placing` は `UPDATE … WHERE state IN (…)`(`db::inbox::transition`)で、読んでから書くまでに - 他が動かした件を上書きしない。配置の途中でプロセスが落ちて `placing` のまま残った件は次の `inbox` - ジョブが `approved` に戻して配置し直す(並列 1 なのでジョブ開始時の `placing` は必ず前の実行の残り。 - 配置は冪等)。件の `placed` と normalize バッチも登録トランザクションの中で確定する(commit 後に落ちても - `placing` が残らず、normalize の投入も欠けない。`edit::prepare_normalize_in`)。`placed` の件のディレクトリに走査で音声が見えたら `pending` に戻す(消せなかった原本や - 配置後に置かれたファイルを `placed` の裏に隠さない) -- **後続は rg / transcode に加えて normalize。** WAV / ALAC / AIFF は `[normalize].wav_to_flac` なら - 編集バッチを作って投入する(D-46 で P2-10 に先送りしていた自動投入。登録と同じトランザクション)。 - 既存ライブラリの一括変換はこれまでどおり手動 - -**理由**: 承認キューの単位をディレクトリにするのは、購入分の zip を展開した形がそのまま 1 アルバムで、 -Library と同じ「ディレクトリ = album」の規則に乗るため。トラック単位の補正を入れるのは、配信購入分の -TITLE / TRACKNUMBER の欠落や表記揺れが CD と同じくらい多く、外部ツールで直してから戻る往復が承認の -価値を削ぐため。配置を CD と共通化するのは、排他・冪等性・再検証・後始末の規則を 2 か所で別々に -守らないため。 - -**却下**: Inbox を直接スキャン対象にする(D-20)。補正を DB だけに持つ(再スキャンで元のタグ値に戻り、 -不変条件 1 と矛盾する)。inotify(コンテナ越しに不安定)。件ごとに別のジョブ(並列 1 の固定キーで走査と -配置を 1 本にすれば、配置中の件を走査が触る競合が無い)。 - -**追記(2026-09-22、P4-18、D-81)**: inotify の却下理由を改める。実機は Inbox を bind mount していて同じ -カーネルなので inotify 自体は動く(動かないのは NFS / SMB をコンテナ内でマウントする構成。SMB からの投入は -Samba がホスト側で書くのでイベントになる)。それでも見送るのは、(1) ディレクトリごとの再帰 watch(zip 展開で -増えるサブディレクトリの登録競合)、(2) コピー中のイベント嵐に対する静穏期間、(3) queue overflow に備えた -ポーリングのフォールバック、が要り、フォールバックが残る以上「変化が無ければ投入しない」を別途入れる必要が -あって、それを入れると inotify の利点は検出の遅れ(最大 60 秒)だけになるため。毎分のジョブ行で一覧が埋まる -問題は指紋の比較で解く(D-81)。要るなら即時トリガとして後から足せる。 - -**未決**: `placed` の行を残す期間(今は 24 時間)。Inbox のサブディレクトリを 1 件にまとめる規則 -(ディスクごとのサブディレクトリ `Disc 1` / `Disc 2` を 1 アルバムに束ねる。今はディレクトリごとに別の件)。 - -**追記**(2026-09-20、P3-4): 配置の直後に、その album のアートワークをスキャンと同じ規則で解決し -thumbnail を投入する(`scanner::resolve_album_artwork_now`)。これまでは次のスキャン(起動時 / 手動)まで -画像が出なかった。YouTube のダウンロード(D-70)で新しい album ができる度に待たされるのが目立つため、 -Inbox 配置全般の改善として入れた。失敗しても予約(`artwork_resolved_at = NULL`)が残るので従来どおり -スキャンが続きをやる。 - -## D-69 動画タイトルの解釈は外部のメタデータプラグインに任せ、spindle は汎用に保つ - -**決定**(2026-09-19。P3-1 / P3-2): - -- **spindle はタイトルの慣習を知らない。** YouTube の動画タイトルとチャンネルからトラックのメタデータ - (タイトル・アーティスト・アルバム・category)を決める処理は、外部コマンド(メタデータプラグイン)に - JSON のプロトコル(SPEC §7.7)で問い合わせる。spindle が持つのは、プラグインの起動(引数配列・ - タイムアウト・終了コード検査・stderr のログ)、Response の検証、タグと `pathgen::TrackFields` への写像、 - category の語彙への自動追加だけ -- **チャンネル定義(チャンネル → artist / category、album 名の形)もプラグイン側。** spindle の設定は - `[ytmusic].metadata_command` と `metadata_timeout_secs`(とダウンロード元の URL。P3-3)だけ -- **プロトコルは提供元に依らない形**(`source` / `op` / `item` を持ち、未知のフィールドは無視する)。 - `ok: false` の `reason` に `skip`(意図的に取り込まない)を持ち、要対応(`unmatched` / - `unknown_channel`)と区別する。プラグインは判定できたかに関わらず終了コード 0 で返し、非ゼロ・不正な - JSON・必須の値の欠落はプラグインの故障として扱う -- **category は無ければ語彙に追加する**(同じ canonical key があればそれを使う)。プラグインの定義が - category の正で、初回起動の空 DB でも追加の操作なしに動く -- 参照実装は `AkashiSN/spindle-ytmusic-meta`(private)。宣言的ルール(TOML)のエンジンとフィクスチャ - (Python 版 ytmusic のテスト 98 件から生成)とチャンネル定義を同梱した Rust のバイナリで、新パターンは - Claude がそのリポジトリでルールとフィクスチャを 1 件ずつ足す - -**理由**: spindle は公開リポジトリであり汎用に保ちたい。タイトルのパターン、チャンネル名、アーティストの -グループ、フィクスチャの実タイトルはすべて利用者固有で、ルールを TOML に外出ししてもエンジン側に -「`(from …)` / `with …` / `【合唱】` / `Live ver.`」といった慣習が残り、テストにも実タイトルが出る。 -実行ファイルの境界で切れば spindle 側に固有名詞が一切入らず、プラグインは別言語(Python 版そのままでも) -でも書ける。 - -**却下**: spindle にルールエンジンと同梱 TOML を持つ(一度実装したが、エンジンに慣習が残るので履歴ごと -取り下げた)。ルールだけを別リポジトリに外出しして spindle が読む(同上)。GUI からのルール編集 -(ルールの正が 2 か所になり、フィクスチャで守れない)。チャンネル定義を spindle の config.toml に置く -(固有名詞の置き場を 2 つに分けない)。 - -~~**未決**: ダウンローダ(P3-3)で `ok: false` のアイテムをどう見せるか。プラグインを Docker イメージに -どう同梱するか。~~ → D-70 で決めた(Inbox の `_unmatched` の件として見せる。プラグインは実行時マウント)。 - -## D-70 YouTube のダウンロードは Inbox に置くだけにし、判定の確認と既存アルバムへの追記は Inbox が担う - -**決定**(2026-09-20。P3-3): - -- **ダウンローダ(`ytdl` ジョブ)は Library に触らない。** yt-dlp で音声(webm の Opus)を取り、 - ffmpeg で `.opus` に remux(再エンコードなし)し、プラグインの判定をタグに書いて **Inbox に置く** - ところまで。配置・登録・後続ジョブは Inbox の承認と配置(D-68)がそのまま担う。プラグインが正しく - 判定したものも人が一度見てから Library に入る(自動で反映されたものを確認したい、という要求。 - D-68 の「承認キューを挟む」と同じ思想) -- **判定できなかったもの(`ok: false` の `unmatched` / `unknown_channel` / 未知の reason)も Inbox に - 置く**(`Inbox/youtube/_unmatched/<channel>/`)。TITLE に動画タイトルをそのまま書き、他は空にして - Inbox の補正フォームで人が埋める。**受け皿を別に作らない。** `skip` はダウンロードせず終わる -- **タグに載らない情報はサイドカー `spindle-inbox.json` で渡す**(category、ファイルごとの判定・ - メッセージ・URL・チャンネル)。Inbox の行はキャッシュで DB に直接書かないという D-68 の規則を守るため、 - ダウンローダが Inbox の DB を触らない。走査は音声でないので無視し、`GET /api/inbox` が読んで件に - 付け、配置の成功時に消す(Library へは持っていかない)。人が手で置いた件には無いので従来どおり -- **Inbox は既存の album に追記できるようにする。** MUSICBRAINZ_ALBUMID が無く、宛先ディレクトリに - active な album があり、その album にも MB キーが無ければその album を採用する(これまでは件ごとの - 新規キーで衝突 → failed)。「花譜のお歌」のように育つアルバムに曲を足すのが YouTube 取り込みの通常 - 形なので、これが無いと成立しない。MB キー同士が違えば従来どおり衝突。TRACKNUMBER の無いファイルは - 採用する album の active な `track_no` の最大 + 1 から名前順に採番して提案し、承認時に採用 album の - active なトラックと `(disc_no, track_no)` が重なれば 400 で先に直させる -- **重複取り込みは `SOURCE_URL` タグで防ぐ。** yt-dlp の `webpage_url`(正規形)をファイルに書き、 - Library(`track_tags`)か Inbox(`inbox_files.tags`)に同じ値があれば取り込まない。DB を消しても - ファイルに残る(ファイルが正)。プロトコルの予約キーに加える -- **webm は `Archive/youtube/<id>.webm`。** Library のパスを写さない(リネームで追随できない)。 - DB に行は作らない(`archived_files` は GC の退避台帳で `eligible_after` が付く。追記のみの生データを - GC の対象にしない) -- **プラグインは実行時マウント。** 参照実装は private で固有名詞を含むので、公開イメージに焼かず - 公開 CI からも pull しない。static バイナリをホストの `/mnt/ssd/apps/spindle/bin/` に置き、compose で - `/usr/local/bin/spindle-ytmusic-meta:ro` にマウントする。起動時診断で実行できなければ警告(yt-dlp と同じ。 - 起動は通す) -- **技術的な失敗だけがジョブの失敗。** 取り込み済み・webm の音声が無い・プラグインの故障は再試行しても - 変わらないので `JobError::Fatal`(バックオフせず `failed`)を足す。ネットワークの失敗は従来どおり再試行 - -**理由**: ダウンローダに配置・採番・排他を持たせる案(SPEC §7.7 の当初の記述)は、Inbox と同じことを -2 か所に持つうえ、判定を人が確認する場所が無い。Inbox に寄せれば downloader は「ファイルを置く」だけの -小さなジョブになり、確認・補正・受け皿・後続ジョブはすべて既存の経路で済む。 - -**却下**: `ytmusic_downloads` テーブルと専用画面(Inbox で代替できる量の UI とマイグレーションが増える)。 -`ok: false` をジョブの失敗で見せる(直す場所が Jobs タブになり、補正もできない)。プラグインを -別イメージから `COPY --from`(private イメージを公開ビルドが pull できない)。webm を Library のパスに -写す(リネームで追随できない)。 - -**追記**(2026-09-20、実機の通し確認): 実 URL で ダウンロード → Inbox(既存 album「花譜のお歌」への -追記、採番 246)→ 承認 → Library まで通り、ytmusic CLI の経路は置き換えられた。見つかった差: 承認画面の -下書きは `artist` を 1 値で見せるが、ファイルの `ARTIST` はプラグインの多値(「花譜」「理芽」)で、 -未編集なら多値のまま Library に入る(上の規則どおり)。承認画面が「実際に書かれるもの」と違って -見えるのは良くないので、**承認画面は ARTIST の全値(`;` 区切り)と埋め込みのカバー画像を見せる** -(Inbox のファイルの `PICTURE` を返す `GET /api/inbox/:id/artwork/:hash` を足す。設計時に `:file` から -hash アドレスへ変更。下の追記)。P4-4。 - -**追記(2026-09-20、P4-4 の設計。codex の設計レビューで 2 往復)**: - -- 下書きのトラックに **`keep_artists`**(ファイルの多値 ARTIST をそのまま保つ)を持たせ、意図を明示する。 - 提案は多値なら true で、`artist` は全値を **`"; "`** で結合した表示文字列(foobar2000 等の多値の慣習。 - Library の一覧が `artist_display` に使う `", "` とは区別)。配置は true なら**現在の個数に関係なく** ARTIST に - 触れず(承認後にファイルが 1 値に変わって走査 → pending → 保存値の再表示、という経路でも安全側)、 - false なら `artist` の 1 値で置き換える(`;` で分割しない。分割規則を持ち込むと `;` を含む 1 値を - 表せない)。画面は元の値を境界の分かるチップで見せ、チェックを外すと 1 値の入力になる。チェックを - 出すのは多値のときだけで、保存値が true でもファイルが多値でなくなっていれば false に戻す -- **却下**: 結合文字列との一致を「未編集」の識別子に兼用する案(codex の指摘: `["A; B", "C"]` と - `["A", "B; C"]` が同じに見える、1 値 `A; B; C` に畳む意図を表せない、同じ文字列を打ち直しても 1 値に - ならない)。先頭値との一致(旧規則。画面と書かれるものが食い違う) -- 旧下書き(`keep_artists` 無し。failed / rejected にも残り、reopen 後は保存値が優先される)は旧規則で - 解釈する(`artist` が先頭値のままなら保つ)。互換を落とすと再承認で多値が 1 値に潰れる -- 画像の口は **`GET /api/inbox/:id/artwork/:hash`**(TASKS の `:file` から変更)。ハッシュアドレスなら - Library の `/api/artwork/:hash` と同じく内容が不変で `immutable` + ETag が成り立つ。ファイル指定だと - ファイルが書き換わったときに同じ URL で別の画像が返り、キャッシュを信用できない。`:id` で件に絞るのは - 他の件のファイルを開かせないため。**304 は実体の照合の後**(DB の `PICTURE` だけで 304 を返すと、実体が - 消えた / 差し替わった後も一致する ETag に 304 を返し「消失 / 不一致は 404」に反する)。MIME はタグの値を - 信用せず内容の sniff(D-49 と同じ規則) -- 代表画像: `inbox_files.tags` の `PICTURE` は画像種別を持たないので、走査が Library の `pick_embedded` - (front cover 優先、無ければ先頭)で選ぶ画像を先頭に置き、画面は先頭を代表にする。件の代表は各ファイル - 代表の最頻(同数なら先に現れたもの)で、件の中の多数派を目安に見せる要約。配置後の album の代表 - (同梱カバー → disc / track 順で最初のトラックの埋め込み、追記先なら既存の代表)とは異なり得る。 - 忠実なのはトラックごとのチップ / サムネイルで、見出しの画像は要約、と切り分ける -- 状態非依存で返す(rejected / failed でも reopen 前に確認したい。placed は実体が消えているので 404)。 - サムネイルは作らない(承認画面だけで使い、件は配置で消える。原寸を `<img>` で縮小して十分) -- 承認画面が見せるのは埋め込み画像だけ。同梱の `cover.jpg` は配置時に埋め込みより優先されるが、その - 表示は今回の範囲外(件のディレクトリを API のたびに歩くことになる。必要になったら走査で - `inbox_items` に同梱カバーの有無を持たせる) -- 受け入れ: `tests/inbox_api.rs`(提案の結合と `keep_artists`、artwork の 200 / 304 / 404(不明 hash・他の件・ - 実体消失で If-None-Match が一致しても 404・差し替え)/ 401、front cover が先頭)、`tests/inbox_job.rs` - (`keep_artists` で多値を保つ・false で 1 値・旧下書きの互換)、`web/src/lib/inbox.test.ts`(`pictureOf` / - `itemCover` / `draftFrom` の旧下書き) - -**追記(2026-09-21。P4-13)**: 導線を操作タブの節から独立した YouTube 画面に移す(SPEC §12.6)。操作タブは -選択したトラックへの操作の場所で、選択と無関係なダウンロードが混ざって見つけにくく、投入後の行方も -ジョブ画面に埋もれていた。画面には ytdl ジョブの一覧(結果と「Inbox で確認」)を置き、`/youtube?url=` で -URL を受ける(ブックマークレット。同一 origin の GET なので CORS / CSRF の話が無い)。再生リストの展開時に -Library / Inbox に `SOURCE_URL` のある動画は投入しない(動画ごとのジョブが「取り込み済み」で失敗する -のは同じ結果だが、一覧が失敗 200 行で埋まる。P4-14 で既存曲に `SOURCE_URL` が付いたので、再生リストを -貼れば新しいものだけが落ちる)。`[ytmusic].ytdlp_args` で yt-dlp に毎回付ける引数を配列で渡せる -(`--extractor-args` / `--cookies`。P4-16 の同期が常用になると要る)。**UA / Referer は付けない**: yt-dlp の -YouTube 抽出は player client(web / ios / android …)の偽装で innertube API を叩くので、ブラウザ風の UA を -上書きすると client の偽装と食い違って弾かれる(yt-dlp の公式見解)。Referer も innertube には効かない。 -ブロックへの対処は yt-dlp の更新(P4-12 (7))と `ytdlp_args` の 2 つ。 - -**追記(2026-09-22、P4-19)**: 二重取り込みの検出は `SOURCE_URL` の一致だけでは足りないので、Inbox の承認 -画面に**同名の警告**を出す。追記先の album に同じタイトル鍵(NFKD + casefold + 空白の畳み込み。`(Cover)` / -`【… Live ver.】` の注記は落とさない)の active な行があれば、そのトラックに `same_title`(track_id / -rel_path / duration_ms)を返し、画面が「⚠ Library に同名: <ファイル名>(長さ)」と出す。**承認は止めない** -(判断は人が行う、が D-70 の原則。同じ曲名の別テイクは正当)。理由: `SOURCE_URL` は補填(P4-14)で -`title-mismatch` / `no-track` になった行や、CD / 購入で入れた曲には付かず、再アップロードや切り抜きは URL が -違うので、同期は「持っていない」と判断して落としてくる。実データで測ると、同一 album 内の同名は 9,102 行中 -27 グループ(119 行。多くはキャラ別 Solo ver. などの正当なもの)で、YouTube 由来の `〜のお歌` では 6 グループ。 -そのうち VALIS「彷徨フォーエバー 【Live ver.】」は**長さが完全一致で URL だけ違う**(P4-16 のリハーサルで -気づかずに承認した二重取り込み)。判定を「注記まで含めて一致」に限るのは、緩めると毎回警告が出て無視される -ようになるため。長さを添えるのは、別テイクと本当の重複を人が見分ける唯一の手がかりだから。 - -**追記(2026-09-25。P4-22。追記のディスク番号)**: 追記先の album の active な曲がどれも `disc_no` を持たず、下書きの -`disc_no` の最大が 1(人が 2 以上にした値は捨てずに書く)で CD の取り込みでないときは、配置で `DISCNUMBER` を**書かず、件のファイルにあれば消す** -(`import::inbox::omits_disc`。`destination.uses_disc` を応答にも載せ、画面は同じ規則で disc 列を空で見せる)。 -理由: YouTube の件は提案が disc 1 固定で、旧パイプライン由来の disc なしの album(Library の 6,659 曲)に追記すると、 -disc → track の並べ替えで追記した曲だけが末尾に回った(リハーサルの明透 165・理芽 233。バッチ 22 で手当て)。 -ファイルが持っていた `DISCNUMBER` も消すのは、ファイルが正(DB は `disc_no` をタグから登録する)で、1 つでも残ると -同じ並びの問題が戻るため。下書きの `disc_no` は u32 のまま 1 で持つ(番号の衝突判定は既に NULL を 1 として扱う)。 -宛先がディスク番号を使っている album・CD の件(複数枚の medium 位置)・新規の album は従来どおり書く。 - -## D-71 偽ハイレゾ検出は表示と絞り込みだけに使い、判定はカットオフの「崖」と実効ビットで下す - -**決定**: 可逆かつ `sample_rate > 48000` または `bit_depth > 16` のトラックを `hirescheck` ジョブ -(読むだけ。track_id + audio_version の版付き、flaccheck と同型)で解析し、結果を `tracks.hires_check` -に記録して一覧のバッジ・固定フィルタ・DSL に出す。**判定を消費する自動処理は置かない**(P3-5、SPEC §7.10)。 - -- **用途は「知るため」に限る。** 具体的には、移行データに別トラックとして両方入っている `Hi-Res/` - の 24/96 とアルバム直下の 16/44 の、どちらを残すかの判断材料。および配信ハイレゾの格付け - (CD 由来の AccurateRip / CTDB に相当する裏付けが無い)。Derived(可逆はどのみち Opus 48 kHz)・ - 配布ビュー・RG・リネームは判定に依らない -- **アップサンプリングはカットオフ周波数だけで判定しない。** `cutoff_hz ≤ 25 kHz` に加えて、 - カットオフ前後 1 kHz の落差 `cliff_db ≥ 30 dB` を条件にする。SRC のローパスは急峻な崖を作るが、 - アナログテープ起こしや静かなアコースティックは本物でも 22 kHz 前後から自然に減衰するため、 - カットオフだけでは誤検出する。崖の無いものは `inconclusive` にして人が見る -- **カットオフの探索は 1/3 オクターブで平滑化した系列で候補を決め、エッジは平滑化前の系列で - 詰める。崖も平滑化前で、同じエッジを中心に測る。** 24 kHz での 1/3 オクターブ幅は約 5.5 kHz - あり、平滑化した系列の「最高ビン」をそのまま cutoff にすると窓の下端ぶん(最大 1/6 オクターブ、 - 22.05 kHz の brickwall なら 24.75 kHz)上へずれて、主対象の 44.1 → 96 kHz を取り逃がす。 - また平滑化した系列で前後 1 kHz を比べると崖を自分でぼかして 30 dB に届かない。境界(完全ゼロの - 帯域、上端まで信号がある本物、Nyquist 直下のカットオフ、全無音)は SPEC §7.10 で有限値か NULL に - 定めてある。既定の `cutoff_hz` は 25 kHz: 48 kHz マスターの 24 kHz brickwall と Hann 窓の漏れ込み - (数百 Hz)を拾える余裕を取る。本物の 96 kHz 録音は 30 kHz 以上まで伸びるのが普通で、崖の条件も - あるので 25 kHz でも誤検出は増えない。 - 実装時の補正(2026-09-20): エッジは「床 + 10 dB を上回る最高ビン」ではなく **200 Hz 幅の dB 平均の - 段差が最大のビン**、探索範囲は候補の 1/3 オクターブ下まで。Hann 窓のサイドローブ(18 dB/oct)が - しきい値を上回る範囲までエッジを押し上げるのを避ける(内容がフルスケール近くまで詰まった信号で - 数 kHz ずれ、24 kHz の brickwall を取り逃がしていた)。床は −150 dB を下限にする(完全ゼロの - 帯域で −200 dB に張り付くと遠い漏れまで拾う) -- **計測できたものが無ければ suspect より先に `inconclusive`。** 全無音は OR = 0 で実効ビットが - 求まらず(末尾ゼロが 32)、そのまま式に入れると `padded` になる。cutoff と実効ビットが - ともに NULL なら疑いの判定に進まない -- **ビット深度は全サンプルの OR の末尾ゼロで見る。** `round(sample × 2^(bit_depth−1))` を i32 に - 戻して OR し、実効ビット = `bit_depth − 末尾ゼロ`。`≤ 16` なら `padded`。ディザやゲインを - かけた水増しは検出できないが、それは「16 bit 相当の情報量」の証明が原理的にできない領域で、 - 素の 8 bit ゼロ詰め(最も多い形)だけを確実に拾う。32 bit は f32 で正確でないので判定しない -- **計測値(`hires_cutoff_hz` / `hires_cliff_db` / `hires_effective_bits`)も保存する。** `upsampled` と - `inconclusive` を分けるのは崖なので、崖の値が無いと 29.9 dB と 3 dB が同じに見える。 しきい値は `[hires]` に - 既定値付きで置くが、判定は検査時に確定し、しきい値を変えても既存の結果は書き換えない - (再判定は手動投入)。計測値が残っていれば目視の判断と DSL(`%cutoff% LESS 23000`)は - いつでもできる -- **対象は 44.1/48 kHz かつ 16 bit の可逆を含まない。** 非可逆由来の可逆(44.1 kHz の FLAC が - 16 kHz で切れている)の検出は、対象が Library の可逆全曲に広がるうえ、しきい値の誤検出リスクが - 高い(MP3 128k の 16 kHz と本物の暗い録音の区別がつかない)。要望が出るまで作らない - -- **並列度は `max(1, CPU コア数 / 2)`。** ジョブの Semaphore は種別ごとに独立で、rg(コア数)・ - transcode(コア数 − 1)・flaccheck(コア数)と同時に走ると合計がコアを超える。デコード + FFT は - `flac -t` より重く、スキャン完了時に rg / transcode と一緒に自動投入されるので、hirescheck 側を - 半分に抑える。CPU 系ジョブ共通の予算は残課題(SPEC §17) -- **`JobType::version_field` に `Hirescheck` を加える。** これが無いとワーカーの stale ゲートと - `track_locks` が効かない(flaccheck と同じ落とし穴) - -**理由**: 仕様の「任意機能」であり、自動処理に結びつけると誤検出の被害が Derived や削除に -及ぶ。表示に留めれば誤検出のコストは「人が見て無視する」だけで済む。 - -**却下**: カットオフだけで判定(自然なロールオフの本物を偽と言う)。計測値を残さず判定だけ保存 -(しきい値を変えるたびに 24/96 の全曲を再デコードする)。判定を読み取り時にしきい値から導出 -(DSL と固定フィルタが設定値に依存し、SQL に閾値を毎回バインドする割に得るものが小さい)。 -重複グループ(同じ `audio_md5`)への統合表示(`Hi-Res/` と 16/44 は PCM が違うので `audio_md5` は -一致せず、別マスターを結びつける仕組みは無い。P3-5 の範囲を超える)。非可逆由来の可逆の検出 -(上記)。 - -**追記**(2026-09-20、実機で較正): TrueNAS の対象 124 本(ALAC 24/96 と 24/48)で観察した結果、 -本物の 96 kHz(106 本)は cutoff 25.4〜43 kHz / 崖 0.8〜8 dB、疑わしいもの(複数プロデューサーの -寄せ集めコンピ 16 本と 48 kHz マスターの 2 本)は cutoff 20.2〜24.8 kHz / 崖 4〜21 dB だった。 -スペクトルを見ると、SRC の段差は「エッジ直下の音楽の残り(−67 dB 程度)」と「上げた後の床 -(−83 dB 程度)」の差で決まり、実 SRC では 12〜21 dB にしかならない。30 dB は帯域内フルスケールの -合成信号でしか出ない値で、17 本が全部 `inconclusive` になった。また 15 kHz からなだらかに落ちて -22 kHz 手前で床に沈む型(段差ゼロ)があり、これは崖をどう測っても拾えない。本物はすべて -25.4 kHz 以上まで伸びていたので、cutoff の絶対値で拾う。決定: -- `[hires].cliff_db` の既定を 30 → **10 dB**(本物の膝 8.1 dB は届かない) -- `[hires].hard_cutoff_hz = 22500` を追加。cutoff がこれ以下なら崖に関わらず `upsampled` - (44.1 kHz の Nyquist + 余裕。`cutoff_hz` 以下であること)。22.5〜25 kHz(48 kHz マスター)は - 従来どおり崖で判定する -- 既存の結果は書き換えない(規則どおり)。実機は手動で再投入した - -## D-72 外部メタデータは MusicBrainz を「盤の識別」にだけ使い、値は公式表記を手で入れる。Discogs / VGMdb / .fpl は作らない - -**決定**(2026-09-20。SPEC §17 の残課題を閉じる): - -- **外部ソースは MusicBrainz だけ。** Discogs も VGMdb も作らない。`.fpl` 書き出しも作らない(SPEC §10 の - 「非対応」で確定) -- **候補から写すのは既定で「盤を見分けるのに要る最小限」**: `ALBUM` / `ALBUMARTIST` / `DATE` / - `DISCNUMBER` / `DISCTOTAL` / `TRACKTOTAL` と MusicBrainz の id(`MUSICBRAINZ_ALBUMID` / - `MUSICBRAINZ_DISCID`。同一性と遡及照合の鍵(DiscID は D-67 追記 3 で album の同一性からは外し、CD の印と - 1 枚の識別に使う)。要らなければプロパティタブで消せる)。`LABEL` / - `CATALOGNUMBER` / `BARCODE`、トラックのタイトル / アーティスト / ISRC は既定では写さず、 - 「全部写す」を選んだときだけ写る。トラック行は番号と長さだけの空行になり、公式サイトの貼り付け解析 - (D-65)で埋める。空のフィールドはタグに書かない(`tagRows` が落とす。従来どおり) -- **プロパティタブに foobar2000 の Properties と同じ「フィールドの削除」と「フィールドの追加」を置く。** - 値のダブルクリック編集(既存)に加えて、フィールド(ラベル)自体を右クリック(または行末の ×)で - 消し、新しいキーを追加できる。どちらも既存の一括編集 op(`delete` / `set`)で選択全体に効き、 - バッチとして巻き戻せる。サーバの変更は無い -- P4 のタスク: 確定フォームの「写す範囲」(既定 = 最小限、「全部写す」)、プロパティタブのフィールド - 削除 / 追加 - -**理由**: foobar2000 での運用実績。MusicBrainz も freedb も表記揺れが激しく(複数アーティストの並び、 -キャラクター名と声優名の書き方)、結局すべて手で埋めることが多かった。そのとき外部ソースの -価値は「複数枚を取り込むときにどれがどれか分からなくならない」ための識別だけで、値は公式サイトや -ジャケット裏の表記から入れていた。Discogs の表記も登録者次第で MusicBrainz と同じ問題を持ち、 -識別としては DiscID(TOC)に劣る。カタログ番号や JAN は要らず、既存ライブラリのタグもアルバム -アーティスト・アルバム名・年・トラック数程度で足りている。 - -**追記 2**(2026-09-23。P4-20。上の「既定で最小限」を CD 画面について覆す): **CD 画面の写す範囲の -既定は「全部写す」**にする。「最小限」は選べるまま残す。 - -理由: P4-20 で CD 画面はトラック表が主役になり、「候補を選んだら表の `Track 01` が実名に変わる」のが -期待される動きになった。既定が最小限だと、候補を選んでも表が空のままで、ユーザから見ると候補を -選んだ意味が分からない。D-72 の「値は公式表記を手で入れる」という考え方自体は変えておらず、 -**入り口の既定を変えただけ**(写した値は Inbox の承認画面で直せるし、「最小限」に切り替えれば -元の挙動になる)。 -Inbox の承認フォームはこの範囲を使っていないので影響しない。 - -**却下**: Discogs をカタログ番号検索の補助に残す(値を取っても直すので効かない)。VGMdb(非公式 -ミラーのスクレイピング依存)。Cover Art Archive(**→ D-82 で覆した**。当時は「MB で当たる盤の -ジャケットは取れるが、要望が無い。アートワークは埋め込み / 同梱 / 手動で足りている。D-60」と判断した -が、候補が複数出たときの見分けに要るという要望が出たので引くことにした)。AcoustID(配信音源を MB に結びつける穴を -埋めるが、要望が無い)。gnudb / iTunes Search / Deezer / Spotify(文字列照合で、識別にもならない)。 - -## D-73 CPU 系ジョブは種別ごとの上限に加えて共通の並列予算(= CPU コア数)を取る - -**決定**(2026-09-20。D-71 の残課題。P4 で実装): - -- `worker.rs` に CPU 系(`rg` / `transcode` / `flaccheck` / `hirescheck`)が共有する Semaphore を - 1 本足し、許可数は CPU コア数。該当種別は種別ごとの Semaphore(今のまま)に加えてこの予算も取る。 - 種別単独のときは今と同じ速さで、複数種別が同時に走るときだけ合計がコア数に収まる -- 予算の取得順は「種別 → 共通」。共通が取れなければ種別の許可を持ったまま待つ(ジョブは - `running` にしない。キューの順序は変えない) -- `GET /api/jobs` の `concurrency` に `cpu_budget` を足して画面に出す -- 受け入れ: 種別ごとの上限を超えないこと(既存)に加え、4 種を同時に投入したとき実行中の合計が - コア数を超えないこと(`tests/jobs.rs`。ハンドラを sleep にして running の数を数える) - -**理由**: 種別ごとの Semaphore は rg = コア数、transcode = コア数 − 1、flaccheck = コア数、 -hirescheck = コア数 / 2 で、スキャン完了時に 4 種がまとめて投入されると最悪 3.5 × コア数の重い -処理が同時に走る。NAS では I/O も競合し、同じプロセスの axum の応答が鈍る。種別の上限を下げるだけ -だと単独で走るときも遅くなる。 - -**却下**: 上限を下げるだけ(単独が遅くなる)。現状維持(自動投入は 1 日 1 回程度だが、初回スキャン -直後は数千件が一度に走る)。 - -**追記(2026-09-20。P4-1 の実装)**: 共通が取れないときは「種別の許可を持ったまま待つ」ではなく、 -**種別の許可も手放して claim せず、次の周回(実行中のタスクの完了で wake が来る)で試す**。スケジューラは -1 本のループなので、そこで待つと他の種別の claim まで止まる。ジョブは queued のままで順序も変わらない -ので、決定の意図(running にしない・順序を変えない)は同じ。`cpu_budget` は `concurrency` の中ではなく -`GET /api/jobs` の兄弟フィールド(`concurrency` は種別名をキーにした表で、画面がそのまま行にするため)。 -`JobType::cpu_bound` が対象種別を決める。**予算を分け合う種別は 1 件ずつラウンドロビンで claim し、 -次の周は最後に claim した種別の次から始める**(種別名順に空きが尽きるまで取ると、先頭の flaccheck のキューが -尽きるまで rg / transcode / hirescheck が始まらない。codex のレビュー指摘。当初は「開始位置を周回ごとに -1 つ進める」だったが、何も取れない周回(tick・別の wake)でも進むため開始位置が実質ランダムになり、 -同じ種別に続けて予算が回ることがあった。2026-09-22 に CI で観測して修正)。受け入れは `tests/jobs.rs`(コア数 2 で -rg + flaccheck を 3 本ずつで実行中の最大が 2、4 種を種別名順に固めて 3 本ずつ投入しても先頭 6 本の開始に -4 種が揃う、thumbnail は予算に縛られず 4)。 - -## D-74 album gain は album ごとの属性。既定 off、CD 取り込みは on、Inbox の承認画面で選ぶ - -**決定**(2026-09-20。P4-5 で実装): - -- `albums.album_gain`(真偽、既定 false。マイグレーション 0017)。**true の album だけ** rg を album 単位 - (`rg:album:<id>`。全曲デコード + ゲート付き集計)で投入し、false の album は track 単位 - (`rg:track:<id>`。対象の曲だけ)。`rg_album_*` は true の album にしか付かず、書き出しも同じ - (false なら album のキーは書かず、あれば消す。D-48 の「値の無いキーは消す」) -- **CD 取り込み**(`cd/place.rs`)で作る album は **true**。アルバムとして通して聴く単位だから -- **Inbox の承認画面**に「album gain を計算する(アルバム通し再生用)」のチェックボックス。既定 off。 - 既存 album への追記なら、その album の現在値が初期値で、変えると album の属性も変わる -- アルバム画面(SPEC §12.6)で後から切り替えられる。false → true で album 単位の rg を投入、 - true → false で `rg_album_*` を NULL にして「RG 未書込」に戻す(次の書き込みで album のキーが消える) -- 既存 album はすべて false、既存の `rg_album_*` は 0017 で NULL に揃える(ファイルには書かない) - -**追記(2026-09-20。P4-5 の実装)**: 切り替えの置き場は**操作タブ**の「ReplayGain / FLAC」節 -(選択行が属する album ごとのチェックボックス、`PATCH /api/albums/:id`)。アルバム画面は無く、アルバム -一覧は表を絞るだけなので。CD の配置は**合流(複数枚組の 2 枚目)でも on** にする。0017 で album の値を -消した行は `rg_written_at` を NULL に戻し `rg_scanned_at` を 1 進める(Derived の `R128_ALBUM_GAIN` が -タグ上書きで追随する)。rg ハンドラは**書き込み時に属性を読み直し**、album 単位の job でも off なら -album の値を書かず、track 単位の job は on の album に属する track の album 値を据え置く(投入と切り替えの -競合で値が嘘にならない)。**スキャンは rg を自動投入しない**(D-47 のまま。新規・音声差し替えの -トラックは `POST /api/rg`(`no_rg` フィルタ)か取り込み経路で解析する)。`store` は `rg_scanned_at` を -前の値より小さくしない(off で `now + 1` へ進めた直後に同じ秒の解析が保存しても巻き戻らない)。 -transcode は Derived に記録する同じトランザクションで元の世代(音声版 / tag_version / 画像 / RG 世代 / -所在)を読み直し、読んでから書く間に動いていれば同じジョブを再キューして揃え直す(属性の切り替えは -track lock を取らず、running の間の投入は dedup で弾かれるため。音声版が動いた場合は再キューした -ジョブが版のゲートで終わり、scanner が投入した新しい版のジョブが揃える)。 -- `POST /api/rg { selection }`、承認後・配置後の自動投入は album の属性を見て投入単位を - 決める。設定ファイルに `[replaygain].album_gain` は置かない(album ごとに決まる) -- **RG の一致判定(`rg_written_at`)は完全一致のまま。** 既存ライブラリの RG タグ(foobar2000 / ytmusic - が書いた track gain。9,099 本すべて track のみ、album は 0 本)は spindle の解析値と丸め・実装差で - 0.01〜0.1 dB ずれるので、初回の「解析値をタグに書く」で全ファイルが書き換わる。**これは許容する** - (spindle の値で統一する)。以後は、書いた値が DB と完全一致して `rg_written_at` が立ち、再解析は - 音声が差し替わったときと手動だけで、同じ音声なら同じ値なので二度と書き換わらない - -**理由**: 実機の通し確認で、YouTube の 1 曲を「花譜のお歌」(245 曲の育つコレクション)に追記した -だけで album 単位の rg が走り、246 曲すべてをデコードし直した。album gain は「全曲をつなげてゲート -した値」なので各曲の値から合成できず、曲が増えるたびに全曲の再解析と全ファイルの書き換えになる。 -運用はアルバムをまたぐシャッフルが主で、既存ライブラリも track gain だけだった。album gain を -使うのはアルバムとして通して聴く CD だけで、それ以外は必要なときに人が選ぶ。 - -**却下**: 全体設定で album gain を off(CD の album gain まで消える)。育つコレクションだけ off -(判定基準が曖昧で、なぜ計算されないかが見えない)。RG の一致判定に許容差(既存の値を尊重して -書き換えを避ける案。spindle の値で統一したいので採らない)。 - ---- - -## D-75 Apple 向けに `aac` 系統の Derived を足す。RG は焼き込み + iTunNORM 0 dB、多値は結合、プレイリストは出さない - -**決定**(2026-09-20。P4-7 で Derived を系統化、P4-8 で `aac` 系統を実装。仕様 SPEC §7.6): - -- Derived を**系統(variant)ごとに 1 本**にする。`opus`(Android の同期・Web 再生・配布ビュー。今まで - どおり)と `aac`(Mac のミュージック.app へ取り込む Apple 向け)の 2 つで固定。パスは - `Derived/<variant>/` 以下に Library のミラー、`derived_files` の主キーは `(track_id, variant)`、 - 設定は `[encode.derived.<variant>]`(`enabled` / `bitrate`。`aac` は `lossy_sources` と - `multi_value_separator` も) -- **エンコーダは ffmpeg 内蔵 `aac`**(`-b:a 256k`。ABR に近く真の VBR ではない)。追加依存を持たない -- **非可逆原本も AAC へ**(`lossy_sources = true`。D-8 の例外)。原本が AAC でも再エンコード(2 本。 - stream copy では焼き込みとリサンプルが効かない) -- **ReplayGain は track gain を音声に焼き込み**(クリップ防止に peak で上限)、タグには `iTunNORM` を - 0 dB 相当で書く。`REPLAYGAIN_*` / `R128_*` は書かない。**RG 未解析なら作らず待つ**(rg の保存で投入)。 - RG の解析世代が変わったら再エンコード(`opus` はタグ上書きで済むが、`aac` は音声に入っている) -- **多値フィールドは `" & "` で 1 値に結合**(設定で変更可)。ミュージック.app は複数値の 1 つしか - 見せないため -- 画像は 768 の JPEG(`covr` の WebP は読まれない) -- **設定の世代を行に持つ**: `audio_profile`(codec / bitrate / サンプルレート規則 / 焼き込み方式)が違えば - Encode、`tag_profile`(区切り / iTunNORM 規則)が違えば Retag。区切りやエンコーダ引数を変えたときに - 既存の Derived が追随する(音声版だけを見る今の判定では UpToDate のまま取り残される) -- `enabled = false` は**凍結**(作らない・触らない・既存行は使い続ける)。GC は系統を区別しない -- **実装(P4-7)**: 系統の設定は起動時に `derived_variants` 表へ写し(`db::derived::sync_variants`。 - `export_profiles` の `fb2k_prefix` と同じ流儀。D-55)、投入判定(`enqueue_if_stale` / `enqueue_all_stale`) - は表から系統ごとの設定を引く(呼び出し側は設定を持ち回らない)。0018 より前に queued だった旧 payload - (`variant` 無し)は opus として扱う。`transcode` の `Resolved` に設定を持ち、表に無い系統のジョブは - 何もせず Done。テストのフィクスチャは `common::enable_opus_variant` で表を作る -- **配布ビューもプレイリストも `aac` には作らない。** `delivery` は `opus` 系統に固定。ミュージック.app - へは `Derived/aac/` のファイルをそのまま取り込み、プレイリストは Apple 側で作る -- **実装(P4-8)**: `derived_variants` に `lossy_sources` / `multi_value_separator` を 0019 で足す(`eligible` - が投入判定で前者を、ハンドラがタグ結合で後者を表から引く。`audio_profile` / `tag_profile` の文字列に - 埋めて解析し直すより素直)。`aac` の対象は `!missing && channels ∈ {1,2} && (lossless || lossy_sources) - && RG 解析済み`(`rg_scanned_at` / `rg_track_gain` / `rg_track_peak` の 3 つが揃っている。時刻だけの行で 0 dB - の焼き込みを確定させない)。節を省略したときの `enabled` は aac だけ false(既存 config のまま新版を - 起動しても始まらない)。エンコードは ffmpeg 1 パス(`-af volume=<gain>dB [-ar 48000] -c:a aac - -b:a <k>k -f mp4`。opus のような中間 WAV は要らない)。焼き込み量は `min(track_gain, −20·log10(peak))` - で、peak は true peak(1.0 超なら減衰側)。これはエンコーダ入力の上限で、AAC 再符号化後のオーバー - シュートは保証しない(安全余裕は入れない。受け入れテストは ebur128 の測定に ±0.5 dB の許容)。 - gain が有限でなければ 0、peak は有限かつ > 0 のときだけ上限を掛ける。`iTunNORM` は 1〜2 値目 `000003E8` に加えて 3〜4 値目 - (同じ 0 dB の基準 1/2500 表現)を `000009C4` で埋める(0 のままだと読む側の解釈が不定になりうる。 - iTunes が読むのは 1〜2 値目)。MP4 のタグは Vorbis 名を lofty の `ItemKey` に写像して標準 atom へ、 - 写像できないキーは `----:com.apple.iTunes:<KEY>` のフリーフォームへ直接置く(lofty の generic Tag は - 未知キーを捨てる)。`iTunNORM` だけは内部キーの大文字化に関わらず atom 名を固定する。画像は thumbnail と同じ変換の JPEG 版(`thumbs/<hex>/768.jpg`)。RG の解析世代 - (`src_rg_scanned_at`)の差分は `aac` では Encode。album gain の on / off も世代を進めるので、その album - の aac は作り直される(track gain しか使わないが、値ベースの判定に列を足すより単純。まれな操作)。 - `lossy_sources` を true → false にしても既存の非可逆の行とファイルは消さない(Skip = 凍結と同じ)。 - ハンドラは `OpusEncoder` と `AacEncoder` の両方を持ち、系統でエンコーダ・タグ書き込み・画像の形式・ - 記録する bitrate を選ぶだけで、期待パスの予約・占有・配置・退避・drift の再キューは共通 -- **実機の順序(P4-8)**: 実機の 9,099 本のうち `rg_scanned_at` があるのは 270 本(残りはファイルに - foobar 時代の track gain タグがあるだけで DB に値が無い)。「RG 未解析は作らず待つ」ので、先に - `POST /api/rg` を全件流し(解析値は DB にしか書かない。Library のタグへの書き込みは別の - `POST /api/rg/write` で、それは aac の前提ではない)、完了後に `[encode.derived.aac]` を有効にして - デプロイする。ファイルの - 既存 `REPLAYGAIN_*` を暫定値に使う案は、値を spindle で統一する方針(D-74)と食い違うので採らない。 - RG 未解析でも焼き込み無しで作って解析後に作り直す案は、二度エンコードで CPU 時間が倍になるので採らない - -**理由**: Apple 純正の「ミュージック」は Opus を読めず、ReplayGain タグも読まない(独自の Sound Check = -`iTunNORM` をトラック単位で、端末の設定が ON のときだけ適用)。端末設定に依らず音量を揃えるには焼き込み -が確実で、焼き込み済みの上でサウンドチェックが ON でも二重にならないよう `iTunNORM` を 0 dB で置く。 -アルバムをまたぐシャッフルが主な運用なので album gain は使わない(D-74)。Apple 側の取り込みは -ファイルだけで足りるので、プロファイル `apple` の m3u8 は YAGNI。 - -**却下**: `fdkaac`(Debian non-free。真の VBR で品質にも定評があるが、依存が 1 つ増える。256k なら -内蔵 `aac` で実用上透過)。`iTunNORM` のみ(端末で ON にする必要があり、換算式と Apple 側の扱いが -不確か)。焼き込みのみ(サウンドチェック ON の端末で Apple が独自に計算した値が重なる)。非可逆原本を -除外する(Apple 側で YouTube 由来の 1,513 本が欠ける)。AAC 原本の stream copy(焼き込みと矛盾。 -複製して実ゲインの iTunNORM を書く案は、その 2 本だけ端末設定に依存する)。`enabled = false` で -ファイルを消す・GC から除外する(消すのは再エンコードの往復、除外は孤児の意味が変わる)。系統ごとに別の root(`paths.derived_aac`。 -データセットを分ける需要が無く、GC と孤児回収が二重になる)。プロファイル `apple` の m3u8 -(SMB の絶対パス / 相対パス。取り込みがファイルだけなので不要)。 - -## D-76 終端を書けなかった `running` は再試行で救い、残ればワーカーが稼働中に回収する - -**決定**(2026-09-20。P4-8 の実機で観測した取り残しの対策。P4-9): - -- **終端遷移の書き込みを再試行する。** `run_one` の終端トランザクション(未 flush の進捗・ - `track_locks` / `job_mutexes` の解放・状態遷移)が `Err` を返したら、1, 2, 4, 8, 16, 32 秒の間隔で - 再試行する(6 回、約 1 分)。ハンドラの結果は `anyhow::Error` を含むので、書く前にメッセージ化した - Clone 可能な形(`Terminal`)にしてから閉包に渡す。再試行の間は種別の許可と CPU 予算を**持ったまま** - (DB が書けない間に次のジョブを始めても claim から失敗する。持ったままなら詰まりが見える)。 - 使い切ったら今までどおり `failed` としての記録を 1 度試み、それも駄目なら `running` のまま手放す。 - 停止(shutdown)が来たら再試行の途中でも abort し、DB は `running` のまま起動時リカバリに任せる - (今と同じ) -- **稼働中の回収。** ワーカーの周回は claim の前に `sweep_cancel_requested` を書き込みで呼んでいる。 - 同じトランザクションで `state = 'running'` の id を読み、プロセス内の実行中表(`Jobs.running`。 - cancel 用の token 表)に無い行を回収する: `cancel_requested_at` が立っていれば `cancelled`、 - そうでなければ `queued`(`started_at = NULL`。`attempts` / `run_after` は触らない = 起動時リカバリと - 同じ)。その `job_id` の `track_locks` / `derived_path_locks` / `job_mutexes` を消し、`last_error` に - 「終端を記録できないまま running に残っていたので再キューした」と書き、ログと SSE にも出す -- **実行中の登録を claim の直後に移す。** 今は `execute` タスクの先頭で `track_running` を呼ぶので、 - claim の直後の周回で「DB は `running` だが表に無い」瞬間があり、回収が本物を戻してしまう。token の - 生成と登録を `claim_one` の spawn 前(スケジューラと同じタスク、claim と順序が固定)に移す。 - 停止時に claim 済みを `queued` へ戻す経路では登録しない。これで `started_at` の閾値は要らない。 - 単一インスタンス前提(SPEC §8)なので、表に無い `running` は「このプロセスが終端を書けなかった行」 - 以外に無い -- 起動時リカバリ(`recover`)はそのまま。回収はその稼働中版で、対象を「表に無い行」に絞っただけ -- 受け入れ(`tests/jobs.rs`): ハンドラが `PRAGMA query_only = ON` を writer に掛けて `Done` を - 返し、別タスクが数秒後に OFF にする → `done` で `attempts = 0`(再試行で完走)。行を直接 `running` - にしてロック 3 種を入れ、ワーカーを起動 → `queued` に戻りロックが消え `last_error` に回収の旨、 - その後ハンドラが走って `done`。cancel 要求付きは `cancelled`。数秒 sleep するハンドラの本物の - `running` は戻されず `done` - -**理由**: 2026-09-20 の実機で、開発コンテナと共有する ssd プールが cargo の `target/` で満杯になり、 -transcode 88 本の終端書き込みが SQLITE_FULL で失敗、`failed` としての記録も同じ理由で失敗して -`running` のまま残った(ロックも残る)。空きができた後も次回起動まで詰まったままで、docker のログも -満杯で欠けていたので、画面の「実行中 88」以外に手掛かりが無かった。一時的な障害なら再試行で仕事の -結果を失わずに済み、長い障害でも回収で再起動なしに自力で戻る。両方あると、短い障害は透過で、長い -障害は再実行(ジョブは冪等)で済む。 - -**却下**: 無限に再試行する(許可を永久に握り、DB 固有の恒久的な失敗で種別が止まる。上限 + 回収で -同じ結果に届く)。回収を N 秒間隔の別タイマーにする(周回ごとの書き込みに `SELECT id … WHERE state = -'running'` を足すだけで済み、索引 `idx_jobs_queue` があるので安い。タイマーを足すと停止と wake の -扱いが増える)。`started_at` に経過秒の閾値を置いて競合を避ける(登録の順序を固定すれば要らず、 -閾値はテストの時間依存を増やす)。回収で `attempts` を進める(ジョブの失敗ではなく基盤の障害。 -起動時リカバリも進めない)。 - -## D-77 MP4(ALAC / AAC)の任意キーは `----:com.apple.iTunes:<KEY>` のフリーフォーム atom。読み書きの写像は 1 か所 - -**決定**(2026-09-21。P4-11。仕様 SPEC §7.5「形式ごとの写像」): - -- Library の MP4 への tagwrite は lofty の generic `Tag` を経由せず、ilst を直接編集する - (`domain::tags::apply_ilst`)。Vorbis 名が lofty の `ItemKey` に写像でき MP4 の atom を持つキーは - 標準 atom(`©nam` `trkn` `cpil` `----:com.apple.iTunes:CATALOGNUMBER` 等。値の encode は lofty の - `Tag` → `Ilst` 変換に載せ、`trkn` / `disk` の対や bool の special-case を借りる)、写像表に無いキーは - `----:com.apple.iTunes:<KEY>` のフリーフォーム atom(多値は 1 atom の複数値)。`iTunNORM` は内部 - キーが大文字でも綴りを固定する -- 読み側(`collect_mp4`)も同じ写像。標準 atom は今までどおり generic `Tag` 経由で Vorbis 名に、 - generic `Tag` が捨てるフリーフォーム atom は `mean = com.apple.iTunes` に限って名前を大文字化した - キーで取り込む(`TagSet` はキーを大文字化する。標準 atom と同名になるものは標準 atom を優先)。 - 他の `mean` は読み書きとも触らない -- 置き換え・削除はフリーフォームの名前を**大小文字を無視して**消してから書く。外部ツールが - `MyKey` で書いた atom を spindle が `MYKEY` で上書きしたとき、旧綴りの atom が残ると読み戻しで - 多値に見えて op が failed に閉じる -- Derived の `aac` 系統(D-75、`write_mp4_tags`)も同じ `apply_ilst` で書く(空の ilst に - `TransferTags` をキーで束ねて渡す)。別名キー(LABEL / ORGANIZATION、TRACKTOTAL / TOTALTRACKS)が - 両方あれば後勝ち、は変えない -- MP3 / WAV 等は generic `Tag` のまま。写像できないキーは書けず、読み戻し照合で failed に閉じる - -**理由**: 2026-09-21 の実機(P4-3 の確認)で、ALAC のプロパティタブに `SPINDLETEST` を足すと tagwrite が -「書き込み結果が意図と一致しない(この形式では表現できない)」で失敗した。lofty の generic `Tag` は -`ItemKey` に無いキーを書けず、読み側でも `Ilst` → `Tag` の変換がフリーフォーム atom を捨てるので、 -外部ツール(foobar2000 / Picard)が書いた独自キーが DB に載らない = ファイルが正(不変条件 1)に -反していた。P4-8 が Derived 向けに書いたフリーフォームの写像を Library の読み書きに使えば解け、 -写像を 1 か所にすると Derived と Library でキーの見え方がずれない。 - -**却下**: 既存の ALAC は FLAC 正規化でいずれ消えるので放置(読み側の取りこぼしは残るし、Inbox に -来る m4a も同じ経路)。lofty の `preserve_format_specific_items`(companion tag)に頼る(読み側の -取り込みは解けず、書きは global option の状態に依存する)。フリーフォームの名前を元の綴りのまま -読む(`TagSet` は大文字化する規約で、FLAC のキーも大文字化して DB に載っている。綴りを残すには -DB とハッシュの規約を変える必要があり、往復の二重化は削除側を大小文字無視にすれば防げる)。 - ---- - -## D-78 再生リストの購読は album を `album_id` で束ね、同期は「列挙 → 揃え → 投入」を毎回 DB から計算し直す - -**決定**(2026-09-21、P4-16。SPEC §7.7「再生リストの購読と同期」): -- **購読 = 再生リスト 1 本 → Library の album 1 つ**(`playlist_subscriptions`)。追記先の同一性は - `album_id`(登録時は NULL。同期か配置が Inbox の追記先と同じ規則(`destination_of`)で引けたときに - CAS で束ねる。D-32 で album 全体の移動は id を維持する)。`albumartist` / `album` / `category` は束ねる前の - 初期値と表示用で、PATCH で変えると NULL に戻る。同じ追記先の購読は 1 つだけ(`target_key` UNIQUE と - `album_id` の部分 UNIQUE で DB で閉じる) -- **同期ジョブの順序は列挙 → 追記先 → 照合 → 番号揃え(tags → rename)→ ytdl 投入**。購読由来の ytdl は - `TRACKNUMBER` = 再生リストの位置を書くので、先に既存の行を揃えて隙間を空ける。揃え終わる前の失敗では - 投入しない -- **phase は永続化しない。** 各段の前に追記先の active 行に pending の op が無くなるまで待ち、差分は - 待った後の DB から計算し直す。rename の候補は「現在の `TRACKNUMBER` が目標位置と一致する対象行のうち - ファイル名の先頭の番号が合っていないもの」で、今回のバッチの applied 集合には依らない。**改名は - ファイル名だけ**で、ディレクトリは今のまま(リハーサル環境で album の category が未推定だったため、 - テンプレートの dir で動かすと album ごと `_Unsorted` へ移った。名前の書式だけ違う行も触らない) -- **ytdl の dedup `ytdl:<url>` は変えない。** Duplicate は満たしたと数えず「別の投入が走行中」として結果に - 出すだけ。承認されれば次の同期が Library で拾い、失敗すれば dedup が空いて購読の情報付きで再投入 -- **承認の後続は latch(`sync_requested_at`)+ Requeue + dispatcher。** 配置と手動要求は latch を書いてから - 投入し、走行中の同期は終わる前に latch を見て `Outcome::Requeue`。handler の最終確認から終端までの窓に - 立った latch と失敗で残った latch は常駐の dispatcher(30 秒ごと)が回収する。時刻の比較はしない -- **取れない entry(非公開・削除)は位置を占め続ける。** Library に無ければ「取れない」一覧 - (`kind: private | deleted | unknown`。現行の yt-dlp は `title: null` で来るので区別できず unknown)。 - 番号揃えもその位置を数えるので番号が飛ぶ。公開に戻れば次の同期で拾う -- **固定行が番号を塞ぐ。** `SOURCE_URL` 無し・再生リストに無い・`SOURCE_URL` 重複の行は動かさず、その - 番号への移動は「揃えられない」で報告する。対象同士の swap / cycle は可 -- プラグインが skip / 判定不能でも購読由来なら投入する(再生リストは人が選んだもの)。追記先は購読の値、 - TITLE / ARTIST はプラグインの判定(無ければ動画タイトルと albumartist) -- 上限(`max_enqueue`、既定 50)を超えた分は次回に持ち越す。定期同期は `last_attempted_at`(成否を問わない - 開始時刻)を基準にする(最終 retry が failed になってもバックオフを迂回しない) -- API は `/api/ytmusic/subscriptions`(TASKS の `/api/playlists/...` から変更。`/api/playlists` は spindle - 自身の m3u8 プレイリスト) -- 実装レビュー(codex)で足した規則: 購読の id は再利用しない(AUTOINCREMENT。0022)、同期が queued / running の間は PATCH / DELETE を - 409(`sync_running`)で拒み(各段の前の `updated_at` 検査は控え)、子バッチの終端は batch_id の - pending = 0 で待ち、同じ動画が再生リストに複数回あればその行は - 固定して投入は最初の位置だけ、子バッチが全件 applied でなければ `Failed` で投入しない(計画時の改名の - 衝突は報告だけ)、購読が消えていれば skip も通常どおり - -**理由**: P4-14 で `SOURCE_URL` が揃い「再生リストのどこまで持っているか」が分かるようになったので、URL を -1 件ずつ貼る運用(P4-13)をなくせる。番号を再生リストの順に揃えたい(P4-15 の要求)が、Library に無い -動画を正しい番号に挿入して後続をずらす操作は手作業では 3 段階の一括操作になるため、同期の一部として -`SOURCE_URL` で計算する。設計レビュー(codex)で直した点: (1) 投入が揃えより先だと承認が揃えの前に進み -番号が重なる、(2) phase を持たない再計算方式は「tags 適用後・rename 前に落ちた」境界を今回の applied 集合に -頼ると取りこぼす、(3) Duplicate を満たしたと数えると購読の情報の無いジョブに項目を永久に取り違える、 -(4) 承認の後続を「走行中なら Duplicate」で流すと lost wakeup になり、時刻比較は UNIX 秒精度で同秒の要求を -落とす、handler の最終確認から終端までの窓は generic worker を触らずに dispatcher で閉じる、(5) album を -毎回パスで解決すると album のタグ変更や layout 変更で別 album を作り得る、(6) `find_source_url` の LIMIT 1 は -重複 URL を隠す、(7) 定期投入を成功時刻だけで判定すると失敗後にバックオフを迂回する。 - -**却下**: phase / バッチ id を job payload に永続化して再開(再計算で同じ安全性が得られ、並行する手動 -バッチも同じ経路で扱える)。URL 単位の download ジョブと購読の関連表(同じ動画が複数の購読に出るのは稀で、 -Duplicate を「走行中」として次の同期に任せる規則で足りる)。generic worker の終端トランザクションに購読の -フックを足す(dispatcher で閉じる方が影響が狭い)。TRACKNUMBER を書かず Inbox の max+1 に任せて配置後に -揃える(毎回 2 バッチ増える。位置を先に書けば通常は承認の初期値がそのまま正しい)。 - ---- - -## D-79 イメージは CI が 1 回ビルドして起動確認に通ったものをそのまま GHCR へ push する。版は `/health` が答え、依存の更新は Renovate - -**決定**(2026-09-21、P4-12。SPEC §14「イメージの配布」): -- 置き場は GHCR `ghcr.io/akashisn/spindle`(public、`linux/amd64`)。`main` → `edge` / `sha-<7>`、 - `vX.Y.Z`(この形だけ。他の `v*` は CI が拒む)→ `X.Y.Z` / `X.Y` / `latest`(+ Release)。PR は push しない。 - 書き込み権限(GHCR / Release)は push イベントだけの `publish` ジョブに限り、PR で走る `docker` ジョブは - `contents: read`(同一リポジトリの PR や Renovate のブランチでも write token を持たない)。publish は - docker ジョブが `docker save` した成果物を skopeo で複製する -- CI の docker ジョブが `load` でビルドして起動確認し、**同じイメージ**を `docker push` する - (二度ビルドしない。確認した digest = 配布する digest) -- 版は `--build-arg SPINDLE_VERSION=$(git describe --tags --always)` → `build.rs` → `spindle --version` と - `GET /health` の `version`。加えて `/health` に `ytdlp`(起動時に `yt-dlp --version` を 5 秒で 1 回)。 - 「いまどの版が動いているか」を docker のログでなく `/health` で答える -- 本番は TrueNAS のカスタムアプリ(compose 相当)。自動配備は作らず、更新は UI で pull し直す手順を - OPERATIONS に書く。マイグレーションは前進のみ、戻すときはバックアップから -- 依存の更新は **Renovate**(GitHub App)。yt-dlp は Dockerfile の `ARG` を `# renovate:` 注釈の - custom manager(github-releases)で追う。マージは手動 -- `edge` は開発用で DB の互換を約束しない。スカッシュは最初の `vX.Y.Z` の前に 1 回だけ - -**理由**: `deploy/compose.yaml` が指す `ghcr.io/akashisn/spindle:latest` は存在せず、実機は `git archive` -→ ホストで `docker build` の手作業だった。YouTube の抽出は yt-dlp が古いと壊れるので、更新を仕組みに -する必要があった。Renovate は GitHub App なので、その PR で CI(`pull_request`)が走る(`GITHUB_TOKEN` -で作った PR は走らない)。自作の更新ワークフロー + PAT や Dependabot(`ADD https://…` の版を追えない) -より少ない部品で済む。 - -**却下**: 週 1 の自作ワークフローで yt-dlp の PR を作る(PAT が要る)。Dependabot(yt-dlp を追えない)。 -`arm64` のマルチプラットフォーム(Rust のクロスビルドが遅く需要が無い。要るときに足す)。自動配備 -(TrueNAS のカスタムアプリの更新は UI 操作。手動で十分)。CI で二度ビルドして push(`push: true` で -別に走らせると確認したものと digest が変わり得る)。 - -## D-80 一覧のキーボードは「カーソル行」を選択と別に持ち、Shift + 移動は範囲そのものに置き換える - -**決定**(2026-09-22、P4-17。SPEC §12.2「キーボード」): -- 表は選択(`lib/selection.ts` の immutable な集合)とは別に **カーソル行**(id)を持つ。クリックと移動キーが - 置き、見た目は枠だけ。選択の意味論(immutable、filter 形、anchor)は変えない -- 素の移動 = その行だけを選択、**Shift + 移動 = anchor からカーソルまでの範囲そのもの**(`rangeSelect`。 - 戻れば縮み、Ctrl で足した飛び地は消える)、Ctrl + 移動 = カーソルだけ、Space = トグル。クリックの Shift は - 従来どおり「足す」(`clickRow`)で、キーとクリックで挙動が違う -- filter 形(Ctrl+A)のまま Shift + 移動すると ids 形の範囲に置き換わる。anchor が無ければ移動前のカーソル行を - anchor にする -- カーソルは読み込み済みの行の中でだけ動く。End は「読み込み済みの末尾」であって total の末尾ではない - -**理由**: foobar2000 / Explorer の Shift + 矢印は「anchor からの範囲」で、戻すと縮む。キーの Shift を -クリックと同じ「足す」にすると、行き過ぎた選択を Shift + ↑ で戻せない。クリックの Shift を「範囲そのもの」に -変えると既存の「Ctrl で飛び地を足してから Shift で別の塊を足す」使い方が壊れるので、クリック側は触らない。 -未読込の行はサーバから id が届いていないので選択に入れようがなく、「読み込み済みの外へは出ない」が -仮想化(hooks/useTracks の先頭から順の積み上げ)と矛盾しない唯一の規則。 - -**却下**: カーソルを選択(`Selection`)の中に持つ(選択は表示に依存しない値で、カーソルは表示上の位置。 -SSE や再取得で選択を書き換えない規則を守るため別に持つ)。End で total の末尾まで先に読み込む(6 万件を -一気に読む導線になる。要るなら「末尾へ」ボタンで別途)。Enter で再生(頼まれていない。要望が出たら足す)。 - -## D-81 ジョブ一覧の衛生: 変化が無ければ投入しない、やることが無いのは失敗でない、確認済みは消せる、古い行は GC が消す - -**決定**(2026-09-22、P4-18。SPEC §7.8「検出」、§8「終端の行の片付け」、§9、§12.5、§13 `[gc]`): -- **Inbox の周期の監視はジョブでなく常駐タスク**が行う。`[inbox].poll_interval_secs` ごとに Inbox の指紋 - (音声ファイルのパス・inode・size・mtime・ctime の集合のハッシュ。`import::inbox::fingerprint`)を取り、 - 前回投入時と違うとき、配置待ち(`approved`)か期限切れの `placed` があるとき、起動直後だけ `inbox` ジョブを - 投入する。投入が Duplicate(前のジョブが未完了)なら「前回投入時の指紋」を更新せず次の周回で投入し直す - (走査を終えた後に置かれた変化を取りこぼさない)。手動の `POST /api/inbox/scan` は常に投入。監視の状態 - (最後に確認した時刻)は `GET /api/inbox` の `watch` で画面に出す -- **取り込み済み**(同じ `SOURCE_URL` が Library / Inbox にある)の ytdl は `done`(note に所在)。失敗 - (`Fatal`)にしない。上部バーの赤丸(`summary.failed > 0`)は本当の失敗だけに付く -- **終端(failed / done / cancelled)のジョブ行は消せる**: `DELETE /api/jobs/:id`(queued / running は 409)、 - `DELETE /api/jobs?state=failed`(まとめて消せるのは failed だけ)。ジョブ行は作業記録でユーザデータでは - ないので物理削除(`edit_ops` / `album_verifications` の `job_id` は SET NULL、ロック表は CASCADE) -- **GC が古い終端の行を消す**: done / cancelled は `[gc].jobs_done_days`(既定 7)、failed は - `jobs_failed_days`(既定 30)。0 で消さない。`GET /api/gc/preview` に `jobs: { done, failed }` - -**理由**: 実機で inbox ジョブが毎分 1 行(1 日 1,440 行)積もり、ジョブ画面の「完了」が Inbox の確認で埋まって -他の完了が見えなかった。汚染の原因は「毎分の確認」でなく「確認のたびにジョブ行を作る」ことなので、確認を -ジョブの外に出して変化があったときだけ投入する。赤丸はリハーサルで既に Library にある URL を投入した 2 件の -「取り込み済み」で点きっぱなしになっていた。やることが無いのは異常ではなく、失敗に混ぜると本当の失敗が -埋もれる。確認済みの失敗を消す口が無いと赤丸を消せない。`jobs` は 5 万行を超えていて消す仕組みが無かった。 - -**却下**: inotify(D-68 追記。即時トリガとして後から足せる形にしてある)。失敗に「確認済み」フラグを足す -(行を残す理由が無い。消せば十分)。done をまとめて消す API(数が多く一覧の上限で切れるので画面からは -1 件ずつ、古いものは GC)。失敗ジョブの削除イベントを SSE で流す(行が無くなるので画面は取り直す。他のタブは -次のジョブイベントで追随する)。監視の状態を DB に持つ(プロセス内の値で足りる。再起動直後は必ず 1 回投入する)。 - - -## D-82 候補のジャケットは Cover Art Archive から引き、サーバが中継する - -**決定**(2026-09-23。D-72 の却下を覆す。P4-20 で実装): - -- CD 画面の候補に**ジャケットを出す**。取得元は Cover Art Archive の `release/<mbid>/front-500` -- **サーバが中継する**(`GET /api/cd/cover/{release_id}`)。ブラウザから直に引かない -- **MB に画像がある盤だけ**。無ければ 404 で、画面は空の枠のまま。手動のアップロードはしない - (既存の `POST /api/artwork/upload` が別にある) -- MB 本体の 1 req/s のスロットは使わない(相手が archive.org で別。画像の取得で照会が待たされない) -- **中継の境界を決めておく**: `cover_art_url` は起動時に `Url` として検証して `join` で組み立てる、 - リダイレクトは 5 回まででかつ HTTPS → HTTP のダウングレードは追わない、本文は 8 MiB まで - 逐次読みで打ち切る、`Content-Type` は `image/*` だけ、返すときは `nosniff`。 - **「画像が無い」(404)と「上流がおかしい」(502)は混ぜない**(混ぜると診断できない) -- 取り込み時に FLAC へ埋める配線は P2-8。`DiscMetadata` に画像を持たせる形は P2-5 で決める - -**理由**: D-72 は「要望が無い」として却下したが、候補が複数出たときジャケットが一番早い見分けに -なるという要望が出た。サーバで中継するのは、(a) `[musicbrainz].address_family` / UA / `error_chain` の -診断をそのまま使えること(この回線は MetaBrainz の IPv4 が塞がれている。D-64 追記 2)、 -(b) P2-8 で FLAC に埋めるときに結局サーバが同じ画像を取ること、の 2 つによる。 - -**信頼の境界(未解決のまま残す点)**: この経路はサーバがユーザの代わりに外へ出ていく -(server-side request)。利用者が入れられるのは MBID だけで、宛先は `cover_art_url` と**上流が返す -リダイレクト先**で決まる。**リダイレクト先のホストは許可リストで絞っていない**。上限 5 回・HTTPS の -ダウングレード拒否・`image/*` だけ・8 MiB までで抑え、「既定の Cover Art Archive と、管理者が -`cover_art_url` に入れた宛先は信頼する」という前提を置く。spindle は宅内の単一利用者向けで、 -`cover_art_url` を書けるのは管理者自身だからこの前提で足りる。**厳密に閉じるなら**既定では -`coverartarchive.org` と `archive.org` 系だけを許可し、自前ミラーは設定した origin に限る形にする -(今回は採らない)。 - -**追記**(2026-09-23。実機確認で判明): **接続に使う IP の族は `[musicbrainz].address_family` を -引き継がない**(`Auto` を渡す)。当初は UA と一緒に共用する設計だったが、実機(`address_family = -"ipv6"`)でジャケットが取れなかった。 - -この経路は **coverartarchive.org(MetaBrainz)→ archive.org(Internet Archive)** の二段構えで、 -`archive.org` には AAAA が無い(`getent ahostsv6 archive.org` は IPv4 射影しか返さない)。ipv6 に -固定すると CAA の 307 は受け取れても飛び先へ届かず、リダイレクトを追えないまま終わる。実機での -確認: `curl -6 -L` は 307 で止まり、`curl -4 -L` は 200。族はホストごとに解決させるのが正しい。 - -`[musicbrainz].address_family` が要るのは musicbrainz.org のエッジがこの回線の IPv4 を落とすため -(D-64 追記 2)で、それは相手ごとの事情。CAA 用の設定項目は足さない(archive.org に AAAA が無いのは -この回線の事情ではなく Internet Archive 側の性質なので、どの環境でも `Auto` が正しい)。 - -**却下**: ブラウザから直接 coverartarchive.org を引く(上の 2 つの理由で不利)。原寸の取得 -(数 MB あり、画面には要らない)。CAA 専用の `address_family` 設定(上の理由で `Auto` 以外に -意味のある値が無い)。 - ---- - -## D-83 読み取りオフセットは照合から見つけて学習する。同梱のオフセット表は持たない - -**決定**(2026-09-23。P2-5。ユーザ判断): - -- `[rip].drive_offset = "auto"` は、ドライブの型番(INQUIRY の vendor + product。ファームウェアの版は - 含めない)で**前に覚えた値**(`drive_offsets`、マイグレーション 0023)があればそれで、無ければ 0 で吸う。 - 整数を書けばその値で吸い、学習しない(`OffsetSource::Manual`) -- 吸った PCM の CRC 表で CTDB / AccurateRip と照合し(`cd::verify`)、**見つかったオフセット `r` が 0 で - なければ PCM を `r` だけずらしてから**(`cd::rip::shift_pcm`。端の `|r|` サンプルは無音)CRC を取り直し、 - 配置する。ずらした後の吸い出しのオフセットは「吸ったときの値 + `r`」で、rip.log と `RipReport` に - `Detected` として残す。`CrcTable` の `offset` は「DB の窓が自分のデータの何サンプル後ろから始まるか」 - なので、`r` が正なら先頭を捨てて末尾を埋める(向きは `tests/cd_rip.rs` で固定) -- **照合が通ったとき(CTDB か AccurateRip のどちらかで全トラック一致)だけ覚える。** 同じドライブは最後に - 通った盤の値で置き換える。どちらにも候補が無い盤は吸ったときの値のまま(`Learned` か `Unknown`) -- 端を無音で埋めてよいのは、探索範囲(±2939)が AccurateRip の末尾の除外(2940 サンプル)と CTDB の末尾の - 除外(10 セクタ以上)に収まり、CRC が変わらないから。音声としては最初か最後のトラックの端の `|r|` - サンプル(BDR-209M の +667 なら 15 ms)が無音になる。ドライブがリードイン / リードアウトを読めない - ときの EAC と同じ結果 - -**理由**: 同梱表の出所として有名なのは AccurateRip のドライブオフセット一覧だが、D-13 のとおり AccurateRip -の DB 利用は許諾がグレーで、表を同梱して配るのはさらに踏み込む。照合側のオフセット探索は P2-9 で既にあり、 -CTDB / AccurateRip に載っている盤を 1 枚吸えば正しい値が分かる(EAC の「key disc」と同じ考え方)。手元の -ドライブは 1 台で、1 回覚えれば以後は同じ値を使う。 - -**却下**: 同梱表(上記)。INQUIRY + 手元のドライブだけの小さな表(表の保守が要り、他のドライブでは結局 0 で -始まる)。見つけたオフセットで再リップする(10 分かかる。ずらすのと結果は同じ)。`cd-paranoia -O` を -見つけた値で掛け直す(同上)。 - -**追記**(2026-09-23。rip ジョブの実装で決めたこと): - -- **`cd-paranoia -O` は使わない。** 手動・学習済みの値も含めて常に 0 で読み、`shift_pcm` で当てる。 - リードイン / リードアウトの外を読みに行かないので、ドライブの overread の可否に結果が左右されない。 - 読む範囲は TOC の長さから `first-last[mm:ss.ff]` で明示する(Enhanced CD の最後の音声トラックの終端を - cd-paranoia の解釈に任せない) -- **吸い直すのは比べる相手があるときだけ。** CTDB か AccurateRip のどちらかに候補があって一致しないとき。 - 照会できない・どちらの DB にも無い盤は、吸い直しても結果が変わらないので 1 回で確定する -- `TrackRead.rereads` は cd-paranoia の進捗で報告された scratch / repair / skip / read error の回数。 - cd-paranoia は C2 を使わないので `c2_errors` は 0(rip.log の C2 欄は測っていない) -- 進捗は SSE の `job` イベントに `detail`(`RipProgress`)を足して流す(DB には書かない) -- 実機(BDR-209M)で 9 分の 2 トラックの盤が 140〜160 秒。その盤は CTDB(信頼度 30、同じ TOC)と - どのオフセットでも一致せず、2 回の吸い出しで中身が違った(傷か読みの不安定)。学習の実機確認は - CTDB / AccurateRip に載っているきれいな盤で行う -- **「照合が通った」はオフセット 0 で全トラック一致のときだけ**(codex 指摘)。`match_*` の Verified は - ずれたままでも成立するので、ずれを PCM に当ててから照合し直したものだけを採用する。CTDB のパリティで - 直したとき、パリティが見つけたずれ(傷のため CRC では見つからなかったもの)も同じく直した PCM に当て、 - オフセット 0 で照合が通れば「吸ったときの値 + そのずれ」を学習する -- `[rip].drive_offset` の整数は起動時に ±2939(探索範囲)へ制限する。学習済みの値が範囲外なら使わない -- 公開の前に失敗・取り消しされたら、組み立て用の隠しディレクトリを消す(走査に見えないので、残すと同じ - 盤を吸い直すまで誰も片付けない)。修復の 2 回の走査は blocking に置き、1 MB ごとに取り消しを見る -- **当てるずれは全曲一致した手法を優先する**(codex 指摘)。全トラック一致(両方なら CTDB)→ 無ければ一部 - 一致(CTDB を優先)の順。部分一致を先にすると、傷のある盤で別のオフセットに引きずられる。学習に残す手法と - 信頼度は、当てた後にオフセット 0 で通った手法のもの - -**追記 2**(2026-09-23。ユーザ要望。foobar2000 と同じく、ディスクが無くてもドライブを判定する): - -- **AccurateRip のドライブ別オフセット表(`DriveOffsets.bin`)を実行時に取って使う**(EAC / foobar2000 / - dBpoweramp と同じ)。同梱はしない(D-83 の却下理由は「表を配る」ことで、実行時の取得は照会と同じ扱い。 - D-13)。置き場は `[verify].accuraterip_url` の下、保存は `data/cd/DriveOffsets.bin`(30 日で取り直す。 - 取れなければ古い保存、それも無ければ表なし)。起動時に 1 回読み込み、`GET /api/cd/status` は読み込み済みの - 表だけを見る(ネットワークを待たない) -- 形式は 69 バイトのレコード(`offset i16 LE`、名前 33 バイト `"<vendor> - <product>"`、提出数 `u32 LE`、 - 予約)。照合は空白の連なり・`" - "` の区切り・大小文字を畳んだ鍵で、INQUIRY の型番(vendor と product を - 空白 1 つで結合)と突き合わせる。同じ型番が複数あれば提出数の多い方。実物で `PIONEER - BD-RW BDR-209M` - は +667(提出 255) -- **吸うときのオフセットは 設定の整数 → 学習済み → 表 → 0**(`cd::rip::choose_offset`。探索範囲の外は - 使わない)。表の値も照合で確かめ、ずれていれば当てて学習する(`OffsetSource::Table`) -- 型番はポーラがデバイスを開けている間に 1 回読んで状態に持つ(ディスク不要)。`GET /api/cd/status` の - `drive: { model, offset, offset_source }` で CD 画面がディスクを入れる前から出す -- 表の妥当性(codex 指摘): 空は拒否。実物の全レコードで成り立つ形(名前が空でなく 33 バイト内で NUL - 終端、予約が 0)から 1 件でも外れれば表ごと拒む。壊れた保存は取り直し、壊れた応答で正しい保存を - 上書きしない。メモリの表も取得時刻を持ち、`MAX_AGE` を過ぎたら次に使うときに取り直す(常駐し続けても)。 - 取り直しに失敗したら前の表で動き、1 時間は叩き直さない。範囲外の学習値は無いものとして表を引く -- 常駐中の取り直しは `spawn_refresher`(1 時間ごとに期限を見る。status は `peek` しか呼ばないので、これが - 無いと吸い出しをしない限り更新されない)。表が一度も無いままの失敗も時刻を覚え、`RETRY_AFTER` の間は - 取りに行かない(吸い出しのたびに 30 秒待たない)。古い保存は取り直しの前に公開する(stale-while-revalidate) - -**追記 3**(2026-09-23。実機の確認で見つけた欠け): - -- `album_verifications.drive_offset` は `source = 'rip'` の行だけに書く。値は **PCM に当てた読み取り - オフセット**(`RipReport.read_offset`。照合で見つけたずれを当てた後の値)で、`detected_offset` はそこからの - 残りのずれ。`source = 'retro'`(遡及照合)はどのドライブで吸われたか分からないので NULL。列は 0001 から - あったが書く処理が無く、吸い出しでも NULL だった。既存の行は埋め直さない(値は rip.log に残っている) - ---- - -## D-84 候補の無いまま取り込んだ CD は、承認画面のボタンで MusicBrainz を引き直す - -**決定**(2026-09-23。P4-21。ユーザ判断): - -- **起点は承認画面のボタン。** CD の件(サイドカーに `rip`)にだけ「MusicBrainz で引き直す」を出す。照会は - CD 画面と同じ `POST /api/cd/lookup`(DiscID → ISRC / バーコード → TOC 近似の段階化、10 分のキャッシュ、 - 1 req/s の間隔待ちはサーバの `MusicBrainzClient`)。**ジョブにせず、結果も保存しない**(件を替えると消える)。 - 自動で引くと「どれも違う」と判断した盤まで毎回引き直すことになり、レート制限の中で無駄が多い -- **吸い出しでドライブの ISRC / MCN をサイドカーに残す**(`RipEntry.isrcs` / `mcn`)。DiscID も TOC 近似も - 当たらず ISRC でだけ見つかる盤がある(嵐「Five」。D-64 追記)。旧サイドカーは空で読める -- **選んだときに写すのは、ID と空欄の名前。** リリース / リリースグループの MBID は常に写す(下書きの - `release_id` / `release_group_id` → 配置でタグと `mb_release_id`。D-67 追記 3 のリリースキー `mb:`)。 - アルバム名・アルバムアーティスト・日付・曲名・曲のアーティストは、下書きで空のところ(曲名は吸い出しが - 付けた `Track NN` も空とみなす)だけ埋め、手で入れた値は上書きしない。曲はトラック番号で候補の曲に - 対応させ、曲のアーティストがアルバムアーティストと同じなら空のまま(配置でアルバムアーティストになる) -- **ディスク番号は候補の medium の位置にする**(名前ではなく盤の識別。2 枚組の 2 枚目を 2 枚目として置き、 - 同じ MBID の 1 枚目に合流させる。1 のままだと番号が重なって配置で弾かれる) -- YouTube の件は対象外(曲の身元は `SOURCE_URL`。D-70) -- **MBID の大文字小文字は同一性に効かせない**(codex 指摘): 外部のツールが大文字の UUID を書くので、検証は - 大小を問わず通し、値(タグと `mb_release_id`)はそのまま保つ。リリースキー `mb:` は ASCII 小文字に揃え - (`placement::mb_key`。Inbox の配置・rename・自分の成果物の判定)、スキャナの album の照合も大小を無視する - -**理由**: D-72 の「MusicBrainz は盤の識別に使い、値は公式表記を手で入れる」を保ったまま、名前の入っていない -盤では候補の表記を下敷きにできるようにした(空欄だけ埋めるので、公式表記で直した値は消えない)。 - -**却下**: 件が現れたら自動で照会してジョブの結果を保存する(上記)。候補の値で全部上書きする(手で直した値が -消える。CD 画面の「全部写す」は名前を入れる前の画面なので事情が違う)。 - ---- - -## D-85 CD 画面は番号付きの段で流れを見せ、ライブラリにあるかを DiscID で示す。Inbox の表は全項目を出す - -**決定**(2026-09-24。ユーザ要望): - -- **CD 画面は ① 挿入中の CD → ② 認識したトラック → ③ 候補を選ぶ → ④ 取り込む の番号付きの枠。** - アルバム名を裸の見出しで出すと何の値か分からないので、① の値はすべてラベル付きにする。長い説明文は本文から - 外し、各枠の見出しの ⓘ(ホバー / フォーカスで出るツールチップ)に畳む。「表示のみ」も注記のバッジにする -- **「この盤はライブラリにある」を最上部の帯で示す。** 新しい `POST /api/cd/library { toc, release_id? }`。 - DiscID はサーバが TOC から出し、active なトラックの `MUSICBRAINZ_DISCID` タグで引く(DiscID は 1 枚ごとの値で - album の列には無い。D-67 追記 3)。当たらなければ、選択中の候補のリリースを `albums.mb_release_id` で引き、 - 「同じリリースの album がある(この盤は未取り込み)」と区別して出す(2 枚組の 1 枚目だけある、を見分ける)。 - DB だけを読み、MusicBrainz の照会の前でも失敗しても判定できる。マイグレーションは要らない - (`idx_track_tags_key` と `idx_albums_release` が効く) -- **Inbox の一覧の各件に代表画像のサムネイル。** 埋め込み画像だけ(既存の `/api/inbox/:id/artwork/:hash`)。 - フォルダの cover.jpg は拾わない(ユーザ判断。CD の件は画像を埋め込まないので空枠になる) -- **Inbox の承認画面のトラック表は全項目。** 直せる列・上の欄の写し・ファイル由来に加え、ファイルの全タグを - 列にする(ユーザ判断)。横に長くなるので枠の中で横スクロールし、「列」メニューで隠せる(見た目の好みなので - localStorage)。disc / # / 画像 / タイトルは左端に固定する(右のタグを読むときに行が分かるように)。タグの列は - 読み取り専用で、配置では変えない - -**理由**: CD 画面で「いま何が入っていて、どれとして取り込むか」が一目で追えず、説明文が多くて流れが読めなかった -(ユーザ指摘)。既に持っている盤を二重に吸い出す手間も、上部の帯で避けられる。 - -**却下**: 所持の判定を MusicBrainz の候補(リリース)だけで行う(候補を選ぶまで分からず、同じリリースの別の -盤と区別できない)。フォルダ画像の配信 API の追加(今回は不要)。 - ---- - -## D-86 Inbox の承認画面でファイルのタグと埋め込み画像も直せるようにする - -**決定**(2026-09-24。ユーザ要望。モックで合意): - -- **下書きのトラックに `tags` と `picture` を足す。** `tags` はキー → 値の配列(`null` はそのタグを消す)で、 - 書くのは変更したキーだけ。`picture` は差し替える画像(`<mime>:<sha256hex>`)で、無ければファイルの画像のまま。 - どちらも省略可なので、旧い下書きはそのまま読める -- **上の欄が扱うキーは `tags` で受け付けない**(TITLE / ARTIST / ALBUM / ALBUMARTIST / DATE / TRACKNUMBER / - DISCNUMBER / DISCTOTAL / PICTURE)。直し方を二通りにしない -- **同一性の判定に使うキーには鍵をかける**(`SOURCE_URL` / `MUSICBRAINZ_*`)。`SOURCE_URL` は YouTube の - 二重取り込みの判定(D-70)、`MUSICBRAINZ_DISCID` などは CD の盤とリリースの識別(D-67 追記 3)に使うので、 - 手で書き換えると判定が静かに壊れる。リリースの付け替えは MusicBrainz の引き直し(D-84)で行う -- **画像は既存のアップロード(`POST /api/artwork/upload`)で先に置き、下書きは sha256 だけを持つ。** CD の件は - `POST /api/artwork/from-caa` で Cover Art Archive の front 画像を同じく置ける。配置は `picture` のある曲だけ - 埋め込み画像をその 1 枚にする(ライブラリの差し替え D-60 と同じく全部捨てて front cover) -- **曲ごとに画像が違う件(YouTube)は、既定で曲ごとの画像を保つ。** 画面は「画像の無い曲に入れる」と - 「全曲を 1 枚にそろえる」を明示的な操作にし、1 曲ずつの差し替えはトラック表の画像列で行う -- **下書きが参照する画像は GC しない**(区分 E の「参照」に `inbox_items.draft` の `tracks[].picture` を足す。 - 下書きには任意のタグの値も入るので全文の部分一致にはせず、JSON として `picture` の値だけを照合する。 - codex 指摘)。承認後に配置が失敗して再承認を待つ間に消えないように。それでも消えていたら、承認は 400、 - 配置は failed(選び直す) -- **宛先に自分の成果物(同じ音声)があっても置き換えない。代わりに今回の補正が入っているかを確かめ、 - 入っていなければ失敗にする。** 途中で落ちた件の再承認では、任意のタグや画像の補正はパスを変えないので - 同じ宛先に当たる。同じ音声だからと再利用すると今回の補正が黙って捨てられ、置き換えると外部(foobar2000 - 等)の変更や missing の行の実体を履歴なしに上書きしうる(codex 指摘。置き換えを孤児に限って照合する案は - 競合を原子的に閉じられなかった)。そこで再利用の前に、既存ファイルのタグと代表画像が今回の下書きの内容 - (`tag_changes` が空、`picture` の sha256 が一致)であることを確かめ、違えば Conflict(件は failed、 - Inbox と下書きは残る。Library のそのファイルを確かめて消すか、配置後にライブラリの編集で直す) -- **ALAC(MP4)を理由に止めない。** P4-11 で MP4 は写像できないキーをフリーフォームで書けるようになっており、 - 配置のテストで m4a に任意のキーを書けることを確かめた(モックで出した「ALAC には書けない」は古い制限だった) -- **配置先の見込みを出す**(`POST /api/inbox/:id/preview`)。音声を読まずに配置の計画(`plan_item`)を引くので、 - 途中で失敗した件の再承認(自分の成果物の再利用)では実際と食い違うことがある。画面では見込みとして出す - -**理由**: 承認画面をライブラリのプロパティと同じ操作で全項目を直せる場所にし、カバーの無い件もその場で -直せるようにする(ユーザ要望)。値の保存先はこれまでどおりファイルのタグ(不変条件 1)。Inbox の原本は -書き換えず、配置の途中のコピーに書く。 - -**却下**: フォルダに cover.jpg を置く(spindle は同梱の cover ファイルを書かない。D-60)。同一性のキーも -自由に直させる(上記)。承認前に下書きをサーバへ保存する(今の「承認で保存」を保つ)。 - -## D-87 YouTube 画面と一括編集・操作タブにも番号付きの段の導線を入れる - -**決定**(2026-09-24。ユーザ要望。モックで合意): - -- **YouTube 画面は「ダウンロード」と「購読」を切り替え、どちらも番号付きの段にする。** ダウンロードは - ① URL → ② 取り込み方 → ③ ダウンロード → ④ Inbox で承認、購読は ① 再生リスト → ② アルバムとして登録 → - ③ 登録して同期。④ は置いた件を並べて「Inbox で開く」だけにし、承認そのものは Inbox の画面で行う - (承認画面を二か所に作らない) -- **① で取り込み済みかを先に見せる**(`POST /api/ytmusic/lookup`。DB だけ)。動画は正規形 - `https://www.youtube.com/watch?v=<id>` にしてファイルの `SOURCE_URL` と照合する(ダウンローダの判定と同じ)。 - `list=` 付きの動画 URL は yt-dlp と同じく再生リストとして扱う。YouTube 以外の http / https は照合せずに - そのまま投入する(yt-dlp の対応サイトは広い)。照合は目安で、投入側の飛ばし(D-70)はそのまま残す -- **再生リストの中身は別の API で引く**(`POST /api/ytmusic/playlist`)。yt-dlp の列挙は数秒かかりネットワークに - 出るので、照合(毎入力)とは分けて URL ごとに 1 回だけ呼ぶ。読むだけでジョブにしない。同時に 2 本まで、 - 90 秒で打ち切り、接続が切れたら yt-dlp も止める。新規が 0 の再生リストは投入しない(取りこぼしがあれば投入する) -- **③ ④ が追う「今回の投入」はサーバが返したジョブと、その展開の子に限る。** 再生リストの展開が投入する - ytdl の payload に `parent_job_id` を入れる(ハンドラは読まない)。id の大小で「以降」を今回とみなすと、 - 購読の同期や別の画面の投入、重複で返った古い id 以降の履歴まで混ざる(codex 指摘) -- **一括編集・操作タブは段を横に 4 列並べる。** パネルは表の上で高さが 260px 前後の横長なので、縦に積むと - スクロールが要る。コンテナ幅が狭ければ 2 列 → 1 列 -- **操作タブは操作を 1 つ選ぶ形にする。** これまでは全部の操作を縦に並べていたが、③ と ④ をその操作用にし、 - 「巻き戻せる変更 / ジョブ / 読むだけ / すぐ反映」を印で示す。④ は押せない理由(プレビュー待ち・画像未選択・ - 追加先未選択)を添える -- **操作タブで操作を切り替えたら、前の操作の結果・失敗・反映待ちの確認を消す。実行中は切り替えない。** - 反映待ちの「除外して適用」は保持中の操作をやり直すので、別の操作の段に出ると別の操作を実行してしまう - (codex 指摘) -- **プレビューが古くなったことを見せる。** これまでは古いプレビューを黙って消していた。③ を「古い」にして - 枠を警告色にし、④ を押せなくする(`useBatchEdit.stale` / `useOperations.pathStale`) - -**理由**: CD / Inbox で入れた導線(D-85、D-86)を他の取り込み・編集の画面にもそろえ、どこまで済んだか、 -次に何をするかを同じ形で読めるようにする(ユーザ要望)。 - -**却下**: YouTube ④ で承認まで済ませる(Inbox の承認画面と重複する)。操作タブを全部並べたまま段だけ付ける -(操作ごとに ③ ④ の中身が違い、段が意味を持たない)。再生リストの列挙を照合 API に含める(入力のたびに -yt-dlp が走る)。 - -## D-88 マイグレーションを最初のリリースの前に 1 回だけ `0001_init.sql` へ畳む - -**決定**(2026-09-24。ユーザ依頼。D-79 / TASKS P4-12 (8) の方針どおり): - -- `db/migrations/0001`〜`0024` を、最終形のスキーマを直接作る `0001_init.sql` 1 本に置き換える。列の並び・ - CHECK・既定値・索引・トリガ・ビュー・種データ(`export_profiles` の 3 行)は 24 本を流した結果と同じ - (空 DB へ流した `sqlite_master` をコメントと空白を除いて比べ、全表の `table_xinfo` / `index_list` / - `index_xinfo` / `foreign_key_list` と行を比べて一致を確かめた。違うのは `sqlite_sequence` に - `playlist_subscriptions` の行(seq 0)が無いことだけで、次の id が 1 なのは同じ) -- 0006 の Rust 側の手当て(既存プレイリスト名の衝突の改名と `idx_playlists_name_key` の作成。`post_sql`)は - 消し、索引は SQL に直接書く。`foreign_keys=OFF` で流す版の一覧(`FOREIGN_KEYS_OFF`)は空にして仕組みは - 残す(以後 CHECK を広げるために表を作り直す版で使う)。各版のアップグレード試験は、それが守っていた - 最終スキーマの性質を空 DB に対する試験へ移した(`tests/migrations.rs`) -- 版 24 までの既存 DB を新しいバイナリで開くと `Newer`(DB の版 24 > バイナリの最新 1)で起動しない。 - `edge` は DB の互換を約束しない(D-79)ので許容し、リハーサル環境はリリース時の再移行で DB ごと作り直す - (MIGRATION §5)。以後は CLAUDE.md の禁止事項どおり、既存ファイルは書き換えず連番を足す - -**理由**: 公開イメージを誰かが pull して DB を作った後は既存ファイルを書き換えられないので、畳めるのは -最初の `vX.Y.Z` より前、DB を作り直すこの時だけ。24 本のうち 8 本は表の作り直し(CHECK を ALTER できない -ため)で、新しい DB を作るたびに同じ表を何度も作っては捨てていた。スキーマの正を 1 ファイルで読めるように -する。 - -**却下**: 畳まずに残す(空 DB の構築が作り直しの連鎖のままで、スキーマの全体を読むのに 24 本を追う必要がある)。 -版 24 の既存 DB を新しい 0001 の版 1 へ読み替える手当てを入れる(互換を約束しない `edge` のためだけの -コードが残り続ける。リハーサル環境は作り直す)。 - -**追記(2026-09-26。ユーザ依頼)**: 「1 回だけ」を「最初の `vX.Y.Z` より前に限る」と読み替え、リリース直前に -もう一度畳む。D-88 の後に 0002〜0004 が増えた(Inbox の破棄・表の画像・category の索引)。理由は本文と同じで、 -畳めるのはリハーサル環境の DB を捨てて作り直す最初のリリースの時だけ。タグを切った後は既存ファイルを書き換えない -(TASKS P4-12 (10)、MIGRATION §5 の手順 0)。2026-09-26 に畳んだ。3 本とも列・索引の追加だけで、空 DB の最終スキーマは -`ALTER TABLE ADD COLUMN` が `sqlite_master` の SQL に残す空白の位置を除いて一致。既存の DB(版 4)は新しいバイナリ -(最新 1)で `Newer` になり起動しない(本文と同じく許容。リハーサル環境は §5 の手順 1 で作り直す)。 - -## D-89 ALAC のプロセス内デコードの範囲を本番の ffmpeg に揃える(MP4 の edit list と末尾の長さ 0 のサンプル) - -**現行の規則は末尾の追記(2026-09-26)。** 以下の「旧決定」(宣言長で打ち切る)は追記で廃止した。 - -**旧決定(追記で廃止)**(2026-09-24。リハーサルの FLAC 正規化で見つかった不一致への対応): - -- symphonia のデコード出力を、**ALAC のトラックに限り**宣言長(`Track::num_frames`)で打ち切る - (`media::fingerprint::FrameLimit`)。対象は `decoded_pcm_md5`(`audio_md5`、正規化の照合値、MD5 補填)、 - `decode_s16`(CD の CRC)、`media::decode` のプロセス内経路(RG / 偽ハイレゾ検出)。宣言長が無いトラックと、 - delay / padding を持つトラックは打ち切らない。ffmpeg の経路は ffmpeg 自身が打ち切るので変えない -- ALAC 以外に広げない。symphonia 0.6.1 の MP4 は edit list を読まず、AAC の encoder delay も - `delay` / `padding` に載せないので、AAC の `num_frames`(`stts` の合計)は trim 前の長さでも再生長でもない - (1 秒 / 44.1k の AAC で num_frames 45,124、symphonia のデコード 46,080、ffmpeg 44,100)。打ち切ると - AAC の RG 値が中途半端な長さで変わるだけなので、従来の出力(46,080)を保つ(codex レビューの指摘)。 - FLAC / WAV / AIFF は宣言長とデコード長が一致するので掛けても値は変わらないが、必要が無いので掛けない -- ALAC を ffmpeg でデコードする経路には変えない。デコーダの構成(symphonia 優先・Opus は ffmpeg)はそのまま - -**原因**: リハーサル環境の正規化で ALAC 7 本(24 bit の 48k / 96k)が「変換前後の PCM MD5 が一致しない」で -失敗した(照合が働き、元ファイルは無傷)。これらの MP4 は `stts` が `[(N, 4096), (1, 0)]` で、最後のサンプルの -長さを 0 と宣言している(`mdhd` の duration も N×4096)。ffmpeg は宣言どおりそのサンプルを捨てるが、 -symphonia 0.6.1 の MP4 読みはパケットとして返し、デコーダは 1,043〜1,664 フレームを余分に出す。 -共通部分の PCM はビット単位で一致しており(内容の違いではなく長さの違い)、余分な部分はほぼ無音 -(1 本は全 0、1 本は最大でも −101 dBFS 程度)。宣言長で打ち切ると 2 本とも ffmpeg と同じ MD5 になる。 - -**理由**: ユーザは当初「ALAC のデコードを ffmpeg に揃える」を指示したが、正規化は元ファイルを symphonia、 -生成物を ffmpeg で読んで突き合わせる(D-46「独立した 2 つのデコーダ」)ので、ALAC を ffmpeg にすると照合が -自己比較になって安全弁の意味を失う。原因が長さの宣言の解釈だけで、symphonia の PCM 自体は正しいので、 -宣言長に従わせる方が変更も小さく照合も保てる。ユーザはこの案を選んだ。 - -**既存 DB への影響**: 該当する ALAC(末尾に長さ 0 のサンプルを持つもの)は次の deep scan で `audio_md5` が -変わり、`audio_version` が上がって RG と Derived が自動でやり直される。それ以外の ALAC / FLAC / WAV / AIFF は -宣言長 = デコード長なので値が変わらない。AAC / MP3 / Opus / Vorbis は打ち切りの対象外で値が変わらない。 - -**却下**: ALAC だけ ffmpeg でデコードする(照合の独立性を失う。同期の呼び出し元に外部プロセスを通す変更も大きい)。 -正規化の照合で長さの差を許す(本物の欠損・切り詰めも通してしまう)。 - -**追記(2026-09-26。本番の再移行で見つかった打ち切りすぎ。ユーザ依頼)**: 本番の再移行の FLAC 正規化で、ALAC 46 本が -逆向き(symphonia の方が短い)の不一致で止まった。上の「ffmpeg は宣言どおり捨てる」は誤りで、ffmpeg は `stts` ではなく -edit list(`elst`)に従っていた。上の 7 本は `elst` の終端が宣言長ちょうど、46 本は `mvhd`(1/1000 秒)への切り上げで -3〜64 サンプル後ろにあり、長さ 0 のパケットの開始(= 宣言長)が終端より前なので ffmpeg はそれを丸ごとデコードしていた。 -46 本の余分は 64〜4,096 フレームで、43 本は無音か −100 dBFS 以下、3 本は −53〜−83 dBFS のフェードの末尾(本物の音声)。 -上の回帰試験のフィクスチャ(`stts` と `mdhd` だけを縮める)も終端が後ろの形で、ffmpeg は実際には捨てていなかった。 - -- **本番の ffmpeg(5.1)の規則**(実 ALAC と、実 ALAC から stream copy・`elst` の書き換えで作った形で確かめた): - 先頭は media_time サンプルちょうど削る。終端は media_time + 区間の長さ(movie timescale → media timescale を - 四捨五入。端数 0.4 は切り捨て、0.5 は切り上げ)。終端以降で**始まる**パケットは捨て、終端をまたぐパケットは - 丸ごと残す(長さ 0 のパケットも同じ規則)。edit list が無ければ何も削らない。「空の編集 + 区間」の 2 項 - (出力側 `-ss` の stream copy が書く)は edit list を無視して全パケットを出す -- **ffmpeg の版で違う**: 新しい ffmpeg(開発機の N-125084、2026-06)は終端で端数を切り(長さ 0 のパケットだけは丸ごと)、 - 「空の編集 + 区間」では末尾を空の編集の長さだけ削る。先頭の削り・終端 = 全パケットの終わりの形・長さ 0 の - パケットの扱いは 5.1 と同じ -- **決定**: `FrameLimit` を「宣言長で打ち切る」から「本番の ffmpeg 5.1 の出力範囲を再現する」に変える。ALAC で、 - `elst` が 1 区間・media_time ≥ 0・等速・長さ > 0 のとき、先頭を media_time 削り、終端以降で始まるパケットを読まない - (`media::mp4edit` で `moov` から読む。symphonia は edit list を読まないため)。`elst` が無い・単純な形でない・ - `mdhd` の timescale がサンプルレートと違うときは何も削らない(「空の編集 + 区間」は 5.1 と一致する)。 - edit list は symphonia が選んだトラック(`Track::id` = `tkhd` の track ID)のものだけを使う(symphonia は codec の - 分かる最初の音声トラックを選ぶので、先頭の音声トラックとは限らない)。壊れた・悪意のあるファイルで膨らまないよう、 - `moov` 64 MiB・トップレベルの箱 64 個・音声トラック 8 本・保持する `elst` の項 4 件を上限にし、子の箱は確保せずに辿る - (codex のレビューの指摘) -- **理由**: 正規化の照合(D-46)は元ファイルを symphonia、生成物を本番の ffmpeg で読むので、両者の出力範囲が同じでないと - 必ず失敗する。46 本の余分を捨てる(FLAC 側も宣言長で切る)とフェードの末尾(最大 85 ms)を失い、ALAC のまま残すと - 正規化の対象から恒久的に外れる。ユーザは ffmpeg の実際のデコード長に合わせる案を選び、実在しない形(先頭の削り・ - 終端がパケットの途中や手前)も実 ALAC で確かめながら再現するよう指示した -- **確かめたこと**: 実 ALAC 53 本(上の 7 本と 46 本)と、実 ALAC から作った 31 の形、長さ 0 のサンプルを持たない - `elst` 付きの実 ALAC 9 本で、`decoded_pcm_md5` が本番コンテナの ffmpeg 5.1 で作った FLAC の STREAMINFO MD5 と一致 -- **ffmpeg を上げるとき**(ベースイメージの Debian を上げると 7.x になる): 終端をまたぐパケットと「空の編集 + 区間」の - 扱いが変わる。実在する形(edit list が無い・終端 = 全長・長さ 0 のパケット)は版で変わらないので既存の 46 本 + 7 本は - 影響を受けないが、変わる形のファイルは照合で止まる(音声は失わない)。そのときは `FrameLimit` を新しい版の規則に - 合わせ、`tests/fingerprint.rs` の 5.1 に固定した期待値を直す -- **既存 DB への影響**: 46 本は次の deep scan で `audio_md5` が変わり、`audio_version` が上がって RG と Derived が - その 46 本だけやり直される。7 本の形と、edit list を持たない ALAC は値が変わらない -- **却下**: 正規化の照合で末尾の差を許す(本物の欠損・切り詰めも通してしまう)。ALAC を ffmpeg でデコードする - (本文と同じく照合の独立性を失う)。新しい ffmpeg の規則に合わせる(本番は 5.1 で FLAC を作る) - -## D-90 却下した Inbox の件は「削除」で破棄待ちにし、GC が retention 日後に消す - -**決定**(2026-09-25。P4-22。ユーザの決定: 「却下した後に Inbox に残っている行を削除したら GC で削除対象にする」): - -- 二段にする。**却下**(従来どおり。ファイルは残し「下書きに戻す」で戻せる)と、却下した件の**削除**(破棄待ち)。 - 削除は `inbox_items.discard_requested_at` に時刻を入れるだけで、ファイルは消さない(旧マイグレーション 0002。D-88 追記で `0001_init.sql` へ統合。 - 状態は `rejected` のままにして CHECK の作り直しを避けた)。`POST /api/inbox/:id/discard` / `/undiscard` -- **物理削除は GC だけ**(CLAUDE.md の禁止事項)。GC の区分 F: `rejected` で `discard_requested_at <= now - - [gc].retention_days` の件。消すのは件のディレクトリの直下の**走査が写した音声**(`inbox_files` と stat が一致する - もの)・既知の同梱ファイル(`cd::riplog::is_companion_name`。配置が Library へ持っていくものと同じ)・サイドカー - (確かめたときと同じもの)だけ。ディレクトリは空になったときだけ消す。サブディレクトリ(別の件)・symlink・ - 知らないファイルには触らない。Inbox 直下の件(`rel_dir` が空)は音声だけ。猶予は Library の論理削除と同じ - `retention_days`(別の設定を増やさない) -- **破棄待ちの間にファイルが変わったら消さない**(安全側)。走査が変化(足された・差し替えられた)を見たら破棄待ちを - 解き、`rejected` のまま `error` に理由を残す(`pending` には戻さない: 却下は人の判断で、戻すと上部バーの件数に - 出て承認を促してしまう。見直して要らなければもう一度削除する)。GC は**確かめ → 削除**の 2 段: 確かめの段で - 直下の音声を `inbox_files` と照合し(写した後に足された・変わったもの)、同梱ファイルとサイドカーは - `inbox_files` に写らないので **ctime が「破棄の要求の秒 − 1」以降**なら要求の後に置かれた・変えられたものとみなす - (境目は安全側に「後」。1 秒の余裕は、要求の時刻が実時刻の秒なのに ctime はカーネルの粗い時計で数 ms 遅れて付き、 - 要求の直後に置いたファイルが要求の前の秒に見えることがあるため。試験で約 1/8 の頻度で見逃しを再現した。2026-09-25)。どれかが外れたら何も消さずに同じく解く。削除の段は 1 本ずつ、消す直前に確かめの - 段の stat(inode / size / mtime / ctime)と照合し、外れたらそこで止めて解く(それまでに消したのは確かめ済みの - ものだけ)。NotFound 以外で消せなければそこで止めて**行と破棄待ちを残す**(成功扱いにしない。次の GC で続きを - 行う。消えたファイルは NotFound として飛ばす)。どちらの場合も行は消さない(codex レビューの指摘) -- `rejected` から出る遷移(下書きに戻す)は破棄待ちを解く(`transition` / `set_state` が `discard_requested_at` を - NULL にする) -- GC の実行は件ごとに書き込みのトランザクションの中で「まだ rejected で期限が来ているか」を確かめ直し、ファイルを - 消してから行を消す。取り消しの API も同じ writer を通るので、確かめてから消すまでの間に取り消されることはない -- 画面: 一覧は従来どおり却下した件もバッジ付きで出す(SPEC の「一覧から外す」は実装と違っていたので直した)。 - 却下した件に「削除」(確認ダイアログに消す時期)、破棄待ちには「削除待ち」のバッジと期限、「削除を取り消す」。 - 期限は `GET /api/inbox` の `discard_retention_days` から - -**理由**: 却下した件のファイルは Inbox に残り続け、人が SMB で消すしかなかった(リハーサルで VALIS の二重取り込みを -却下した後に観測)。却下 = 即削除にすると、下書きに戻して承認し直す道が消え、物理削除が GC 以外の経路に出る。 -二段にして GC に任せれば、人が「要らない」と明示した件だけが猶予の後に消える。 - -**却下**: 却下と同時に削除する(戻せない)。確認ダイアログで即時に消す(物理削除が GC 以外に出る)。却下した件を -すべて GC の対象にする(「却下したが後で見直したい」件まで消える)。破棄待ちの専用の状態を足す(CHECK の作り直しが -要り、`rejected` と別に扱う箇所が増える)。 - -## D-91 CD の取り込みの表の画像は Cover Art Archive から一度だけ取り、承認画面の初期値に入れる - -**決定**(2026-09-25。P4-22。ユーザの決定: 「CD の取り込みでリリースが決まっていて Cover Art Archive に front 画像が -あれば、承認画面の初期値に最初からその画像を入れる」): - -- **取るのは inbox ジョブの走査と配置の後に一度だけ。** 対象は承認前(`pending` / `failed`)で画像がまだ無く、試行が上限 - (2)未満の取り込み。サイドカーの `rip.metadata.release_id`(吸い出しで選んだリリース。MBID)があるものだけ - 取りに行く。承認画面を開くたび・`GET /api/inbox` のたびには外へ出ない(一覧の表示を外部の遅さに引きずらせない)。 - 吸い出しの配置は inbox ジョブを投入するので、吸い出した直後の走査で取れる。この機能より前に Inbox に置いた取り込み - (実機の嵐「Five」)も、次の走査で同じ条件に当たって取れる -- **経路は D-82 / D-86 と同じ。** `CoverArtClient`(`address_family` は `Auto` 固定、リダイレクト 5 回・8 MiB・ - `image/*` の境界)で front を取り、`POST /api/artwork/upload` / `from-caa` と共通の - `import::cover::store_image` で `artwork` の置き場と行に置く(サムネイルのジョブも同じ) -- **記録は DB(`inbox_items.caa_picture` / `caa_tries`。旧マイグレーション 0003。D-88 追記で `0001_init.sql` へ統合)。** 外部から取ってきた値のキャッシュ - なので DB に持つ(DB を作り直せば次の走査で取り直す)。サイドカーは吸い出しの記録で、後から書き換えると D-90 の - 破棄の確かめ(サイドカーの ctime)とも干渉する -- **配置を待たせない**: inbox ジョブ(並列 1・固定の dedup キー)の中で、**承認済みの配置を先に済ませてから**取る。 - 1 回の実行で取りに行くのは 4 件(`CAA_PER_RUN`)・90 秒(`CAA_RUN_BUDGET`)まで(走行中の 1 件は - `CoverArtClient` の全体 timeout 60 秒で終わる)。残りは周期の監視が「やることが残っている」 - (`needs_attention`。inbox ジョブに Cover Art Archive を渡したときだけ数える。渡していないのに数えると候補が - 減らず毎周回投入する)として次の周回で続ける。取り消しは通信の待ちの間も見る(`tokio::select!`)。別のジョブ - 種別に分けるのは `jobs.type` の CHECK の作り直しが要るので見送った -- **回数は外へ出る前に claim する**(ジョブの冪等): `caa_tries` を読んだ値との CAS で 1 回分進めて永続化してから - 通信する(`claim_caa`)。結果の記録は回数を戻さない(`max`)。通信の途中で落ちても・取り消されても、その回は - 数えられ、再起動をまたいで上限を超えない -- **画面**: 開いている取り込みは、人がまだ触っていなければ、走査の後に変わった提案(表の画像が入った等)を取り込む - (`refreshedDraft`。触っていれば上書きしない)。一覧のサムネイルは埋め込み画像が無ければ下書き・提案の画像 - (`/api/artwork/:hash`)を出す(`itemThumbUrl`) -- **再試行**: 画像の無い盤(404)と、リリースの無い取り込み(候補を選ばずに吸い出した・CD でない)は 1 回で上限に - する。上流の失敗(5xx・通信・画像でない)と置けなかったときは、claim で進めた回数のまま次の走査でもう 1 回だけ試す。 - どれも取り込みは失敗させず、画像なしのまま(理由はログ) -- **提案**: `caa_picture` があれば全曲の `picture` の初期値にする。保存した下書きがあればそちらが勝つ(`merge_saved`。 - 人が外した・差し替えたものを上書きしない)。配置は D-86 どおり `picture` のある曲の埋め込み画像をその 1 枚にする -- **画面**: 全曲がその画像のままなら「Cover Art Archive の表の画像(吸い出したリリースから自動で取った)」と出し、 - 戻すボタンは「画像を外す」(`resetPictures`。CD のファイルには画像が無いので外すと画像なし)。手動の - 「Cover Art Archive から取る」は残す(リリースを引き直した後など) -- **GC**: `caa_picture` の画像は区分 E の参照とみなす(提案の初期値として配置まで要る)。取り込みの行が消えれば - 参照も外れる(配置した後は `tracks.artwork_id` が参照する) - -**理由**: CD 画面では候補のジャケットが見えていた(D-82)のに、吸い出したファイルには画像が無く、承認画面で -「Cover Art Archive から取る」(D-86)を押さないと入らなかった。見えていたものが Inbox で消えたように見え、押し忘れる -と画像の無いまま配置される。候補を選んだ時点で盤は決まっているので、既定で入れて外せる形にする方が分かりやすい。 - -**却下**: 吸い出しの時点でファイルに埋め込む(D-67 の吸い出しは FLAC を作って Inbox に置くまでで、取り込みの内容は -承認で人が決める。外す操作が配置の前に効かない)。承認画面を開いたときに取る(一覧の表示が外部に引きずられ、 -開くたびに引き直す)。サイドカーに記録する(吸い出しの記録を後から書き換えることになり、D-90 の ctime の照合と -干渉する)。 - -## D-92 Library 直下のディレクトリ名はスキャナが自動で category の語彙にする - -**決定**(2026-09-25。2 回目のクリーンリハーサルで見つかった。ユーザの決定): - -- スキャナ(Phase 4 の album 照合の前、同じ書き込みトランザクション)が、音声を含む album ディレクトリ - (直下 + 1 段以上。`Anime/<albumartist>/<album>` の `Anime`)の親になっている **Library 直下の - ディレクトリ名**を、canonical key で語彙(`categories`)に無ければ登録する(`db::categories::insert`。 - API の `POST /api/categories` と同じ一意性。書き込みは単一コネクションなので API と並行しても重複しない)。 - 続く album 照合の `infer_category` は、登録後の語彙で直下の名前を category に付ける -- **語彙は Phase 4 のトランザクションの中で読み直してから**登録・照合する。Phase 2 で読んだ語彙のまま進むと、 - Phase 3 の間に API が同じ名前を足したとき登録が `None`(重複)になり、古い語彙のまま GENRE の写像が付いて - NULL の埋め戻しでも直らない(codex レビューの指摘。試験は Phase 4 の直前のフック `before_commit` で割り込む) -- 対象外: `_Unsorted`(既定)と `[layout].unsorted` の先頭の固定部分(`Scanner::with_unsorted_layout`)、 - 直下のディレクトリそのものに置かれた曲(`Loose/01.flac`。category/albumartist/album の形でない)、root - 直下の曲 -- **category が NULL のまま残っている album を埋める**: 増分スキャンの最速パスでは変化の無い album を - 照合し直さないので、album 照合の後に `category_id IS NULL AND missing_since IS NULL` の album だけを読み、 - 直下の名前に当たる語彙があれば `WHERE category_id IS NULL` の条件付きで付ける(`scans::albums_without_category` - / `set_album_category_if_null`)。一度埋まれば残るのは `_Unsorted` 等の数件なので、毎回の全件走査に - ならない(旧マイグレーション 0004。D-88 追記で `0001_init.sql` へ統合。その部分索引 `idx_albums_category_null ON albums(rel_dir) WHERE category_id IS NULL - AND missing_since IS NULL` で NULL の行だけを読む)。人が付けた値(NULL でない)は変えない -- **画面へ知らせる**: 埋めた album のトラックを `changed_ids` に足して `library` イベントを出す(ファイルが - 変わらない増分スキャンでも表の category が変わるため)。語彙だけが増えて行が何も変わらない run(直下の - フォルダの album すべてに人が別の category を付けていて、その名前の語彙がまだ無い既存の DB)も、 - `categories_registered > 0` なら `bulk` の `library` イベントを流す(codex 再レビューの指摘。まれなので表の - 取り直しの重さは問題にしない。新しい SSE の種別は足さない)。category の一覧(`useCategories`)は - `library` イベントのたびと、画面から語彙を足した / 消したときに取り直す(複数の選択欄を揃える) -- **自動登録は追加だけ。不要になった語彙は人が消す**: `DELETE /api/categories/:id`(設定画面の「category」)。 - active な album・GENRE の写像(CASCADE で黙って消えるので明示的に外してもらう)・購読(名前で持つ)・ - 承認前 / 配置中の Inbox の保存済みの下書き(名前で持つ)のどれかが使っていれば 409 `in_use`。確かめと削除は - 同じ書き込みトランザクション。missing の album は ON DELETE SET NULL で外れる。下書きを保存していない - 取り込み(提案は CD のサイドカーの category)は見ない。消した語彙の取り込みは配置で `_Unsorted` になる -- 優先順位はディレクトリ名 → GENRE → `genre_category_map`(D-38 のまま)。直下の名前は常に語彙になるので、 - GENRE の写像が効くのは `_Unsorted` の下など直下の名前を使わない album だけになる -- ScanReport に `categories_registered` / `albums_categorized` を足し、登録した語彙はログに出す - -**理由**: SPEC はカテゴリを「パスの最上位階層。統制語彙」と定義し、`[layout]` も `{category}/…` だが、 -`infer_category` は直下の名前が語彙に**あれば**付けるだけで、語彙を作る経路が API と購読・プラグインしか -無かった。作り直した DB では語彙が空のため、リハーサルの実機で 721 album 中 707 が category なしになり、 -(1) Inbox・購読・CD の category の選択肢に Anime / J-POP 等が出ない、(2) 一括リネームが 7,505 曲を -`_Unsorted/` へ移す提案をした。移行(MIGRATION)で毎回手で語彙を作る手順は忘れやすく、Library の実際の -構成が語彙の正なので、スキャナが揃える。 - -**既知の副作用**: album 照合(変化のあった album の `update_album_meta`)は従来どおり category を直下の -名前で上書きする(D-38)。直下の名前が語彙に入るようになったので、人が API で直下の名前と違う category を -付けた album は、その album のファイルが変わると直下の名前に戻る。category は配置先(パス)を決める値 -なので、違う category にしたいときはリネームで直下のフォルダを動かす - -**却下**: 移行手順で 1 回だけ登録する(手順漏れで同じ状態に戻る。SMB で直下に足したフォルダも拾えない)。 -直下のディレクトリを全部(音声の無いもの・ディレクトリの無い曲の置き場も)登録する(`Playlists` 等の -関係ないものが語彙になる)。NULL の埋め戻しを deep scan だけで行う(最初の deep scan までの最大 30 日、 -category の無い album が残る) - -## D-93 表の画像だけを同じリリースグループの別の版から取れるようにする - -**決定**(2026-09-25。P4-23。ユーザの要望: 「MusicBrainz の候補に出ている別のリリース(BD 版や初回限定版)には -カバー画像があるが、取り込みたい通常盤には登録されていないとき、メタデータは通常盤のまま、カバー画像だけ別の -リリースにしたい」。自動の補完と手動の選択の両方を入れる案をユーザが選んだ): - -- **自動(D-91 の拡張)**: 承認前の CD の取り込みで、吸い出したリリースの front が 404 なら、サイドカーの - `rip.metadata.release_group_id` があるとき**同じ回の中で**リリースグループの front - (`release-group/{id}/front-500`。CAA がグループの代表に選んだ版の表の画像)を 1 度だけ試す - (`import::cover::fetch_front`、`CoverArtClient::group_front`)。claim した 1 回の中の試行なので回数は増やさない。 - グループの 404 はリリースの 404 と同じく打ち止め、グループの上流の失敗はリリースの失敗と同じく次の走査で - もう 1 回だけ。リリースに画像があればグループは引かない。どちらから取ったかはログ(`source`)にだけ残し、 - DB には持たない(`caa_picture` は置いた画像の値で、画面の「画像を外す」の判定にしか使わない。 - 出どころを持つにはマイグレーションが要り、見返りが無い)。画面の文言は「吸い出したリリースかそのリリース - グループから自動で取った」にする -- **手動**: Inbox の承認画面の画像の欄に「別のリリースから取る…」(`CaaReleasePicker`。曲ごとに画像が違う - 取り込みには出さない)。メタデータは取り込むリリースのまま、画像だけを置いて全曲の `picture` に入れる - (従来の `applyPicture(…, 'all')`。配置で埋め込む) - - 下書きの `release_group_id` があれば、同じグループの版を `GET /api/cd/release-group/{id}/releases` - (MusicBrainz の browse `ws/2/release?release-group=…&inc=media+labels&limit=100`。既存の - `MusicBrainzClient` の 1 req/s に乗る)で並べる。MusicBrainz のリリースは `cover-art-archive.front` を - 持つので、表の画像のある版だけをジャケット(`/api/cd/cover/{id}` の中継)付きで出し、無い版は件数だけ。 - 並びは front のある版が先、同じなら日付の古い順。見分けに日付・国・形式(`CD + Blu-ray` 等)・レーベル・ - カタログ番号を出し、取り込むリリースに印を付ける。グループが 100 件を超えるときは先頭のぶんだけ - - 「リリースグループの代表画像を取る」(`from-caa { release_group_id }`) - - 別のグループに登録された版(BD 版が別のリリースグループになっている等)のために、MusicBrainz のリリースの - URL か MBID の貼り付け。`from-caa { release_id }` はサーバで `parse_release_ref` を通すので URL も受ける -- `POST /api/artwork/from-caa` は `{ release_id }` か `{ release_group_id }` のどちらか片方(両方・無しは 400) - -**理由**: 実機の嵐「Five」のリリースグループは CD + Blu-ray(画像あり)・CD + DVD-Video(画像なし)・配信 -(画像なし)の 3 版で、DVD 付きを吸い出すと D-91 は画像なしで終わる。表の画像は版が違ってもほぼ同じで、 -CAA もグループの代表画像を持っている。自動の補完で多くは埋まり、違う版の画像にしたいときだけ一覧から選ぶ。 -これまでの回避策(別の版の画像をブラウザで保存してドロップする)は MusicBrainz を別に開く必要があった。 - -**却下**: 取り込むリリースをグループ内の画像のある版へ変える(メタデータ = カタログ番号・媒体構成・ -`MUSICBRAINZ_ALBUMID` が実物の盤と食い違う)。手動を URL / MBID の貼り付けだけにする(変更は小さいが、別の版の -MBID を MusicBrainz で探して貼る手間が残る。ユーザの選択で一覧を付けた)。グループの版の一覧をキャッシュする -(開くのは件ごとに 1 回程度で、MusicBrainz の間隔制御に乗っていれば足りる)。 - -## D-94 CD の取り込みで品番(と JAN)を手で入れ、版の識別と照会に使う - -**決定**(2026-09-26。P4-25。ユーザの要望: 「挿入した CD は通常盤なのに、MusicBrainz で一致したのは初回限定盤で、 -そのまま取り込むとどの盤かという情報が落ちる。見た CD にはすべて品番(UPCJ-9085 のような)が付いていたので、 -取り込み時に手で入れて保持すれば、後で版を直せる」。下の形をユーザが選んだ): - -- **CD 画面の ① に「品番」と「JAN」の入力欄を置く(どちらも任意)。** 値の補正ではなく照会と識別の入力で、 - D-67 追記の「CD 画面からは直せない」の例外にする(他の値は従来どおり Inbox の承認画面で直す)。 - 入力は盤が替わったら消す -- **照会**: `POST /api/cd/lookup` に `catno?` を足す。リリース URL / MBID と同じく**段に関係なく常に引く** - (DiscID で当たっても引く。通常盤と初回盤は収録が同じなら TOC も同じで、DiscID では版を区別できないため)。 - `ws/2/release?query=catno:"…"`(値は英数字・ハイフン・空白だけを通し、Lucene の記号を通さない。取得の上限は - バーコードと同じ 3 件)。検索は大文字小文字・ハイフンの揺れを吸収するが部分一致もありうるので、リリースの - label-info の品番のどれかが入力と**正規化して一致する**(大文字小文字・ハイフン・空白を無視)ものだけに - 経路 `catno` を付ける。JAN は既存の `barcode:` の検索に、盤から読めた MCN が無いときの入力として渡す -- **候補の並び**: 品番の入力があるときは、経路 `catno` を持つ候補を先頭にする(既定で選ばれる)。 - `matched_by` の強い順は `catno | discid | release | isrc | barcode | toc`。品番は実物から読んだ版の識別子で、 - DiscID が別の版にしか登録されていない盤では、品番で当たった版が手元の盤になる。収録の違う版 - (ボーナストラック付き等)は DiscID 自体が変わり、トラック数の合わない候補は従来どおり落ちる -- **下書きへの写し方**(写す範囲が「最小限」でも、入力した品番と JAN は書く。ユーザが実物から読んだ値のため): - - 選んだ候補の品番が入力と一致する → `CATALOGNUMBER` は候補の表記、`BARCODE` は候補の値 - (JAN の入力があり食い違えば入力値) - - 食い違う(MusicBrainz に手元の版が無く、別の版で取り込む)→ 画面に注意を出し(例:「この候補の品番は - UPCJ-9001(初回限定盤A)で、入力した品番と違います」)、版に固有の値を引き継がない: - - | タグ | 扱い | - |---|---| - | `CATALOGNUMBER` | 入力値 | - | `BARCODE` | 引き継がない(JAN の入力があればそれ) | - | アルバム名・アーティスト・トラック名・ISRC・`MUSICBRAINZ_TRACKID` | 引き継ぐ(DiscID / ISRC で中身が同じと確かめている) | - | `LABEL`・`DATE` | 引き継ぐ(通常盤と初回盤で違うことはまず無く、日付も年はほぼ合う) | - | `MUSICBRAINZ_ALBUMID`・`MUSICBRAINZ_RELEASETRACKID`・`MUSICBRAINZ_RELEASEGROUPID` | 引き継ぐ(近い版を指す値として) | -- **版がずれている印は DB に持たない。** `CATALOGNUMBER` が `MUSICBRAINZ_ALBUMID` のリリースの品番に無いことが - 印になり、タグと MusicBrainz から後で拾える(ファイルが正)。拾って版を直す画面は今回は作らない - -**理由**: 実機で 7 枚取り込んだところ、DiscID で当たった候補が手元の盤と別の版のことがあった。DiscID は TOC から -出るので、収録が同じ版どうしは区別できない。品番は盤に必ず印刷されていて手入力の手間も小さく、MusicBrainz の -`catno:` 検索で版まで決まる(2026-09-26 に `catno:UPCJ-9001` → 「シンデレラガール」初回限定盤A の 1 件、 -`catno:upcj9001` でも同じ 1 件を確認)。MusicBrainz に手元の版が無くても、品番がタグに残れば後で直せる。 - -D-72 の「カタログ番号や JAN は要らない」はこの範囲で覆す(値としてではなく、版の識別子として要る)。 -D-72 の Discogs の却下はそのまま残す。品番の入力を入れたあと、MusicBrainz で当たらない盤がどれくらいあるかを -見て再検討する(TASKS の残課題)。 - -**却下**: -- 品番を「補助」に留め、DiscID / ISRC で当たった候補を常に優先する(収録が同じ版を区別できないので、 - 品番で手元の版が見つかっても初回盤で取り込まれる) -- 食い違うとき `MUSICBRAINZ_ALBUMID` も外す(版に固有なので筋は通るが、ライブラリにあるかの判定(D-85)と - ジャケットの自動取得(D-91 / D-93)が効かなくなる。品番との食い違いが後で版を直す手がかりとして残る) -- 食い違うとき候補の JAN を残す(品番が違えば JAN も別物で、実物と違う値がタグに残る) -- 版がずれている印を DB やサイドカーに持つ(タグと MusicBrainz から導ける。マイグレーションに見合わない) -- レーベルの公式サイトやタワーレコード / HMV を品番で引く(公開 API が無くスクレイピングになり、サイトごとに - 解析が要って改修で壊れる。規約上も自動取得は想定外) - -## D-95 端末への配信を管轄に入れる。Android は ADB 直結、iPhone は Mac のミュージック.app を中継し、端末ごとのマニフェストを毎回計算する - -**決定**(2026-09-29。ユーザ依頼。仕様 docs/superpowers/specs/2026-09-29-device-delivery-design.md): - -- SPEC §1 の非目標「Android へは Derived + m3u8 のファイル同期を継続する」と §7.7 の「`sync.py`(ADB)は - 移植しない」を改め、端末への配信を管轄に入れる -- Android(Poweramp。SD カード or 内部共有ストレージ)は TrueNAS に USB で直結し、特権を持つ adb サイドカー - 越しに差分を送る。**差分を見てからボタンで**送る(自動では送らない) -- iPhone は純正ミュージックを使うため Mac のミュージック.app を中継する。常駐しない CLI `spindle-agent` が - Mac 側のコマンドで反映し、iPhone へは Finder 同期 -- 端末ごとに「あるべき状態」(マニフェスト)を保存せず毎回計算する(D-78 と同じ)。端末側(manifest / - エージェントの報告)が正で、`device_items` はキャッシュ -- 曲は desired / hold / remove に分け、hold(待ち・エラー)の既存の写しには触らない。パス衝突は曲単位で保留 -- トークンは意味トークン(版)と配信トークン(版 + sha256)の 2 層。送る元のハッシュは `source_hashes` に - 事前計算し、配信時に開いた FD の identity で照合する -- **不変条件の例外**: 端末上の写しと Mac の `~/Music/spindle` は Library から再生成できる複製なので、物理削除 - (`missing_since` の猶予なし)と `edit_batches` に載せない書き込みを許す。「ファイルが正」は端末側にも - 当てはめる。生 SQL の禁止は変わらない - -**理由**: 端末に何が入っていて何が古いかが spindle から見えず、一括編集のたびに人が送り直しを判断していた。 -android プロファイルの m3u8 は Derived と Library の 2 root にまたがり、端末へそのまま運べなかった。 -Linux から純正ミュージックへ直接書き込む手段は現行 iOS に無い(メディア DB が非公開、libgpod は非対応)。 - -**却下**: 配信ツリーを NAS 上に実体化(Derived と同量をもう一度持つ)。spindle の外のスクリプト(状態が -見えず、2 root 問題をスクリプトで吸収することになる)。Linux から純正ミュージックへの直接書き込み(不可能)。 -Apple Music のライブラリ同期(契約が要り、照合で音源が差し替わる)。常駐エージェント(ユーザが Mac 側での -起動を選んだ)。接続即同期(ユーザが差分の確認を選んだ)。パス衝突で端末全体を止める(1 件で全体が止まる)。 - -**実装(P5-1)**: -- `source_hash` ジョブは CPU の共通並列予算(D-73)の対象(並列度 2)。全曲分の SHA-256 は CPU も使う -- 原本のハッシュは、`source_hashes` の identity(inode・size・mtime_ns・ctime_ns。dev は見ない。D-62)が `tracks` 行と一致するときだけ有効。違えば「ハッシュ計算中」に戻して取り直す(外部ツールが同じタグで再保存した原本を「変化なし」と誤判定しないため) -- `source_hash` ジョブは原本について、開いた FD の identity が `tracks` 行と違う(または見つからない)ときは読まずに「スキャン待ち」で終える(スキャン前に読んでも差分側で使われず、差分を開くたびに全体を読み直す空回りになるため)。Derived の実ファイルが無いのは D-51 の drift なので失敗にする -- 端末の差分は、行き先を「パスを変えない管理下の曲」(保留中の曲を含む)が占めていれば、その曲をパス衝突で保留にし、不動点まで繰り返す(入れ替えの片側が保留になれば、もう片側も保留)。プレイリストはこの衝突を解いた後の desired / hold から組み立てる(保留になった曲を塞がれた行き先のパスで載せない) -- 系統が設定に無い(`derived_variants` に行が無い)端末は凍結と同じ扱いで、音声版が一致する既存の Derived と opus の非可逆原本は送る -- パス長の上限に数える root の前置きは端末から求める(adb は `/storage/<volume>/<root>/`、内部ストレージは `/storage/emulated/0/<root>/`。agent は `Music/spindle/`) -- P5-2 / P5-3 / P5-4 へ持ち越し: Derived の実ファイル欠損(D-51 の drift)を「送る元が無い」保留理由にし、`source_hash` の再投入を抑えて transcode へ誘導する / プレイリスト操作の順序(削除を先に)と改名の 2 段 / エラーのプレイリストが古い写しと衝突する場合 / agent のプレイリストの dest_path / プレイリストのパス長とトラックの `.spindle` 予約名 / `compute` を 1 つの読み取りトランザクションにする - -**実装(P5-2)**(2026-09-29): -- Inbox の段(B)の対象は、その件で配置した曲。マイグレーション 0003 で `inbox_item_tracks(item_id, track_id)` を足し、配置のトランザクションで書く(既存アルバムへの追記でも、前からある曲を数えない)。0003 以前に配置した件は曲の記録が無いので、段が対象外に見える -- Derived の実ファイル欠損(D-51 の drift)は自動で作り直す。`source_hash` が Derived を開けず NotFound なら、その曲・系統の transcode が queued / running でなく、行の `rel_path` も読んだときのままであることを同じ書き込みトランザクションの中で確かめてから `derived_files` の行を消し、`enqueue_if_stale` で作り直しを投入する。差分には「Derived 未生成」と出る -- スナップショットのキャッシュ: 全端末の差分の計算結果を `Db` が持ち、書き込みの通番(`Db::write` を通るたびに ++。読み取りの前に通番を読む)が変わるまで使い回す。通番は「読んだ時点以後の書き込みを含むかもしれないが、それ以前の書き込みは必ず含む」ので、古い結果を新しい通番で返すことは無い -- キャッシュの計算は 1 本にまとめる(ロックを持ったまま計算し、同時に外れた要求は待って同じ結果を使う)。表示だけに使う呼び出し(一覧・詳細の行の端末の状態、Inbox の段、端末一覧の件数)は、計算から 2 秒以内ならその後の書き込みがあっても使い回す(ジョブが続く間は通番がほぼ毎回進むため)。差分・plan_token・変更 API の応答・未反映の集合とスマートプレイリストの評価(書き込みの材料)は通番の一致を要する。端末が 1 台も無ければ一覧・詳細はスナップショットを取らない -- `source_hash` の投入: 差分の計算(`GET /api/devices` と差分)のたびに、未完了の dedup キーと抑制中のキーを 1 本の問い合わせで除き、残りを 1 つの SAVEPOINT で投入する。読みプールで絞って何も無ければ書き手に触らない(通番を進めない)。Web はジョブの完了による端末の取り直しを末尾の間引き(3 秒、最長 10 秒)にする -- 原本の物理同一性が `tracks` と食い違う(SMB 越しの外部の書き換えで、まだ走査していない)と、`source_hash` は走査待ちで終わる。このとき増分スキャンを投入し、note に走査待ちの印を残す。原本の payload には投入時の `tracks` の物理同一性(inode・size・mtime_ns・ctime_ns)を入れ、同じ dedup キーの最新のジョブが走査待ちで終わっていて payload の物理同一性が `tracks` の現在の値と同じ間は再投入しない(走査で `tracks` が変われば再び投入される)。抑制がないと、完了 → SSE → 一覧の取り直し → 再投入が秒に数回回り続けた -- 評価待ちの印(`ReevalFlag`)は `source_hash` と `thumbnail` の完了では立てない(端末の選曲に載るプレイリストは端末のフィールドを使えないので、端末の状態だけの変化では結果が変わらない)。再評価そのものの合図はこれまでどおり -- `device_pending` の集合は呼び出し側が渡す: DSL の `compile` は DB を知らない純粋関数のままにし、`PendingSets`(端末の `name_key` / id → 未反映の track_id 列)を引数で受け取る。SQL には `t.id IN (SELECT value FROM json_each(?))` として JSON 配列をバインドする。保存前の検証(`check`)は空の集合で行う。`Selection::Filter` の filter は `Box<Filter>` にした(PendingSets を足して enum が大きくなったため) -- `on_device` / `device_pending` の値は端末名。`canonical_key(値)` と `devices.name_key` を比べる。知らない端末名は空集合(端末の改名・削除でルールが壊れて保存できなくなるのを避ける)。演算子は `IS` だけ(それ以外は 400)。これらを使うスマートプレイリストは端末の選曲に載せられない(循環の禁止) -- 曲ごとの状態は `synced` / `pending`(追加・更新・移動・更新 + 移動)/ `waiting` / `error` / `removing`(対象外で、次の同期で端末から消える)。対象外で端末にも無い曲は状態を持たない。同期・報告で出た `device_errors` の track 行は、その曲が未反映なら理由として添え、反映済みなら `error` にする -- 容量の見積もり(差分の `estimate`): `transfer_bytes` = 追加・更新・更新 + 移動の size の合計、`peak_bytes` = 仕様 ⑤ の実行順(削除 → パス変更のバッチ → 更新 → 追加)に沿って「今より増える量」の最大値(負なら 0)。`free` は P5-3 まで `null`。余裕(64 MiB など)は同期の実行時(P5-3)に足す -- 選曲の見積もり: `GET /api/devices/:id/estimate?selection=…&playlist_ids=…` が、仮の選曲の曲数(重複除く、missing とマルチチャンネルを除く)と、送る元のハッシュが取れている分の size の合計・取れていない曲数を返す -- PATCH で変えられるものは `name` / `selection` / `variant`。`name` 以外は generation を ++ し、open な計画・同期中は 409 `open_plan`。adb の保存先の変更は P5-3 -- ナビのバッジは `GET /api/devices` の件数(追加 + 更新 + 移動 + 削除 + エラー)の全端末の合計。60 秒のポーリングとジョブの完了で取り直す(新しい SSE イベントは P5-3 で同期の進捗と一緒に足す) -- 端末へ送るの切り替えは端末ごとに PUT を直列にし、PUT の本文は実行時に「望む選曲」から作る(続けて押したときに先の変更を消さない) -- 差分表は先頭 1000 行だけ描き「ほか N 件」と出す(初回同期は 9,000 行規模) -- `JobType` の serde 名を snake_case にして `as_str` と揃えた(`playlist_sync` / `source_hash` が JSON で `playlistsync` / `sourcehash` になっていた) -- P5-1 からの持ち越しのうち、Derived の欠損と `compute` の 1 トランザクション化は解消した -- P5-3 / P5-4 へ持ち越し: プレイリスト操作の順序(削除を先に)と改名の 2 段 / エラーのプレイリストが古い写しと衝突する場合 / agent のプレイリストの dest_path / プレイリストのパス長とトラックの `.spindle` 予約名。加えて `has_open_work` が `device_sync` の payload の `$.device_id` を前提にしていること(P5-3 で合わせる) - -### P5-3a で決めたこと(2026-09-30) - -- 実機の spike(P5-0)は後回しにし、端末の操作を `DeviceFs` trait に閉じ込めて偽の端末 FS と偽の adb で - 先に作った。実機に依存する挙動(shell v2 の stdin、toybox の `find` / `stat` / `sha256sum`、Poweramp の - レシーバと extras)は `#[ignore]` の実機テストと定数に閉じ込め、P5-3b で Xperia を繋いで固定する -- adb のサブコマンドは `shell` だけを使う。shell v2 は pty を割り当てない限り stdin / stdout がバイナリ - 安全で終了コードも返る。`exec-in` / `exec-out` は終了コードを返さず `No space` を取りこぼす -- バッチ以外の意図は `put` と `rm` だけ。パス変更は必ずバッチに入れる。バッチのメンバーは曲だけで、 - プレイリストの改名は旧パスの削除を全部先に行ってから新パスへ書く(中身は毎回生成し直すので失うものが無い) -- 失敗した操作には `done` を書かない(`done` の付いた `put` は回復で sha256 を見ずに反映済みとみなす)。 - `done` は次の意図と同じ追記に載せ、manifest の書き直し(圧縮)は完了 50 件ごとと同期の最後に行う -- 回復は `prepared` 以前のバッチを縮めずに破棄する(`batch_abort` を耐久化してから `new` を消す)。 - 同期の実行中に準備で失敗したメンバーが出たときだけ、その場で成分ごとに縮める -- 実ファイルが無い・サイズが違う曲は、回復の結果でトークンを空にして差分に「更新」として出す - (manifest から外すと管理外になって削除できなくなる) -- パス変更のバッチの一時ファイル(`staging` / `new`)の名前に `batch_id` を含める - (`.spindle/moving/<batch_id>-<op_id>`、`.new`)。縮めた後の新バッチが中止済みの旧バッチと同じ名前を - 使うと、回復が旧バッチの掃除として新バッチの `new` を消してしまうため -- 回復はバッチのメンバーの位置を、仕様の表どおりファイルが在るかどうかで判定する(`staging` に在れば - 空け済み、`src` に在れば置く、無くて `to` に在れば置き済み)。sha256 の一致で判定すると、同じサイズで - 外部から書き換えられた曲で入れ替えが壊れる。内容の食い違いはサイズの照合と「内容を検証」に任せる -- 置き先を manifest に無いファイル(手で置いたもの)が占めていれば上書きしない。同期の開始時に一覧を - 1 回取り、その曲・プレイリストは「管理外のファイルと衝突」のエラーで保留する。パス変更では、その曲を - 含む成分を全員外す -- 置き先を別の管理下の曲・プレイリスト(manifest にある別の id)が占めていれば、意図を書かずに - 「パス衝突(移動しなかった曲が置き先にいる)」のエラーで保留する。差分は移動中の曲の今のパスを「空く」と - 扱うので、その移動が実行されない(行き先が管理外・準備の失敗で縮んだ・再開時に外れた)と、追加が移動しな - かった曲を上書きし、manifest のパスが重複して以後の回復が必ず失敗するため。manifest の書き出しも読み込みと - 同じ検査(件数・id とパスの一意性・パスの範囲)を通し、壊れた正本を書かない -- 回復は、ジャーナルのバイト列が空でなければ(読めるレコードが 0 件の切れた断片だけでも)圧縮する。 - また、自分が追記する前に末尾の切れた断片を落としたジャーナルに差し替える(tmp + `mv -f`)。断片を残すと - 次の追記が断片と 1 行に連結され、その意図が消えるか、さらに次の追記で全体が読めなくなるため -- 回復が実ファイルの無い・サイズの違う項目に付ける `STALE_TOKEN` は、結果の中だけでなく manifest にも - 書く(トークンが変わった項目が 1 件でもあれば、ジャーナルが空でも圧縮する)。同期は回復した manifest から - 始めるので、今回の計画に入らなかった項目のトークンが同期の結果で元に戻ってしまうため -- 「内容を検証」は回復済み(ジャーナルが空)の正本に対してだけ行う。ジャーナルが空でなければ失敗させる - (検証の書き直しの圧縮で未完了の意図を捨てないため) -- 「内容を検証」で食い違った・無くなった曲は、manifest から外さずトークンを空(`STALE_TOKEN`)にする。 - 外すと実ファイルが管理外になり、上の規則で上書きできなくなるため。次の差分で「更新」に戻る -- adb の未接続は、adb クライアント自身の文言(行頭の `adb: device '…' not found`・ - `error: device offline` など)だけで判定する。端末側のスクリプトの `…: not found` を未接続と - 取り違えないため -- 端末の一覧(`list_files`)は、1 件でもサイズを取れなければ全体を失敗させ、同期・回復も書き込みの前に - 失敗させる。握り潰すと手置きのファイルが一覧から消えて管理外と分からず、上書きしうるため。列挙の後に - 消えたファイルでも失敗するが、同期が失敗するだけで次回やり直せる(上書きの危険より安全側)。失敗は終了コードに - 頼らず出力でも伝える: 先に `find | wc -l` で数えた件数を `N <件数>` で書き、`find -exec stat -c '%s %n' {} +` が - 0 で終わったときだけ(root が無い経路でも)終端の印 `END` を書く。終端が無い・終端の後に続きがある・形の - 崩れた行がある・件数と行数が合わない出力は失敗とする(find が stat の失敗を終了コードに伝えなくても、欠けた - 一覧を完全な一覧と読まないため)。ファイルごとに stat を起こす形は実機で遅すぎたので改めた(「実機での確認」) -- プレイリストの書き込み(追加・更新)が成り立つかは、旧パスを消す前に全操作について判定する。中身が無い・ - 行き先が管理外のファイル・行き先を今回空かない別の管理下の項目が占める操作は、旧パスも消さずに丸ごと保留 - する(保留になった改名の旧パスは空かないので、そこへ来る改名も保留になる。不動点まで繰り返し、入れ替え・ - 循環は通す)。旧パスを先に消すと、保留のときに残すべき既存の写しを失うため -- 確定済みの計画の再開で、プレイリストの改名(更新・from ≠ to)が今の状態に行が無く(旧パスの削除が済んだ)、 - 今の差分に同じ id・同じ行き先・同じトークンの追加があれば、「旧パス削除済みの正当な中間状態」として、計画の - `op_id` のまま追加として実行する。外すと同じ計画の再開が新パスを書かずに成功扱いになり、次の計画を確定する - までプレイリストが欠落するため。曲のパス変更はバッチで原子的に完遂されるので同様の中間状態は無い -- 却下: 追記ごとに `sync` を 2 回(意図と完了)打つ。1 曲あたりの adb 呼び出しが増え、初回の全曲転送で - 数時間の差になる。完了は次の意図と一緒に耐久化しても、回復が sha256 で判定できる - -## D-96 検証バッジは一致したかだけを出し、手法ごとの結果はプロパティと照合ジョブの結果に出す - -**決定**(2026-09-29。ユーザ依頼): - -- バッジの ✔ は `verified_ctdb` / `verified_ar` を区別せず「検証済み」の 1 つにする。`tracks.verification` の - 値と写像(CTDB 優先。§7.3)は変えない(スマートプレイリストの `%verification%` もそのまま) -- どちらで一致したかは `GET /api/tracks/:id` の `detail.verifications`(手法ごとの最新の - `album_verifications` × `track_verifications`)から、プロパティの General に「CTDB」「AccurateRip」の行で出す -- `verify` ジョブは結果 1 行を `jobs.note` に残す(ディスク × 手法の一致数と信頼度 / 登録なし / 検証不能・ - 照合しなかった理由)。結果 1 行は記録と同じトランザクションで書き、commit の後に落ちた再実行はそれを返す。操作タブは投入した - ジョブを終わるまで 2 秒ごとに `GET /api/jobs?type=verify` で追い、終わったアルバムから 1 行ずつ出す。 - 一覧は状態ごとに件数の上限があるので、見えた状態をジョブ id ごとに貯め、終端が見えていないジョブは verify の - 待ち・実行中(`by_type`)が残る間は待ちとして数え、残っていなければ「結果を取れない」にする(完了の上限の - 外へ押し出されて終端を観測できなかったジョブで、待ち続けないため) -- 結果は選択を変えても消さない(閉じる・別の操作・次の照合で消す)。照合したアルバムの曲を選び直して - プロパティで手法ごとの結果を見る使い方を妨げないため。行ごとに対象のアルバムを出すので取り違えない - -**理由**: 両方で一致したトラックも CTDB 優先で「検証済み(CTDB)」になるため、実機の 6 ディスク(AccurateRip -でも 4 ディスク一致)がすべて CTDB だけで一致したように見えていた。AccurateRip の結果は verify.log にしか -無く、画面から見えなかった。投入時の「遡及照合を投入した: アルバム 1」は結果の出る場所も分からなかった。 - -**却下**: バッジを手法の組み合わせ(両方 / CTDB だけ / AccurateRip だけ)で塗り分ける(ユーザが「一致したか -だけでよい」を選んだ)。`tracks.verification` に両方一致の値を足す(マイグレーションが要り、DSL と -スマートプレイリストの値も変わる)。SSE の job イベントで追う(イベントに種別も結果も無い)。 -`GET /api/jobs` に id の指定を足す(全ライブラリの投入では数千件の id が URL に載る)。 - -## D-97 ReplayGain は解析の後に自動でタグへ書き、Derived はその書き込みの後に作る - -**決定**(2026-09-29。ユーザ依頼): - -- **`[replaygain].write_tags = true` なら、解析の後に自動でタグへ書く。** 書くべき行は `tracks.rg_write_due` - (0004 で追加)の印で持つ。印は rg の保存で確認が成り立たなくなった行と、album gain を off にして album の値を - 消した行に立ち、書き込みの編集バッチを記録したとき(一致済みで `rg_written_at` だけ立てた行も)に下りる。 - 巻き戻し・外部の変更では立たない(解析し直すまで書き直さない。書き直すと巻き戻しが効かない。ファイルが正)。 - 印が立った後に**外部ツールが RG のキーを書き換えた**ことを走査が見つけたら(取り込み前の DB の RG のキーの値と - 比べる)、印を下ろす(外部の値を自動書き込みで上書きしない)。RG 以外のタグだけが変わった行は印を残す -- 印を書くのは新しいジョブ `rgwrite`(0004 で `jobs.type` に足す)。**Library 全体で 1 本**(dedup `rgwrite`、 - payload は空)で、実行時に印の付いた active な行を album ごとにまとめ、手動の `POST /api/rg/write` と同じ - `Editor::prepare_rg_write` で **album ごとに 1 つの編集バッチ**を作る(旧値の記録・overlay・巻き戻しはタグ編集と - 共通。D-48)。album の無い行は 1 行ずつ -- 同じ album を解析する rg が queued / running の album と、反映待ちの編集がある album は後回しにし、書ける - album を書いた後で**キー付きの後継を 2 秒後に 1 本積んで終わる**(album gain が off の album は曲ごとに解析される - ので出揃ってから 1 バッチにする。飛ばすと書き漏れ、rg 自体を待たせると重い再解析がやり直しになる)。キーを - 外した自分を `Requeue` すると、rg の完了ごとに積まれるキー付きのジョブと別に待機ジョブが増え続ける(codex - レビュー)。後継は dedup で常に 1 本 -- 待つ判定とバッチの記録は別のトランザクションなので、その間に始まった rg が値を変えれば 1 回余分に書くことが - ある。値が変わった行は保存で印が立ち直すので次の rgwrite で揃う(最終的な値は収束する。厳密な「1 album - 1 バッチ」の保証より、Editor の編集バッチの経路を共通に保つ方を取る) -- **`rgwrite` は開始時に自分の dedup キーを外す**(`db::jobs::release_dedup_key`)。対象を読んだ後に立った印の - ための投入は、実行中のジョブへ合流せず新しいジョブになる(合流すると読み終えた対象に吸われて消える) -- 積むのは印を立てた書き手(rg の保存・album gain の API・Inbox の配置)。取りこぼしの回収として scan の完了 - (missing から戻った行)と起動時(`write_tags` を有効にした直後)に、印が残っていれば積む -- `write_tags = false` は「タグに一切書かない」(自動も手動も。手動は従来どおり 409 `rg_write_disabled`)。印は - 残り、有効にした後の起動時に書く -- **Derived はどの系統も RG が揃ってから作る。** 解析済みで、`write_tags` なら Library のタグへの書き込みも - 確認済み(`rg_written_at >= rg_scanned_at`)。`derived::eligible` の条件で、揃わない間は凍結と同じく既存の行と - ファイルに触らない。設定は起動時に `derived_variants.rg_write_required` へ写す(D-75 の系統設定と同じ流儀)。 - 順序は rg → rgwrite → tagwrite の applied(`enqueue_if_stale`)→ transcode -- **確認時刻 `rg_written_at` は `rg_scanned_at` より小さくしない**(`set_written` / `sync_written_at` / 同じ値の - 再解析)。`rg_scanned_at` は album gain の切り替えや同じ秒の再解析で「前の値 + 1」へ進み壁時計より先にありうる - ので、同じ秒の確認が「未書き込み」に見えて Derived が止まる -- **スキャンで新規に登録したトラックにも rg を積む**(album の照合の後、`enqueue_analysis` の規則で)。既存の - 未解析の行は拾わない。Inbox を通さずに SMB で置いた曲も漏れなく乗せるため -- 操作タブの手動ボタンは両方残す。「ReplayGain を解析」は既存の未解析の曲・測り直し用、「解析値をタグに書き直す」 - は自動で書かれなかった行(デプロイ直後の既存の未書き込み・外部の書き換え・巻き戻し後)用 - -**理由**: 解析と書き込みが別の手動操作だったため、書き込みを忘れた曲が Library に残り、Derived(opus の `R128_*`、 -aac の焼き込み)は DB の値で作られるので Library のタグとずれていても気づけなかった。Derived を書き込みの後に -回すと、再解析で値が変わったときの aac の作り直しも 1 回で済む(従来は解析時の Encode と書き込み時の Retag の 2 回)。 -書き込みを編集バッチに載せたままなので不変条件 3・4 はそのまま満たす。対象を行の印で持つのは、ジョブの payload -(album の範囲や解析時刻の下限)で持つと、album の移動・missing・実行中のジョブへの合流・同じ秒の境界で -書き漏れや書き過ぎが起きるため(codex レビュー)。 - -**却下**: -- rg ジョブの中で直接バッチを作る(反映待ちの行を飛ばすと書き漏れ、待つと解析のやり直しになる) -- rg ジョブ 1 本 = 1 バッチ(album gain が off の album は曲ごとの解析なので、全曲の解析で曲数ぶんのバッチが並ぶ) -- `POST /api/rg` の 1 回 = 1 バッチ(全ジョブの完了を集約する仕組みが要り、途中の失敗の扱いが複雑) -- album 単位の rgwrite に解析時刻の下限(`since`)を持たせる(上の書き漏れ・書き過ぎ。最初の実装で codex が指摘) -- 巻き戻し・外部の変更で未書き込みになった行を自動で書き直す(巻き戻しが効かない。外部の値を上書きする) -- Derived を解析だけで作る(Library のタグに書く前の値の Derived が配られ、書き込み後にもう一度作り直す) - -**影響**: -- D-47 の「scan 完了時の自動投入」の却下は、新規に登録した行に限って改める(移行直後の全曲解析の懸念は - 「既存の未解析を拾わない」で避ける。ただし DB を作り直した直後の走査では全曲が新規なので全曲の解析が積まれる。 - ファイルが正・DB はキャッシュの前提から、解析値を失った DB ではこれが正しい) -- opus も RG が揃うまで作らない(従来は RG 不要)。音声が差し替わった曲は再解析 → 書き込みの後に作り直す -- album gain を off にした album は、ファイルの album のキーを消す書き込みの後に Derived が追随する -- デプロイ直後、既存の「解析済みで未書き込み」の曲は印が無いので自動では書かれず、Derived の追随が止まる(既存の - Derived は凍結されて配られ続ける)。一覧の `rg_unwritten` で絞り込んで「解析値をタグに書き直す」を一度実行する -- Inbox の件の段(D-95 の可視化 B): 「RG」は書き込みが要る設定なら書き込みまで済んで完了、系統の段の母数は - RG が揃えば作る曲(`derived::covered`。RG 待ちも「これから作る」に数える) -- 持ち越し: 端末配信の待ちの理由(`Wait::RgPending`「RG 未解析」)は aac の解析だけを見ている。hold になる点は - 同じだが、表示を「未解析・未書き込み」に揃えるのは P5-2 の `devices.rs` の改修と合わせる - -## D-98 Android の同期をジョブと常駐監視で組み込み、adb サーバは host ネットワークのサイドカーが Unix ソケットで待ち受ける - -**背景**(2026-09-30。ユーザ依頼): P5-3a のエンジン(D-95)を spindle に組み込む(ジョブ・`adb track-devices` の常駐監視・API・端末タブ・adb サイドカー)。設計は実機 spike(2026-09-30。XQ-CC44 / Android 14 / Poweramp build-1025 / platform-tools r37.0.1)の結果で D-95 と設計文書から直した点がある。以下がその決定。 - -**決定**: - -- **サイドカー**: adb サーバは別コンテナで `network_mode: host` + `adb -L localfilesystem:/run/adb/adb.sock nodaemon server` として動かす。ソケットは名前付きボリュームで spindle と共有し、TCP は開かない。**spindle 側はそのボリュームを読み取り専用(`:ro`)でマウントする**。`ADB_SERVER_SOCKET` を渡しても自前のサーバの起動を止められるのは `tcp:` の指定だけで、`localfilesystem:` はローカルの指定とみなされ、サーバに繋がらないとクライアントが同じパスで `fork-server` を起こす(setsid するので ChildGroup の kill も届かない)。サイドカーが落ちている間にこれが起きると、戻ったサイドカーとサーバが 2 つになり、監視の `track-devices` が USB を持たない方に繋がったままになる。読み取り専用なら connect() は通り、待ち受けの作成だけが `Read-only file system` で失敗して `error: cannot connect to daemon`(未接続)になる(r37.0.1 で確認)。独自のネットワーク名前空間では端末の挿し直しを拾わない(`/dev/bus/usb` の bind・`/dev` の bind・`ADB_LIBUSB=0` のどれでも不可。netlink の uevent が届かない)ため -- **HOME**: adb の子プロセスに `HOME=<data>/adb` を渡す。`ANDROID_USER_HOME` は効かない -- **切断の分類**: 端末を抜くと stderr は空で rc=255 になる。adb の文言に当たらない失敗は `get-state` で確かめ、`device` 以外は未接続とみなす -- **Poweramp**: 再スキャンの extras は付けない。パスを指定する extra は無く、`eraseTags` は全タグ消去と CUE の副作用がある。スキャン範囲は Poweramp のフォルダ設定なので、保存先はその下に置く -- **大小文字だけの改名**: 実機の `mv` は成功扱いで何もしない。エンジンは一時名を経由するので正しく改名される。試験用の FakeFs を実機の挙動に合わせた -- **排他**: 端末ごとに `job_mutexes` の `device:<id>` とプロセス内のロックを取る。API の破棄はロックが取れなければ 409 `busy`。**`device_sync` はロックを取った後で計画がまだ open かを確かめ直し、同期の途中で閉じられていたら巻き戻す**(`close_plan` が false なら Fatal)。ロックの前の確認だけでは、待っている間に破棄された計画を進めてしまう -- **未接続の待ち**: `Outcome::RequeueAfter(300)`(attempts を数えない)。`track-devices` が `device` を見たら `device_scan` を投入して `run_after` を前倒しする。`track-devices` の stderr は常時読み捨てずに流し続ける(詰まると子が止まる)。`parse_frame` はタブを含む短い形式の行をタブで分割する(`serial\tstate`。実機が使う `-l` 形式は影響なし) -- **adb を使わない構成**: `[devices].adb_server` が空なら、Android の登録・未登録の一覧・同期・再開・検証・通常の破棄は 503 `adb_disabled` を返す。強制破棄と端末の削除は adb を使わないので動く。設定は前後に空白のある `adb_server` を拒否する(起動時の検証) -- **登録の失敗**: 行を作ってから manifest を作り、失敗したら `.spindle` を片付けて行を消す。`.spindle` だけの残り(manifest が読めない・uuid が DB に無い)は空とみなして作り直す -- **保存先の変更**: PATCH では変えない。削除して登録し直す(既存の写しの扱いを推測しない) -- **破棄**: 接続中に回復してから閉じる。回復は `vacating` 以降を完遂し `prepared` 以前を破棄するので、閉じた後に進みかけのバッチは残らない。計画の JSON が壊れていても行の id で破棄できる -- **端末が戻らないときの逃げ道**(2026-10-01。最終レビューの指摘に対しユーザが両方を選んだ): 同期の途中で端末が壊れた・手放したなどで戻らないと、計画が open のまま残り設定を変えられなくなる。そこで 2 つを用意する。(a) `DELETE /api/devices/:id` は途中の計画があっても通す。断るのは端末のジョブ(`device_sync` / `device_scan` / `device_verify`)のどれかが running のときと、端末のロックを別の処理(ジョブ・破棄)が持っているとき(409 `busy`。2026-10-01 のレビューで同期だけから広げた。走査・検証も回復で端末上のジャーナルを完遂・取り消すので、消した後も端末を書き換え続け、最後の書き戻しが外部キーで落ちる)。削除はロックを取ってから行を消し、待ちのジョブを取り消すまで持つ。ロックを待っていたジョブは取った後に行を読み直し、消えていれば端末に触らずに終わる。待ちの `device_sync` / `device_verify` / `device_scan` は断らずに取り消し(未接続の端末を待つジョブが残っていても消せる)、計画は `ON DELETE CASCADE` で行と一緒に消える。端末上のファイルには触らない。(b) `POST /api/devices/:id/plans/open/abandon` に `{ "force": true }` を渡すと、端末につながず回復もせずに計画を行の id で `abandoned` に閉じ、待ちの同期を取り消す。断るのは `device_sync` が running か端末のロック中のとき(409 `busy`)。ADB 同期が無効でも使える。安全なのは、次につないだときの回復が計画の状態と無関係に、端末のジャーナルから封印済みバッチを完遂または取り消すため。端末タブは、未接続か破棄が `not_connected` で断られたときに「強制破棄」を確認つきで出す。PATCH の規則は変えない - -**理由**: ネットワーク名前空間の分離と USB の hot-plug が両立しないのは実機で確かめた事実で、host ネットワークのサイドカーを Unix ソケット限定で公開すれば LAN に 5037 を開かずに済む。それ以外は、同期を再開できるジョブにして(不変条件 6)、端末側の破棄・回復を編集バッチと同じく巻き戻せる形に保つため。 - -**却下**: -- internal ネットワーク + 再起動で拾い直す(転送中の再起動になる。仕掛けが増える) -- host ネットワーク + `-a`(LAN に 5037 が開く) -- Poweramp の `eraseTags` 付きの再スキャン(全タグ消去と CUE の副作用) - -**持ち越し**(P5-3a の記録から、今回やらないもの): 回復の在否判定の stat 化(進みかけのバッチの分だけなので件数は小さい)、成分ごと外したときの巻き添えの理由、`Book::occupied_by_other` の索引。`list_files` を `transfer_timeout` で待つようにしたが、大きなライブラリでの実際の所要時間は Task 11 で測る。 - -**配備**(本番に Android の同期を入れるとき): 本番の config.toml には `[devices]` が無く、Android の同期は無効のまま。有効にするには `[devices] adb_server = "localfilesystem:/run/adb/adb.sock"` を足し、`/mnt/ssd/apps/spindle/adb-keys` を作り、TrueNAS のアプリの YAML に adb サービスを足す(spindle 側のソケットのボリュームは `:ro`)。 - -**実測**: shell v2 の stdin で約 38MB/s(`adb push` は約 41MB/s)。3GB で約 73 秒。 - -### 実機での確認 - -2026-10-01、TrueNAS の試験インスタンス(spindle:p5-3b + adb サイドカー)と Xperia(XQ-CC44、SD E1C6-6113)で確かめた。 - -- 未登録の一覧に Xperia(model XQ_CC44)が出て、内部と SD の候補・空き・状態(missing)が正しい。SD に登録して manifest の device_uuid が DB と一致 -- 全曲の同期(4 曲 → 226 曲)が完了し、Poweramp のスキャンが始まる(Scan.DirWalker: Starting scan=E1C6-6113/Music/) -- 大小文字だけの改名(feat. → Feat.)が「移動」になり、端末上の名前も変わる(一時名経由) -- 転送中に USB を抜く → ジョブは attempts 0 のまま queued(300 秒後)→ 挿し直しで前倒しされ、送り終えた分を飛ばして残り 214 件で完了 -- 端末上の 1 曲を書き換える → 検証で「不一致 1」→ 差分に更新 → 同期で直る -- サイドカーを止めても spindle のコンテナに adb サーバは立たない(:ro のソケットで Address already in use / Read-only で失敗)。サイドカーを戻すと約 26 秒で再接続 -- 未接続で計画が開いたまま → 通常の破棄は 409 not_connected、強制破棄は 200 で閉じて待ちの同期を取り消す -- 実測: 転送は 1 曲ずつの意図・sync・sha256 の往復込みで約 6.5MB/s(1.1GB / 170 秒)。全曲 9000 曲の初回はおよそ 2 時間の見込み。高速化(意図の追記や sync のまとめ)は持ち越し -- 実機で直したこと: 一覧を 1 回の stat にまとめた(9030 ファイルで 5 分超 → 5 秒)、評価待ちはスマートプレイリストを選曲に含む端末だけに効かせる - -一覧をまとめても欠けは塞いだまま: 先に `find | wc -l` で数えた件数(`N <件数>`)と `stat -c '%s %n'` の行数を突き合わせ、最後に終端の印 `END` を置く。stat の失敗は `find -exec … +` の非 0 で終端の印が無くなり(toybox でも伝わることを確かめた)、find が失敗を隠しても件数が合わなくなる。改行を含む名前は Android の FUSE では作れず(spike で確認)、spindle 自身のパスも `sh_quote` が改行を拒むので、この形式は行の区切りに改行が使えることに頼る。壊れた行や件数の不一致は今も失敗にするが、改行入りの名前を作り込まれた場合の検出までは保証しない。 - -## D-99 エージェント API はセッションと別の Bearer 経路にし、報告は全項目を検証してから 1 トランザクションで反映する - -**背景**(2026-10-01。仕様は `docs/superpowers/specs/` の P5-4 設計と D-95): P5-4 で iPhone 向けに Mac の `spindle-agent` が使うサーバ側の API を作る。P5-4 を 4a(サーバ側の API と UI)/ 4b(`agent/` クレートのエンジン)/ 4c(実機 Mac での JXA の固定と macOS バイナリ)に分け、本件は 4a。計画は `docs/superpowers/plans/2026-10-01-p5-4a-agent-api.md`。以下がその決定と、仕様の空白を埋めた判断。 - -**決定**: - -- **P5-4 の分割**: 4a = サーバ側の API と UI、4b = `agent/` クレートのエンジン(ミュージック.app の操作を trait で差し替えて試験)、4c = 実機 Mac で JXA を固定し、CI で macOS バイナリを作る(ユーザ承認済み。ssh で届く Mac がある) -- **ワイヤ型の共有**: 「manifest の型は本体と共有」を、serde だけに依存する新クレート `agent-proto`(ワークスペースメンバー)で実現する。エージェントが spindle 本体(rusqlite bundled・lofty など)に依存しないため -- **トークンのシークレットは SHA-256**: 仕様は「シークレットのハッシュ」とだけ書く。トークンのシークレットは 256 bit の乱数なので総当たりに耐え、ファイル取得のたびに argon2id(数十 ms)を払う理由が無い。セッションと同じく SHA-256 を保存し定数時間で比較する。ワンタイムコードは仕様どおり argon2id -- **試行の予約**: pair の試行は検証(argon2id)の前に書き込みトランザクションで `pair_code_attempts` を 1 増やして予約する(ログインのレート制限と同じ流儀)。並行送信で 5 回を超えさせない。5 回目の失敗でコードを失効(セレクタとハッシュを NULL)させる -- **agent のプレイリストの dest_path はプレイリスト名そのもの**(P5-1 からの持ち越し): ミュージック.app の「spindle」フォルダ直下に同名で作るので、`device_playlist_state.dest_path` に名前を入れ、差分の「改名」判定もそれで行う。adb は従来どおり `Playlists/<name>.m3u8` -- **manifest の応答に差分を載せる**: 仕様の manifest は `items` / `playlists` だけだが、確定する計画はサーバの差分(`device_items` 基準)なので、エージェントが表示する差分がそれと食い違わないよう、サーバの差分(操作・保留・プレイリストの操作とエラー)と `plan_token` を同じ応答に載せる -- **プレイリストの中身**: manifest の各プレイリストに `tracks: [track_id]`(順序どおり)を載せる。中身の同一性はトークンが表す -- **ファイル取得の「現在のマニフェスト」は表示用スナップショット(2 秒)で引く**: 項目の有無の判定だけに使い、内容の照合は `source_hashes` の行を毎回読み直して行う。Derived の変換が続く間、要求ごとに全曲の差分を計算し直さないため。FD の中身とトークンの組がずれない性質は `source_hashes` の identity 照合が保つ -- **ファイル取得の失敗**: `If-Match` が無い → 428 `if_match_required`。弱い ETag・`*`・並記は一致とみなさない(412)。送る元の identity が行と違う・無い・symlink や通常ファイルでないものに差し替わった → 412 `source_changed` にして `source_hashes` の行を消す(次の差分の計算で `source_hash` が再投入される) -- **破棄の本文**: `POST /api/agent/plans/:id/abandon` の本文は報告と同じ `{generation, plan_id, state, errors}` に `pending_ops` / `pending_batches`(件数)を足したもの。両方 0 で state の検証が通れば、報告と同じくキャッシュを全置換して計画を `abandoned` にする -- **UI からの強制破棄を agent にも許す**: Mac が失われたときの逃げ道。`POST /api/devices/:id/plans/open/abandon {force:true}` を agent でも受け付ける(端末にもキャッシュにも触らずに閉じる)。`force` 無しの破棄は agent では 400(Mac から行う) -- **報告の本文の上限**: 10 万件の曲を載せられるよう、report と abandon だけ本文の上限を 64 MiB にする -- **実装(P5-4a)で決めたこと**: - - ファイル取得は表示用スナップショットで先に引き、項目が無いときは厳密なスナップショットで引き直す。404 / 412 は新しいデータで決める(直前に外された曲が最大 2 秒配られることはあるが、害のない写しにとどまる) - - 送る元が変わった・無いときの `source_hashes` の削除は条件なし(CAS しない)。最悪でも 1 回の再計算で済む - - ロックモードでは pair も 503 にする(`auth::guard` を通る経路と揃える) - - pair の 5 回目の失敗は、並行して届いた正しい試行より先にコードを失効させうる。安全側に倒す - - 報告の再送は、計画が `completed` で `report_digest` が一致するときだけ 200 を返す。それ以外の終端は 409 `plan_closed`。計画の JSON を読めなければ 409 `plan_unreadable` を返し、何も変えない - - エージェントの経路は `auth::guard` を通らない(`ClientInfo` が無い) - - Bearer のスキームは大小文字を区別する(使うのは自前のクライアントだけ) - -**理由**: エージェントはブラウザではなく、Cookie も CSRF も trusted_cidrs も持たない。セッションの経路に分岐を足すと、どちらかの検査が緩む事故が起きやすいので、`/api/agent/*` は Bearer だけで通る別の経路にし、セッション側は Bearer で通らない。報告は端末側の状態の主張なので、件数の上限と全項目の検証を済ませてから 1 トランザクションで反映し、途中までの反映を残さない(不変条件 6)。確定した計画の再送は `report_digest` で冪等にする。 - -**却下**: -- トークンにも argon2id を使う(256 bit の乱数には不要で、ファイル取得のたびに数十 ms を払う) -- manifest に差分を載せず、エージェントが自前で差分を出す(サーバの `device_items` 基準の差分と食い違い、表示と確定がずれる) -- ファイル取得のたびに厳密なスナップショットを取る(Derived の変換中に要求ごとの全曲の差分計算になる) -- `/api/agent` を `auth::guard` の中に置いて分岐する(セッションと Bearer の検査が混ざり、どちらかが緩む) - -## D-100 Mac エージェントのエンジンは同期処理で書き、ミュージック.app・サーバ・トークンの保管を trait で差し替えて試す - -**背景**(2026-10-01。仕様は `docs/superpowers/specs/` の P5-4 設計の ⑥ と、③「開始済みの計画の再開」「計画の終端」「例外: 始まったパス変更のバッチ」、D-95 の P5-3a 追記と D-99): P5-4b で Mac の `spindle-agent`(`agent/` クレート)のエンジンを作る。pair の冪等な初期化・state・回復・再発見・計画の確定と再開・追加 / 更新 / 削除・パス変更のバッチ・プレイリストの入れ替え・resolve・報告まで。ミュージック.app の本物の操作(JXA)は 4c で実機の Mac で固定するため、4b は外界を trait に閉じ込め、偽のミュージック.app と本物の spindle サーバを相手に試す。計画は `docs/superpowers/plans/2026-10-01-p5-4b-agent-engine.md`。以下がその決定と、仕様の空白を埋めた判断。 - -**決定**: - -- **外界は 3 つの trait**: `Music`(ミュージック.app。4b は偽実装 `FakeMusic` と「未実装」を返す `UnsupportedMusic`、JXA 実装は 4c)、`Server`(`/api/agent/*`。本物は reqwest の blocking クライアント `HttpServer`)、`Secrets`(トークンの保管)。ローカルの root(`~/Music/spindle`)と `state.json` は本物のファイルシステムを tempdir で試す。クラッシュの試験は要所の中断点(`Failpoints::hit`)と偽実装の「実行してから失敗」で起こし、同じ state で `sync` をやり直して確かめる -- **エンジンは同期処理**: エージェントは 1 回ずつのコマンドで並行性が要らない。tokio を入れず、HTTP は reqwest の blocking、ミュージック.app は `osascript` の子プロセス(4c) -- **ローカルのファイルの同一性はキャッシュで照合する**: 仕様は再発見で「size・sha256 が state と一致することも確かめる」とする。state の各曲に `inode`・`mtime_ns` を持ち、`(size, inode, mtime_ns)` が一致すれば sha256 を読み直さない。違えば読み直して比べる -- **外で書き換えられた曲は「古い写し」にする**: size・sha256 が state と違う曲は track を消さず(再生回数を保つ)、state のトークンを空文字(ADB の `STALE_TOKEN` と同じ)にして報告する。サーバは報告のトークンが空の曲を、`(track_id, dest_path)` が希望・現状・計画の行き先のどれかと一致すれば受け入れ、`device_items` にトークン空で入れる(差分は「更新」になる)。サーバ側の変更(`src/device/report.rs`)を含む -- **ミュージック.app から track が消えた曲**: state から外し、自分の置いたローカルのファイルも消す(state にあるパスなので管理下。残すと次の追加が「管理外と衝突」になる)。報告から外れるので、サーバの差分は「追加」になる -- **報告だけの回**: 回復か再発見で state が変わり(`needs_report` を state に耐久化)、open な計画が無いときは、manifest の `plan_token` で計画を確定し、何も実行せずに報告して閉じる。その後で manifest を取り直して差分を表示する。表示する差分(サーバの `device_items` 基準)をエージェントの実状態に合わせるため。空の差分でも確定できる -- **ダウンロードの途中再開は 1 回の実行の中だけ**: 接続が切れたら同じ `If-Match` + `Range` で最大 3 回まで続きを取る。実行をまたいだ再開はしない(回復が一時ファイルを消す)。412 はその操作を実行せずに次の差分に回して続け、実行の最後の報告の後に manifest を取り直して差分を出す -- **コピー設定 ON の検出と停止**: `Music::copy_to_library()` が `Some(true)` なら pair と sync を始めない。`None`(読めない)なら仕様どおり `add` の後の `location` で検出し、通常経路でその track を消してから残りの操作を止めて報告し、設定を切るよう案内して非 0 で終わる -- **resolve の「表示したときと同じ」**: 候補(persistent ID と sha256 の組の集合)を表示した時点で `pending_ops` のその add に `candidates` として耐久化し、`resolve` は取り直した候補の集合と比べる。違えば記録を新しい候補で書き直し、表示し直して拒否する -- **トークンの保管は 4b ではファイル**(state のディレクトリの `token`、0600)。Keychain は 4c で `Secrets` の別実装として入れる。各パスは環境変数 `SPINDLE_AGENT_STATE_DIR` / `SPINDLE_AGENT_ROOT` で上書きできる(試験用。既定は `~/Library/Application Support/spindle-agent` と `~/Music/spindle`) -- **CLI に `abandon` を足す**: 仕様の CLI は pair / sync / status / resolve だが、`POST /api/agent/plans/:id/abandon` を叩く手段として `spindle-agent abandon` を置く(`pending_ops` と `pending_batches` が空のときだけ送る) -- **プレイリストの入れ替えの一時名は `.tmp-<op_id>`**(spindle フォルダの直下)。改名の後で本名が別の管理下のプレイリストと一時的に重なる(入れ替え・循環の途中)のは許す。ミュージック.app は同名を許し、照合は persistent ID で行うため。管理外(state にも `pending_ops` にも無い persistent ID)の同名があれば、その操作を「エラー: 管理外と衝突」で保留する -- **パス変更のバッチの一時ファイル名**: `~/Music/spindle/.moving/<batch_id>-<op_id>`(移動の旧ファイル・更新 + 移動の旧版)と `….new`(更新 + 移動の新しい内容)。D-95 の P5-3a 追記と同じ理由で batch_id を含める。更新 + 移動の旧版は仕様どおり `.moving/` へ動かしてから(`location` を付け替えて track を残す)、`done` の前に消す -- **計画の再開の照合(`runnable`)は本体の `device::plan::runnable` をエージェント側に移植する**(`agent-proto` の型の上で)。`canonical_key`(casefold + NFD)も移植し、結合試験(`tests/agent_e2e.rs`)で本体と同じ結果になることを確かめる -- **SIGINT は扱わない**: Ctrl-C で止まっても、次の `sync` の回復が続きを行う(`vacating` 以降は前進して完遂) -- **persistent ID を記録する前に落ちた一時プレイリスト**: 仕様は「`pending_ops` に記録した persistent ID のものだけを消す(名前の接頭辞だけで消さない)」とする。作った直後・ID を記録する前に落ちると片付けられず残るので、フォルダの直下で名前がちょうど `.tmp-<op_id>`(op_id は 128 bit 乱数でその操作だけのもの)のものが 1 つなら消す。pair の `spindle-setup-<nonce>` と同じ考え方で、接頭辞での一括削除はしない -- **実装(P5-4b)で決めたこと**(レビューで変わった点を含む): - - `HttpServer` のタイムアウト: blocking の reqwest には無通信の `read_timeout` が無い。API 呼び出しは全体で 60 秒、曲の取得だけ要求ごとに 2 時間の上限を付け、TCP keepalive(30 秒)で死んだ接続を切る。接続は 10 秒。止まった NAS で永久に固まらないため - - サーバの 409 は、確定では `plan_changed` / `open_plan_exists` / `pending_reevaluation`、報告と破棄では `plan_closed` / `generation_mismatch` だけを区別し、それ以外のコード(`plan_unreadable` など)はエラーとして止まる。計画を読めないことを「閉じた」と誤読しないため - - 報告は計画の `generation` で送る(確定の後で端末の設定が変われば、サーバが拒否する) - - open な計画は、`state.plan_id` と一致するとき(自分が y/N を経て確定した計画)だけ再開して実行する。違えば実行せずに報告だけして閉じる。報告だけの回の失敗や確定直後の中断で、利用者が確認していない計画(削除を含む)を実行しないため - - パス変更のバッチの途中で track が手で消された(ミュージック.app に無い)ときは、ファイルの移動は続けて `location` の付け替え・refresh を飛ばし、`done` で行を書く。後の再発見が「track が消えた曲」として外す。回復が永久に止まらないため - - バッチの行き先を、動かない管理下の曲が占めていれば、その成分ごと `PATH_OCCUPIED` で保留する(ADB 版には無い追加の安全策)。rename の上書きと、2 曲が 1 ファイルを共有する事故を防ぐ - - pair の初期化が途中(`setup.phase == Started`)の間は、sync の marker 照合を飛ばして初期化の続きに任せる。報告だけの回で再評価待ちかつ差分が無いときは「再評価待ち」を返す - - サーバの報告の検証(`src/device/report.rs`)は、プレイリストの名前の重複を拒否しない(id の重複は拒否する)。プレイリストの入れ替えの途中(片方だけ改名した後)で止まると、管理下の 2 つが一時的に同名のまま報告される。拒否すると報告が永久に 400 になり、回復しても抜けられない。名前の重複を検証するのはエージェントの報告だけで、ミュージック.app は同名を許し、同一性は persistent ID(サーバでは playlist_id)で見る。`device_playlist_state` にも名前の一意制約は無い - - 置き先の管理外のファイルは、実行の始めに取った鍵だけでなく直前にも確かめる。追加は取得の後・配置の前に置き先を見て、ファイルがあれば一時ファイルを消して「管理外と衝突」にする。パス変更のバッチは空ける前(まだ破棄できる)に全メンバーの行き先を見直し、他のメンバーの移動元でないファイルがあればその成分を外して縮める。空けた後に現れたものは上書きせず、バッチを残して止める(巻き戻せないため。利用者が退けた後の回復が続きを置く)。回復・resolve が add の置き先を消すのは、中身の sha256 が pending の記録と一致するときだけ - - 更新の実行と回復は、track が手で消されていれば refresh を飛ばして行を書く(次の再発見が外す)。refresh の失敗で回復が永久に止まらないため - - 回復はバッチを片付けた後、残ったどのバッチも指さない `.moving/` のファイル(破棄の途中で落ちて残った new など)を消す。実行が最後まで済んだら空いたディレクトリを消す(root は残す) - - 実行の報告の後に manifest を取り直し、差分が残っていれば件数を知らせる(判断 6。取り直しの失敗は結果を変えない) - - `exec::run` が返す `Outcome.dropped` は `runnable` の見送り(`Runnable.dropped`)を含む。`apply` はそれだけを報告に使い、足し直さない - - `HttpServer` はリダイレクトを追わない(トークンを別の宛先へ送らず、HTTP へも落ちない)。基底 URL のパスは `/` で終わらせ、`https://nas/spindle` のようなサブパスを `Url::join` で失わない - -**理由**: エージェントは利用者が叩く 1 回ずつのコマンドで、並行性は要らない。外界(ミュージック.app・サーバ・Keychain)を trait にすれば、実機の Mac が無い Linux の CI でも、回復・再発見・バッチ・入れ替えの中断と再開を偽実装で列挙して試せる。管理対象は state と `pending_*` にある persistent ID とパスだけにし、名前やパスの一致だけで管理外のものに触らない。ファイルとミュージック.app を変える前に state へ意図を耐久化し、次の `sync` の回復が前進か巻き戻しで完遂する(不変条件 6)。 - -**却下**: -- エンジンを tokio で書く(並行性が要らず、`osascript` の子プロセスと blocking HTTP で足りる) -- 再発見のたびに全曲の sha256 を読む(数十 GB を毎回読む) -- 外で書き換えられた曲を state から外す(track を消して足し直すと再生回数を失う。外すだけだと次の追加が管理外と衝突する) -- 報告だけの回をせず、次の sync で直す(手で消した曲が戻るまで 2 回の sync が要り、表示する差分が実状態とずれる) -- 一時プレイリストを名前の接頭辞で片付ける(ユーザの「.tmp-」を消しうる) -- 本体の `device::plan` を `agent-proto` の型へ書き換えて共有する(4b の範囲を超えて本体の ADB 同期を触る) - -## D-101 Mac エージェントのミュージック.app は JXA で操作し、実機の spike で固定する。トークンは Keychain、バイナリは署名せず Release に置く - -**背景**(2026-10-01。P5-4c。計画は `docs/superpowers/plans/2026-10-01-p5-4c-agent-mac.md`): 4b(D-100)は外界を trait に閉じ込め、ミュージック.app は偽実装で試した。4c で実機の Mac(mbp14、macOS 27.0、ミュージック 1.7)に対して JXA を固定し、`Secrets` の Keychain 実装と、macOS 用バイナリの配布を入れる。D-100 が 4c に残した項目は、JXA 実装・Keychain・コピー設定の読み取り・検査と rename の間の TOCTOU。 - -**spike で分かったこと**(実機で確認): - -1. `Application('Music').add(パス文字列)` で file track ができ、`persistentID()`(16 桁の 16 進)・`databaseID()`・`location()`・`dateAdded()`・`size()` が取れる。同じファイルをもう一度 `add` すると既存の track が返る(重複しない) -2. **location の付け替えは文字列の代入で行う**(`t.location = "/abs/path"`)。`Path(...)` の代入は -1728 で失敗する。拡張子の無いファイル(`.moving/<batch>-<op>`)へも付け替えられる。存在しないパスへの代入は「タイプを変換できません」で失敗する -3. ミュージック.app は **mv されたファイルを自動で追う**(location が新しいパスになる)。タグの無いファイルは名前もファイル名に変わる -4. 同じパスのファイルを tmp → rename で差し替えると、size・duration は差し替え後のものになる。`refresh` は成功し、再生回数は保たれる。location が無い(ファイルが無い)track の `refresh` は 9044 で失敗する -5. **曲の削除**: メディアフォルダの外の曲は track だけが消えてファイルは残る。メディアフォルダの中の曲はファイルもゴミ箱へ移る -6. **コピー設定**(「ライブラリへの追加時にファイルを[ミュージック]フォルダにコピー」)が ON だと、`add` はメディアフォルダ(`<media>/Music/<Artist>/<Album>/`)へ複製し、location は複製先になる。ただしファイルが既にメディアフォルダの中にあれば複製しない。設定はライブラリごとで、ライブラリ内の暗号化された DB にあり、外から読めない(`~/Library/Preferences/com.apple.Music.plist` には無い) -7. メディアフォルダには `Automatically Add to Music.localized` と `.Media Preferences.plist` ができる -8. フォルダは `make({new: 'folderPlaylist', withProperties: {name}})`、その中のプレイリストは `make({new: 'userPlaylist', at: フォルダ, withProperties: {name}})`、改名は `p.name = 新名`、中身の追加は `duplicate(track, {to: p})`、空にするのは `delete(p.tracks)`、削除は `delete(p)`。同じフォルダに同名のプレイリストを作れる。親は `p.parent().persistentID()`。`app.folderPlaylists.whose` は使えない(`app.playlists.whose({persistentID})` を使う) -9. 一覧は `libraryPlaylists[0].fileTracks.persistentID()` のような一括取得で 1 回の Apple Event になる。一括取得は、ファイルが無い track には null を返す(実機で確認。Rust 側で欠けた曲として扱う) -10. ssh 越しの `osascript` でも Automation の許可ダイアログは出なかった(許可済み、または ssh の親が許可を持つ)。初回の操作で出る場合に備え、pair の最初に読み取りだけの操作を行う(4b の `probe`) - -**決定**(spec からの変更・空白を埋める判断): - -1. **コピー設定は読まない**: 外から読めないので、`copy_to_library()` は常に `None` を返し、`add` の後の location で検出する(4b で実装済みの通常経路の削除と停止)。仕様の「読めれば前提検査にする」は不可として閉じる -2. **root がメディアフォルダなら止める**: root(`~/Music/spindle`)の直下に `Automatically Add to Music.localized` か `.Media Preferences.plist` があれば、pair と sync を `Error::Stop` で止める。メディアフォルダの中の曲は削除でファイルまで消え、コピー設定 ON でも複製されないため、前提が崩れる。検査はコピー設定の確認の直後、副作用の前に行う -3. **JXA の呼び出し単位**: trait のメソッド 1 回 = `osascript` 1 回。一覧(`tracks_under` / `tracks_added_after` / `max_database_id`)は 1 回の一括取得で全 file track を返し、絞り込みは Rust 側で行う。名前の比較(`folders_named`)とフォルダ直下の絞り込み(`playlists_in`)も Rust 側(`canonical_key`) -4. **タイムアウト**: 1 回の `osascript` は 120 秒(ミュージック.app の起動を含むため長め)。超えたら kill して `Error::Music`。終了コードが 0 以外なら stderr を `Error::Music` に含める -5. **Keychain**: サービス名 `spindle-agent`、アカウント `token` の汎用パスワード(`security-framework`)。macOS 以外はファイルのまま。4b のファイルのトークンは移行しない(まだ配布していない) -6. **上書きしない rename**: 追加の配置とバッチの置き先への rename は、行き先が無いことを OS に保証させる(macOS は `renameatx_np(RENAME_EXCL)`、Linux は `renameat2(RENAME_NOREPLACE)`。rustix の `renameat_with(.., RenameFlags::NOREPLACE)`)。D-100 で残した検査と rename の間の TOCTOU を閉じる。行き先があれば `EEXIST` を今の「管理外と衝突」/ `FOREIGN_AT_DESTINATION` と同じに扱う -7. **配布**: バイナリは署名・公証しない。tar.gz を Release に置き、README に `xattr -d com.apple.quarantine` と Automation の許可を書く -8. **実機の通し試験**: 本体側に `#[ignore]` の試験の台(seed した spindle を指定アドレスで待ち受け、ペアリングコードを表示)を置き、mbp14 の本物のバイナリで pair → sync → 選曲を変えて sync を手で確かめる。試験用ライブラリで行う - -**実装中に決めたこと**: - -- **osascript の起動**: trait のメソッド 1 回につき `osascript -l JavaScript -` を 1 回起動する。スクリプトは標準入力、リクエストの JSON は argv で渡す(スクリプトの文字列にパスや名前を埋め込まない。注入を防ぐ)。120 秒で kill してエラーにし、そのとき stdout / stderr の読み取りスレッドは join せず切り離す(孫プロセスがパイプを握り続けても固まらないため)。stderr は共有のバッファへ読み続け、タイムアウトと終了待ちの失敗でもそこまでに読めた分(末尾 4 KiB まで)をエラーに載せる。終了コード 0 でも stderr が空でなければエージェントの stderr へ「osascript の stderr: …」として出す(外部レビューで直した) -- **上書きしない rename の実装**: `place_new` / `rename_new` が rustix の `renameat_with(NOREPLACE)` を呼ぶ。EINVAL / ENOSYS / ENOTSUP / EOPNOTSUPP(その FS や OS が対応しない)のときは `Error::Stop` で止め、root をローカルのボリューム(APFS)へ移すよう案内する(「行き先を stat してから rename」に落とすと、その間に現れたものを上書きしうる。外部レビューで直した)。既存の事前検査は残し、`AlreadyExists` は事前検査と同じに扱う(追加は「管理外と衝突」でその曲だけ保留、バッチは `FOREIGN_AT_DESTINATION` で止める)。試験用のフェイルポイント `exec.add.checked` / `batch.place.checked` で検査と rename の間に行き先を作って確かめる -- **Keychain のエラー**: システムのメッセージだけを載せ、トークンは載せない。`SPINDLE_AGENT_SECRETS=file` で macOS でもファイルの保管に固定できる。**ssh 越しではログイン Keychain が使えない**(`User interaction is not allowed`)ため、Mac の Terminal.app で実行するか、`SPINDLE_AGENT_SECRETS=file` を付ける。README の「困ったとき」に書く -- **CI**: `agent-macos` ジョブ(macos-latest)で clippy・test(`fake` 機能)・release ビルドを行い、tar.gz と `.sha256` を成果物にする。`publish` ジョブはこれに依存し、`v*` タグでは tar.gz を Release に添付する。**edge のイメージの publish も macOS ジョブの成功に依存するようになった** -- **サーバ側**: `/api/agent/*` は Bearer トークンだけで認可する(D-99。サーバに LAN / HTTPS の検査は無い)。エージェントの側が、`--insecure-http` なしでは平文の http を拒否する -- **JXA で実機に合わせた点**: 一括取得はファイルの無い track に null を返す(spike 9) - -**最終レビューで直したこと**(実機の mbp14 で確認した事実に合わせる): - -1. **root をシンボリックリンクを解いたパスにする**: ミュージック.app は track の場所をシンボリックリンクを解いたパスで持つ(実機で `/tmp/x` → `/private/tmp/x`、`~/Music` 下のリンクしたディレクトリはリンク先)。root が解かれていないと `tracks_under` や root 相対への変換の前方一致が外れ、反映済みの曲を見失う。起動時(`Paths`)に、在る最も近い祖先を `canonicalize` して、まだ無い残りの要素を足す。相対パスの root は止める。root の**中**のリンクは従来どおり辿らない -2. **メディアフォルダの検査を祖先にも広げる**: 決定 2 は root の直下だけを見ていたが、メディアフォルダが `~/Music` などの祖先なら root はその中に入る。root とその祖先のどれかの直下に印があれば止める(祖先は root の外なので、パスで `symlink_metadata` を見る) -3. **pair はトークンを保存できるかをコードを使う前に確かめる**: ssh 越しでは Keychain の保存が `errSecInteractionNotAllowed`(-25308)で失敗し、ワンタイムコードを使った後だったので発行し直すしかなかった。`Secrets::check_writable` を足し(Keychain はトークンの項目が在れば同じ値を書き戻し、無ければ同じサービスの試しの項目 `probe` を書いて消す。ファイルは `set` と同じ tmp → fsync → rename → ディレクトリの fsync を試しの名前で行って消し、本番の `token` / `.token.tmp` が在れば通常ファイルであることも見る。外部レビューで、試しのファイルの作成だけでは本番の置き場の型や既存項目の ACL での失敗を拾えないと指摘されて広げた)、`server.pair` の前に呼ぶ。-25308 のエラーには Terminal.app か `SPINDLE_AGENT_SECRETS=file` の案内を付け、「トークンがありません」には pair のときと同じ `SPINDLE_AGENT_SECRETS` の指定で実行する案内を足す -4. **`set_playlist_tracks` の上限を曲数で延ばす**: 実機で 1 曲あたり約 35 ms(200 曲で 7 秒)かかり、決定 4 の固定 120 秒では約 3400 曲を超えるプレイリストが毎回失敗する。この操作だけ 120 秒 + 100 ms × 曲数にする -5. **`max_database_id` は専用の op で取る**: `add` のたびに呼ぶので、5 項目の一覧(`tracks`)ではなく databaseID の最大だけを返す `max_db` にする(決定 3 の例外)。file track が 0 件だと一括取得が -1728 で落ちるので、`tracks` と同じく件数を先に見て 0 を返す - -**外部レビュー(codex)で直したこと**: - -1. **上書きしない rename に対応しない FS では止める**(上の「上書きしない rename の実装」) -2. **バッチの回復は行き先の中身を確かめてから採用する**: 置いた直後に落ち、落ちている間に行き先が差し替えられると、回復は src が無く `to` が在るだけで置き済みとみなし、実ファイルの stat と計画の sha256 を組にして記録していた(以後 stat が変わらない限り再検査されない)。回復で `to` を採用する前に、大きさと sha256 が計画と一致するかを見る。違えば `FOREIGN_AT_DESTINATION` で止め、ファイルも track の場所も触らず、バッチを残す(利用者が退けた後の回復で片付く) -3. **root の dirfd は `/` から 1 要素ずつシンボリックリンクを辿らずに開く**: 最終レビューの 1 で root は起動時に解いた実パスになったが、操作のたびにそのパスをリンクを辿って開き、無ければ `create_dir_all` していたため、起動後に root か祖先をリンクへ差し替えると root の dirfd が外を指した(その下を 1 要素ずつ辿る守りが効かない)。`/` から `O_DIRECTORY | O_NOFOLLOW` で 1 要素ずつ開き、無い末尾は親の dirfd からの `mkdirat` で作る。途中にリンク(かフォルダでないもの)があれば、読みも書きも `Error::Stop` で止める。試験は root を解いた tempdir で作る(macOS の tempdir は `/var` → `/private/var` のリンクの下) -4. **トークンの保存の確認を広げる**(最終レビューの 3 を参照) -5. **osascript の stderr を捨てない**(上の「osascript の起動」を参照) - -**理由**: 実機の spike で、Apple Event の呼び方(文字列の代入、一括取得、`whose` の制限)とコピー設定の読めなさが確定した。呼び出しを 1 回ずつの `osascript` にすると、状態を持つ常駐プロセスが要らず、失敗と打ち切りの扱いが単純になる。ミュージック.app は mv を追い、メディアフォルダの外の曲は track だけが消えるため、`~/Music/spindle` を管理下の root にしてメディアフォルダと分ければ、4b の設計(track の削除は通常経路、ファイルは自分で消す)がそのまま成り立つ。上書きしない rename は OS に保証させるのが、検査と rename の間の競合に対する唯一確実な閉じ方。 - -**却下**: -- `security` コマンドでの Keychain 操作(トークンが引数に出て、`ps` で見える) -- JXA のスクリプトをファイルで配置する(改ざんと版ずれの余地。バイナリに埋め込む) -- `Path()` での location の代入(-1728 で失敗する) -- コピー設定を plist から読む(ライブラリごとの暗号化 DB にあり読めない) -- 署名・公証(Developer ID が無い。必要になったら) - -## D-102 印のついたプレイリストの削除は印の変更と同じ規則にし、終端した計画は GC が回収する。DB 全損後の引き継ぎと id の再利用は後回しにする - -**背景**(2026-10-09。P5-6): P5-5 で SPEC §17 に残した端末配信の 3 件(プレイリスト削除と `generation`、終端した計画の回収、DB を失ったときの保存先の引き継ぎ)を調べた。 - -1. **プレイリストの削除**: 素の `DELETE` で、印だけが CASCADE で消える。`generation` は進まず、open な計画も止めない。調べると、`runnable` と報告の検証が吸収するので間違った結果や詰まりにはならない。ただし実行中の同期が、消えたプレイリストとその曲をいったん端末に書く無駄が残り、`generation` の定義(印を変えるたびに進める)とも合わない -2. **終端した計画**: `device_sync_plans` の終端の行を読むのは `finish` の再送判定だけで、消えるのは端末の削除だけ。1 回の計画は初回の全曲で 3〜4 MB、最悪で数十 MB あり、初回の大きい計画の破棄と取り直しを繰り返すと DB とバックアップが太る -3. **DB 全損後の引き継ぎ**: 設計書は manifest の `device_uuid` を新しい行に写すだけとしていたが、DB を作り直すと `tracks.id` / `playlists.id` が振り直される。端末の正本(manifest、Mac のエージェントの state)は id をキーにするので、uuid だけ写すと manifest の `track_id` が新しい DB の別の曲を指し、差分は他人のファイルを移動して上書きする。Mac では `Update` / `UpdateMove` がミュージック.app の track を保ったまま location を付け替える(D-101)ので、再生回数・レート・追加日が別の曲に移る - -**決定**: - -1. **プレイリストの削除は印の変更と同じ規則**: 印のついた端末のどれかに open な作業(open な計画か待ち・実行中の `device_sync`。`has_open_work`)があれば 409 `open_plan`(端末名を message に出す)。通れば、印のある端末の `generation` と `updated_at` を進め、プレイリストを削除する。確認から削除までを 1 つの SAVEPOINT で行う。印の無いプレイリストの削除は今までどおり。プレイリストの中身・名前・ルールの変更は止めない -2. **終端した計画は GC が回収する**: `completed` / `abandoned` で `closed_at` が `[gc].retention_days` より古いものを消す。端末ごとに最後の終端 1 件は残す(長く寝ていた Mac の報告の再送を 200 で受ける保険)。open は対象外(`close_plan` は `WHERE state='open'` の CAS なので競合しない)。`GET /api/gc/preview` に `plans`(消す件数)を足す。マイグレーションと設定は足さない -3. **DB 全損後の保存先の引き継ぎ(P5-7 Android、P5-8 Mac)と id の再利用は作らず、TASKS に残す**。引き継ぎは uuid を写すだけでは誤りで、`canonical_key(dest_path)` による id の付け替え(トークンは端末の sha256 が今の送る元と一致すれば今の配信トークン、違えば STALE)が要る。`dest_path` は Library の `rel_path` の拡張子だけを替えたもので、DB を作り直しても同じになるため。Android と Mac を別の PR にする。Mac が本命(再生回数の保全)だが、Android の方が閉じていて試しやすいので先に作る -4. **SPEC の §17 は、この 2 件は [x] に変えて残し、引き継ぎ(id の付け替えが要ることを書く)と id の再利用(`AUTOINCREMENT` でないので GC が最大 id の行を消すと再利用される。Mac で再生回数が別の曲に移りうる)を残す** - -**理由**: 削除を印の変更と同じ規則にすると、`generation` を進めるのが open な作業が無いと確かめたのと同じトランザクションの中だけになり、説明がつく。終端した計画は読む箇所が少なく、消えて困るのは遅れた再送が 404 になることだけで、エージェントは次の sync で自動回復する。引き継ぎは id の付け替えを伴い、設計書より大きい(600〜1,000 行、Android と Mac で別 PR)ので、今回の小さな 2 件と混ぜない。 - -**却下**: -- 削除で `generation` だけ進める(止めない): open な agent の計画がある間に進めると、エージェントの報告も破棄も 409 `generation_mismatch` になって閉じられず(UI の強制破棄だけが逃げ道)、adb の実行中の同期は `GenerationChanged` で失敗する。PUT が「進める」と「open な間は 409」を組にしているのはこのため -- 今の挙動を仕様にする(削除は止めず `generation` も進めない): 正しさは保たれるが、実行中の同期が消えたプレイリストを一度端末に書く無駄が残る -- 閉じるときに同じ端末の古い終端を消す(保持期間なし): 履歴が残らず、設計書・SPEC の「GC が保持期間後に消す」と変わる -- uuid を新しい行に写すだけの引き継ぎ(設計書どおり): 上の背景 3 のとおり、全曲が誤った更新・移動になり、Mac では再生回数が別の曲に移る。今の「片付けて登録し直す」より悪い -- 登録で管理外の同一ファイルを内容で引き取る: 「管理外のファイルには触らない」に例外を作り、sha256 の全曲計算が要り、Mac では persistent ID を引き取れない - -## D-103 DB 全損後の保存先の引き継ぎは manifest の rel_path で id を付け替え、中身は manifest の sha256 を信じる - -**背景**(2026-10-13。P5-7): D-102 の 3 のとおり、DB を作り直すと `tracks.id` / `playlists.id` が振り直され、uuid だけを写すと端末の正本の id が別の曲・プレイリストを指す。トークンも版(`audio_version` 等)や `playlist_id` を含むので一致しない。さらに DB を作り直した直後は `source_hashes` も消えており、端末の写しが今の送る元と同じかをその場では判定できない。D-102 は `dest_path` の `canonical_key` で付け替えるとしたが、`dest_path` は Library の `rel_path` の拡張子を替えたもので、Derived では元の曲が一意に引けない(WAV → FLAC の正規化など)ため、manifest が送った元の `rel_path` を覚えているのが確実だった。 - -**決定**: - -1. **manifest の曲に `rel_path`(Library の `tracks.rel_path`)と `source`(`SourceKind::as_str()`。`master` / `opus` / `aac`)を必須で足す**。`FORMAT` は 1 のまま(リリース前で、本番の Android は未登録)。旧 manifest は読めなくなるので、保存先を消して登録し直す。ジャーナルの `Intent` にも任意で足し(曲の `put` のみ)、回復で取り込む。移動のバッチのメンバーは必須。同期の終わり(最後の `compact` の前)に、パスのキーが今の desired と一致する項目へ今の値を書き直す(補填) -2. **remap**(`src/device/adopt.rs`、純粋関数): 曲は `rel_path` の `canonical_key` で今の tracks(missing を除く)から 1 件引いて `track_id` を付け替え、トークンを `ADOPTED_TOKEN`(`"adopted"`)にする。`path` / `size` / `sha256` / `rel_path` / `source` は変えない。プレイリストは `Playlists/<名前>.m3u8` の名前の `canonical_key` で付け替え、トークンは `STALE_TOKEN`(m3u8 の書き直しは安い)。1 つのキーに複数の曲、1 曲に複数の項目、同名のプレイリストなど曖昧なものはどれも採らない。採らなかった項目は manifest から外して**管理外**にする(ファイルは消さない)。冪等で、回復が先に付けた `STALE_TOKEN` の曲はトークンを変えない -3. **同期の近道**: `Update` でパスが同じ、book のトークンが `ADOPTED_TOKEN`、`sha256` と `size` が操作と一致する曲は、転送もジャーナルも省いてトークンだけを書き換える。信頼の水準は日常の差分と同じ(サイズまで。中身は manifest の `sha256` を信じる。これは転送時に端末上で測って照合した値)。転送量の見積もりと空き容量の確認から外し、差分の op は `adopted`(UI は「引き継ぎの照合」)。壊れた写しは回復や `device_verify` が `STALE_TOKEN` にするので送り直される。送る元は通常の転送と同じく開いて identity を照合する(計画の後に送る元が差し替わったのに同期済みにしない。変わっていれば項目エラー + 取り直し) -4. **印の復元**: 端末に m3u8 があるのはそのプレイリストに印が付いていたからなので、付け替えたプレイリストに印を付け直す(行の作成と同じトランザクション)。付けないと `selection=playlists` の端末の最初の差分が全削除になる。`generation` は作成時の既定のまま進めない -5. **API**: 通常登録が片付けるのは、中身が `.spindle` の下だけで回復に要る物が無い(`.spindle/moving` の下にファイルが無く、ジャーナルが無いか空)前の登録の失敗の残りで、manifest が無い・読めない・空(uuid が DB に無い)ものだけ。それ以外は 409 `not_empty`(曲かプレイリストを指す manifest は `volume` 違いや uuid の形が不正でも片付けない。`.spindle/moving` に曲の唯一の実体があり、ジャーナルが回復に要り得る)で、曲を指す manifest(`volume` が一致し、uuid が DB に無いもの)は 409 `adoptable`。`POST /api/devices` に `adopt: true`(adb のみ)。`register_lock` の下で、`scan` ジョブが待ち・実行中なら 409 `library_busy` → manifest を読む(無い・読めない・`volume` 違い・空・uuid の形(32 桁の小文字 16 進)が不正・回復で manifest の不整合が見つかったときは 409 `not_adoptable`、uuid が DB にあれば 409 `uuid_registered`。端末との通信の失敗は既存のエラー応答の 502 `device_failed` / 409 `not_connected`)→ `scan` の有無と remap の入力を 1 つの読み取りトランザクションで読む(スキャン中なら 409 `library_busy`。最初の確認は早めに断るためで、正はこちら)→ 回復 → remap → `compact` → 1 トランザクションで行(manifest の uuid)と印を作る → `device_scan` を投入する。途中で落ちても remap が冪等なのでやり直せる。プレビュー `GET /api/devices/adb/adopt-preview` は読むだけ(ジャーナルは回復しないので件数は目安)で `library_busy` も返す。未登録の一覧の `volumes[].state` に `adoptable` を足す -6. **バックアップ復元で行の uuid が端末と食い違う場合の付け替えは作らない**。端末を削除(端末上のファイルには触らない)してから引き継げば同じ結果になるので、SPEC にその手順を書く -7. **P5-8(Mac の `pair --adopt`)は remap をサーバで計算する**(報告で返す部分は D-104 で改訂。サーバの行の uuid を差し替え、adopt API がキャッシュを直接書く)。エージェントは state の `(track_id, path, rel_path, source)` とプレイリスト名を送り、付け替え表を受け取って state のキーを直し、`needs_report = true` で `report_only` する。pending が残っていれば拒否し、`--adopt` 無しの uuid 不一致は今どおり止める。state と `agent_proto` の項目にも `rel_path` / `source` を必須で足す -8. **id の再利用は GC が `tracks` の最大 id の行を消さないことで防ぐ**(SQLite は `max(rowid)+1` を振る)。プレイリストの id の再利用は受け入れる(Update = 改名として収束し、失う情報が無い)。どちらも今回は作らず、SPEC §17 と TASKS に残す - -**理由**: 付け替えの鍵を manifest 自身に持たせると、DB が空でも端末だけで曲を引ける。`FORMAT` を上げて旧版を読む経路を作るより、リリース前なので旧 manifest を読めなくして保存先を作り直す方が単純で、読めない経路が残らない。トークンを `ADOPTED_TOKEN` にして近道を設けるのは、全曲(約 9,000 曲、初回およそ 2 時間)の送り直しを避けるため。信頼の水準を日常の差分に揃えるので、新しい信頼の穴は作らない。remap をサーバ側の純粋関数にすると、Mac も同じ規則を使える。 - -**却下**: -- uuid を新しい行に写すだけ(D-102 で却下済み): 全曲が誤った更新・移動になる -- 端末で sha256 を測り直して今の送る元と照合する: 全曲の読み出しが要り、DB を作り直した直後は送る元のハッシュも無い -- 引き継ぎをジョブにして、先に送る元をハッシュしてから照合する: 登録が長いジョブになり、途中状態の扱いが増える。manifest の `sha256` は転送時に端末上で測った値で、日常の差分と同じ水準で足りる -- 全曲を送り直す: 数時間と、端末・スナップショットの書き込みが無駄になる -- `rel_path` が manifest に無い旧 manifest を、パスなどから推測して引き継ぐ経路を残す: `dest_path` から元の曲を逆引きすると誤りうる。推測の経路が残ると、誤った付け替えで他人のファイルを上書きする -- 外れた写しを差分で削除する: 「manifest に無いファイルには触らない」に例外を作る。管理外に残して手で扱う -- token・sha256・パスがすべて違う既存の写しを Delete + Add にして id の再利用を防ぐ: Library で一括にタグを直して改名するのは正当な日常操作で、そのたびに Mac の再生回数が消える - -## D-104 Mac の `pair --adopt` はサーバの行の uuid を差し替え、adopt API が付け替えた状態をキャッシュへ直接書く(D-103 の 7 を改める) - -**背景**(2026-10-13。P5-8。設計は `docs/superpowers/specs/2026-10-13-p5-8-agent-adopt-design.md`): D-103 の 7 は、エージェントが state の `(track_id, path, rel_path, source)` とプレイリスト名を送って付け替え表を受け取り、state のキーを直して `needs_report = true` で `report_only` する、としていた。作る段で調べると、DB を作り直した直後は `source_hashes` が無く、送る元のハッシュが揃うまで曲が desired に入らない。`device_items` も空なので、報告の各項目は「現在の desired」「既存の `device_items`」「計画で許された遷移」のどれにも一致せず、検証(`src/device/report.rs`)を通らない。また DB を失うと Mac の端末行(uuid・トークン)も消え、新しい行は新しい uuid を持つが、エージェントの state と `~/Music/spindle/.spindle-device` は旧 uuid のままなので、uuid をどちらに揃えるかも決める必要があった。 - -**決定**: - -1. **pair の `adopt_uuid` で、サーバの行の uuid を前の登録(state)の uuid へ差し替える**。`agent_proto::PairRequest` に `adopt_uuid: Option<String>`(`#[serde(default)]`)を足す。形(32 桁の小文字 16 進)はコードに触る前に確かめ、外れたら 400 `bad_request`。差し替えはコードの消費と同じトランザクションで、行が端末の状態を一度も持っていない(`last_synced_at` が NULL、計画が 1 件も無い、`device_items` と `device_playlist_state` が空)うえ `adopt_uuid` が他の行に無いときだけ許す。行の uuid が既に `adopt_uuid` なら、報告も計画も無い(`last_synced_at` が NULL、計画が 1 件も無い)ときだけ差し替え済みのやり直しとして通す。`device_items` / `device_playlist_state` は見ない(前の adopt がキャッシュを書いた後、エージェントが付け替えを保存する前に落ちたやり直しを通すため)。報告か計画があれば DB を失っていない端末で、通すと `adopt_pending` が下りないまま adopt が 409 を返し続けるので断る(その端末は `--adopt` 無しの pair で入れ直す)。`agent_selector` はコードの発行で NULL に戻るので条件にしない。それ以外は 409 `not_adoptable` で、コードは消費せず、検証の前に予約した試行の枠も返す(`refund_pair_attempt`。コード自体は正しかったので試行の上限に数えない) -2. **`POST /api/agent/adopt` が付け替えた状態をキャッシュへ直接書く**(報告を通さない)。本文は state の曲(`track_id`・パス・`rel_path`・`source`・size・sha256・`stale`)とプレイリスト(`playlist_id`・名前)。検証は報告と同じ水準(件数の上限、パスの形、既知の `source`、id とパスの鍵の重複、空の名前。加えて sha256 は小文字 16 進 64 桁で、空(中身を失った曲)は `stale` のときだけ許す、size は i64 に収まること)で 400 → スキャン中なら 409 `library_busy`(`adopt_snapshot`)→ 書き込みのトランザクションの中で open な計画があれば 409 `open_plan`、`last_synced_at` が NULL でなければ 409 `not_adoptable` の順に確かめる。付け替えは Android と同じ規則(`remap_ids` に切り出し、manifest の `remap` もそれを使う)。書くのは `device_items`(トークンは `stale` なら `STALE_TOKEN`、それ以外は `ADOPTED_TOKEN`)・`device_playlist_state`(`dest_path` は名前、トークンは `STALE_TOKEN`)の全置換と印の付け足し(既存の印は外さない)で、`device_errors` は空にする。`generation` と `last_synced_at` は進めない。冪等で、応答は付け替え表(外れたものは `to: null`) -3. **エージェントの `pair --adopt`**: コードを使う前に、通常の pair の前提、前の登録の state(初期化が済みサーバの情報がある)、root の `.spindle-device` の照合、ローカルの回復を通し、`pending_ops` / `pending_batches` が残れば止める。pair の後は新しいトークンで adopt を送る。トークンを保存する前に `adopt_pending = true` とサーバの URL・名前を保存し(逆順だと間で落ちたとき新しいトークンと付け替え前の state が組み合わさり、次の sync が付け替えずに差分を出す)、付け替え(キーの付け替え、曲のトークンを `ADOPTED_TOKEN`(`STALE_TOKEN` はそのまま)、プレイリストのトークンを `STALE_TOKEN`、`plan_id = None`、`needs_report = false`、`adopt_pending = false`)を 1 回の保存で行う。外れた曲・プレイリストは state から外すだけで、ファイルもミュージック.app の track も消さない(管理外)。間で落ちたら、次の `sync` が回復の後・manifest の取得の前に続きを行う。`status` は途中の引き継ぎを出す -4. **state と `agent_proto::ManifestItem` に `rel_path` / `source` を必須で足す**(D-103 の 7 のとおり)。`STATE_VERSION` と `PROTO_VERSION` は 1 のまま(本番の Mac は pair 前)。追加・更新は manifest の同じ `track_id` の項目から値を取り、無ければその操作を項目のエラーにする。`PendingOp` は任意、`BatchMember` は必須で持ち、回復で写す -5. **同期の近道**: `Update`(`from` 無し)で、state の曲のトークンが `ADOPTED_TOKEN`、パスの鍵・size・sha256 が計画の項目と一致すれば、受け取りもミュージック.app の操作もせずにトークン(と `rel_path` / `source`)だけ書き換える。報告はそのトークンで行い、計画に含まれるので検証を通る。受け取らないので、実行前の空き容量の確認からも外す(近道と同じ判定を使う) -6. **`--adopt` 無しの uuid 不一致は今どおり止め、文言で `pair --adopt` を案内する** - -**理由**: 報告の検証はハッシュ待ちの間 desired が空で通らず、DB を作り直した直後の状態を検証の例外で通すより、引き継ぎ専用の API が前提(報告を一度も受けていない、open な計画が無い)を確かめてキャッシュを書く方が、通る経路が狭く説明がつく。結果は Android の引き継ぎ(書き直した manifest を走査で取り込む)と同じになる。uuid をサーバ側で差し替えると、`.spindle-device` と state の uuid を変えないので、エージェントの途中で落ちたときの再開に印と state の食い違いの手当てが要らない(`adopt_pending` の続きだけでよい)。プレイリストのトークンを `STALE_TOKEN` にするのはミュージック.app のプレイリストの中身の書き直しが曲の転送に比べて安いからで、サーバとエージェントで揃えるので最初の同期で同じ名前のまま中身を入れ直す。断った pair でコードを消費しないのは、端末タブでコードを発行し直す手間を増やさないため。 - -**却下**: -- エージェントが新しい行の uuid を受け入れて state と `.spindle-device` を書き換える: 印・state・サーバの 3 か所を揃える途中で落ちる場合の手当てが要り、印の照合(別の端末の root を守る)を緩めることになる -- D-103 の 7 のとおり付け替えた state を `report_only` で報告し、検証に引き継ぎの例外を設ける: 例外が報告の経路全体に効き、通る範囲が広がる。desired が空の間は例外なしでは通らない -- `--adopt` 無しの uuid 不一致をコードの消費前に検出する: 同じ端末の pair し直し(コードを発行し直したとき)と区別できない。文言で案内するだけにする - -## D-105 id の再利用は、GC が `tracks` の最大 id の行を消さないことで防ぐ - -**背景**(2026-10-14): `tracks.id` / `playlists.id` は `INTEGER PRIMARY KEY` で `AUTOINCREMENT` ではない。SQLite は次の行に `max(rowid)+1` を振るので、GC が最大 id の行を消すと次に入る曲が同じ id を得る。端末の写し(`device_items`、Android の manifest、Mac のエージェントの state)は `track_id` で曲を指すので、7 日以上同期していない端末では差分が `UpdateMove` になる。Android は正しく収束するが、Mac ではミュージック.app の track を保ったまま location を付け替える(D-101)ので、再生回数・レート・追加日が別の曲に移る(D-102 の 4、D-103)。 - -**決定**: - -1. **GC は `tracks` の最大 id の行を消さない**。`db::gc::missing_tracks`(一覧。`/api/gc/preview` の件数もここから)と `delete_tracks`(削除の SQL の再確認)の両方に `id < (SELECT max(id) FROM tracks)` を足す。計画の後に曲が増えても減っても、削除の時点の最大で判断する -2. 残した行は missing のまま残る。後で新しい曲が入れば最大でなくなり、次の GC で消える。その id は最大より小さいので二度と振られない。残した行のアルバム・Derived・アートワークは既存の「参照があれば消さない」で残る(最大 1 曲分) -3. **プレイリストの id の再利用は受け入れる**(D-103 のとおり。差分は改名の `Update` として収束し、失う情報が無い) -4. **古いバックアップへの復元は対象外とし、手順で扱う**。復元した DB は採番も巻き戻るので(`AUTOINCREMENT` でも `sqlite_sequence` ごと巻き戻る)、バックアップより後に足した曲に起動時スキャンが振る id は別の曲に当たり得る。端末の行と認証はバックアップの時点のまま戻るので、引き継ぎは自動では促されない。復元の手順(`docs/OPERATIONS.md`「端末は引き継ぎし直す」、SPEC §14「バックアップ」)に、同期する前に端末を削除して引き継ぎし直す(Android は「前の登録の保存先」、Mac は `pair --adopt`)と書く。消す前に名前・形式・送る範囲を控えて同じ設定で作り直す(追加の既定は「印のプレイリスト」で、元が「全曲」なら引き継いだ全曲が削除になり、Mac ではミュージック.app の track ごと消える)。範囲が分からなければ「全曲」(Library にある曲を端末から消さない)。引き継いだ後の最初の差分に予期しない削除があれば確定しない。codex のレビューの指摘 - -**理由**: DB の中で再利用が起きるのは最大 id の行を消したときだけで、その 1 行を残せば十分。スキーマも、差分も、端末の手順も変えずに済む。復元は稀な手作業の操作で、引き継ぎ(D-103 / D-104)が既にあるので、手順に 1 段足せば済む。 - -**却下**: -- `tracks` を `AUTOINCREMENT` に移すマイグレーション: SQLite では表の作り直しになり、FTS5 の external content・トリガー・外部キーの参照元を巻き込む。得るものが上の 1 行と同じ -- token・sha256・パスがすべて違う既存の写しを `Update` ではなく Delete + Add にする: Library で一括にタグを直して改名するのは正当な日常操作で、そのたびに Mac の再生回数が消える -- 最大 id を別表に持って挿入時に振る: 別表も DB の中なので復元で巻き戻り、DB の中の再利用は最大 id の行を残すだけで防げる -- 最大 id を DB の外のファイルに記録し、スキャンがそれより上から振る(復元でも巻き戻らない): DB の外に正本を 1 つ増やす。データセットごと ZFS のスナップショットへ戻すと両方巻き戻るので、手順の案内はどのみち要る。復元は稀なので手順で扱う -- 復元を検出して端末に引き継ぎを強制する: 検出に DB の外の記録が要り、上と同じ - -## D-106 ログはファイルのまま残し、画面は API で読む。GC・スキャン・バックアップの結果はジョブの note に残す - -**背景**(2026-10-15): 操作タブが照合の詳細を `data/verify/` の実ファイルで案内していた。コンテナの中のパスはユーザが開けない。 -設定画面の退避ファイルの一覧は本番で 7,570 件あり、すべて GC で実体を消した記録だった。GC・スキャン・バックアップは -結果を tracing にしか出さず、画面では「完了」しか分からなかった。 - -**決定**: - -1. ログはファイルのまま(照合は `data/verify/<album_id>.log` に album ごとに最新 1 回分、吸い出しは album と一緒に動く - `rip.log` / `rip<N>.log`)。画面は `GET /api/logs` で実行ごとの一覧を、`GET /api/logs/{key}/text` で本文を読む。 - 本文は root の dirfd から `openat2` で開き、上限 1 MiB。照合ログの過去分は残さない(古い実行は「上書きされた」と出す) -2. 吸い出しログの場所は DB の `log_path` を使わず、その disc の active な曲がいまあるディレクトリから引く - (`log_path` は配置時の Library 相対でリネームに追随しない。名前だけ使い、`rip*.log` 以外は読まない) -3. GC・スキャン・バックアップの結果は `Outcome::DoneWith` でジョブの `note` に残す(1 行目を一覧に、全文を展開に)。 - ジョブ行の保持期間(done 7 日)で一緒に消える。別の表は作らない -4. 編集履歴と退避ファイルとログを ☰ の「記録」画面のタブにまとめる。退避ファイルは保持中を既定にして 100 件ずつ - -**理由**: ログを DB に入れると照合のたびに数十 KB の行が積もり、ファイルが正という原則(不変条件 1)とも二重になる。 -読む経路だけ足せば、既存の書き手(照合・吸い出し・配置)は変えずに済む。結果の要約は数行なので、ジョブ行に -載せればジョブ画面からそのまま見えて、保持の規則も既存のものに乗る。 - -## D-107 選曲の見積もりは、送る元のハッシュが無い曲を長さ × ビットレート(原本を送るなら原本の size)で推定する - -**背景**(2026-10-15): 端末の選曲タブで「全曲」を選ぶと「合計 9,162 曲・約 605.3 MB(容量未確定 9,070 曲は含まない)」と出た。 -見積もりは送る元のハッシュ(`source_hashes`)の size だけを足していたが、ハッシュは同期の計画に入った曲にしか取らないので、 -選曲を広げる前の見積もりでは大半の曲が数えられず、容量が桁違いに小さく見えた。 - -**決定**: - -1. 送る元のハッシュが有効な曲は、これまでどおりその size を数える(確定) -2. ハッシュが無い・古い曲は推定して bytes に足し、その曲数を `estimated` で返す。原本を送る曲は `tracks.size`、Derived を送る曲 - (Derived がまだ無い・古い曲も、系統が有効なら作られる)は `duration_ms` × `derived_variants.bitrate` / 8 -3. 長さが分からない曲と、系統が凍結で Derived の無い曲(送られない)は推定せず `unhashed` に数え、bytes に含めない -4. 画面は「合計 N 曲・約 X(うち M 曲は長さとビットレートからの推定)」と出す - -**理由**: 実機の AAC 256 kbps では、ハッシュ済みの 881 曲で実際の合計 5,026 MB に対し推定 4,873 MB(差 3%。埋め込みの -アートワーク分だけ小さく出る)で、選曲を決める目安には十分。Derived を作る・ハッシュを取るのは重く、見積もりのためだけに -走らせる理由はない。差分画面の送る量(計画に入った曲)は推定を混ぜず、これまでどおり確定値だけを使う。 - -## D-108 sync の進捗は Mac の端末とサーバの端末タブに出す。サーバはメモリだけに持ち、送信の失敗では同期を止めない - -**背景**(2026-10-15): `spindle-agent sync` は確認(y/N)の後、何も出さずに数分〜数十分動いた。どこまで進んだか、速度、 -残り時間が分からず、端末タブも「同期中」とも出なかった(Android はジョブの進捗がある)。 - -**決定**: - -1. 対象は計画の実行中だけ。件数(`done` / `total`)、バイト数(受け取りが要る項目の `size` の和に対する受信量)、 - 直近 5 秒の速度、残り時間、今の項目を数える。Mac では TTY なら 1 行を上書き(0.2 秒に 1 回)、TTY でなければ - 10 秒か 10 件ごとに 1 行。終わったら要約を 1 行出す -2. エージェントは `POST /api/agent/plans/{id}/progress` へ 2 秒に 1 回まで送る(タイムアウト 5 秒。間隔は前回の送信が - 終わってから数える)。**送信の失敗(通信エラー・タイムアウト・5xx)では同期を止めず**、その回を捨てて間隔を 2 秒・ - 4 秒・8 秒…(上限 60 秒)と延ばし、3 回続けて失敗したらその計画では以後送らない。404 と 409 もその計画では以後 - 送らない(古いサーバの 404 と区別しない)。次の計画(`start`)でまた送る。途中で止めた(`Stop`)ときは要約を - 出さず、進捗の行を消すだけにする。各項目の始めにも送る機会を作り(間隔の内なら送らない)、CLI では別スレッドの - **心拍**が、前回の送信から間隔が空けば最新の値を送り直す。項目が長く掛かっても(最初の削除、ミュージック.app への - 追加で osascript を最大 120 秒待つ、大きなプレイリストの置き換え)2 秒ごとに届き、画面が「応答なし」にならない。 - 心拍とエンジンの送信は間隔・失敗の数・打ち切りを 1 つの門で共有し、重ねて送らない(送信中は他方が見送る)。 - ただし報告の前の最後の 1 回は、心拍の送信中ならその終わりを待って(上限 6 秒)から送る。門は計画ごとの番号を - 持ち、前の計画の送信の結果(404 など)は次の計画に記さない。速度は送る時点で計り直す(受信の止まった段では - 直近 5 秒の平均として 0 に落ちる)。計画を終えたら(要約・行の消去)心拍は送らない -3. サーバは**メモリだけ**に持つ(端末ごとに 1 件、DB に書かない)。応答は 204、閉じた・別の端末の計画は 404 `no_plan`、 - 世代違いは 409、値の検査は 400、本文は 4 KiB まで。受理後に計画をもう一度確かめ、閉じていたら消して `progress: null` - を流す。計画が閉じる経路(報告・破棄・強制破棄)と端末の削除でも消して流す -4. 端末の応答の `sync_progress` は、進捗の `plan_id` が今の open な計画と一致するときだけ出す。経過は `now` ではなく - サーバが計算した `age`(秒)で渡し、画面は端末の時計に依らず古さを出す。SSE は `device_progress` -5. 画面(端末タブ)は進捗バーと速度・残り時間を出し、最後の更新から **30 秒**を超えたら「応答なし」にする。SSE で届いた - 新しい進捗を、古い一覧の応答で上書きしない。表示は `sync_progress` だけで決め、`plan_open` は見ない(計画の確定は - イベントを流さず、`plan_open` は一覧の取り直し(60 秒)まで偽のままなので、短い同期では進捗が出なくなる)。非 null の - 進捗を受けたら `plan_open` も真にする。null はサーバが進捗を実際に消したとき(計画が閉じた)だけ流すので、 - 受けたら一覧を取り直して `plan_open` を合わせる(「Mac で同期が途中です」と強制破棄を 60 秒残さない)。 - 一覧の応答と SSE の前後は時刻ではなくイベントの到着順の番号で比べる(同じ 1 ms のイベントを取り違えない) -6. 描画は `Ui` ではなく `ProgressSink`(描く先の抽象)に任せる。進捗の計算(`Progress`)は時計を注入でき、描画と - 送信を間引く。`Ui` の台本は変えない - -**理由**: 進捗は表示のための一時的な値で、計画が閉じれば要らない。DB に書くとスキャンやバックアップの対象になり、 -2 秒ごとの書き込みが SQLite の単一書き込みコネクションを占める(不変条件 1 とも無関係)。送信は表示の補助なので、 -サーバや回線の不調で数十 GB の受け取りを止めてはならない(ジョブは冪等で、止めるべきなのは報告の失敗だけ)。 -404 / 409 を繰り返し送ると、閉じた計画や古いサーバにログを積むだけになる。送信は受信の書き込みの中で同期的に行うので、 -詰まったサーバに書き込みのたびに送るとタイムアウト(5 秒)ごとに 1 チャンクしか進まない。間隔を送信の終わりから数え、 -失敗で延ばし、続けば打ち切るのはこのため。エンジンは単一スレッドで、1 項目の処理(osascript など)の間は -送る機会が無い。心拍を別スレッドにしたのは、受信の書き込みの合間だけでは「生存」を知らせきれないため。心拍は -エンジンが書いた最新の値を送るだけで、数え方には触れない(エンジンの `Progress` は単一スレッドのまま)。`age` にしたのは、Mac・サーバ・ブラウザの -時計がずれていても「応答なし」を正しく出すため。描画を `ProgressSink` に分けたのは、`info` など既存の出力との -交錯(進捗の行を消してから出す)を 1 か所に閉じ込め、試験で台本 Ui を壊さずに済ませるため。 - -## D-109 Android の同期の進捗は job イベントの detail で流し、Mac と同じ表示を使う - -**背景**(2026-10-16): D-108 で Mac の同期には進捗が付いたが、Android(adb)の `device_sync` は件数の進捗を SSE で流していたが、 -端末タブは使わず「同期中…」としか出さなかった。数百 MB 以上を送る間、どこまで進んだか、速度、残り時間が分からなかった。 - -**決定**: - -1. 進捗は `device_sync` ジョブの `job` イベント(SSE)の detail に載せる。形は - `{"kind":"device_sync","phase","current","bytes_done","bytes_total","rate"}`。件数は `JobEvent` の `done` / `total` - をそのまま使う。D-108 の `device_progress`(Mac のエージェントが送る進捗)は使わない。detail は SSE にだけ流し、 - DB の進捗の書き込みは既存の `JobContext` の間引き(250 ms)に任せる -2. 段(`phase`)は `delete` / `move`(パス変更のバッチ)/ `send`(更新・追加)/ `playlist`。Mac の `fetch`(受信中)は - 使わず、画面の見出しは `send` を「送信中」とする。`current` は送り先(削除は `from`)のファイル名、プレイリストは - そのファイル名 -3. `bytes_total` は端末へ中身を送る曲(`Add` / `Update` / `UpdateMove`)の `size` の和。引き継ぎの照合で済む曲 - (`adopted_unchanged`)は送らないので含めず、純粋な `Move` も含めない。`bytes_done` は**曲を 1 曲処理し終えるごと**に - 増やす(曲の途中では増えない)。保留(開けない・衝突・照合失敗など)になった曲の `size` も処理し終えた量として足し、 - バーを最後まで進める。パス変更のバッチは失敗した曲を外してやり直すことがあるので、バッチの中では送り終えた量の - 最大値を見せて減らさず、バッチを終えたら(成功・中断のどちらでも)バッチの全体を確定させる -4. エンジンは各項目の始め(`current` を入れて)と終わりの両方で `progress` を呼ぶ。パス変更のバッチは、仕上げ - (空ける・置く)でもメンバーごとに `current`(送り先のファイル名)を出し、置くたびに件数を 1 進める(純粋な `Move` は - 準備の段で何もしないので、ここで出さないと今の曲が空のまま、または更新 + 移動の曲名が残る)。バッチを終えたら件数を - 「バッチの開始時の件数 + バッチの項目数」に確定させる(置いた分を二重に足さない。外した曲もここで数える)。通知は - 失敗せず、耐久化と `mv` の順序は変えない。速度(`rate`)は `JobControl` が - `(時刻, bytes_done)` の標本を直近 5 秒だけ持ち、D-108 と同じく 5 秒前以前で最新の標本を基準(無ければ 0)に - バイト差 ÷ 経過秒で出す。標本が 1 つ・経過 0 なら 0。純粋関数にして単体テストする -5. 画面(端末タブ)は、`sync_job` の job の進捗を受けているとき(一覧の state が queued のままでも。running の間だけ表に項目がある)に D-108 と同じ進捗の行 - (`SyncProgressLine`)を出す。無ければ従来どおり「同期中…」。**「応答なし」(30 秒)は Android では出さない** - (ジョブの生死はワーカーが管理し、パス変更のバッチのように間が空く段がある)。ページを開き直したときは、 - 次の `job` イベントが届くまで「同期中…」のまま(DB の件数を一覧に足さない) - -**理由**: ジョブにはもう進捗の仕組み(`JobEvent` と SSE)があり、新しい API や保持場所を作らずに済む。進捗は表示の -ための一時的な値で、DB に書く必要が無い(D-108 と同じ)。バイトを曲の完了ごとに数えるのは、1 曲の転送の途中を -ストリームから測るとエンジンの構造(照合と `mv` を含む曲の単位)に手を入れることになり、曲が数 MB〜数十 MB の -この用途では粗さが気にならないため。保留の曲の `size` を足すのは、足さないとバーが合計に届かずに終わるため。 -「応答なし」を出さないのは、生死の判定をジョブの回収(D-76)が持っており、画面が二重に判定すると、間の空く段で -誤って警告するため。 - ---- - -## D-110 MP3 の写像できないキーは ID3v2 の TXXX に書き、ブロックは split / merge で書き換える - -**背景**(2026-10-17): 00_ プレイリストから外す曲に `PLAYLIST_EXCLUDE=1` を一括編集で書いたところ、Charlotte ラジオの -mp3 13 曲が「書き込み結果が意図と一致しない(この形式では表現できない)」で failed になった。MP3 は lofty の generic -`Tag` 経由で読み書きしており、generic `Tag` は写像表に無いキー(TXXX の任意の description)を読みでも書きでも捨てる。 -しかも書きは ID3v2 全体を generic `Tag` から作り直すので、spindle が知らないフレーム(外部ツールの独自 TXXX など)は、 -別のキーを編集しただけで消えていた。 - -**決定**: - -1. MP3 の書き込みは `MpegFile` を読み、ブロック(ID3v2 / ID3v1 / APE)ごとに `split_tag` → generic `Tag` に変更と画像を - 当てる → `merge_tag` で戻す。写像できないフレーム・項目は残る -2. ID3v2 では、Vorbis 名を `ItemKey` に写像でき、その `ItemKey` が ID3v2 に写像を持ち、**かつ `ItemKey` の正規の Vorbis 名が - キーそのもの**であるキーだけを generic `Tag` で書く。別名(`TOTALTRACKS` → `TRACKTOTAL`、`ORGANIZATION` → `LABEL` 等)を - 標準フレームに書くと正規の名前で読み戻され、読み戻し照合が必ず外れるので、別名は TXXX に書く。それ以外も - description = キー(大文字)の TXXX に書く。多値は NUL 区切りの 1 フレーム(ID3v2.4 の多値) -3. どのキーでも、書く前に description を読み側と同じ規則(Unicode の大文字化)で大文字にしてキーと一致する既存の TXXX を - 消す(外部が `replaygain_track_gain` や `MyKey`、`äkey` で書いたものを置き換えても二重化しない) -4. 読みは従来の generic の集約(primary 優先、副ブロックは primary に無いキーだけ)の間に、ID3v2 の TXXX のうち generic に - 載らないもの(description を大文字化したキー、NUL で多値に分ける)を primary の一部として差し込む。description の - 無い TXXX は読まない -5. 書いた mp3 の ID3v2 は v2.4 になる(lofty の既定。v2.3 の入力も読み書きでき、書くと v2.4 に上がる)。NUL 区切りの多値は - v2.4 の形で、foobar2000 / Picard / ffmpeg は読める -6. WAV / AIFF / WavPack / APE の形式は従来どおり(generic `Tag`。対象の曲がほぼ無く、WAV は FLAC に正規化される) - -**影響**: 既存の mp3 で、これまで読めていなかった TXXX がある曲は、次のスキャンでタグの集合が増えて `tag_version` が進む -(本番の mp3 は 30 曲程度)。 - -**理由**: MP4 のフリーフォーム(D-77)と同じく、任意キーを書ける形式では書けるようにして、形式による一括編集の -失敗を無くす。TXXX は foobar2000 / Picard / ffmpeg が任意フィールドに使う標準の置き場。 - -**却下**: generic `Tag` の写像だけで済ませる(任意キーが書けないまま)。ID3v2 全体を自前で組み立てる(lofty の標準 -フレームの写像・`TRCK` の対・画像の扱いを二重に持つことになる)。 - ---- - -## D-111 Derived のジョブは、行った処理(エンコード / タグ更新 / 移動)を結果列に出す - -**背景**(2026-10-17): タグの一括編集の後、ジョブ画面に「Derived 生成 … 実行中」が並び、再エンコードが走っているのか -タグの書き換えだけなのかが画面から分からなかった。`transcode` は 1 種類のジョブで、ハンドラが実行時に現在値から処理 -(`Plan`)を決めるので、種別の名前では見分けられない。 - -**決定**: - -1. `Plan` ごとの名前を持つ(エンコード / タグ更新 / 移動 / 移動 + タグ更新 / 変更なし / 対象外) -2. 処理を決めて Derived のパスを予約した後、実行の前にその名前を `jobs.note` に書く(`running` の行だけ)。ジョブ画面は - 既存の結果列(`note` の 1 行目)で、実行中の行にも名前を出す。書けなくても処理は止めない -3. 完了時は実際に行った処理を `DoneWith` で残す。移動・タグ更新が Derived の消失でエンコードに倒れたときは、エンコードが - 自分で「エンコード」を書いて返す。何もしなかったジョブは「変更なし」「対象外」 -4. 再キュー(パスの予約が取れない・世代が動いた)では `note` を消さない。次の実行が書き直す -5. ハンドラに入る前にワーカーが stale(版が進んだ・トラックが無い)として no-op で終えるときは、`JobType::noop_note` - (transcode だけ「変更なし」)で `note` を**置き換える**(`COALESCE` で前の試行の処理名を残さない)。他の種別は従来どおり - `note` に触らない - -**理由**: 新しい列や SSE の詳細を足さずに、既存の `note` と結果列(D-106)で足りる。一覧は job イベントのたびに DB から -取り直すので、長いエンコードは進捗のイベントで実行中の名前が見える。タグ更新は数秒で終わるので、完了時の `note` で見える。 - -**却下**: 投入時にジョブの種別を分ける(処理は実行時の現在値で決まり、投入時の判断は古くなる。dedup の鍵も変わる)。 - ---- - -## D-112 Derived のバッジは系統ごとに O(opus)と A(aac)を並べ、凡例の高さは窓の下端までにする - -**背景**(2026-10-17): Library の表の D バッジは opus の系統だけを見ていた(aac は D-75 で後から足し、プロパティでだけ見せて -いた)。iPhone への配信(Mac のエージェント)で aac も日常的に使うようになり、aac の Derived があるか・タグが追いついているかを -表で見たい。また、凡例のポップオーバーは高さを固定の `70vh` で切っていたため、上のプロパティ欄が高く表のツールバーが下に -あると、凡例の下端が窓の外に出て最後まで読めなかった(ページ自体はスクロールしない)。 - -**決定**: - -1. Derived のバッジは系統ごとに 1 つ。`opus` は **O**、`aac` は **A**(この順)。タグ版が古い(`stale_tags`)ときは点付き - (O• / A•)。どちらも同じ色(`b-derived`)。バッジの key は `derived-opus` / `derived-aac` -2. `has_derived`(DSL / フラグ)は従来どおり `opus` 系統の行の有無(配布ビューの意味を変えない) -3. 凡例は開いたとき・窓の大きさが変わったとき・位置決めの親(ツールバー)とその親(表)の大きさが変わったとき - (`ResizeObserver`。開いたままプロパティ欄の高さを変える・ツールバーが折り返す)に、自分の上端から窓の下端(余白 8px) - までを `max-height` にする。残りを超える下限は置かない。中は凡例自身がスクロールする -4. 凡例は O / O• / A / A• をそれぞれ 1 行に載せる - -**理由**: 系統の区別は 1 文字で足りる(D のままでは aac が分からない)。凡例は配置(ツールバーの位置)が利用者の欄の高さで -変わるので、固定の比率では収まらない。 - -**却下**: 凡例を `position: fixed` の中央のダイアログにする(「凡例」ボタンの直下に出る今の操作感が変わる)。 - ---- - -## D-113 Inbox の承認で種類(TRACK_TYPE)と除外(PLAYLIST_EXCLUDE)を選び、タグのキーの入力欄に前方一致の候補を出す - -**背景**(2026-10-17): プレイリストの運用を「曲の性質はファイルのタグ(`TRACK_TYPE` = karaoke / bgm / drama / talk、 -曲だが外したいものは `PLAYLIST_EXCLUDE=1`)で持ち、00_<category> とシリーズのプレイリストはそれを除くスマートプレイリスト」に -した。取り込みはどの経路(CD / YouTube / 手置き)も Inbox の承認を通るので、そこで付ければプレイリストと端末の配信が -自動でついてくる。ただし任意のキーを「タグを追加」で手入力するのは手間で、付け忘れると曲でないものが紛れ込む。 -タグのキーの入力欄(Inbox のタグの追加・プロパティのフィールドの追加・一括編集)は、ライブラリでどんなキーが -使われているかが分からず、綴りを覚えておく必要があった。 - -**決定**: - -1. Inbox の承認の表に**種類**(選択欄: 曲(空)/ カラオケ / BGM / ドラマ / トーク。値は karaoke / bgm / drama / talk)と - **除外**(チェック。`PLAYLIST_EXCLUDE=1`)の列を置く。中身は既存の下書きのタグ(D-86)なので、サーバの変更は無い。 - 汎用のタグの列には出さず、「タグを追加」でこの 2 つのキーを足そうとしたら列を案内する。知らない値(手で書いたもの)は - 選択欄にそのまま出す -2. 種類が空の行に候補を出す(`suggestTrackType`)。タイトルの instrumental / inst. / off vocal / karaoke / カラオケ / 歌無し / - without vocal / backing track → カラオケ、ドラマ / drama / ボーナス・トラック → ドラマ、MC(番号だけ)/ ラジオ / トーク / - コメント / DJトラック → トーク、アルバム名がサウンドトラック / サントラ / soundtrack / OST / Music Collection / 劇伴で、 - トラックのアーティストがアルバムで一番多いアーティスト(作曲者とみなす)なら BGM。**候補は自動では入れない**。 - 行の「候補: …」か表の上の「種類の候補をすべて適用」で人が入れる -3. `GET /api/tags/keys` でライブラリにあるキーと曲数(missing と `PICTURE` を除く、曲数の多い順)を返す。入力欄は - フォーカスしたときに取り(30 秒は使い回す)、よく使うキー(`COMMON_KEYS`)のうちライブラリに無いものを後ろに足し、 - 入力のたびに**前方一致**(大小文字を区別しない)で絞って出す。既にあるキー(Inbox の列・プロパティの行)は除く。 - ↑↓ で選び Enter / Tab かクリックで入れ、Esc で候補を閉じる。候補を選んでいないときの Enter / Esc は従来どおり - (追加の確定・取り消し)。共通の部品 `TagKeyInput` を 3 か所で使う - -**理由**: 取り込み時に付けるのが一番漏れにくく、承認の画面ならどの経路も通る。候補を自動で入れないのは、誤判定(曲名に -「ラジオ」を含む歌など)がそのままファイルに書かれるのを防ぐため。キーは数十種類しか無い(本番で 34)ので、打つたびに -サーバへ問い合わせず、一覧を取って手元で絞るほうが速い。 - -**却下**: 候補を下書きの初期値として入れる(確認せずに承認すると誤判定が書かれる)。CD の取り込みの画面にも同じ欄を置く -(CD も Inbox の承認を通るので二重になる)。部分一致(キーの途中)で絞る(`TRACK` で `TOTALTRACKS` まで出て、狙いの候補が -埋もれる)。 - ---- - -## D-114 Inbox の YouTube 取り込みを再判定し、判定したプラグインの版を記録する - -**背景**(2026-10-18): YouTube の曲名・アーティストはメタデータプラグイン(`spindle-ytmusic-meta`)がタイトルから決める。 -ルールに穴があると、どれにも合わず `Inbox/youtube/_unmatched/<channel>/` に動画タイトルのまま置かれるか、判定はされても -値が違う(全角 `#` で 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:<item_id>`、`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)。ファイル名も初回と同じ規則(`<YYYYMMDD> <title> [<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 側の変更は -追わない)。 - ---- - -## D-115 スマホ版は別の入口(`/m/`)・別のビルドで作り、PC 版に手を入れない - -**背景**(2026-10-19): いまの UI はデスクトップ専用(SPEC §12)で、外出先のスマホからライブラリを眺める・探す・試し聴きする・ -誤字を直す・プレイリストを手直しする・YouTube 購読の同期を押す、ができなかった。ユーザの条件は「PC 用のページを触らずに、 -まったく影響を与えずに作る」こと。外出先は LTE + Cloudflare Zero Trust 経由で、ハイレゾ FLAC(1 曲 100 MB 近い)をそのまま -流せない。 - -**決定**: - -1. **別入口・別ビルド**: コードは `web/src/mobile/`、ビルドは PC と別の vite 設定で `web/dist/m/` に出す。PC の - `components/` `hooks/` `App.tsx` `main.tsx` `index.css` `vite.config.ts` は import も編集もしない。`api/` と `lib/` は - 編集せず import だけ。受け入れは `dist/m/` 以外の出力のファイル単位の同一(`scripts/pc-dist-hash.sh`)。配色は PC と同じ値を - スマホ側の CSS に持ち、テーマの保存値だけ共有する -2. **機能範囲**: 載せるのは ライブラリ / フォルダ / プレイリスト(普通とスマートの作成・編集・削除)/ 購読(手動同期と進捗)、 - 曲のシート、1 曲のタグの値の修正、試し聴き。**載せない**: 一括編集、リネーム、巻き戻し、CD、Inbox の承認、端末同期、 - ジョブ、記録、設定、購読の追加・変更・削除、フィールドの追加・削除、バックグラウンド再生・ロック画面・キュー、 - PWA のオフライン動作 -3. **UA で `/` を振り分ける**: `Mobi` / `Android` / `iPhone` を含む UA の `/` を 302 で `/m/` へ(iPad は Mac の UA なので PC)。 - 「PC 版を開く」は Cookie `spindle_view=pc` を立てて `/` を開く。`/m/` を開くと Cookie を消す(PC から戻る導線は PC を - 触らないと作れないため、`/m/` を直接開く) -4. **試し聴きは軽い形式**: 原本は流さず `?transcode=opus`。Opus が鳴らない端末向けに `GET /api/stream/:id?transcode=aac` - を足した(現在値の Derived の m4a は Range で直送、無ければ ffmpeg の ADTS を chunked で `audio/aac`、Range なし・ - `?start=`)。非可逆の原本は両方とも変換せず返す。iOS の `canPlayType` は当てにならないので、Opus が再生前にエラーに - なったら 1 回だけ AAC で引き直す。RG は掛けない -5. **1 曲のタグの修正も履歴に載る**: PC と同じ `batch/preview` → `apply`(`ids: [id]`)を通すので `edit_batches` に載り、 - PC の「履歴」から巻き戻せる(不変条件 4)。空にする・フィールドの追加は無い(誤字直しが目的)。標準のタグの空の行を - 埋めるのは「値の修正」として許す -6. **購読は手動同期のまま、「完了」はダウンロードの終了**: `playlist_sync` の投入完了では早すぎ、ユーザが知りたいのは成功したか - なので、投入した ytdl がすべて終わった時点を完了とする。進み具合は読み取り専用の - `GET /api/ytmusic/subscriptions/:id/progress`(直近の `playlist_sync` と、その購読の ytdl)。投入が走行中の古い ytdl に - dedup されて行が増えない同期があるため、この購読の queued / running の ytdl は作成時刻によらず含める -7. **DSL の引用符のエスケープ**: スマホのルール組み立ては値を `"` で囲み、値の `"` と `\` をエスケープして書く。サーバの文法 - (`dsl.pest` の `quoted`、`unescape`)は既にこれを受けるので、「値に `"` を含められない」として弾く必要が無い。DSL.md を直した - -**理由**: PC のビルドに入口を足すと Rollup が共有モジュールを共通チャンクへ切り出し、PC の JS の中身が変わる。別ビルドなら -PC の成果物は変わらないことをハッシュで示せる。API を足すだけなら既存の応答は変わらず、PC のページに影響しない。UA の振り分けは -サーバで 302 にでき、PC 側に何も足さずに済む。試し聴きを Opus/AAC にするのは、外出先の帯域で原本を流さないため(D-52)。 -手動同期にするのは、同期の後に Inbox の確認がいり、時間のあるときに人が押すのが前提だから。 - -**却下**: PC 版のレスポンシブ化(PC 版に手が入る)。購読の自動同期(確認が追いつかない)。スマホでの Inbox の承認(細かい確認は -PC で)。原本のまま試し聴き(帯域)。PC の vite 設定への入口の追加(共通チャンクで PC の成果物が変わる)。 diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md deleted file mode 100644 index f51b3096..00000000 --- a/docs/MIGRATION.md +++ /dev/null @@ -1,240 +0,0 @@ -# 移行手順 - -旧ライブラリ(`ssd/musics`)から spindle のデータセット構成へ移す手順。振り分けの判断は -`docs/DECISIONS.md` D-45、旧構成の実態もそこに書いてある。 -**`casesensitivity` と `normalization` はデータセット作成時のみ指定可能**で後から変更できない -ため、既存データセットの `zfs rename` による流用はしない(D-19)。 - -**運用方針**: spindle が完成するまで `ssd/musics` が正のライブラリで、日常の追加もそちらに行う。 -2026-09-16 の移行は本手順のリハーサルで、`ssd/media` / `hdd/media/Archive` / `ssd/apps/spindle` は -開発・検証用の環境。**リリース時にこれらを作り直し、§0 から再度実施する**(`ssd/musics` の -その時点の内容で `migrate_plan.py` を回す)。ACL プリセットと定期スナップショットの設定も -リリース時の再移行で行う。 - -実機の前提(2026-09-16 時点): - -| | | -|---|---| -| 旧ライブラリ | `ssd/musics` → `/mnt/ssd/musics/{Opus,Original,AAC}`(342G。所有者 `nobody:nogroup`、ファイルは `snishi`) | -| 新データセット | `ssd/media/{Library,Derived,Inbox}`、`ssd/apps/spindle`、**`hdd/media/Archive`** | -| コピー量 | Library 約 229G(ALAC 221G + opus 7.4G)、Archive 5.7G、Playlists 1.7M | -| 空き | `ssd` 435G、`hdd` 36T | -| コンテナ | `user: 1000:1000`(`deploy/compose.yaml`) | - -作業はホストで行う。`scripts/preflight.py` と `scripts/migrate_plan.py` は標準ライブラリだけで -動くので、`scp` で `/tmp` へ置いて `sudo python3` で実行する。 - -## 0. 事前チェックと振り分け計画 - -```bash -# ブロッカー検出(衝突 / 不正 UTF-8 / 255 バイト超 / symlink / hardlink / SMB 禁止名 / 容量)。 -# Library へ入る 2 本の木を別々に見る。どちらも exit 0 になるまで進まない -sudo python3 /tmp/preflight.py /mnt/ssd/musics/Opus --dest /mnt/ssd --plan /tmp/rename-opus.sh -sudo python3 /tmp/preflight.py /mnt/ssd/musics/Original --dest /mnt/ssd --plan /tmp/rename-original.sh - -# 振り分け一覧(rsync --files-from 用)。unmatched が 0 で exit 0 になること -sudo python3 /tmp/migrate_plan.py /mnt/ssd/musics --out /tmp/plan -sudo cat /tmp/plan/summary.json # library_audio_total が初回スキャン後のトラック数 -sudo cut -f1,2 /tmp/plan/skip.tsv | sort | uniq -c # 移さないものの理由を目視 -``` - -`insensitive` + `formD` では、比較キーが一致する 2 ファイルは共存できない。判定は -「NFD 正規化 → casefold」した結果の一致で行う。ブロッカーがあれば `--plan` のリネーム案を -**目視のうえ**実行する(中身が同一の重複ならリネームではなく削除が正しい)。symlink は -実体に置き換え、hardlink は片方を実コピーにするか削除する(D-26)。 - -`migrate_plan.py` は `Opus/` と `Original/` の 1:1 対応を前提に、原本の形式で振り分ける -(ALAC → Library、webm → その `.opus` が Library で webm は Archive、mp3 → Library、 -AIFF は同名 ALAC があれば Archive、`AAC/` とマーカーは移さない)。対応が取れない stem が -あれば exit 1 で止まるので、`skip.tsv` の `unmatched` を見て手で決める。 - -## 1. データセット作成 - -```bash -COMMON="-o casesensitivity=insensitive -o normalization=formD" # utf8only=on が強制される -sudo zfs create ssd/media -sudo zfs create $COMMON -o recordsize=1M -o compression=lz4 -o atime=off ssd/media/Library -sudo zfs create $COMMON -o recordsize=1M -o compression=lz4 -o atime=off ssd/media/Derived -sudo zfs create $COMMON -o recordsize=1M -o compression=lz4 -o atime=off ssd/media/Inbox -sudo zfs create hdd/media -sudo zfs create $COMMON -o recordsize=1M -o compression=lz4 -o atime=off hdd/media/Archive -sudo zfs create -o recordsize=16K -o compression=lz4 -o atime=off ssd/apps/spindle -sudo mkdir -p /mnt/ssd/media/Playlists/m3u8 -``` - -| dataset | recordsize | snapshot | 備考 | -|---|---|---|---| -| ssd/media/Library | 1M | 毎日 + 一括編集前 | 唯一の正 | -| ssd/media/Derived | 1M | なし | 再生成可能。レプリケーション対象外 | -| hdd/media/Archive | 1M | 週次 | 追記のみ。FLAC 化で退避する ALAC 220G の受け皿 | -| ssd/media/Inbox | 1M | なし | 承認前の一時領域 | -| ssd/apps/spindle | 16K | 毎日 | SQLite。ページサイズに合わせる | - -- `casesensitivity=insensitive`: Windows の foobar2000 が `Cover.jpg` を、spindle が - `cover.jpg` を作る事故を ZFS 層で潰す -- `normalization=formD`: 日本語の濁点・半濁点には NFC と NFD の 2 表現がある。macOS 由来の - パスは NFD になりがちで、正規化なしだと「見た目が同じで別ファイル」が発生する - -Inbox は Library と別データセットなので move は実コピーになるが、1 回あたりアルバム 1 枚 -なので実用上の問題はない。Archive は別プールなので退避も実コピー(追記のみで速度は要らない)。 - -## 2. コピーと検証 - -```bash -# 1. 旧ライブラリを固定してから作業する(ブロッカー解消のリネームは readonly の前に済ませる) -sudo zfs set readonly=on ssd/musics -sudo zfs snapshot ssd/musics@pre-migration - -# 2. 振り分け一覧に従ってコピー(zfs send/recv は不可。作成時プロパティが継承されてしまう) -R="sudo rsync -aHX --info=progress2 --from0" -$R --files-from=/tmp/plan/library-original.list /mnt/ssd/musics/Original/ /mnt/ssd/media/Library/ -$R --files-from=/tmp/plan/library-opus.list /mnt/ssd/musics/Opus/ /mnt/ssd/media/Library/ -$R --files-from=/tmp/plan/archive-original.list /mnt/ssd/musics/Original/ /mnt/hdd/media/Archive/ -$R --files-from=/tmp/plan/playlists.list /mnt/ssd/musics/Opus/Playlists/ /mnt/ssd/media/Playlists/m3u8/ - -# 3. 検証(チェックサム比較。何も出なければ成功)。-O はディレクトリの時刻を比べない: -# Original と Opus の 2 本を同じディレクトリへコピーするので、ディレクトリの mtime は必ず違う -V="sudo rsync -aHXnO --checksum --itemize-changes --from0" -$V --files-from=/tmp/plan/library-original.list /mnt/ssd/musics/Original/ /mnt/ssd/media/Library/ -$V --files-from=/tmp/plan/library-opus.list /mnt/ssd/musics/Opus/ /mnt/ssd/media/Library/ -$V --files-from=/tmp/plan/archive-original.list /mnt/ssd/musics/Original/ /mnt/hdd/media/Archive/ - -# 4. 設定とメタデータプラグインを置く(初回起動より前。config.toml が無いと既定値で起動する) -sudo install -m 644 config.toml /mnt/ssd/apps/spindle/config.toml -sudo install -D -m 755 spindle-ytmusic-meta /mnt/ssd/apps/spindle/bin/spindle-ytmusic-meta - -# 5. 所有者をコンテナの実行 UID/GID に合わせる -sudo chown -R 1000:1000 /mnt/ssd/media /mnt/hdd/media /mnt/ssd/apps/spindle - -# 6. spindle 初回スキャン完了と数日の運用までは ssd/musics を破棄しない -``` - -`config.toml` は `deploy/config.example.toml` を元にする。`[encode.derived.aac]` を最初から有効にして -よい(aac は RG の解析が済んだ行から作られる。§4)。プラグインはイメージに入っていない(D-70)。 -`[ytmusic].ytdlp_args` に `["--extractor-args", "youtube:lang=ja"]` を入れる(無いと翻訳タイトルのある動画は英語の -タイトルとチャンネル名が返り、日本語の曲名が取れない)。 - -コピーと検証が済んだら、`docs/OPERATIONS.md`「カスタムアプリの作り方」でアプリを作って起動する。 -起動すると初回スキャン(deep)が走る(2026-09-24 のリハーサルで 9,098 本 9 分)。 - -**ACL は rsync で引き継げない。** TrueNAS の SMB データセットは NFSv4 ACL を使うが、 -`rsync -A` が扱うのは POSIX ACL であり互換がない。コピー後に TrueNAS の ACL エディタで -新データセットにプリセットを適用し直す(SMB ユーザとコンテナの `1000:1000` の両方が書ける形)。 -1 回しか使わないのでスクリプト化はしない(D-72 と同日に決定)。手順: - -1. Datasets → `ssd/media` → Permissions → Edit(`hdd/media` も同じ) -2. Owner / Group を `1000` / `1000`、Preset は **Restricted** を基点に、`owner@` と `group@` に - FULL_CONTROL、SMB で書くユーザ(またはそのグループ)にも FULL_CONTROL の ACE を足す -3. **Apply permissions recursively** と **Apply permissions to child datasets** にチェックして保存 -4. 確認: `nfs4xdr_getfacl /mnt/ssd/media/Library` に `owner@:rwxpDdaARWcCos:fd-----:allow` 相当の - 行が出て、コンテナ(`1000`)と SMB ユーザの両方で `touch` できること - (`sudo -u '#1000' touch /mnt/ssd/media/Inbox/.acl-test && rm` と、SMB クライアントからの新規ファイル) - -定期スナップショットも UI で上の表どおりに設定する。 - -## 3. 移行後の照合 - -```bash -# Library の音声一覧が計画と一致すること -sudo python3 /tmp/preflight.py /mnt/ssd/media/Library --manifest /tmp/after.tsv --audio-only -tail -n +2 /tmp/after.tsv | wc -l # = summary.json の library_audio_total -diff <({ tr '\0' '\n' < /tmp/plan/library-original.list; tr '\0' '\n' < /tmp/plan/library-opus.list; } \ - | grep -Ev '\.(png|jpe?g|webp)$' | sort) \ - <(tail -n +2 /tmp/after.tsv | cut -f1 | sort) -``` - -初回スキャン完了後、DB のトラック数が `after.tsv` の行数(ヘッダ除く)と一致することを -確認する。音声拡張子の判定は `preflight.py` の `AUDIO_EXT` が唯一の定義で、スキャナの -受け付ける拡張子もこれと揃える。一致しない場合はタグ読み取り失敗を疑う。 - -あわせて、webm 由来の曲を数件選び、`Archive/` の `.webm` と `Library/` の `.opus` を `ffprobe` で -比べてコーデック(opus)とストリーム長が一致すること(remux であり再エンコードでないこと)を -確認する。これが「webm 由来の曲は Library の `.opus` が master、`Archive/` の webm は生データ」 -という前提の根拠になる。ALAC 由来の曲は `.m4a` がそのまま master なので照合は不要。 - -## 4. 移行後にやること - -- `backup` ジョブが 1 世代以上取れていること(`/mnt/ssd/apps/spindle/backup/`。起動時に 1 世代取る) -- 旧 m3u8 を手動プレイリストとして取り込む(プレイリスト画面「Playlists/ の m3u8」、または - `GET /api/playlists/import` の各 `path` を `POST /api/playlists/import`)。28 本 14,205 行、未解決 0 が期待値 -- ReplayGain を全件解析する(`POST /api/rg {"selection":{"filter":""}}`、または全選択で「ReplayGain を解析」)。 - `[replaygain].write_tags = true` なら解析と同時にタグへ書く(別の「解析値をタグに書く」は要らない)。 - aac の Derived は RG が揃った行から作られるので、解析が終わるまで aac は増えない -- ロスレス → FLAC 正規化(P1-4)で ALAC を FLAC にする(全選択で「正規化をプレビュー」→ 適用)。 - 退避した ALAC は `Archive/` に 7 日(`[gc].retention_days`)置かれてから GC される。正規化は `audio_version` を上げないので、 - Derived の生成と並行してよい(作り直しは起きない)。2 並列で 7,570 本に約 5 時間 -- 一括編集を 1 件行い、巻き戻しが通ること(P0 の完了条件) -- Derived は P1-10 が Library から再生成する(起動時スキャンの後に自動で投入)。旧 `Opus/` の 7,568 本は - 移していない。opus + aac で約 130G 使うので、始める前に `ssd` の空きを見る - -## 5. リリース時の再移行と旧データセットの破棄 - -リリースまで `ssd/musics` は正のライブラリとして使い続ける(`readonly` は外してよい。 -`@pre-migration` スナップショットは残す)。リリース時は次の順で作り直す: - -0. **マイグレーションをもう一度 `0001_init.sql` へ畳む**(リリースのコミットで。D-88 追記。2026-09-26 実施済み)。D-88 の後に - 足した `0002`〜(2026-09-26 時点で `0002_inbox_discard` / `0003_inbox_cover` / `0004_albums_category_null_index`)を - `0001_init.sql` に統合し、空 DB へ流した最終スキーマが畳む前と一致すること(D-88 と同じ比べ方)を確かめる。 - リハーサル環境の DB は版が新しすぎて開けなくなるが、次の 1 で消すので構わない。これを入れたイメージで - 最初の `vX.Y.Z` を切り、以下はそのイメージで行う。**タグを切った後は二度と畳まない** -1. リハーサル環境を消す: spindle を止め、`ssd/media`、`hdd/media`、`ssd/apps/spindle` を - `zfs destroy -r`(Derived / DB / バックアップも開発用なので捨てる) -2. §0 から本手順をやり直す(preflight → migrate_plan → データセット作成 → readonly → snapshot → - rsync → 検証 → chown → ACL / スナップショット設定 → 起動 → 照合) -3. **移行後の一度きりの手順**(webm 由来の `〜のお歌` アルバム。docs/TASKS.md P4-14 / P4-15)。 - 入力(アーティストごとの YouTube 再生リスト URL の TSV と前回の計画 CSV)はリポジトリ外に - 保管してある。旧パイプラインは「再生リスト名 = アルバム名、リスト内の位置 = `TRACKNUMBER`」で - 並べていたので、この順を正とする: - 1. `scripts/backfill_source_url.py --playlists … --spindle … --out …` で計画 CSV を出し、目視 - (`title-mismatch` / `no-track` / `extra-track` / `kept` と、位置推定の `verified-by-neighbors` を - 確認)→ `--apply` で `SOURCE_URL` を書く(アルバムごとに 1 バッチ。巻き戻し可)。位置推定は区間内の - 入れ替えを検出できないので、CSV で確かめてから `--include-inferred` を付けて書く。既に - `SOURCE_URL` を持つ行は上書きしない - 2. **再生リストが無いアルバム**の `SOURCE_URL` を手で付ける(リポジトリ外の `singles.tsv`。列: - albumartist / album / TITLE / 動画 URL。1 曲ずつ受領したもの。表のセルのダブルクリックでは - `SOURCE_URL` を編集できないので、プロパティタブの Metadata で「フィールドを追加」する)。いまは - `柊マグネタイトの曲` の「再見ロマネスク feat. 花隈千冬」1 曲だけ。前回 1 の後に残った 5 件 - (ヰ世界情緒 メズマライザー、花譜 ゲシュタルト -崩壊Remix- / コバルトメモリーズ、理芽 Touch、 - 明透 毎日)は**ユーザが YouTube 側で再生リストに追加済み**なので、1 の再実行で一緒に付く - (手作業は要らない。前回は追加前に走らせたため残っていた) - 3. YouTube 画面で 9 本の再生リストを購読に登録(アルバムアーティスト / アルバム = 各 `〜のお歌`、 - category = Library の最上位ディレクトリと同じ語彙: `神椿Studio` 5 本 / `深脊界Studio` 3 本 / `Vtuber` - (HIMEHINA)。語彙は初回スキャンが Library 直下のディレクトリ名から自動で作るので(D-92)、 - 先に作る必要はない)し、「同期」(P4-16、D-78)。同期は先に既存の行の `TRACKNUMBER` と - ファイル名を再生リストの位置に揃え(tags バッチ → rename バッチ。Derived はタグ上書き・移動で追随)、 - `SOURCE_URL` の無いものだけを位置付きで Inbox に投入する → 承認で配置(初期値の番号がそのまま - 位置)→ 配置の後続で自動的にもう一度同期 → 結果が「番号を 0 件揃え」「揃えられない」無しで完了。 - 非公開・削除の動画の位置は番号が飛ぶ(「取れない」一覧に出る)。 - 承認画面で「⚠ Library に同名」(P4-19)が出たら、件の長さと Library 側の長さを比べる。**長さが一致する - ものは同じ音源の二重取り込み**なので承認しない。原因は 2 通りある: (a) 同じ音源の再アップロード、 - (b) 既存行の `SOURCE_URL` が別の動画を指している(2026-09-24 のリハーサルの VALIS: 再生リストの位置 224 が - 曲名をタイトルに含む 628 秒のドキュメンタリーで、270 秒の曲の行にその URL が付き、本来の動画を同期が - 別の曲として落とした。手順 1 の補填は位置とタイトルが合っても長さが食い違えば採らないようにしたので、 - 今は起きない)。どちらも既存行の `SOURCE_URL` を件の動画の URL に付け替える(プロパティタブの Metadata)と、 - 次の同期で「持っている」と判定されて落ちてこない。再生リストに Library へ入れない動画があるなら、 - 再生リスト側から外す(除外の仕組みは作らない)。長さが違えば別テイク(Cover / Live ver.)なので - そのまま承認してよい。**Library のファイルを消す op は無い**ので、 - 既に二重に入れてしまったら SMB でファイルを消す → スキャンが `missing_since` を立てる → GC - (既定 7 日)で行が消える - 1 と 2 は実データでは 1 回しか行わない(以後は購読の同期が日常運用)。リハーサル環境で一度通してから行う -4. **ライブラリ全体を `[layout]` の書式に揃える**(一度きり。2026-09-25 のリハーサルで決定)。旧パイプラインの - パスは spindle の書式と表記が違うところがある。リネームは巻き戻せるが数千件のファイル移動になるので、 - ほかの一度きりの手順(上の 3)と Derived の生成が終わってから行う: - 1. **同名の別版に `EDITION` を付ける**(`ssd/musics` のファイルには無いので再移行のたびに要る)。 - THE IDOLM@STER CINDERELLA MASTER Solo Series の `IM@S CM Solo - 034 速水奏/Hi-Res/` と - `IM@S CM Solo - 041 大槻唯/Hi-Res/` の各 1 曲に `EDITION=Hi-Res`(一括編集で「フィールドを追加」)。 - 付けないと通常版と同じパスになり、リネームのプレビューで「降格に必要な値が無い: edition」になる。 - 付ければ通常版は元の名前、Hi-Res は `… (Hi-Res)` に分かれる(D-43 追記) - **付けた後に増分スキャンを 1 回走らせてからプレビューする**(album の edition はスキャンの最後に構成トラックの - `EDITION` から埋まる。タグを書いただけでは album 側が NULL のままで、「同名の別リリースと衝突」が 10 件出る。 - 2026-09-26 の再移行で確認) - 2. 全選択で「リネームをプレビュー」。リハーサルでは 9,100 曲中 約 5,200 曲が変わり、衝突 0 だった。 - 変わるのは表記の違いだけ: アルバム名の `/` を旧パイプラインは `-`、spindle は `/` にする - (`FAKE OFF - 天使と悪魔` → `FAKE OFF / 天使と悪魔`)、複数枚組のファイル名 `1.01.` → `1-01.`、 - `Anime/THE IDOLM@STER/<albumartist>/…` のような `[layout]` に無い中間の階層が無くなる。 - **`_Unsorted/` 行きが 0 であること**(category は初回スキャンで直下のフォルダ名から付く。D-92)と、 - 「パスを生成できない」が 0 であることを確かめてから適用する - 3. 適用後、Derived は移動に追随し、m3u8 の書き出しは自動で書き直される。foobar2000 など外部の - プレイヤーのライブラリはパスが変わるので読み直しが要る -5. 正式運用後、数日間問題が出ないことを確認してから `ssd/musics` を破棄する。`AAC/`(48G)は - この破棄で一緒に消える diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..20cd3185 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,40 @@ +# spindle のドキュメント + +読む人ごとにディレクトリを分けてある。 + +| 読む人 | 場所 | 内容 | +|---|---|---| +| 使う人 | [user-guide/](user-guide/README.md) | 画面の操作手順。ログインから一括編集・巻き戻し・取り込み・端末への同期まで | +| 使う人 | [user-guide/dsl.md](user-guide/dsl.md) | スマートプレイリストの文法(リファレンス) | +| 使う人 | [agent/README.md](../agent/README.md) | Mac 用 `spindle-agent`(iPhone への同期) | +| 動かす人 | [admin-guide/install.md](admin-guide/install.md) | 導入。TrueNAS のカスタムアプリの作り方、初期パスワード、設定 | +| 動かす人 | [admin-guide/operations.md](admin-guide/operations.md) | 運用。イメージの更新、バックアップと復元、正規化 | +| 動かす人 | [admin-guide/migration.md](admin-guide/migration.md) | 既存の音楽ライブラリからの移行 | +| 作る人 | [spec/](spec/README.md) | 仕様。データモデル、パイプライン、API、UI、設定、デプロイ | +| 作る人 | [decisions/](decisions/README.md) | 設計判断とその理由(却下した案も含む)。D-1〜 | +| 作る人 | [../CONTRIBUTING.md](../CONTRIBUTING.md) | 開発環境、コマンド、変更の進め方 | +| 作る人 | [../AGENTS.md](../AGENTS.md) | 実装の不変条件・規約・禁止事項(AI エージェントもここから読む) | +| 作る人 | [development/](development/README.md) | 作業中の設計書・計画の置き場と、タスクの履歴 | + +## 参照の読み方 + +コードのコメントや文書の中の参照は、次の場所を指す。 + +- `SPEC §7.5`、`§12.3` → [spec/](spec/README.md) の目次で同じ番号の章・節 +- `D-62` → [decisions/D-062.md](decisions/D-062.md)(番号はゼロ詰め 3 桁) +- `P1-6` などのタスク番号 → [development/tasks-history.md](development/tasks-history.md) + +## 旧パスとの対応 + +2026-10 にドキュメントを再編した。古いコミットやマイグレーション SQL のコメントに残る旧パスは、次の場所に移っている。 + +| 旧パス | 新しい場所 | +|---|---| +| `docs/SPEC.md` | [spec/](spec/README.md)(§ 番号はそのまま) | +| `docs/DECISIONS.md` | [decisions/](decisions/README.md)(D 番号はそのまま) | +| `docs/TASKS.md` | [development/tasks-history.md](development/tasks-history.md) | +| `docs/USERGUIDE.md` | [user-guide/README.md](user-guide/README.md) | +| `docs/DSL.md` | [user-guide/dsl.md](user-guide/dsl.md) | +| `docs/OPERATIONS.md` | [admin-guide/operations.md](admin-guide/operations.md) | +| `docs/MIGRATION.md` | [admin-guide/migration.md](admin-guide/migration.md) | +| `docs/superpowers/specs/*`、`docs/superpowers/plans/*`(リポジトリに入れていなかった作業用の設計書。`db/migrations/0002_devices.sql` などが参照する) | 要点を [spec/](spec/README.md) と [decisions/](decisions/README.md) に移した。端末への配信の設計(`2026-09-29-device-delivery-design.md`)の ①〜⑥ は [§7.11](spec/07-pipeline/11-device-delivery.md) と [§6](spec/06-data-model.md)「端末への配信の表」、[§12.6](spec/12-ui/06-other-screens.md)「端末」、[D-95](decisions/D-095.md) 以降 | diff --git a/docs/SPEC.md b/docs/SPEC.md deleted file mode 100644 index 90e86c67..00000000 --- a/docs/SPEC.md +++ /dev/null @@ -1,3665 +0,0 @@ -# spindle — 設計仕様 - -> ステータス: ドラフト v0.2 / 主要な設計判断は確定済み -> -> CD を積むスピンドルから。バイナリ名・コマンド名も `spindle`。 - ---- - -## 1. 目的とスコープ - -TrueNAS 上で動作する単一コンテナのWebアプリケーション。CDリッピング、メディアライブラリ管理、 -メタデータ一括編集、ReplayGain、プレイリスト管理、簡易再生、端末への配信を提供する。 -端末への配信は、Android へは USB の ADB で直接、iPhone へは Mac の `spindle-agent` が -ミュージック.app へ反映する(D-95。§7.11)。 -Windows版 foobar2000 の実用機能を代替し、既存CLIツール `ytmusic` を統合する。 - -### 非目標 - -- 高度な再生機能(ギャップレス、DSP チェーン、クロスフェード、ASIO/WASAPI 相当) -- マルチユーザ、権限管理、公開インターネットへの露出 -- モバイルアプリ(ブラウザで足りる) -- 音楽配信サーバ(Subsonic API 互換等)— **非対応で確定。** -- DSD / SACD ISO の取り扱い - -### 規模想定 - -- 総容量 300GB、1〜6万トラック -- 単一ユーザ、LAN 内アクセスのみ -- 常時稼働(NAS) - ---- - -## 2. 用語 - -| 用語 | 定義 | -|---|---| -| Library | 唯一の正となるメディアツリー。1トラック=1ファイル | -| Derived | Library から生成される配布用の非可逆(`opus` 系統と Apple 向け `aac` 系統。§7.6)。破棄・再生成可能 | -| Archive | アプリが再生成できない生データ(YouTube webm 等)。追記のみ | -| 配布ビュー (delivery) | 可逆なら Derived、非可逆なら Library を指す解決規則 | -| Category | パス最上位階層。統制語彙。GENRE タグとは別概念 | -| master | そのトラックについて手元にある最高品質のファイル(= Library の実体) | -| 端末 (device) | 配信先。Android(ADB 直結)か iPhone(Mac のエージェント経由)の 1 台。系統(`opus` / `aac`)と対象(全曲 / プレイリスト)を持つ(§7.11、D-95) | -| マニフェスト(あるべき状態) | 端末に置くべき曲とプレイリストの一覧。保存せず毎回 Library から計算する。端末側の実態が正(D-95) | -| 写し(端末上の曲) | 端末(Mac の `~/Music/spindle` を含む)に置いた曲の複製。Library から再生成できるので、物理削除してよい(D-95) | -| 差分 | マニフェストと端末の実態の違い。曲ごとに add / update / move / update_move / delete に分け、確認してから送る(§7.11) | -| 計画 | 差分から作る、端末へ反映する操作の列。差分を見てからボタンで実行する(自動では送らない。D-95) | -| エージェント (spindle-agent) | Mac で動く常駐しない CLI。spindle から計画を受け取り、ミュージック.app へ反映して結果を報告する(§7.11、D-95) | - ---- - -## 3. 基本原則 - -1. **ファイルが正、DBはキャッシュ。** DB を消してもファイルから再構築できること。 - ただし以下は DB にしか存在しないため個別にバックアップする: - プレイリスト / 編集履歴 / 検証結果 / ジョブ履歴 / 端末の設定・印・計画・エージェントのトークン - (§6「端末への配信の表」。端末上の写しは Library から再生成できる複製で、これには含めない) -2. **パスは識別子ではない。** リネームは日常操作。同一性は inode と audio_md5 で解決する -3. **音声とタグを分けて版管理する。** タグ編集で再エンコードを起こさないため -4. **すべての破壊的操作はバッチ単位で巻き戻せる。** ZFS スナップショットは最後の砦であり、 - 一括編集の取り消しには粒度が粗すぎる -5. **外部からの変更を前提とする。** SMB 経由で foobar2000 や他プレイヤーが同じファイルを - 触る。アプリはファイルを排他ロックせず、再スキャンで調停する -6. **ジョブは冪等。** コンテナ再起動、電源断、途中キャンセルから安全に再開できること - ---- - -## 4. アーキテクチャ - -``` -┌─────────────────────────────────────────────────┐ -│ Browser (React SPA, rust-embed で同梱) │ -└───────────────┬─────────────────────────────────┘ - │ REST + SSE -┌───────────────▼─────────────────────────────────┐ -│ axum HTTP server │ -│ ├── API handlers │ -│ ├── streaming (Range / on-the-fly transcode) │ -│ └── SSE event bus │ -├─────────────────────────────────────────────────┤ -│ Job scheduler (tokio) │ -│ ├── queue: rip(並列1) / scan / rg / │ -│ │ transcode / tagwrite / verify / │ -│ │ normalize / thumbnail │ -│ └── recovery on startup │ -├─────────────────────────────────────────────────┤ -│ Domain │ -│ scanner / tagger / pathgen / rg / │ -│ cdrom / accuraterip / musicbrainz / parser │ -├─────────────────────────────────────────────────┤ -│ SQLite (WAL) 外部プロセス: │ -│ cd-paranoia / ffmpeg / │ -│ yt-dlp │ -└─────────────────────────────────────────────────┘ -``` - -### 技術スタック - -| 領域 | 選定 | 備考 | -|---|---|---| -| 言語 | Rust (edition 2021+) | タグ書き込み・EBU R128・デコードが揃う唯一の現実解 | -| HTTP | axum + tokio + tower-http | | -| DB | rusqlite (bundled) + FTS5 | 書き込みは単一コネクション、読み込みはプール | -| タグ | lofty | FLAC / Opus / MP4 / WAV を一貫 API | -| デコード | symphonia | FLAC/ALAC/AAC/WAV。**Opus のデコードは非対応 → ffmpeg 経由**(demux は Opus も symphonia で可。`audio_fp` はこれを使う) | -| ラウドネス | ebur128 | libebur128 の純 Rust 移植 | -| 正規表現 | fancy-regex | 後方参照・先読みが必要(ytmusic パーサ) | -| HTTP client | reqwest | MusicBrainz / CTDB | -| FFT | rustfft | 偽ハイレゾ検出(P3、任意) | -| ファイル操作 | rustix | `openat2(RESOLVE_BENEATH \| RESOLVE_NO_SYMLINKS)`、dirfd 基準の open / rename | -| フロント | React + TypeScript + TanStack Table/Virtual | | -| 外部バイナリ | cd-paranoia, cdrdao, ffmpeg, yt-dlp, flac | コンテナイメージに同梱 | - ---- - -## 5. ライブラリレイアウト - -``` -/mnt/ssd/media/ (実機のプールは ssd / hdd。D-45) -├── Library/ [dataset] snapshot: 毎日 + 編集前 -│ └── <Category>/<AlbumArtist>/<Album>/ -│ ├── 1-01 Title.flac -│ ├── cover.jpg -│ ├── disc.cue CD リップ時のみ(複数枚組は disc<N>.cue。D-67) -│ ├── disc.toc CD リップ時のみ(同 disc<N>.toc) -│ └── rip.log 自前リップ時のみ(同 rip<N>.log。先頭行 `spindle rip log v1`) -├── Derived/ [dataset] snapshot: なし -│ ├── opus/<Category>/<AlbumArtist>/<Album>/1-01 Title.opus Android・Web 再生・配布ビュー -│ └── aac/<Category>/<AlbumArtist>/<Album>/1-01 Title.m4a Apple 向け(P4-8。D-75) -├── Archive/ → /mnt/hdd/media/Archive [dataset, hdd] snapshot: 週次 -│ ├── youtube/<id>.webm YouTube の原本(id 名。DB に行は作らない。D-70) -│ └── (FLAC 正規化で退避した WAV / ALAC / AIFF もここ。GC まで保持) -├── Inbox/ [dataset] snapshot: なし -│ ├── (承認前の一時領域。ハイレゾ購入分などをここへ置く) -│ └── youtube/<AlbumArtist>/<Album>/ ytdl ジョブが置く(判定できないものは youtube/_unmatched/<channel>/。 -│ spindle-inbox.json を同梱。D-70) -└── Playlists/ - ├── m3u8/ 旧ライブラリから移した m3u8(取り込み元) - └── <profile>/<name>.m3u8 書き出し(internal / android / foobar。D-53) - -/mnt/ssd/apps/spindle/ [dataset] snapshot: 毎日 -├── spindle.db SQLite -├── backup/ VACUUM INTO による日次バックアップ -├── thumbs/ ハッシュアドレスのサムネイルキャッシュ -├── verify/<album_id>.log 遡及照合の verify.log(Library には置かない。D-63) -├── tmp/ リップ・変換の作業領域 -└── config.toml -``` - -### パステンプレート - -```toml -[layout] -multi_disc = "{category}/{albumartist}/{album}/{disc}-{track:02}. {title}" -single_disc = "{category}/{albumartist}/{album}/{track:02}. {title}" -unsorted = "_Unsorted/{albumartist}/{album}/{track:02}. {title}" -``` - -- 階層は Artist ではなく **AlbumArtist**。コンピレーションは `Various Artists` -- マルチディスクはサブフォルダを作らず `1-01` 前置き(アルバム = 1ディレクトリを維持) -- 発売年はパスに含めない。DATE / ORIGINALDATE タグは必ず保持する -- パス衝突時のみ自動降格: `{album}` → `{album} ({year})` → `{album} ({edition})`。最後の段で edition を - 持たないリリースは元の `{album}` のまま(通常版と Hi-Res 版のように片方だけが EDITION を持つ同名の組は、持つ - 側だけが分かれる。edition の無いリリースが 2 つ以上、または edition が同じなら conflict)。edition は - album の構成トラックの `EDITION` タグの最頻値(`albums.edition`。スキャナ・Inbox の配置が入れ、変化の無い - 既存の album は NULL のときだけ次のスキャンで埋める。D-43 追記) -- **衝突 = マージではない。** 配置前に MusicBrainz Release ID(無ければ album 行)で - 同一リリース判定を行い、異なる場合は必ず別ディレクトリにする。DiscID は 1 枚ごとの値なので - リリースの鍵にしない(D-67 追記 3) - -### ファイル名正規化 - -ytmusic の foo_fileops 互換置換テーブルを継承し、以下を追加: - -``` -~ → ~ * → * ∕ → / : → : > → > < → < ? → ? -Ø → O À → A ô → o è → e é → e ë → e ゔ → う -``` - -- タグ値は NFC 正規化のみ(原文字を保持)、ファイル名にのみ置換テーブルを適用 -- 表に無い禁止文字 `/` `\` `|` `"` も同じ流儀で全角化(`/` `\` `|` `"`)、制御文字は除去(D-43) -- SMB 制約: 末尾のドット・スペース禁止(削る)、予約名 (CON, PRN, AUX, NUL, COM1-9, LPT1-9) 回避 - (`_` を後置: `CON` → `CON_`)。空になった要素は `_` -- Android 側 exFAT 制約: パス長上限、`|` `"` 禁止 -- **各コンポーネント**は 255 バイト以下(ZFS / SMB の上限。パス全体の上限ではない)。 - パス全体は Windows / Android 互換のため **240 文字(UTF-16 単位)**を上限とし、 - 超える場合はタイトル部を省略記号付きで切り詰める -- 値が無いときのフォールバック: `albumartist` → `artist` → `Unknown Artist`、`album` → - `Unknown Album`、`title` → 現在のファイル名、`track` → 0、`disc` → 1(D-43) -- テンプレートの選択: category が無ければ `unsorted`、複数ディスク(`disc_count > 1` または - 構成トラックの `disc_no` の最大が 2 以上)なら `multi_disc`、それ以外は `single_disc` - -### パスの表現と境界 - -DB・API・テンプレート展開・プレイリスト出力で扱うパスはすべて **root(Library / Derived / -Archive / Inbox / Playlists のいずれか)からの相対パス**で、次を満たす文字列だけを受け付ける。 -満たさない入力は API では 400、スキャンでは「対象外」としてログに出す。 - -- 区切りは `/`。先頭 `/` なし、空コンポーネントなし、`.` / `..` コンポーネントなし、 - NUL なし、`\` を含まない -- 各コンポーネントは §「ファイル名正規化」の SMB / exFAT 制約を満たす -- 比較用に `casefold(NFD(path))` を **canonical key** として別列(`rel_path_key` / - `rel_dir_key`、Archive の `archived_files.rel_path_key` も同様)に持ち UNIQUE にする。 - ZFS は `insensitive` + `formD` なので、SQLite の BINARY 比較で別と見えるパスが同じ - ファイルを指し得る。衝突判定・リネームの一意性・2 段階更新の一時パスはすべて key 側で - 行う。表示は原文のまま -- この key は **spindle 側の保守的な同値規則**であり、OpenZFS の `u8_textprep` と同一とは - 言い切れない(ß、トルコ語の I、合字、Unicode バージョン差)。DB の key はあくまで - 事前判定で、**最終的な衝突判定はファイルシステムに任せる**: 作成は `O_EXCL`、rename は - `RENAME_NOREPLACE` を使い、失敗したら衝突として扱う。導入時に対象 TrueNAS 上で - 代表ケースの corpus を作成して両者の差を確認するテストを P0-5 に置く - -ファイルを開くときは**パス文字列を結合して open しない**。root の dirfd を起点に -`openat2(RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS)`(`rustix`)で開く。canonicalize -してから prefix を比較する方式は symlink の差し替え(TOCTOU)を防げない(D-31)。 -本番は Linux 5.6+ を前提とし、`openat2` が使えなければ**起動時に失敗**する(fail-fast)。 -非 Linux の開発機では実ファイルを触る結合テストを skip し、Linux の CI で走らせる。 -`canonicalize` + prefix 比較へのフォールバックは設けない。 - -- スキャナは symlink を**辿らない**(ディレクトリもファイルも)。見つけたら対象外として - 一覧に出す。hardlink(`nlink > 1`)は inode も md5 も同一性に使わず、`rel_path_key` だけで - 解決して警告バッジを出す(D-26) -- SMB / exFAT 制約に反する名前(禁止文字・末尾ドット/スペース・予約名)は**スキャナが - 対象外にする**。したがって移行前の preflight でもブロッカーにする(§14、P0-14) -- 書き込みの一時ファイルは**対象と同じディレクトリ**に `.spindle-tmp-<random>` で - `O_EXCL` 作成し、fsync → rename。取り残された `.spindle-tmp-*` はスキャンが回収する -- 外部コマンドは `sh -c` を使わず引数配列で起動する。パスは対応するツールでは `--` の後、 - 非対応なら `./` を前置して先頭 `-` を無害化する。可能ならファイルを開いて - stdin / stdout で渡す。タイムアウト・終了コード検査・stderr のログ出力・ - プロセスグループごとの kill を必須にする(規約) - -### Category - -- **パス決定に使う `category` と、ファイルに書く `GENRE` タグは別フィールド** - - GENRE は多値を取り得るがパスは単一値 - - MusicBrainz 由来のジャンル文字列は表記揺れが激しい - - ジャンルは後から変わる。パス直結だと数千ファイルが動く -- `categories` テーブルで統制語彙として管理、`genre_category_map` で自動推定 -- **Library 直下のディレクトリ名はスキャナが自動で語彙にする**(D-92)。音声を含む album ディレクトリ - (直下 + 1 段以上。`Anime/<albumartist>/<album>`)の親になっている直下の名前を、canonical key で語彙に - 無ければ登録し、その album の category にする。`_Unsorted`(`[layout].unsorted` の先頭)と、直下の - ディレクトリそのもの・root 直下に置かれた曲は対象外。category が NULL のまま残っている album(語彙が - 後から入った既存の DB)も次のスキャンで直下の名前から埋める(NULL だけ。人が付けた値は変えない。 - 部分索引 `idx_albums_category_null` で NULL の行だけを読む)。埋めた album のトラックは `library` イベントで - 知らせ(語彙だけが増えた run は `bulk`)、画面の category の一覧は `library` イベントのたびと語彙の追加・削除のたびに取り直す。自動登録は追加だけで、不要になった - 語彙は使われていなければ `DELETE /api/categories/:id`(設定画面)で消す。 - album の category はディレクトリ名 → GENRE → `genre_category_map` の順 -- 語彙にマッチしないものは `_Unsorted/` に配置し、取り込みを止めない -- ytmusic 由来のレーベル軸カテゴリ(神椿Studio 等)も同じ語彙表に混在させてよい - ---- - -## 6. データモデル - -`db/migrations/0001_init.sql` を参照。主要な設計判断のみ以下に記す。 - -### 同一性解決の優先順位 - -1. `(dev, inode)` — 外部の rename や in-place のタグ書き換えでは inode は変わらない。 - spindle 自身の tagwrite(tmp + rename、§7.5)は inode を変えるので、成功トランザクションで - DB を追随させる -2. `audio_md5` — FLAC は STREAMINFO に非圧縮音声の MD5 を持つ。タグ変更で不変。 - ALAC/WAV はデコードして算出。非可逆は算出しない(識別子としては使わない) -3. `rel_path_key` 一致 -4. いずれも当たらなければ新規トラック - -**どの識別子も「トラック実体の一意 ID」ではない**ので、各段で候補を検証する(D-30)。 -そのために走査は**多相**で行い、判定は inventory 全体が揃ってから下す(§7.1): - -| 段 | 候補の採用条件 | 外れる例 | -|---|---|---| -| inode | inventory 内でその `(dev, inode)` を持つパスが **1 つだけ**(2 つ以上なら hardlink とみなし、全候補で inode 段を無効化)、候補行が未 claim、`nlink = 1`、かつ `size` か `mtime_ns` のどちらかが一致。両方違えば `audio_md5` を計算して一致を要求 | inode 再利用(削除後に別ファイルが同じ inode を得る)、hardlink | -| inode(dev の付け替え) | 同じ `dev` の候補が無いとき、**別の `dev` に同じ `inode` を持つ行がちょうど 1 つ**あり、`size` / `mtime_ns` / `ctime_ns` が**すべて**一致。同じ実体とみなし「変更なし」で `dev` だけ現在値へ直す(D-62) | 属性が 1 つでも違う(inode 再利用と区別できない)、候補が複数 | -| audio_md5 | 候補が**ちょうど 1 行**で、その行の `rel_path_key` が **inventory 全体に存在しない**(= 移動元が消えている。走査途中の未訪問ではなく、全 stat が終わった後に判定する) | コピー元が残っている、ベスト盤との重複、無音トラック | -| rel_path_key | 候補が未 claim | — | - -- claim は `scan_runs.id` 単位(`tracks.seen_run_id`)。`seen_at` は時刻で、claim 判定には使わない - (epoch 秒では同秒の再実行と区別できない) -- 複数のパスが同じ候補行を取り合ったら `rel_path_key` の昇順で先のものが取り、後は次の段へ落ちる。 - 順序が固定なので並列に stat しても結果は決定的 -- audio_md5 の候補が複数、または元パスが inventory にまだ存在する場合は**移動ではなく新規トラック** - として登録する。自動マージはしない。同一 `audio_md5` の active 行が 2 つ以上あるものは - `duplicate_groups` ビューに現れ、UI がバッジと一覧で示す(D-29) -- `nlink > 1`、または inventory 内で同じ inode を複数パスが持つファイルは **inode 段と md5 段を飛ばし - `rel_path_key` だけで解決**し、警告バッジを出す。移行時は preflight のブロッカー(D-26) -- **復活**: どの段で採用されても、候補行に `missing_since` があれば NULL に戻す -- **missing の確定**: `scan_runs.state = 'completed'`(root を開けて walk がエラーなく完了)の finalize - でだけ、その run で claim されなかった active 行に `missing_since` を立てる。途中失敗・キャンセルの - run では立てない(SMB 一時切断で全曲が missing になる事故を防ぐ) - -### 変更検出と版の遷移 - -音声の同一性を見るフィンガープリントは形式で分ける: - -| 形式 | 音声フィンガープリント | 用途 | -|---|---|---| -| FLAC | STREAMINFO の MD5(`audio_md5`) | 同一性(移動検出)と音声版 | -| ALAC / WAV | デコードした PCM の MD5(`audio_md5`) | 同上 | -| 非可逆(opus / aac / mp3 / ogg) | エンコード済みパケット列の SHA-256(`audio_fp`。symphonia で demux のみ、デコードしない。Opus も同様) | **音声版のみ**。同一性には使わない | - -| 観測 | 判定 | 版 | -|---|---|---| -| `(dev, inode, size, mtime_ns, ctime_ns)` すべて一致 | 変更なし | `seen_at` / `seen_run_id` のみ | -| `dev` だけ違う(inode 段の「dev の付け替え」で採用) | 変更なし | `dev` を現在値へ直し、`seen_at` / `seen_run_id` | -| いずれか変化 → タグを読み `tag_hash` を再計算、音声フィンガープリントを再計算 | どちらも同じ | 版は動かさない | -| 〃 | `tag_hash` のみ変化 | `tag_version++` | -| 〃 | 音声フィンガープリントのみ変化 | `audio_version++` | -| 〃 | 両方変化 | 両方 `++` | -| spindle 自身の tagwrite / rename / RG 書き込み / MD5 補填(§7.9) | 音声は不変と**既知** | フィンガープリントを再計算せず `audio_version` 据え置き(tagwrite は `tag_version++` 済み) | - -非可逆のタグ書き換えはコンテナサイズを普通に変えるので、`size` の変化を音声の変化と -みなしてはならない(不変条件 3)。パケット列のハッシュは demux だけで済み、外部変更が -あったファイルにしか走らない。 - -`ctime_ns` を含めるのは、mtime を保存して書き戻すタグツールや `cp -p` を検出するため。 -ZFS のスナップショット rollback は ctime も戻すので検出できない。これは **deep scan** -(全ファイルの `tag_hash` と音声フィンガープリントを再計算。`[scan].deep_interval_days` 既定 30 日、 -UI から手動実行可)で吸収する。rollback を行ったら deep scan を手動で回すこと(運用手順)。 - -`tag_version` は「同じ値の書き直し」で進めない。tagwrite の再実行や外部ツールによる -同値の再保存で Derived の追随が空回りしないため。 - -### 出自の3属性(直交) - -| 属性 | 値 | -|---|---| -| `source_type` | cd_rip / download / youtube / unknown | -| `lossless` | コーデックから自動判定 | -| `verification` | verified_ar / verified_ctdb / mismatch / **unverifiable** / not_attempted | - -`unverifiable` はハイレゾ・配信音源など TOC が存在しない音源。 -「未検証」とは意味が異なるため UI でも別バッジとする。 - -### 版管理 - -- `audio_version`: 音声バイト列が変わったら ++ → Derived 再エンコードが必要 -- `tag_version`: タグのみ変更で ++ → Derived はタグ上書きのみ - -`derived_files` に生成時の両版番号を記録し、差分で必要な処理を判定する。 -数千件の一括タグ編集後に再エンコードが走らないための中核機構。 - -配布ビュー `delivery` は Derived の存在だけでなく**版の一致**を見る: - -| 状態 | 配布するもの | -|---|---| -| `src_audio_version = audio_version` かつ `src_tag_version = tag_version` | Derived | -| 音声版は一致、タグ版のみ不一致 | Derived(`stale_tags = 1`。タグ上書きジョブが追随する) | -| 音声版が不一致(再エンコード待ち) | **Library 原本へフォールバック** | - -音声が古い Derived を配ると、修復・差し替え前の音声を正常品として配ることになる。 -タグだけ古い場合に原本へ落とさないのは、容量の大きい可逆を配布してしまうため。 - -### 全文検索(FTS5) - -`tracks_fts` は external content 方式(`content='tracks'`)。索引対象の -`title / artist_display / album / albumartist` は**すべて `tracks` に実在する列**で -なければならない(列値の取得と `rebuild` が content 表を直接読むため)。 -このため `tracks.album` を `albums.album` の複製キャッシュとして持ち、トリガで同期する。 -FTS の更新トリガは索引対象列の `UPDATE OF` にだけ張る。`seen_at` 更新のたびに -削除・再挿入が走ると、スキャンの最速パスが FTS 書き込みで律速される。 - -### 論理削除 - -`missing_since` による論理削除。SMB 一時切断やスキャン中のマウント欠落で行を物理削除すると、 -プレイリストと編集履歴が巻き添えになる。既定 7 日経過後に GC(`gc` ジョブ。missing トラック・ -アルバムの行、`Archive/` の退避ファイル、Derived の孤児、アートワークの孤児、却下して「削除」した -Inbox の取り込みのファイル(D-90)を回収する。 -1 日 1 回自動、`POST /api/gc` で手動、`GET /api/gc/preview` が dry-run。D-56)。GC は保持期間を過ぎた -終端の**ジョブ行**も消す(done / cancelled は `[gc].jobs_done_days`、failed は `jobs_failed_days`。0 で -消さない。P4-18、D-81)。端末の同期の計画(`device_sync_plans`)も、終端(`completed` / `abandoned`)して -`[gc].retention_days` を過ぎたものを消す。端末ごとに最後の終端 1 件と open な計画は残す(D-102)。 -GC は `tracks` の**最大 id の行を消さない**(missing で期限を過ぎていても一覧・削除の両方から外す)。 -`tracks.id` は `AUTOINCREMENT` ではなく、SQLite は次の行に `max(rowid)+1` を振るので、最大 id の行を -消すと次の曲が同じ id を得て、端末の写し(`device_items`・Mac の state)が別の曲を指す。残した行は -後で新しい曲が入れば最大でなくなり、次の GC で消える(D-105)。 - -### ReplayGain の内部表現 - -- 内部は **RG 2.0 / -18 LUFS 基準の dB 値**で一元管理 -- 書き出し時にフォーマットごとへ変換: - - Opus: `R128_TRACK_GAIN` / `R128_ALBUM_GAIN`(Q7.8 固定小数、**-23 LUFS 基準**) - - FLAC / Ogg: `REPLAYGAIN_TRACK_GAIN` 等の Vorbis Comment - - MP4 (AAC/ALAC): RG 互換タグ -- Opus への変換式(`G18` = 内部の dB 値 = `-18 - 測定 LUFS`): - `R128 値 = round((G18 - 5.0) * 256)`、符号付き 16bit に飽和(-32768..32767)。 - 基準が 5 dB 低いので**必ず減算**する。テストベクトル: 測定 -18 LUFS → `G18 = 0` - → `R128 = -1280`。測定 -23 LUFS → `G18 = +5` → `R128 = 0`。 - `OpusHead` の output gain は 0 のまま触らない(合成すると再解析時に二重に掛かる) -- `rg_scanned_at` と `rg_written_at` を分離。数万件のスキャン後に書き込みが中断しても - 再スキャンなしで書き込みのみ再開できる -- **解析が済むと自動でタグに書く**(`[replaygain].write_tags = true` のとき。D-97)。書くべき行は - `tracks.rg_write_due` の印で持つ(rg の保存で確認が成り立たなくなった行と album gain の off で立ち、書き込みの - バッチを記録したときに下りる。巻き戻し・外部の変更では立たないので勝手に書き直さない。印が立った後に外部が - RG のキーを書き換えたら走査で下ろす)。Library 全体で 1 本の `rgwrite` ジョブが印の付いた active な行を album - ごとに 1 つの編集バッチにする(同じ album の rg が残っている album と反映待ちの編集がある album は後回しにし、 - キー付きの後継を 1 本だけ積む)。`write_tags = false` はタグに一切書かない(自動も手動も) -- `rg_written_at` は `rg_scanned_at` より小さくしない(`rg_scanned_at` は「前の値 + 1」へ進んで壁時計より先に - ありうる。同じ秒の確認を未書き込みに見せない。D-97) -- **タグ書き込みは通常の編集バッチ**(`POST /api/rg/write { selection }` → tags op。旧値の - 記録・overlay・巻き戻しはタグ編集と同じ)。書くキーは形式ごとに固定で、値の無いキーは消す - (Opus: `R128_TRACK_GAIN` / `R128_ALBUM_GAIN` を書き `REPLAYGAIN_*` を消す。他形式: - `REPLAYGAIN_TRACK_GAIN` `+0.00 dB` / `REPLAYGAIN_TRACK_PEAK` `0.000000`(線形、true peak)/ - `REPLAYGAIN_ALBUM_*`。album の値が無いトラックは album のキーを消す。D-48) -- **`rg_written_at` は「ファイルの RG タグが解析値と一致していると確認した時刻」**。書き込み - バッチの applied だけでなく、DB をファイルの現在値へ揃えるすべての経路(overlay の解消・ - 外部変更の追随・巻き戻し)で判定し直し、一致しなければ NULL に戻す。再解析で値が変わった - 行も NULL にする(秒単位の時刻では同じ秒の再解析を `<` で検出できない)。DB のタグが既に - 一致している行は op にせず `rg_written_at` だけ立てる。スキャナも外部のタグ変更を取り込んだ - 行(`tag_hash` が変わった行)で判定し直す(D-48) -- **外部で音声が差し替わった(`audio_version` が進んだ)行の解析値は捨てる**(`rg_*` と - `rg_scanned_at` / `rg_written_at` を NULL。スキャナと tagwrite の overlay 解消の両方。D-47)。 - 古い解析値を Derived や再生に使わない。album の他のトラックの `rg_album_*` は次の album 解析で揃う。 - 捨てた行は**同じトランザクションで rg ジョブを積んで解析し直す**(投入単位と dedup は `POST /api/rg` と - 同じ。タグへの書き込みは `[replaygain].write_tags` に従う。Derived は解析と書き込みが揃ってから作られる。 - D-47 追記 2、P4-22) -- **スキャンで新規に登録したトラックにも rg を積む**(album の照合の後に投入単位を決める。既存の未解析の行は - 拾わない。Inbox を通さずに置かれた曲も漏れなく解析 → 書き込み → Derived に乗せるため。D-97) -- album gain は `album_id` 単位で、**`albums.album_gain` が true の album だけ**計算・書き出しする(既定 - false。CD 取り込みは true、Inbox の承認画面で選ぶ。D-74、P4-5)。**2ch 以外は album 集計から除外**(判定はデコード結果の - チャンネル数。除外されたトラックの album の値は NULL)。構成トラックが 1 本でも - デコードできなければ album 全体を書かない(D-47) -- 無音(絶対ゲート以下、積分ラウドネス `-inf`)の gain は 0 dB - -### 端末への配信の表(§7.11) - -`db/migrations/0002_devices.sql`(P5-1、D-95)。端末に何があるかの正は**端末側**(Android は -`<root>/.spindle/manifest.json`・ジャーナル・実ファイル、iPhone は Mac のエージェントの -state とミュージック.app。spindle へは報告で届く)で、DB の曲とプレイリストの表はそのキャッシュ(不変条件 -1 を端末にも当てはめる)。 - -| 表 | 中身 | 正 / キャッシュ | -|---|---|---| -| `devices` | 端末 1 台 1 行。`uuid`(128 bit 乱数。端末側の manifest / Mac の `.spindle-device` との照合に使い、再利用しない)、`name` / `name_key`(`canonical_key`。一意)、`transport`(`adb` / `agent`)、`variant`(`opus` / `aac`)、`selection`(`all` / `playlists`)、`generation`(下記)、adb は `adb_serial`(一意)/ `adb_volume`(`emulated` か SD の UUID)/ `adb_root`(ボリューム内の相対パス。登録時は `Music/spindle`)、agent はトークン(`agent_selector` とシークレットの SHA-256)とワンタイムコード(`pair_selector`、argon2id の `pair_code_hash`、期限、試行回数。D-99)、`last_synced_at`(adb は同期の完了、agent は報告の受理) | DB にしか無い(端末の設定) | -| `device_playlists` | 端末 × プレイリストの印 | DB にしか無い | -| `device_items` | 端末に反映済みの曲: `track_id`、`dest_path`(root 相対)/ `dest_path_key`(端末内で一意)、配信トークン、`size`、`sha256`、`synced_at`。`track_id` は FK にしない(GC で `tracks` の行が消えても削除の差分を出すため) | キャッシュ | -| `device_playlist_state` | 反映済みのプレイリスト: `dest_path`(adb は `Playlists/<名前>.m3u8`、agent はプレイリスト名そのもの。D-99)とトークン。`playlist_id` は FK にしない | キャッシュ | -| `device_errors` | 最後の同期・報告で出た項目(`track` / `playlist`)ごとのエラーの理由。差分表の理由に出す | キャッシュ(同期の完了・エージェントの報告と破棄で全置換。差分の計算だけの `device_scan` は前回の分を残す) | -| `source_hashes` | 送る元(`master` / `opus` / `aac`)ごとの SHA-256 と、取ったときの意味トークン・物理同一性(inode・size・mtime_ns・ctime_ns。dev は持たない。D-62) | キャッシュ(`source_hash` ジョブで取り直せる) | -| `device_sync_plans` | 確定した計画。`plan` は不変の JSON(版 `v`・`generation`・`plan_token` と、曲の操作(`op_id`・種類・`track_id`・from・to・トークン・size・sha256)とプレイリストの操作(`op_id`・種類・`playlist_id`・from・to・トークン)の列)、`state`(`open` / `completed` / `abandoned`)、`job_id`(adb)、`report_digest`(agent の報告の再送の照合)。端末ごとに `open` は 1 つまで(部分 UNIQUE 索引) | DB にしか無い | - -- `devices.generation` は端末の選曲に効く設定(`selection` / `variant` / 印)を API で変えるたびに 1 進める - (名前の変更では進めない。印のついたプレイリストの削除でも、open な作業が無ければ進める。D-102)。計画とジョブと報告はそれを持ち、食い違えば実行・受理しない(§7.11「計画」) -- `device_items` / `device_playlist_state` は端末側の正本を読んだ時点で**全置換**する(adb は回復の後と同期の - 完了、agent は報告・破棄の受理)。部分的な更新はしない -- 端末の行と計画・キャッシュが消えるのは端末の削除(`ON DELETE CASCADE`。端末上のファイルには触らない)。 - 印はそれに加えてプレイリストの削除でも消える(`device_playlists.playlist_id` も CASCADE)。印のついた - プレイリストの削除は印の変更と同じ規則で、印のある端末に open な作業(open な計画か待ち・実行中の - `device_sync`)があれば 409 `open_plan`、通れば印のある端末の `generation` を進める(同じトランザクション。 - D-102)。`source_hashes` は `tracks` の行の削除で消える。GC は終端した計画のうち、閉じてから - `[gc].retention_days` を過ぎたものを消す。端末ごとに最後の終端 1 件(報告の再送を 200 で受ける保険)と - open な計画は残す(D-102) -- 同じマイグレーションで `playlists.evaluated_at`(スマートプレイリストを評価した時刻。差分画面に出す)を - 足し、`jobs.type` に `source_hash` / `device_scan` / `device_sync` / `device_verify` を足した - (`playlist_sync` も同時)。0003 の `inbox_item_tracks` は Inbox の件で配置した曲で、件の「配置 → RG → - 系統 → 端末」の段に使う(D-95 の P5-2 追記) -- **キャッシュ表だけを失い、`devices` の行と認証情報が残った場合**は、曲とプレイリストのキャッシュは次の回復・ - 報告で戻る。**DB 全体を失うと** `devices` の行・`devices.uuid`・エージェントのトークンも失われ、端末側の - manifest / `.spindle-device` と照合できず、回復・報告を受ける行も無い。**Android** は端末タブの「前の登録の - 保存先(引き継げる)」で、manifest の uuid の行を作り、曲の `rel_path` で `track_id` を付け替える - (§7.11「引き継ぎ」、D-103)。 - **Mac** は端末タブで新しく作った行の pair コードで `spindle-agent pair --adopt` を実行し、行の uuid を - エージェントの state の uuid へ差し替えてから、state の曲の `rel_path` で `track_id` を付け替える - (§7.11「iPhone」の「引き継ぎ」、D-104)。ミュージック.app の track(再生回数・レート・追加日)はそのまま残る。 - Android でバックアップを復元して行の uuid が - 端末の manifest と食い違うときは、端末を削除(端末上のファイルには触らない)してから引き継ぐ - ---- - -## 7. パイプライン - -### 7.1 スキャン - -走査は 4 相。同一性の判定は inventory 全体が揃ってから行う(走査途中では「移動元が消えた」 -を判定できない)。DB への書き込みは最終相で単一 writer トランザクションにまとめる。 - -``` -scan_runs に行を作る(state='running') -Phase 1 inventory: walk(Library) を並列に stat(symlink は辿らず一覧へ)。 - (rel_path_key, dev, inode, nlink, size, mtime_ns, ctime_ns) をメモリに固定 -Phase 2 candidates: inventory の各エントリと既存行の候補を、inode → audio_md5 → rel_path_key の - 順で列挙(§6 の採用条件。md5 段の「移動元が消えている」は inventory 全体で判定)。 - 解決が要求しうる audio_md5(inode 一致で size も mtime も違う行が md5 を持つ / - 移動候補があるときの未決エントリ)だけを先に並列で計算し、進捗を出す。初回や - 移動の無い増分では要求が無く、可逆のデコードは Phase 3 に回る(D-50) -Phase 3 claim: rel_path_key 昇順で決定的に採用。取り合いに負けたエントリは次の段へ。 - 変更ありのエントリはここでタグ読込・フィンガープリント計算(並列) -Phase 4 commit: 1 トランザクションで - a. 採用行の rel_path / rel_path_key を「予約済み一時 key」へ全件移す - b. 最終値へ全件移す(swap rename A↔B や循環でも UNIQUE を踏まない) - c. 物理属性・タグ・版・seen_run_id・復活(missing_since=NULL)を反映 - d. 新規行の挿入 - e. album 照合(下記)。album の rel_dir も同じ 2 段階で更新 - f. state='completed' なら、この run で claim されなかった active 行に - missing_since を立てる。failed / cancelled では立てない - FTS はトリガで追随 -``` - -**アルバムの照合**(ディレクトリ rename で album id を失わないため。D-32): - -``` -ディレクトリ D に今回見つかったトラック集合 T(D) について - 1. T(D) の行が直前まで属していた album を数える - 2. mb_release_id が一致する既存 album が**ちょうど 1 つ**で、その旧 rel_dir が - inventory に無ければそれ(複数一致なら自動では寄せない → 3 へ) - 3. なければ、T(D) の過半数が属していた album A で、A の旧 rel_dir が inventory に無いもの - → A.rel_dir を D に書き換える(id 維持。verifications / artwork が残る) - 4. どれにも当たらなければ新規 album -分割(一部だけ別ディレクトリへ): 移った側は新規 album、残った側は id 維持 -統合(2 ディレクトリが 1 つに): 最多の album が id を維持、他は構成 0 になり - albums.missing_since を立てる(行は消さない。GC が回収) -``` - -- 初回フルスキャンは並列度 = CPU コア数。以降は最速パス(§6)で大半を飛ばす -- 外部(foobar2000 等)による書き換えを検出したら DB を上書きする。 - **ファイルが常に勝つ**(原則1) -- **例外: ファイル反映待ちのトラック(`edit_ops.result = 'pending'` がある)は、 - pending の op が所有する論理フィールドを再評価しない。** 一括編集は DB を先に更新し、 - ファイルへの反映は非同期なので(§7.5)、この窓で「ファイルが勝つ」を適用すると - 編集が DB から巻き戻され、後続の tagwrite と食い違う。 - ただし**物理的な所在は pending 中も常に追随する**: `(dev, inode)` で見つけた - ファイルの `rel_path` / `size` / `mtime_ns` / `ctime_ns` は更新する。外部 rename は inode も - mtime も変えないので、これを止めると tagwrite が旧パスを開けなくなる。 - 抑止するのは `tags` op ならタグとキャッシュ列・`tag_version`、`rename` op なら - `rel_path`(外部 rename と衝突したら op を `skipped_conflict` にし、同じトランザクションで - `rel_path` を実在パスへ追随させる。D-43)だけ。 - 外部 rename の**宛先が別の行(missing 行を含む)に占有されていた**場合は、Phase 4 で - その行の missing 確定と key の入れ替えを同じトランザクションで行う -- 走査対象は Library のみ。Derived / Archive はスキャンしない - -**アートワーク**(Phase 5、commit の後。D-49): - -- album のアートワークは、ディレクトリの**同梱カバー画像**(`cover` / `folder` / `front` × - `jpg` / `jpeg` / `png` / `webp`。名前 → 拡張子の優先順、大文字小文字は区別しない)があれば - それ、無ければ**構成トラックを `disc_no` / `track_no` / `rel_path` 順に見て最初に見つかる埋め込み - 画像**(front cover を優先)。形式はバイト列のヘッダで判別し、拡張子やタグの MIME は信用しない -- 画像は SHA-256 でハッシュアドレスし(`artwork` 表。行は消さない)、原画像を**元の形式のまま** - `<data>/thumbs/<hex>/orig.<ext>` に置く。サムネイル(`<size>.webp`、256 / 768。長辺、拡大なし)は - `thumbnail` ジョブが ffmpeg で作る。Library には何も書かない -- Phase 4 は行が変わったトラックの現在の album と直前まで属していた album の - `albums.artwork_resolved_at` を NULL にして再解決を**予約**する(同じトランザクション)。 - Phase 5 が解決し直すのは deep なら全 album、それ以外は「予約された(NULL)」「同梱画像の有無・ - stat(inode / size / mtime / ctime。`albums.cover_*`)が前回と違う」「参照中の原画像がキャッシュに - 無い」album。missing の album は触らない。決められない album(I/O 失敗、読んでいる間に同梱画像が - 変わった、構成トラックを読めない)は状態を動かさず次回やり直す。同梱画像が画像として認識できない - ときは埋め込みへ倒し、stat は記録する(変わるまで読み直さない) -- Phase 4 の commit 後なので、Phase 5 の cancel / 失敗は run の状態(completed)と missing の確定を - 戻さない。予約が残るので次のスキャンで続きを行う -- **トラック自身の画像**(D-61): Phase 3 が各トラックの埋め込み画像(front cover 優先)を読んで - キャッシュへ置き、Phase 4 が `tracks.artwork_id` に記録する(無ければ NULL)。tagwrite の書き戻しも - 同じ。変更なしの行は読まないので、既存行は deep scan で埋まる - -### 7.2 CD 取り込み - -``` -[ディスク検出] CDROM_DRIVE_STATUS ioctl を 2 秒間隔ポーリング(`cd/device.rs`) - ↓ (udev がコンテナに届かないため)。デバイスを開けなければ no_drive として起動は続ける -[TOC 取得] CDROMREADTOCHDR / CDROMREADTOCENTRY ioctl(LBA 形式。中身は READ TOC コマンドで、 - ↓ /dev/sg* も外部プロセスも要らない)。DiscOk になってから読めるまで毎周回試し、 - ↓ 読めたらディスクが抜かれるまで読み直さない。`GET /api/cd/status` が CTDB 形式の - ↓ 文字列で返し、UI は新しいディスクの TOC が出たら照会を自動で始める - ↓ -[ID 算出] MusicBrainz DiscID / AccurateRip id1,id2 / FreeDB ID - ↓ すべて TOC からの整数演算。libdiscid FFI 不要 -[メタデータ照会] MusicBrainz → 候補提示 → ユーザ確認・手動補正 - ↓ 候補は「リリース × medium」(DiscID を持つ medium は exact、トラック数の合う medium は - ↓ 近似。D-64)。**経路は 3 段で、上の段で候補が残れば下は引かない**(D-64 追記 4): - ↓ - ↓ 段 引くもの 次の段へ進む条件 - ↓ discid ws/2/discid/<id> 404、または 200 でも候補 0 件 - ↓ ids ISRC(recording 検索)+ MCN = バーコード 候補 0 件 - ↓ (release 検索) - ↓ toc ws/2/discid/<id>?toc=(TOC 近似) — - ↓ - ↓ 「候補」はトラック数で絞ったあとの数で数える。DiscID が 200 でも候補 0 件なら次の段へ - ↓ 落とすが、exact は真のままにする(DiscID は登録済みなので登録を勧めない)。ユーザが - ↓ 貼ったリリース URL / MBID は段に関係なく常に足し、toc へ進むかの判定では候補に数える - ↓ (ids は引く)。ユーザが盤の帯・背から読んで入れた品番(`ws/2/release?query=catno:"…"`)も - ↓ 段に関係なく常に引き、カタログ番号が正規化して一致するリリースだけを経路 catno の候補にして - ↓ 先頭に並べる(DiscID は収録が同じ版を区別できないため。D-94)。手入力の JAN は盤の MCN が - ↓ 無いときだけ ids の段のバーコード検索に使う。 - ↓ 同じ medium は 1 件に束ねて経路(matched_by)を付ける(D-64 追記)。 - ↓ TOC 近似はトラック長の近い別の盤を大量に返すので最後の手段。応答の stage / can_widen で - ↓ 画面に段を見せ、「さらに広げて探す」(widen)で明示的に下の段まで引ける。候補を選んだら DiscID の登録リンク - ↓ ※同人・VTuber・インディーズ国内盤は MusicBrainz 未登録が常態。 - ↓ 照会結果ゼロでもウィザードが完走できることを必須要件とする。 - ↓ 候補を写す先は 1 つのフォーム(D-65。写す範囲は既定で「全部写す」、「識別用の - ↓ 最小限」を選べる。D-72 追記 2、P4-2 / P4-20)。**CD 画面では直せない**(D-67 追記。 - ↓ 補正とトラックリスト貼り付けは Inbox の承認画面)。例外は手入力の品番と JAN(D-94)で、 - ↓ 写す範囲に関わらず下書きに重ねる。品番が選んだ候補と食い違えば(手元の版が MusicBrainz に - ↓ 無い)品番は入力値にし、候補の JAN は写さない。MB の id は近い版を指す値として残す。これが - ↓ DiscMetadata(album / album_artist / date / label / catalog_number / barcode / - ↓ disc_no / disc_count / tracks[{ number, title, artist, mb }]、source)になる。 - ↓ トラックリスト貼り付け(通販ページ等からのテキストを行解析して - ↓ トラック番号・タイトル・アーティストへ割り付け。web/src/lib/tracklist.ts)は - ↓ **Inbox の承認画面の**一級の入力経路とする(D-67 追記。CD 画面からは外した)。 - ↓ 行は TOC の音声トラックと 1:1 で、番号と長さは TOC から - ↓ -[オフセット決定] INQUIRY でドライブ型番取得 → 学習済みの値(drive_offsets)→ AccurateRip の - ↓ ドライブ表(DriveOffsets.bin を実行時に取得・保存)→ 0(D-83 / 追記 2) - ↓ `[rip].drive_offset` の整数で上書き可。照合で見つかったずれは PCM に当てて学習する -[吸い出し] cd-paranoia -e -r -d <dev> -- <first>-<last>[mm:ss.ff] で音声トラック全体を 1 本の PCM に - ↓ (オフセット 0 で読み、範囲は TOC の長さで明示。`cd::rip::read_disc`。D-83) - ↓ ※トラック単位で吸うとオフセット補正が境界をまたげない -[オフセット適用 → トラック分割] - ↓ -[CRC 計算] ARv1 / ARv2 / CTDB CRC32 - ↓ 先頭トラック冒頭・末尾トラック終端の除外規則を厳守 -[照合] CTDB を主、AccurateRip を補助 - ↓ -[エンコード] FLAC (master) を **Inbox へ**(D-67 追記。Library へ直行しない)。 - ↓ tmp の PCM は検証完了まで保持 -[ログ出力] rip.log / disc.cue / disc.toc を取り込みのディレクトリへ。MusicBrainz から写した内容と - ↓ RipReport(照合結果)は サイドカー spindle-inbox.json に書く(D-70 の仕組み) -[承認] Inbox の承認画面で名前と category を直して「承認して配置」 - ↓ → 配置が Library へ運び、source_type = cd_rip と検証記録を入れる -[後続ジョブ投入] rg(album 単位。配置した album は album gain on。D-74)→ transcode(Derived) → thumbnail -``` - -Inbox への配置(`src/cd/place.rs` の `place_disc`、D-67 追記、P2-5): rip ジョブの最終段。 -入力は `Toc`、吸い出しを始めたときの `DiscMetadata`(**名前は空でもよい**。`validate` は TOC との -行の対応・日付・ディスク番号・category だけを見る。必須の検証は Inbox の承認が担う)、オフセット -適用済みの tmp の PCM、`RipReport`。PCM を `TrackLayout` で切り、raw のまま `flac -N --verify` で -エンコード(トラックごとの PCM の MD5 と STREAMINFO の MD5 が一致するときだけ成果物)、タグは -下書きの写像(D-65)+ `TRACKTOTAL` / `MUSICBRAINZ_DISCID`。空のタイトルは `Track NN`、空の名前は -タグに書かない。`category` はタグに書かず(パス専用。SPEC §5)サイドカーで渡す。 - -- **組み立ててから公開する。** Inbox 直下の隠しディレクトリ `.spindle-rip-<DiscID>` に `NN.flac` - (TOC の番号。名前での対応付けの鍵なので承認で変わる値を入れない)、同梱ファイル、サイドカー - (`category` と `rip`。§7.8)を置き、`CD/<albumartist - album> [<DiscID>]`(名前が空なら - `CD/[<DiscID>]`。DiscID は `.` で始まり得るので括弧で包む)へディレクトリごと `RENAME_NOREPLACE`。 - 走査は `.` で始まるディレクトリを見ないので、揃う前の盤が取り込みとして見えて承認されることはない。 - 公開したら `inbox` ジョブを投入する。複数枚組は DiscID が違うのでディスクごとに別の取り込みになる -- **冪等性。** 公開先が既にあり、全トラックの STREAMINFO の MD5 が自分の PCM と一致し、サイドカーの - 記録が同じファイル名を持てば自分の成果物(公開の後に落ちた再実行)として組み立てを飛ばす。違えば - 衝突。前の実行の組み立ての残骸は消して作り直し、公開が衝突したら組み立てたものを消す -- `albums` / `tracks` / 検証記録の登録、パスの計画(`[layout]`)、`library` の排他、`rg` / `transcode` の - 投入は承認後の Inbox の配置(§7.8)が行う - -同梱ファイルは 1 枚なら `disc.cue` / -`disc.toc` / `rip.log`、複数枚組は `disc<N>.cue` / `disc<N>.toc` / `rip<N>.log`。`disc.cue` は -EAC 流の複数ファイル cue(ギャップは前トラック末尾。INDEX 00 は書かない)、`disc.toc` は `Toc` から -cdrdao 構文で生成、`rip.log` は先頭行 `spindle rip log v1` の自前形式でドライブ・オフセット・ -トラックごとの CRC と照合結果を持つ。トラック表の「ずれ」は、cd-paranoia が読み取り位置のずれ(ドライブの -ジッター)を検出・補正した回数(drift / dropped / duped。`TrackRead.slips`。補正の通知なので回数だけでは誤りを -意味しないが、多発と照合の不一致が併発した)。1 回でもあれば表の下に合計と -意味を、試行ごとの合計(`RipReport.attempt_slips`)を「試行ごとのずれ」に出す。照合が通らずずれがある取り込みは、 -Inbox の承認画面の警告にも出る(P2-5 の調査。TASKS)。スキャナはこの rip.log のあるディレクトリで新規に登録する行を -`cd_rip` にする(DB を消しても出自が戻る。検証は §7.3 で付け直す)。 - -同梱ファイル(cover / disc.cue / disc.toc / rip.log)は album 全体の一括リネームに追随する: rename -ジョブが phase 2 の後、commit トランザクションの中で既知の名前のファイルを新ディレクトリへ移し、空になった -旧ディレクトリを消す(D-67。`edits` には記録せず、巻き戻しは逆向きの移動で戻る)。 - -不一致時の既定動作: 自動再リップ(最大2回)→ CTDB 修復データ適用 → -それでも不一致なら `mismatch` フラグ付きで取り込み、UI で要確認表示。 - -CTDB の修復(`src/cd/repair.rs`、D-66): ディスクの 16 bit 語を 11760 語ずつの行に並べ、列ごとに -GF(2^16) の Reed-Solomon 符号語とみなす(データ行は先頭 1 行と末尾 1 行 + 端数を除く。CTDB の -ディスク CRC の範囲と同じ)。1 回目の走査でシンドローム表(`SyndromeSampler` → `SyndromeTable`。 -80 分で 3〜5 秒)を作り、エントリの `syndrome`(列 0)でオフセットを探す(`find_offset`、±2939)。 -誤りがあれば `hasparity` のパリティファイル(各列のシンドローム。`CtdbClient::fetch_syndromes` が -Range で先頭 npar 面だけ取る)と突き合わせ、列ごとに Berlekamp-Massey → Chien → Forney で位置と値を -出す(列あたり npar/2 個まで。1 セクタ丸ごとの傷は 1176 列に 1 個ずつなので直る)。直した後の -ディスク CRC がエントリの値に一致するときだけ計画(`RepairPlan`)を採用し、2 回目の走査で -`RepairApplier` が語を XOR する。直せなければ再リップ / `mismatch` へ。吸い出しジョブへの配線は -P2-5 で行う - -**ドライブは物理的に1台なので rip キューの並列度は 1 に固定する。** - -### 7.3 遡及照合(ログなし既存 FLAC) - -CUETools の "verify from files" 相当。 - -``` -アルバムをディスク(disc_no)ごとに分け、全トラックが揃っているか確認 - → 各トラックのサンプル数(STREAMINFO)から TOC 再構成 - offset = 150 + Σ(前トラックのセクタ数) - → AccurateRip DiscID / MusicBrainz DiscID を算出 - → デコードして CRC 表を作る(1 回流すだけで ±2939 サンプルの全オフセットの CRC が出る) - → CTDB / AccurateRip 照会 → オフセットを探して照合 - → verify.log 出力(data/verify/<album_id>.log。rip.log とは別物) -``` - -照合ログは album ごとに最新 1 回分しか残らない。記録画面のログタブ(§12.4)が `GET /api/logs` で実行ごとに -並べ、本文を `GET /api/logs/{key}/text` で読む(古い実行は「上書きされた」と出す。D-106)。 - -`verify` ジョブ(album 単位、並列 2。`POST /api/verify { selection }`)。読むだけでファイルは -書かない。ファイルは root の FD を fstat して DB の行と照合してから読む(D-62)。 -照会の 404 は「登録なし」(0 件)として扱い、失敗にしない(AccurateRip は `dBAR-*.bin` が無い、 -CTDB は `lookup2.php` が登録の無い TOC に本文 `Not Found` で返す)。それ以外の非 2xx は照会失敗で再試行。 - -適用条件: - -- **44.1kHz / 16bit / 2ch の FLAC のみ。** それ以外を含むディスクは `unverifiable` - (TOC が存在しない音源。品質の劣後ではない) -- 各トラックのサンプル数が **588 の倍数**(1セクタ)であること。 - 端数があれば CD 由来でないか加工済みと判定して `unverifiable` -- トラック番号が 1 から連続していること(不完全なディスクは TOC を作れないので何もしない) -- **オフセットは探す**(D-63)。DB に登録された値は他人のドライブで吸ったもので、読み取り - オフセットの補正が違えば同じ盤でも数十サンプルずれる。CUETools と同じ ±(5×588−1) の範囲で - 「一致したトラック数 → 信頼度の和 → 0 に近い」順に 1 つ選び、`detected_offset` に残す - (ディスクで 1 つ。トラックごとに別のオフセットは採らない)。AccurateRip v2 はオフセットに - 対して線形でないので 0 だけ、それ以外は v1 で比べる -- CTDB は `fuzzy=1` で別リリースも返るので、音声部分の長さと音声トラック数が同じエントリだけを - 候補にする。AccurateRip の ID は CUETools / dBpoweramp 式(Enhanced CD でも実リードアウト) -- トラックごとに: CTDB 一致 → `verified_ctdb`、AccurateRip だけ一致 → `verified_ar`、 - 候補はあるが不一致 → `mismatch`、どちらにも候補なし → 据え置き(`not_attempted`)。 - 結果は `album_verifications`(手法 × ディスク。履歴として積む)と `track_verifications` - (自分の CRC と一致の有無)に残す -- 照会に失敗したらジョブを失敗させて再試行し、何も記録しない(不一致を「照会できなかった」で - 汚さない)。記録は 1 トランザクション(ディスク × 手法の行、トラックの行、`tracks.verification`)で、 - 照合を始めたときの `audio_version` が 1 本でも進んでいれば何も書かない。verify.log は tmp に書き、 - トランザクションの中で本来の名前に rename してから commit する。`album_verifications.job_id` で - 同じジョブの再実行(commit の後に落ちた場合)を見分け、何もしない(履歴もログも初回のまま) -- ジョブの結果 1 行(`jobs.note`)にディスクごと・手法ごとの結果(一致数と信頼度 / 登録なし / - 検証不能・照合しなかった理由)を残し、操作タブが投入したジョブを追って出す(D-96)。結果 1 行は記録と - 同じトランザクションで `jobs.note` に書き、commit の後に落ちた再実行はそれをそのまま返す -- **不一致は不良を意味しない。** ギャップ処理差、隠しトラック、データトラックの存在で - 普通に外れる。`mismatch` は「要確認」として扱い、警告色で表示しない - -### 7.4 ロスレス正規化(WAV / ALAC / AIFF → FLAC) - -Library のロスレスは FLAC に統一する(D-45)。移行で取り込んだ ALAC も、取り込まれた WAV / -AIFF も、同じ機構で FLAC にする。 - -``` -POST /api/normalize/preview → /apply(selection。rename と同型。D-46) - → edit_batches + edit_ops(kind='archive', pending) + edits(rel_path 旧→新, codec 旧→'flac') - を記録し、track 単位の normalize ジョブを投入。DB は先行更新しない -normalize ジョブ(track をロック) - → 事前条件(stat + tag_hash)を確認。外れていれば skipped_conflict - → symphonia でデコードして PCM MD5 算出 - → ffmpeg でデコード → flac -8 --verify でエンコード(data/tmp)。タグと画像は lofty で移す - → 生成 FLAC の STREAMINFO MD5 と突き合わせ - ├ 一致 → Library の同じディレクトリに tmp → fsync → RENAME_NOREPLACE で <name>.flac を置く - │ → 元ファイルを同じディレクトリの一時名 spindle-normalize-<op_id>.<ext> へ退避 - │ (inode を確認。外部の tmp + rename と分離する) - │ → Archive/ の同じ相対パスへ実コピー(SHA-256 を照合し、edits に記録) - │ → unlink の直前に同じ FD の stat とバイト列を再照合 → 一時名を unlink - │ → 1 トランザクションで op を applied、rel_path / 物理属性 / codec を追随、 - │ archived_files に台帳(eligible_after = now + retention)を追加、 - │ original_codec('wav' | 'alac' | 'aiff')、normalized_at 記録。 - │ audio_md5 は同じ PCM なので変わらず、audio_version も上げない(Derived は据え置き)。 - │ tag_version は写したタグの tag_hash が元と違うときだけ進める - └ 不一致 → op は failed、元ファイルを残す(生成した FLAC は捨てる) -``` - -宛先は拡張子を `.flac` に置き換えた同じパス。宛先を別のトラックが占有していれば計画の時点で -conflict。反映の直前に宛先へ別のファイルが現れていれば `skipped_conflict`(音声 MD5 が期待値と -一致するときだけ自分の成果物とみなして続きを行う。クラッシュ後の再投入も同じ判定)。 -スキャナは pending の archive op の元・一時名・宛先のパスを「作業中」として扱い、新規登録も -missing 判定もしない。unlink の前に元ファイルが外部で更新・差し替えされていれば conflict にして -元パスへ戻す(自分が置いた宛先と Archive のコピーは消す)。cancel は Library を触る前まで -(変換中の子プロセスは止める)。 - -WAV は RIFF INFO / ID3 のどちらを使うかがソフトごとに異なり、ReplayGain タグの -互換性も低い。ALAC(MP4 ilst)は複数値タグが弱く、Safari 以外のブラウザで再生できない。 -可逆変換なので情報は失われない。読み込み互換のため WAV / ALAC / AIFF の再生・ -取り込み自体は引き続きサポートする。 - -**元ファイルは即時削除しない。** 物理削除はユーザデータ全般と同じく GC ジョブのみが行う -(禁止事項)。退避した元ファイルは `archived_files`(state='held')を台帳として GC が -`eligible_after` 経過後に回収し state='deleted' にする。それまでは履歴の巻き戻しで -Library へ戻せる: 元ファイルは Archive から Library へ**コピー**で戻し(Archive の実体は残る。 -state='restored')、Library にあった FLAC は Archive へ move して台帳に `reason='restore'` の行を -足す(GC の対象)。やり直し(逆バッチの revert)はその逆で、同じパスの台帳行は作り直さず `held` に -戻す。復元元が GC 済みの op は `skipped_conflict`(D-46)。 -編集履歴は revert / redo で状態が動くので GC の台帳には使わない。 -Archive の「追記のみ」の例外はこの GC だけ。 - -PCM MD5 の一致が保証するのは**音声サンプルの同一性だけ**で、WAV の RIFF INFO / -ID3 / 未知チャンク / コンテナのバイト列は FLAC から再生成できない。保持期間後に -コンテナを不可逆に捨てるのは意図した決定(D-10)。 - -### 7.5 タグ書き込み(編集バッチ) - -``` -編集バッチ確定 - → プレビュー(dry-run)。サーバは selection のスナップショット(対象 track_id と各行の - tag_version / 事前条件)を作り selection_token を返す(§9、D-33) - → 適用は selection_token の集合だけを対象にする。preview 後にスキャンで増えた行は含まれない - → 対象トラックに pending の op があれば 409 で拒否(件数と track_id を返す)。 - `skip_pending: true` なら該当トラックを除外して続行(UI の「除外して適用」) - → 1 トランザクションで: - edit_batches(state='prepared') - edit_ops(result='pending') … トラック 1 本につき 1 op。事前条件 - (expected_dev / inode / size / mtime_ns / ctime_ns / - tag_hash / rel_path) を記録 - edits … op 配下にフィールドごとの旧値・新値(JSON) - → DB(track_tags / キャッシュ列)を新値へ更新、tag_version をトラックごとに 1 回 ++ - → track 単位の tagwrite ジョブを投入(edit_batch_id で紐づけ) - → 各 tagwrite ジョブ(並列 4): バッチを state='applying' に - rel_path で open → その FD の fstat で inode/size/mtime_ns/ctime_ns を確認 - (dev は照合しない。D-62)、同じ FD からタグを読んで tag_hash を確認 - ├ いずれか不一致 → result='skipped_conflict'。ファイルは触らない。 - │ 同一トランザクションでその FD の内容から DB を戻す(下記「overlay の解消」) - ├ rel_path のみ不一致(外部 rename。スキャナが inode で新パスを追随済み) - │ → 新パスの rel_path_key が他の行に占有されていなければ tags op は追随して続行。 - │ 占有されていれば skipped_conflict。rename op は常に skipped_conflict。 - │ rename は ctime を進めるので、このとき ctime_ns だけの不一致は許容する(D-41) - └ 一致 → その FD の親ディレクトリに tmp を O_EXCL で作成 → 内容をコピー → - op 配下の全フィールドを lofty で書き換え → fsync → rename → - 同一トランザクションで (dev, inode, size, mtime_ns, ctime_ns, tag_hash) 更新、 - op を result='applied' - → 全 op が終端になったらバッチを集計: applied / partial / failed / cancelled - → Derived のタグ上書きジョブを投入(tag_version の差分で判定) -``` - -- **ファイル反映の単位は op(トラック)。** フィールドごとに tmp+rename すると - 1 件目で inode が変わり 2 件目以降の事前条件が必ず外れる。同一トラックの全差分を - 1 回の書き込みで反映し、`tag_version` はトラックごと・バッチごとに 1 回だけ進める。 - 結果(`result`)は op にだけ持ち、**フィールド単位の部分適用はしない** - (1 フィールドでも事前条件に合わなければ op 全体が conflict) -- **形式ごとの写像は読み書きで 1 つ。** FLAC / Opus / Ogg は Vorbis Comment に任意キー・多値を - そのまま書く。MP4(ALAC / AAC)は Vorbis 名を lofty の `ItemKey` に写像して ilst の標準 atom - (`©nam` `trkn` `disk` `----:com.apple.iTunes:CATALOGNUMBER` 等)に、写像表に無いキーは - `----:com.apple.iTunes:<KEY>` のフリーフォーム atom に書く(多値は 1 atom の複数値。`iTunNORM` は - 綴りを固定。D-77)。読み側は同じ写像で、フリーフォーム atom を名前を大文字化したキーとして取り込む。 - 置き換え・削除は名前の大小文字を無視して既存 atom を消す(外部ツールが `MyKey` で書いた atom を - `MYKEY` で上書きしても二重化しない)。`trkn` / `disk` は番号と総数が同居するので、片側だけの変更は - 相方を保つ。写像と書き手(`apply_ilst`)は Derived の `aac` 系統(§7.6)と共有する。MP3 は ID3v2 の - 標準フレーム(lofty が名前を知っている TXXX を含む)に写像し、写像表に無いキーは description = キーの - TXXX に書く(多値は NUL 区切りの 1 フレーム。ID3v2.4)。読み側は TXXX の description を大文字化した - キーとして取り込み、置き換え・削除は description の大小文字を無視して既存の TXXX を消す。ブロック - (ID3v2 / ID3v1 / APE)ごとに split / merge で書き、写像できないフレームを保つ(D-110)。WAV / AIFF 等は - lofty の generic `Tag` 経由で、写像できないキーは書けない(読み戻し照合で failed に閉じる) -- **事前条件に `ctime_ns` と `tag_hash` を含める。** inode も mtime も保ったままの in-place - 更新(`touch -r` を伴うタグツール)は dev/inode/mtime では見えない。ただし外部 rename も - ctime を進めるため、rel_path が記録時点と違う(スキャナが追随した)ときに限り ctime_ns だけの - 不一致は許容する(D-41) -- **`expected_dev` は記録するが照合しない。** dev 番号はマウントのたびに振り直されうる(ZFS は - ホスト再起動で変わる)ので、記録の後に再起動があると全 op が外れる。実体の同一性は - inode / size / mtime_ns / ctime_ns / tag_hash で確認する。rename / normalize の所在判定 - (`same_inode`)、flaccheck / RG / 再生の「行と FD の照合」も同じ扱い(D-62) -- **DB 先行更新 + pending 記録**を採る。DB の値は「確定した真実」ではなく - **書き込み意図のオーバーレイ**で、ファイル反映が終わるまで暫定。UI は pending を - バッジで見せる。DB を失うと未反映の意図も失われる(ファイルは旧値のまま残るので - データは壊れない)。DB を後から確定する案は、6 万件の一括編集で UI が数分間 - 古いままになるため却下(D-24) -- **overlay の解消**: op が `applied` 以外の終端(skipped_conflict / failed / cancelled)に - なるときは、**同じトランザクションで**そのトラックの DB 値をファイルの現在値に戻す - (タグ・キャッシュ列・`tag_hash`・物理属性)。次回スキャン任せにしない。`tag_version` は - 既に進んでいるので据え置く(Derived の追随ジョブはファイルの現在値で書くだけなので無害)。 - ファイルを読めない(消えている・壊れている)ときだけ `edits.old_value` と事前条件の物理属性へ - 戻す(D-41) -- **pending 中の再編集は 409 で拒否する。** `edit_ops(track_id) WHERE result='pending'` の - UNIQUE で DB 側でも保証する。後続バッチが先行 intent を統合する方式(`superseded`)は - 予約のみで P0 では実装しない -- **tmp + rename を採用**する。電源断でファイルが壊れないことを優先。 - 代償として inode が変わるので、書き込み成功時に必ず DB を追随させる -- **リカバリ**: 起動時に `prepared` / `applying` のバッチについて、`pending` の op に - 対応する track ジョブを再投入する(dedup で二重にはならない)。クラッシュ前に - rename まで済んでいた op は inode 不一致になるので、ファイルのタグを読み - **全フィールドが新値と一致すれば `applied` として確定**、そうでなければ - `skipped_conflict`(overlay の解消を伴う)。この確定は再投入されたジョブが通常の反映経路で - 行う(起動時の特別処理ではない)。同じジョブを何度再実行しても結果は変わらない。 - バックオフの上限に達する失敗では、ハンドラが op を `failed` に閉じて overlay を解消する(D-41) -- **stale ジョブ**: tagwrite の `dedup_key` は `tagwrite:<track_id>:<tag_version>`。 - 409 により pending 中の再編集は起きないので、通常は stale にならない。 - それでも payload の版 < 現在値なら no-op で `done` にする(防御) -- **キャンセル**: バッチ単位。子ジョブに `cancel_requested_at` を立て、未着手の op は - `failed`(error='cancelled'、overlay の解消を伴う)、進行中の op は完了を待つ。全 op が - 終端になったらバッチを `cancelled` に。applied になった op はそのまま残り、revert で戻せる -- **巻き戻し**(§9 `/api/history/:batch/revert`): - - 対象は**終端状態**(applied / partial / failed / cancelled)のバッチのみ。 - `prepared` / `applying` はまずキャンセルする - - 対象 op 集合 = 元バッチで `applied` になった op − 既存の逆バッチ(`reverts_batch_id` = 元)で - 既に `applied` になっている op。**この集合が空なら 409 `already_reverted`** - - 各 op について**全フィールドの現在値が元バッチの `new_value` と一致する場合だけ** - `old_value` へ戻す(op 単位。1 フィールドでも違えば `skipped_conflict`)。 - 一致しないものは外部変更または後続バッチの変更なので UI に件数を出す - - 元バッチの `reverted_at` は、**逆バッチが終端になり、対象集合が全件 `applied` になったとき** - だけ立てる。逆バッチが partial なら立てず、再度 revert すると残りだけが対象になる - - やり直し(redo)= 逆バッチを revert する。同じ規則で処理される - - 巻き戻しも通常のバッチなので、対象トラックに pending があれば 409 -- **埋め込み画像の差し替えも tags op**(P1-3 書き側、D-60)。キー `PICTURE`(値は `tag_hash` と同じ - `<mime>:<sha256hex>` の配列。`track_tags` にもこの形で入っている)を `Editor::prepare_picture` が - 「全画像を捨てて上げた 1 枚を front cover にする」変更として記録する。ユーザの JSON タグ操作は - `PICTURE` を受け付けない。実体は `ArtworkStore`(`thumbs/<hex>/orig.<ext>`)にあり、tagwrite は - **書く前に捨てる旧画像を同じ store へ退避**してから書く(退避できなければ `failed`。新画像が store に - 無ければ `failed`)。巻き戻しは通常の逆 op(旧画像は退避済み)。applied のとき album の再解決を予約し - 増分スキャンを投入する。spindle は同梱の cover ファイルを書かない(埋め込み統一。D-60) -- リネーム(P0-11)と論理削除も同じ機構に乗せる(`kind = rename / delete`)。 - 巻き戻しの対象は tag に限らない。**一括リネームは coordinator が 2 phase で行う**(D-43): - prepare で DB の `rel_path` を 2 段階更新で新値にし(overlay。`expected_rel_path` が記録時点の - 物理パス)、album の所属も追随させ、**バッチ 1 つに `rename` ジョブ 1 つ**を投入する。ジョブは - phase 1 で全 op の source を同じディレクトリの一時名 `spindle-rename-<op_id>.<ext>` へ - `RENAME_NOREPLACE` で退避(事前条件は inode / size / mtime_ns / ctime_ns。dev は照合しない、 - D-62。外れていれば - `skipped_conflict` で触らない)、phase 2 で `ordinal` 順に最終名へ置く(宛先ディレクトリは作る。 - 宛先が取られていれば source へ戻して `skipped_conflict`)。最後に 1 トランザクションで op を - 終端にし、`rel_path` をファイルの所在へ揃え、物理属性を追随し、バッチを集計する。 - swap / 循環はこれで解ける。phase 境界でのクラッシュは、再投入されたジョブが op ごとの所在 - (最終名 / 一時名 / source)を inode で判定して続きを行う。cancel は phase 2 に入るまで - (退避済みを戻して全 op を `failed('cancelled')`)。スキャナは pending の rename op があるトラックの - 所在が source / 一時名 / 最終名のいずれかなら衝突にしない - -### 7.6 Derived 生成 - -Derived は**系統(variant)**ごとに 1 本ずつ作る。系統は `opus`(Android の同期・Web 再生・配布ビュー)と -`aac`(Mac のミュージック.app へ取り込む Apple 向け)の 2 つで固定(P4-7 で系統化、P4-8 で `aac` の実体。 -D-9 追記、D-75)。系統の設定は `config.toml` が正で、起動時に `derived_variants` 表へ写す(投入判定は -どこからでも同じ接続で設定を引ける。D-55 の `fb2k_prefix` と同じ流儀)。設定に無い系統は投入も生成も -しない。 - -| 項目 | 規則 | -|---|---| -| パス | `Derived/<variant>/` 以下に Library と完全ミラー(拡張子だけ `.opus` / `.m4a`)。`paths.derived` は 1 つ | -| 設定 | `[encode.derived.<variant>]` に `enabled` と `bitrate`(下記)。節を省略したときの `enabled` は **`opus` が true、`aac` が false**(既存の config をそのまま新版で起動しても aac は始まらない。RG 全件解析 → 有効化の順序を崩さない)。`enabled = false` の系統は**凍結**: 新しく作らず、既存の行とファイルは Move / Retag / Encode のどれも行わず、配布ビュー・バッジ・`has_derived` は既存行をそのまま使う。GC は系統を区別せず `Derived/` 全体で「行に無いファイル = 孤児」を回収する(凍結でも行は残るので消えない) | -| 出力仕様の世代 | 系統ごとに 2 つの文字列を設定から作り、行に保存する。**`audio_profile`**(音声に効く設定: codec / bitrate / サンプルレート規則 / RG 焼き込み方式の版。例 `opus:256:v1`、`aac:256:48k:bake1`)と **`tag_profile`**(タグに効く設定: `multi_value_separator` / iTunNORM 規則の版。例 `aac:sep= & :itunnorm0:v1`)。エンコーダの引数や規則を変えるときは版を上げる | -| 判定 | `audio_version` 差分・**行の `audio_profile` が設定と食い違う** → 再エンコード / `tag_version`・埋めた画像(`src_artwork_id`)・RG の解析世代(`src_rg_scanned_at`)・**`tag_profile`** の差分のみ → タグ上書き(**`aac` は RG を音声に焼き込むので RG 世代の差分は再エンコード**)/ パスの差分のみ → rename。優先順はこの順(再エンコードは残りを兼ねる) | -| RG の前提 | **どの系統も RG が揃ってから作る**: 解析済み(`rg_scanned_at` と `rg_track_gain` / `rg_track_peak`)で、`[replaygain].write_tags = true` なら Library のタグへの書き込みも確認済み(`rg_written_at >= rg_scanned_at`)。揃っていない間は凍結と同じく既存の行とファイルに触らない。順序は rg → rgwrite → tagwrite の applied → transcode(D-97) | -| 投入 | scan ジョブの完了時に食い違う全トラック × 系統、tagwrite / rename の applied、RG 解析の保存(D-51)。ジョブは `transcode`(`(track_id, variant)` 単位、`audio_version` で dedup)で、ハンドラが現在値から必要な処理を決める。決めた処理(エンコード / タグ更新 / 移動 / 移動 + タグ更新)を実行中から `jobs.note` に書き、完了時は実際に行った処理(移動・タグ更新が Derived の消失でエンコードに倒れたら「エンコード」、何もしなければ「変更なし」「対象外」)を残す。種別はどれも「Derived 生成」なので、ジョブ画面の結果列で見分ける(D-111)。P4-1 の共通並列予算の対象 | -| 追随 | Library の移動に追随(Derived を rename)。削除には追随せず(missing は可逆)、`retention_days` 超の回収と孤児(行に無いファイル。`Derived/` 全体)は GC ジョブ | -| マルチch | どの系統も既定で対象外(チャンネル数不明も対象外)。トラック単位の `-ac 2` ダウンミックスは需要が出たら(D-51。実データは全件 2ch) | -| 配布ビュー | `delivery` は **`opus` 系統に固定**(Android の m3u8、`?transcode=opus`、`has_derived`)。`aac` は配布ビューを持たない。端末へは、Android に `opus` 系統、iPhone に `aac` 系統を既定で送る(端末ごとに選べる)。iPhone へは Mac のエージェントが `aac` を取り込み、曲は `~/Music/spindle` に置き、プレイリストはミュージック.app の「spindle」フォルダに作る(§7.11、D-75、D-95) | - -**`opus` 系統** - -| 項目 | 規則 | -|---|---| -| 対象 | Library 内の可逆のみ(flac / alac / wav)。opus / aac / mp3 は原本をそのまま配布(D-8) | -| 出力 | `opusenc --vbr --music --bitrate <bitrate>`。既定 **256 kbps**(D-9 追記。可逆 237 GB で約 58 GB) | -| RG | 再解析しない。Library 側の解析値を `R128_*` へ変換して埋める(`REPLAYGAIN_*` は書かない)。RG が揃うまで作らない(上の「RG の前提」) | -| 画像 | トラック自身の埋め込み画像(`tracks.artwork_id`。D-61)、無ければ album のアートワーク(§7.1)の長辺 768 の WebP を 1 枚だけ埋める(D-51) | - -**`aac` 系統**(Apple 向け。D-75) - -| 項目 | 規則 | -|---|---| -| 対象 | 可逆(flac / alac / wav)に加え、`lossy_sources = true` なら**非可逆も**(opus / ogg / mp3 / aac → AAC。世代劣化は承知の上で、ミュージック.app が Opus を読めないため。**D-8 の例外**)。原本が AAC でも同じ経路で再エンコードする(stream copy では RG の焼き込みとリサンプルができない。D-75)。`lossy_sources` を true → false にしても既存の非可逆の行とファイルは消さない(対象外 = Skip で凍結と同じ扱い。物理削除は GC のみ) | -| 出力 | ffmpeg **1 パス**(中間 WAV なし。FD を stdin に繋ぐのは opus と同じ): `-i /dev/stdin -map 0:a:0 -vn -map_metadata -1 -af volume=<gain>dB [-ar 48000] -c:a aac -b:a <bitrate>k -f mp4`(内蔵エンコーダ。既定 256 kbps)。48 kHz 超は 48 kHz へ落とす(`-ar 48000`)、44.1 / 48 は据え置き。`audio_profile` は `aac:<bitrate>:48k:bake1`、`tag_profile` は `aac:sep=<区切り>:itunnorm0:v1` | -| RG | **track gain を音声に焼き込む**(`-af volume=<gain>dB`。gain は `min(rg_track_gain, −20·log10(rg_track_peak))` で**エンコーダ入力を 0 dBTP 以下に抑える**(peak は true peak なので 1.0 超なら減衰側に倒れる。AAC 再符号化後のオーバーシュートまでは保証しない。gain が有限でなければ 0、peak は有限かつ > 0 のときだけ上限を掛ける)。album gain は使わない)。**RG 未解析のトラックは作らず待つ**(「解析済み」= `rg_scanned_at` と `rg_track_gain` / `rg_track_peak` の 3 つが揃っていること。時刻だけ残った行は対象外。`write_tags` ならタグへの書き込みも待つ(上の「RG の前提」。D-97)。書き込みの tagwrite の applied で投入される。二度エンコードの回避)。RG の解析世代(`src_rg_scanned_at`)の差分は**再エンコード**(album gain の on / off も世代を進めるので、その album の aac は作り直される。track gain しか使わないが値ベースの判定に列を足すより単純で、まれな操作なので許容。D-75)。タグには `iTunNORM` を **0 dB 相当**で書き、端末のサウンドチェック ON でも二重に掛からないようにする。値は 10 個の 8 桁 16 進を空白区切り(先頭にも空白)で、1〜2 値目(基準 1/1000)は `000003E8`、3〜4 値目(同じ量の基準 1/2500 の表現)は `000009C4`、残り 6 値は `00000000`: `" 000003E8 000003E8 000009C4 000009C4 00000000 00000000 00000000 00000000 00000000 00000000"`。`REPLAYGAIN_*` / `R128_*` は書かない | -| タグ | Library のタグを写す(`REPLAYGAIN_*` / `R128_*` / 既存の `ITUNNORM` は落とす)。**同じキーの複数値は出現順に `multi_value_separator`(既定 `" & "`)で 1 値に結合**(ミュージック.app は複数値の 1 つしか見せない。ARTIST / ALBUMARTIST / GENRE / COMPOSER など多値になり得る全フィールド)。写像は Library の tagwrite と同じ(§7.5「形式ごとの写像」): Vorbis 名を lofty の `ItemKey` に写像して ilst の標準 atom(`©ART` `trkn` `disk` `©gen` 等)に書き、写像できないキーは `----:com.apple.iTunes:<KEY>` のフリーフォーム。`iTunNORM` は内部キーが大文字化されても atom 名を `iTunNORM` に固定する(大小文字を special-case) | -| 画像 | `opus` と同じ選び方で、長辺 768 の **JPEG**(ミュージック.app は `covr` の WebP を読まない)。`thumbs/<hex>/768.jpg` をキャッシュに足す(thumbnail ジョブと同じ変換に形式を足したもの。`-pix_fmt yuvj420p -q:v 2`) | - -```toml -[encode] -flac_compression = 8 - -[encode.derived.opus] -enabled = true -bitrate = 256 # opusenc --vbr --music --bitrate - -[encode.derived.aac] -enabled = true -bitrate = 256 # ffmpeg -c:a aac -b:a -lossy_sources = true # 非可逆原本も AAC へ(D-8 の例外。aac 原本も再エンコード) -multi_value_separator = " & " # 多値フィールドの結合 -``` - -`derived_variants` の `lossy_sources` / `multi_value_separator` は aac 系統の設定(`eligible` が前者を、ハンドラが後者を -表から引く。設定は config が正で、起動時に両系統を写す)。 - -`derived_files` の主キーは `(track_id, variant)` で、配布ビュー `delivery` は `variant = 'opus'` の行を見る。 -系統を持つ前の行(ルート直下・`audio_profile = 'opus:128:v1'`)は、既定の 256k では次の transcode が -`audio_profile` の差分で再エンコードして `opus/` 配下へ置き、旧ファイルは transcode の退避経路で消える。 -`bitrate = 128` のまま(`audio_profile` が一致)ならパスの差分だけなので Move で `opus/` 配下へ移る(D-75)。 - -`has_derived`(DSL / フラグ / バッジ)は **`opus` 系統の行**の有無(= `opus` 系統を生成済みか。音声版が古い間は -配布ビューが原本へ倒れるが、`has_derived` は行の有無のまま)。 -`aac` しか無いトラック(非可逆原本)は `has_derived` にならない。`GET /api/tracks` の `derived` は系統ごとに -集約して返す(2 系統あっても行は 1 つ)。 - -容量逼迫時のみ、トラック単位で `force_transcode` を手動指定可能(**元が 256kbps 以上の場合のみ**) -という `opus` 系統の例外は、需要が出るまで実装しない(D-51。実データの非可逆は Opus が大半で削減にならない)。 - -### 7.7 ytmusic 統合 - -| 元モジュール | 移行先 | 備考 | -|---|---|---| -| `parser.py`(タイトルパーサ)/ チャンネル定義 | **外部のメタデータプラグイン**(別リポジトリ) | spindle はタイトルの慣習を知らない(D-69) | -| `models.py` 正規化 | Rust | 置換テーブルは D-43 で 1 本化済み | -| `tagger.py` (mutagen) | lofty | | -| `audio.py` R128 | ebur128 | ffmpeg loudnorm より高精度 | -| `organizer.py` / `playlist.py` | Rust | m3u8 生成は継続 | -| `downloader.py` | Rust(yt-dlp を subprocess) | | -| `sync.py` (ADB) | **移植する**(端末同期として再設計) | §7.11、D-95 | - -**メタデータプラグインのプロトコル v1**(`src/import/ytmusic/metadata.rs`、P3-1 / P3-2、D-69)。 -動画のタイトルからトラックのメタデータ(タイトル・アーティスト・アルバム・category)を決める知識は -利用者固有(チャンネル名、タイトルの慣習、パターンのルール)なので spindle には置かず、外部コマンドに -問い合わせる。spindle が知る契約はこのプロトコルだけ。 - -- **起動**: `[ytmusic].metadata_command`(引数配列。`sh -c` は使わない)をアイテム 1 件ごとに起動し、stdin に - Request を 1 つ書き、stdout の Response(JSON 1 つ)を読む。`metadata_timeout_secs`(既定 30)で kill。 - stderr はログに出す。終了コードが非ゼロ・stdout が JSON(厳密な UTF-8)でない・`protocol` が違う・ - 必須の値が空(`title` / `albumartist` / `album` / `artists`)・`ok: false` なのに `reason` / `message` が - 無い・`category` がディレクトリ名として不正(`POST /api/categories` と同じ規則: 前後の空白、禁止文字、 - 末尾のドット、予約名は不可)・`tags` に spindle が決めるキー(TITLE / ARTIST / ALBUM / ALBUMARTIST / - DATE / TRACKNUMBER / DISCNUMBER、画像の PICTURE / METADATA_BLOCK_PICTURE、ReplayGain の - REPLAYGAIN_* / R128_*)や不正なキー・空の値があるものは - **プラグインの故障**として取り込みを止める -- **Request**(未知のフィールドはプラグインが無視する。前方互換) - ```jsonc - { "protocol": 1, "op": "metadata", - "item": { "source": "youtube", // 提供元 - "channel": "<設定のチャンネル識別子>", - "channel_title": "…" | null, // 提供元での表示名 - "id": "<動画 id>" | null, "url": "…" | null, - "title": "<動画タイトル>", // 必須 - "uploaded_at": "YYYY-MM-DD" | null, "duration_ms": 123 | null } } - ``` -- **Response**(判定できたかに関わらず終了コード 0) - ```jsonc - { "protocol": 1, "ok": true, - "track": { "title": "…", "artists": ["…"], "albumartist": "…", "album": "…", - "category": "<統制語彙の名前>" | null, // null は未分類(_Unsorted) - "date": "YYYY[-MM[-DD]]" | null, - "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 は取り込まない。 - // 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 の語彙が無ければ追加する(プラグインの定義が正。 - 初回起動の空 DB でも動く)。トラック番号と配置は Inbox が行う(下記、§7.8、D-70) -- 参照実装: `AkashiSN/spindle-ytmusic-meta`(private。ルール TOML + フィクスチャ + チャンネル定義を同梱した - Rust のバイナリ。新パターンは Claude がそのリポジトリでルールとフィクスチャを 1 件ずつ足す運用)。 - イメージには同梱せず、static バイナリをホストに置いて compose でマウントする(§14、D-70) - -**ダウンローダ**(`src/import/ytmusic/downloader.rs`、`ytdl` ジョブ、P3-3、D-70)。Library には触らず、 -**Inbox に置くところまで**。配置・登録・後続ジョブは Inbox(§7.8)が担い、プラグインが判定したものも -人が一度見てから Library に入る。 - -- **入口**: `POST /api/ytmusic/download { urls }` が URL ごとに `ytdl` ジョブ(並列 1、dedup `ytdl:<url>`)を - 投入。YouTube 画面(1 行 1 URL。§12.6)から呼ぶ -- **手順**(ジョブ 1 件 = URL 1 件。作業領域は `[paths].data/tmp/ytdl/<job_id>/`。全部引数配列、`--` の後に URL) - 1. `yt-dlp --dump-single-json --flat-playlist --no-download -- <url>`(120 秒)。`_type` が playlist なら - `entries` の `url` ごとに `ytdl` を投入して終わり(展開だけ)。動画なら `id` / `webpage_url` / `uploader` - (channel)/ `channel`(channel_title)/ `title` / `upload_date` / `duration` を取る。stderr が - `Unsupported URL` / `is not a valid URL` なら `Fatal`、他の失敗は `Failed`(再試行) - 2. プラグインに問い合わせ(Request は上記。`channel` は `uploader`)。`ok` → 宛先 - `Inbox/youtube/<albumartist>/<album>/`(`sanitize_component`)。`ok: false` の `skip` → ダウンロードせず - `done`(ログに message)。それ以外の reason(`unmatched` / `unknown_channel` / 未知)→ 宛先 - `Inbox/youtube/_unmatched/<channel>/`(受け皿)。プラグインの故障(`ProviderError`)→ `Fatal`。 - ここで Inbox のファイル名(8.)まで決まる - 3. 取り込み済みチェック: `SOURCE_URL = webpage_url` が Library(`track_tags`)にあれば**やることが無い**ので - `done`(note に「取り込み済み(Library): <パス>」。失敗ではない。P4-18、D-81)。Inbox(`inbox_files.tags`)にあり、それが自分の宛先そのもの - (`rel_path_key` で比べる)なら「置いた後に落ちて走査が先に拾った」再実行の可能性があるので、 - **実ファイルの `SOURCE_URL` を読み直して**同じならダウンロードせずに 8. の仕上げ(サイドカーと投入) - だけ済ませて `done`。違えば `Fatal`(同名で別の内容)、ファイルが消えていれば普通に置き直す - (行はキャッシュ、ファイルが正)。Inbox の別の場所なら `done`(「取り込み済み(Inbox): <パス>」) - 4. `yt-dlp -f "ba[ext=webm]" --no-playlist --write-thumbnail --convert-thumbnails jpg -o <tmp>/%(id)s.%(ext)s -- <url>` - (`[ytmusic].download_timeout_secs`、既定 900)。webm の音声が無ければ `Fatal`。ネットワーク等の失敗は - `Failed`(再試行) - 5. `ffmpeg -nostdin -y -i <tmp>/<id>.webm -vn -c:a copy -map_metadata -1 <tmp>/<id>.opus`(再エンコードなし) - 6. lofty でタグ。`ok`: `Track::tags(なし)` の TITLE / ARTIST / ALBUM / ALBUMARTIST / DATE / 追加 tags - (TRACKNUMBER は書かない。採番は Inbox)+ PICTURE(サムネイル jpg)+ `SOURCE_URL`。`ok: false`: TITLE = - 動画タイトル + PICTURE + `SOURCE_URL` だけ(他は Inbox の警告で人が埋める) - 7. webm を `Archive/youtube/<id>.webm` へ(tmp + `RENAME_NOREPLACE`。既にあれば同じ id なので採用) - 8. `.opus` を宛先へ `<YYYYMMDD> <title> [<id>].opus`(title は `sanitize_component`。名前順 = 公開順 = - 採番順。`RENAME_NOREPLACE`。既にあれば、その `SOURCE_URL` が同じときだけ自分の成果物(置いた後に - 落ちた再実行)として採用し、違えば `Fatal`)。仕上げ: 同じディレクトリの `spindle-inbox.json` を読んで - このファイルの項を足し tmp + rename で書く → ディレクトリを fsync → `inbox` ジョブを投入(すぐ取り込みが - 出る)。仕上げの失敗は `Failed`(再試行。次の実行は 3. か 8. の採用でここへ戻る) -- **サイドカー `spindle-inbox.json`**(タグに載らない情報を Inbox へ渡す。Inbox の DB には書かない) - ```jsonc - { "version": 1, - "category": "<統制語彙の名前>" | null, // 取り込みの category(プラグインの判定。最後に書いたものが勝つ) - "files": { "<ファイル名>": { "source": "youtube", "url": "<webpage_url>", "channel": "<uploader>", - "verdict": "ok" | "unmatched" | "unknown_channel" | "skip" | "<未知の reason>", - "message": "…" | null, - "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 は - `SOURCE_URL` で自分の成果物を見分けて続きから -- `POST /api/ytmusic/download` の URL は http / https でホストがあり空白・制御文字を含まないものだけ受ける - (ホストは限定しない。対応していなければジョブが `Fatal` で伝える) -- yt-dlp は YouTube の抽出に JS ランタイムを要求する版があるため、runtime イメージに deno を同梱する(§14) - -**再生リストの購読と同期**(`playlist_subscriptions`、`playlist_sync` ジョブ、P4-16、D-78)。URL を貼る -運用をなくす。再生リスト 1 本 → Library の album 1 つ(追記先)。`SOURCE_URL`(P4-14 で補填、ytdl が -書く)で「再生リストのどこまで持っているか」が分かる。 - -- **購読**(`db::subscriptions`、`/api/ytmusic/subscriptions`): `id` は再利用しない(AUTOINCREMENT。 - ジョブの payload とサイドカーが裸の id を持つので、DELETE 直後に作った購読へ旧ジョブが誤帰属しない)、 - `list_id`(URL の `list=`。YouTube のホストだけ。UNIQUE)、`albumartist` / `album` / `category`(追記先の初期値と表示用)、`album_id` - (**追記先の同一性**。登録時は NULL。同期か配置が `import::inbox::destination_of`(Inbox の追記先と - 同じ規則)で引けたときに CAS(`album_id IS NULL` のときだけ)で束ねる。album 全体の移動は id を - 維持する(D-32)ので以後は album 行の値が正。album が消えれば SET NULL で再解決。albumartist / - album / category を PATCH で変えると NULL に戻す)、`align`(番号揃えをするか。既定 on)、`enabled` - (定期と承認の後続の対象か。手動の同期は enabled に関わらず受ける)、`max_enqueue`(1 回の同期で - 投入する上限。既定 50。誤登録した巨大なリストで数百本落とさない)。同じ追記先の購読は 1 つだけ - (`target_key` = `sanitize_component` した albumartist / album の canonical key、UNIQUE。`album_id` も - 非 NULL の間 UNIQUE = 束ね同士の競合は DB で片方が失敗する)。Inbox の `youtube/<albumartist>/<album>` - = 1 購読 = 1 category になるので、サイドカーの category が dir 単位でも他の購読に波及しない -- **同期ジョブ `playlist_sync`**(並列 1、dedup `playlist_sync:<id>`、payload `{ subscription_id }`)。 - 順序は **列挙 → 追記先 → 照合 → 番号揃え → ytdl 投入**(購読由来の ytdl は TRACKNUMBER = 位置を書くので、 - 先に既存の行を揃えて隙間を空ける): - 1. `last_attempted_at = now`、latch(`sync_requested_at`)を NULL に。`yt-dlp --flat-playlist - --dump-single-json`(`[ytmusic].ytdlp_args` 付き)で列挙。entry の位置(1 始まり)が再生リストの位置。 - `entries < playlist_count` なら**古い yt-dlp の取りこぼし**として `Failed`(再試行)で何も投入しない - 2. 追記先: `album_id` があればその album(missing なら `Fatal`)、無ければ引いて束ねる(別の購読が束ねて - いれば `Fatal`。読んでから束ねるまでに配置が束ねていればそちらが正)。まだ無ければ揃えは無し(最初の - 配置で束ねる)。同期が queued / running の間は PATCH / DELETE を 409 `sync_running` で拒む(走行中の同期が - 古い追記先で揃えたり、消えた購読の album を揃えたり、死んだ id で投入したりしない。検査と UPDATE は同じ書き込み閉包 = 投入と直列)。加えて各段の - 前に購読を読み直し、消えていれば `Fatal`、`updated_at` が進んでいれば `Failed`(直接 DB を書いた場合の - 控え) - 3. entry ごとに正規 URL `https://www.youtube.com/watch?v=<id>` で Library の active 行を**全件**引く - (`find_source_url` の LIMIT 1 でなく 0 / 1 / 2 件以上を区別)。追記先にあれば「Library」、別の album - なら「別の album にある」(触らない、投入しない)、Inbox にあれば「取り込み中」(投入しない) - 4. **番号揃え**(`align` が on。`import::ytmusic::playlist::plan_align`): 対象 = 追記先の active 行で - `SOURCE_URL` が entry に一致し disc 1(NULL 含む)で単一のもの。目標番号 = 位置。**固定行** = それ - 以外(`SOURCE_URL` 無し・再生リストに無い・`SOURCE_URL` 重複)の disc 1 の行で、その番号は塞がる。 - 目標番号が塞がれた対象と disc 2 以降・重複の対象は動かさず「揃えられない」一覧へ(理由付き)。 - **同じ動画が再生リストに複数回ある**ときも表現できない(1 行は 1 番号)ので、その行は固定して「揃え - られない」(`duplicate_entry`、位置の一覧)に出し、投入は最初の位置でだけ行う。対象 - 同士の swap / cycle は可(番号は UNIQUE でなく、rename は一時パス経由)。非公開・削除・未取り込みの - 位置も数えるので、それらの分は番号が飛ぶ。 - **各段の前に追記先の active 行に pending の op が無くなるまで待ち**(2 秒間隔、上限 10 分。超えたら - `Failed`)、差分は待った後の DB から計算し直す(phase を永続化しない: 落ちて再実行しても、失敗した op は - overlay がファイルの値に戻るので次回また差分になり、適用済みなら差分ゼロで通る)。ずれた行だけ - `Editor::prepare_tags`(`TRACKNUMBER`。説明「再生リスト『…』に番号を揃える」)→ 終端待ち → album を - 読み直し「**現在の TRACKNUMBER が目標位置と一致する対象行のうち、ファイル名の先頭の番号が合っていない - 行**」を `plan_rename` → **ファイル名だけ**テンプレートのものにして(ディレクトリは今のまま。テンプレートの - dir で動かすと category 未推定の album を `_Unsorted` へ移してしまう)`prepare_rename` → 終端待ち - (今回のバッチの applied 集合には依らない = tags 適用後・rename 前に落ちた境界を再実行で拾う。名前の - 書式だけ違う行は触らない)。計画の時点の衝突(同名のファイルがある等)は続く状態なので失敗にせず - 「改名できない」として報告だけ。どちらも履歴に載り巻き戻せる。Derived(opus / aac)はタグ上書き・移動で - 追随する。子バッチの終端は **batch_id の pending = 0** で待つ(album の active 行で見ると、対象が - 走査で missing になったときに pending の op を見落とす)。**全件 applied でなければ**(pending が残る、 - 外部の書き換えによる衝突・失敗・キャンセル、applied ≠ total)揃えは終わっていないので `Failed`(再試行で差分から取り直す)。揃え終わる前の失敗・ - キャンセルでは投入しない(そこまでのバッチ id と件数は結果に残す) - 5. Library / Inbox / 別 album に無く、**取れる** entry を位置順に `max_enqueue` まで `ytdl` 投入(payload - `{ url, subscription_id, position }`、dedup は今までどおり `ytdl:<url>`)。超えた分は「次回」。 - Duplicate(手動貼り付け・別の購読の投入が走行中)は**満たしたと数えず「別の投入が走行中」**として - 結果に出すだけ(そのジョブには購読の情報が無い。承認されれば次の同期が Library で拾い、失敗すれば - dedup が空いて購読の情報付きで再投入。同じ動画が複数の購読に出る場合も同じ規則で、先に取り込んだ側の - album に置かれ他方には「別の album にある」) - 6. **取れない entry**(`title` が無い、旧版の `[Private video]` / `[Deleted video]`): 投入しないが位置は - 占める。Library にあれば(補填済み、公開だった頃に取り込んだ)何もしない。無ければ「取れない」一覧 - `{ position, id, kind: private | deleted | unknown }`(現行の yt-dlp は `title: null` で来るので区別 - できず `unknown`。旧版の題名から分かるときだけ private / deleted)。後で公開に戻れば次の同期で拾う - 7. `last_result`(結果 JSON: entries / playlist_count / in_library / in_inbox / elsewhere / enqueued / - running / deferred / unavailable / align{moved, unchanged, blocked, outsiders, unnumbered, tags, renamed, - rename}、state = done / failed / cancelled)、成功なら `last_synced_at`、`jobs.note` に 1 行。終わる前に - latch が立っていれば(走行中に承認の後続・手動要求が来た)`Outcome::Requeue`(試行回数を数えず同じ - ジョブがもう一度走る) -- **ytdl の購読由来**(payload に `subscription_id`): ALBUMARTIST / ALBUM / category は購読の追記先(束ねて - あれば album 行の値)、プラグインの判定は TITLE / ARTIST に使う。**skip / 判定不能でも投入**(再生リストは - 人が選んだもの。TITLE = 動画タイトル、ARTIST = albumartist、verdict はサイドカーに残す)。宛先は - `Inbox/youtube/<albumartist>/<album>/`。`align` が on なら **TRACKNUMBER = 位置**を書く(同期が先に既存の - 行を揃えて隙間を空けている。承認画面の初期値がそのまま正しい番号になり、塞がっていれば承認の既存検査 - (400)で人が直す)。off なら書かず Inbox が max+1 を振る。購読が消えていれば通常の ytdl として振る舞う(skip も通常どおり。 - 購読の有無はプラグインを呼ぶ前に引く) -- **承認はそのまま**(D-70。判断は Inbox で人が行う)。配置(`place_item`)はサイドカーを消す前に取り込みの - `subscription_id` を集め、inbox ジョブが配置後に追記先を束ね(CAS)、latch を立ててから `playlist_sync` - を投入する(走行中なら Duplicate だが latch は残る) -- **dispatcher**(常駐、30 秒ごと。`[ytmusic].enabled` のとき): (a) latch の立った購読を投入(enabled に - 関わらず。active なジョブがあれば Duplicate で何もせず、終端になった次の tick で投入される = handler の - 最終確認から終端までの窓に立った latch、Failed / Cancelled で残った latch も必ず回収される)、(b) - `[ytmusic].sync_interval_hours`(既定 0 = 手動と承認の後続だけ)が 0 でなければ enabled で - `last_attempted_at`(成否を問わない開始時刻)から interval 経った購読を投入(最終 retry が failed に - なっても interval までは新しいジョブを作らない) -- yt-dlp を定期的に叩くので、ブロック時の `ytdlp_args`(P4-13)と yt-dlp の更新(P4-12)が前提 - -### 7.8 Inbox 取り込み - -取り込み対象ディレクトリを増やす方式は採らない。外部ディレクトリを直接 -スキャン対象にすると、そのツリーのパス規約とタグ品質がそのまま Library に -混入するため。Inbox をステージングとして挟む。 - -``` -Inbox/ に配置(ポーリング検出) - → ステージング: タグ解析、コーデック判定、アルバム単位でグルーピング - → 承認キュー: UI で category / albumartist / album を確認・補正 - → ※メタデータ不足のまま Library に入れない - → 配置: テンプレート展開 → Library へ move - → 後続ジョブ: normalize(WAV) → rg → transcode → thumbnail -``` - -承認キューを挟むことが要点で、これがないと `_Unsorted` が際限なく育つ。 - -実装(`src/import/inbox.rs`、`inbox` ジョブ、D-68): - -- **検出**は `inbox` ジョブ(並列 1・固定キー)。周期の**監視**が `[inbox].poll_interval_secs`(既定 60、0 で - 自動なし)ごとに Inbox の指紋(音声ファイルの集合: パス・inode・size・mtime・ctime。非音声と空ディレクトリは - 効かない)を取り、**前回投入時と違うとき**と、配置待ち(`approved`)・期限切れの `placed` があるときだけ - 投入する(変化の無い毎分のジョブ行で一覧を埋めない。投入が Duplicate なら次の周回で投入し直す。起動直後は - 必ず 1 回。P4-18、D-81。inotify は使わない: D-68 追記)。`POST /api/inbox/scan` は常に投入。 - `GET /api/inbox` の `watch` に最後に確認した時刻と間隔(Inbox 画面が「最後に確認: HH:MM:SS」を出す)。 - ジョブは Inbox を歩き、音声ファイルのあるディレクトリを - 1 つの取り込み(アルバム候補。root 直下の音声は `""` の 1 つ)として `inbox_items` / `inbox_files` に写す。 - stat(inode / size / mtime / ctime)が変わったファイルだけタグを読み直す。**正は Inbox のファイル**で、 - 行はキャッシュ: ディレクトリが消えれば行も消す(`placed` は 24 時間残して結果を見せる)。`approved` の取り込みで - ファイルが変わっていたら `pending` に戻す(再承認) -- **CD の取り込みの表の画像**(D-91): 走査と承認済みの配置の後、承認前(`pending` / `failed`)で画像がまだ無い取り込みのうち、 - サイドカーの `rip.metadata.release_id`(吸い出しで選んだリリース)があるものは、Cover Art Archive の front - 画像を**一度だけ**取り(`POST /api/artwork/from-caa` と同じ経路・境界。`CoverArtClient` は `address_family` - を `Auto` 固定)、`artwork` の置き場に置いて `inbox_items.caa_picture`(`<mime>:<sha256hex>`)に記録する。 - そのリリースに front が無く(404)、サイドカーに `rip.metadata.release_group_id` があれば、同じ回の中で - リリースグループの front(`release-group/{id}/front-500`。CAA がグループの代表に選んだ版の表の画像)を 1 度だけ - 試す(D-93。通常盤に画像が無く初回限定盤・BD 付きの版にある盤。回数は 1 回と数え、グループの 404 はリリースの - 404 と同じ打ち止め、グループの上流の失敗はリリースの失敗と同じ再試行)。 - 提案(`proposal`)は全曲の `picture` の初期値をその画像にする(保存した下書きがあればそちらが勝つ)。 - 画像の無い盤(404)とリリースの無い取り込み(候補を選ばずに吸い出した・CD でない)は 1 回で打ち止め、上流の - 失敗は次の走査でもう 1 回だけ試す(`caa_tries`、上限 2。回数は外へ出る前に CAS で進めて永続化する)。1 回の - 実行で 4 件・90 秒まで、残りは周期の監視が次の周回で投入する(`needs_attention`)。取り消しは通信の待ちの間も - 効く。どれも取り込みは失敗させず画像なしのまま。 - 承認画面を開くたび・`GET /api/inbox` のたびには外へ出ない。`caa_picture` の画像は GC しない(区分 E の参照) -- **却下した取り込みの削除**(D-90): `rejected` の取り込みに「削除」で `discard_requested_at`(破棄待ち)を入れる。ファイルは - すぐには消さず、GC が `[gc].retention_days` 経過後に取り込みのディレクトリの直下の**走査が写した音声**(stat が一致 - するもの)・既知の同梱ファイル・サイドカーを消し、ディレクトリが空なら消し、行を消す(サブディレクトリ = 別の取り込みと - 知らないファイルは残す)。それまでは「削除を取り消す」で `rejected` に戻る。`rejected` から出る遷移(下書きに - 戻す)でも破棄待ちは解ける。走査が破棄待ちの取り込みでファイルの変化(足された・差し替えられた)を見たら、人が - 見ていないものを消さないよう破棄待ちを解いて `rejected` のまま理由を `error` に残す(GC も確かめてから消す: - 音声は `inbox_files` と、同梱ファイル・サイドカーは ctime が破棄の要求より後でないかで照合し、合わなければ - 何も消さずに解く。消す途中で stat が変われば止めて解き、消せないものがあれば止めて行と破棄待ちを残し次の GC で - 続ける。D-90) -- **同名の警告**(P4-19、D-70 追記): 追記先の album に**同じタイトル**の active な行があれば、そのトラックに - `same_title: [{ track_id, rel_path, duration_ms }]` を付ける(承認は止めない。画面は「⚠ Library に同名: - <ファイル名>(長さ)」と、取り込みの見出しに「同名 N」)。鍵は NFKD + casefold + 空白の畳み込み(全角・半角の - 揺れは同じ、`(Cover)` / `【… Live ver.】` の注記は**落とさない** = 別曲扱い)。狙いは `SOURCE_URL` の - 補填漏れや別 URL の再アップロードによる二重取り込みで、判断は人が行う(同じ曲名の別テイクは正当なので、 - 長さを添えて見分けられるようにする) -- **承認キュー**は `GET /api/inbox`(`items` と監視の状態 `watch: { checked_at, poll_interval_secs }`)。取り込みごとにタグから作った下書き(`proposal`: albumartist / album / date の - 最頻値、category は GENRE → `genre_category_map`、トラックは TRACKNUMBER / DISCNUMBER / TITLE / ARTIST)と - 不足の警告を返し、UI の Inbox タブで category / albumartist / album / date と各トラックの - disc_no / track_no / title / artist を補正して `POST /api/inbox/:id/approve { draft }`。検証(album / - albumartist / 各 title が空でない、`(disc_no, track_no)` が 1 以上で重複なし、rel_path が取り込みのファイルと - 一致)に通らなければ 400 で、メタデータ不足のまま Library に入れない。`reject` / `reopen` で状態を戻す -- **トラックのタグの変更と画像の差し替え**(D-86): 下書きのトラックは `tags`(キー → 値の配列、`null` で - そのタグを消す。書くのはここにあるキーだけ)と `picture`(`<mime>:<sha256hex>`。`POST /api/artwork/upload` - か `/from-caa` で置いた画像)を持てる(どちらも省略可。旧い下書きはそのまま読む)。キーは大文字・空でなく - `=` と制御文字を含まない(tagops と同じ文字の規則)。上の欄が扱うキー(TITLE / ARTIST / ALBUM / - ALBUMARTIST / DATE / TRACKNUMBER / DISCNUMBER / DISCTOTAL / PICTURE)と、曲・盤の同一性に使うキー - (`SOURCE_URL` / `MUSICBRAINZ_*`)は受け付けない。承認は差し替える画像の `artwork` 行が無ければ 400。 - 配置は `tags` の変更を補正のタグに足して書き(現在値と同じものは書かない)、`picture` のある曲だけ埋め込み - 画像を全部捨ててその 1 枚(front cover)にする。宛先に自分の成果物(同じ音声。途中で落ちた前回の配置)が - あれば置き換えずに再利用するが、今回の補正(タグ・画像)がそのファイルに入っていなければ Conflict で failed - (補正を黙って捨てない。外部の変更も上書きしない)。画像が store から消えていれば配置せず - failed。入力欄は Enter / Esc / blur のどれで決着しても 1 回だけ確定か取り消しをする(`lib/editSession.ts`)。 - 下書きが参照する画像は GC しない(区分 E の「参照」に Inbox の下書きを足す。D-56 / D-86)。`POST /api/inbox/:id/preview { draft }` は音声を読まずに - 配置の計画を引いて置き場所の見込みを返す(承認画面の ④) -- **配置**は `approved` の取り込みを `inbox` ジョブが順に処理する。`library` の排他(scan / gc / CD の配置と - 同じ)を取れなければ Requeue。draft から各ファイルの `TrackFields` を作り `pathgen::plan`(category 無しは - `_Unsorted`、`disc_no` の最大 ≥ 2 なら `multi_disc`、リリースキーは MUSICBRAINZ_ALBUMID の最頻値があれば - `mb:`、無ければ取り込みごとの新規)→ Inbox からハッシュを取りながら Library の tmp へコピー → 補正で変わる - タグだけ `write_tag_changes` で書く(ファイルが正のまま再スキャンしても DB と一致する)→ fsync → - `RENAME_NOREPLACE` → 読み戻して 1 トランザクション登録(`source_type = 'download'`。宛先 album の - リリースキー再検証と同パス行の MD5 検証は §7.2 の配置と同じ)→ Inbox 側を unlink → 既知の同梱ファイル - (cover 画像 / cue / toc / log)も移し、空になった Inbox のディレクトリを消す。コピーに使う FD を - fstat して承認時の行(inode / size / mtime / ctime)と照合し、コピーの後にも同じ FD を照合し、置いた - ファイルの音声の指紋が承認時に読んだものと一致することを確かめる。どれかが外れたら `Changed`(取り込みは - `pending` に戻して再承認)。衝突・不足は取り込みを `failed` にして理由を残し、この呼び出しで置いたファイルは - 片付ける。 -- **状態遷移は CAS。** `approve` / `reject` / `reopen` と worker の `approved → placing` は - `UPDATE … WHERE id = ? AND state IN (…)` で行い(`db::inbox::transition`)、読んでから書くまでの間に - 他(API / worker / 走査)が動かした取り込みを上書きしない(API は 409 `state`、worker はその取り込みを飛ばす)。 - 前のプロセスが配置の途中で落ちて `placing` のまま残った取り込みは、次の `inbox` ジョブの先頭で `approved` に - 戻して配置し直す(並列 1 なので、そこで見える `placing` は必ず前の実行の残り。配置は音声の指紋で自分の - 成果物を採用するので冪等)。取り込みの `placed` と、WAV / ALAC / AIFF の normalize バッチ(`[normalize].wav_to_flac`)も登録 - トランザクションの中で確定する(commit の直後に落ちても `placing` が残らず、投入も欠けない)。`placed` の取り込みのディレクトリに走査で音声が見えたら(消せなかった原本、 - 配置の後に置かれたファイル)`pending` に戻して取り込みとして出し直す。 - Inbox は Library と別データセットなので move は実コピー(§5) -- **後続**は `rg`(album の属性が on なら album 単位、off なら登録した track ごと。D-74)と `transcode`。WAV / ALAC / AIFF は `[normalize].wav_to_flac` なら `normalize` の - 編集バッチを作って投入する(D-46 の予告)。**アートワークは配置の直後にその album だけ解決する** - (`scanner::resolve_album_artwork_now`。同梱カバー画像 → 構成トラックの埋め込み画像というスキャンの - Phase 5 と同じ規則・同じ DB 反映で、thumbnail もここで投入。**`library` の排他を持ったまま**行う(解放して - からだと並行する scan の予約 → 解決をこちらの古い読み取りで上書きし得る)。始める前に - `artwork_resolved_at = NULL` で予約するので、決められなくても(同梱カバーの探索や読み取りの I/O 失敗を - 含む)次のスキャンが拾う。P3-4) -- **既存の album への追記**(D-70): リリースキーは MUSICBRAINZ_ALBUMID の最頻値があれば `mb:`、自分の成果物の - album があればそれ、**無ければ宛先ディレクトリに active な album があり、その album にも MB キーが - 無ければその album を採用**(`album:<id>`)、それも無ければ取り込みごとの新規。MB キー同士が違えば従来どおり - 降格か衝突。**CD とそれ以外は混ぜない**(D-67 追記 3): トラックのタグに `MUSICBRAINZ_DISCID` があるものを - CD とし、CD の取り込みは CD の album にだけ、しかも album にまだ無い `disc_no` のときだけ採用する(MBID の無い - 複数枚組の 2 枚目が 1 枚目に合流する。同じ番号は同名の別の盤)。CD でない取り込みは CD でない album にだけ採用する。 - 同じ判定を再実行の「自分の成果物」と登録のトランザクションでも通し、外れたら `failed`。購読(§7.7)の - 束ね先が CD の album になっていたら同期は失敗する。採用する album は `GET /api/inbox` の `destination`(`{ album_id, album, track_count, - max_track_no, album_gain }` | null。下書きの category / albumartist / album と取り込みのファイルから引く。取り込みに - MUSICBRAINZ_ALBUMID があれば別リリースなので null)で見せる -- **album gain**(D-74): 下書きの `album_gain`(既定 false。承認画面のチェックボックス。追記先があればその - 現在値が初期値)を配置時に album の属性へ書く(追記先の属性も上書きし、off にすれば album の値を消す) -- **ARTIST の多値**(P4-4、D-70): 提案のトラックは `artist`(ファイルの ARTIST の全値を `"; "` で結合した - 表示文字列。1 値なら同じ)と **`keep_artists`**(ファイルの ARTIST が多値なら true)を持つ。配置は - `keep_artists` が true なら**現在の ARTIST の個数に関係なく** ARTIST に触れない(`artist` は表示用で、値は - 見ない。承認後にファイルが 1 値に変わっていても、そのまま)。false なら `artist`(空ならアルバムアーティスト) - の 1 値で置き換える(`;` で分割はしない)。提案と画面が true にするのは多値のときだけ(保存済みの - 下書きを再表示するとき、ファイルが多値でなくなっていれば画面は false に戻す)。`keep_artists` を持たない - 旧下書きは旧規則(`artist` が先頭値のままなら保つ)で解釈する。保つときのパス生成の `{artist}` は - Library の `artist_display` と同じ `", "` 結合(ARTIST が無ければ `artist` → アルバムアーティストの順) -- **埋め込み画像**(P4-4、D-70): 走査は各ファイルの `PICTURE`(`"<mime>:<sha256>"`)を、Library の - `pick_embedded` と同じ規則(front cover 優先、無ければ先頭)で選んだ画像が**先頭**になる順で記録する。 - 各ファイルの代表画像はその先頭。取り込みの見出しに出す画像は各ファイルの代表の最頻(同数なら先に現れた - もの)で、**取り込みの中の多数派を目安に見せる要約**。配置後の album の代表(同梱カバー → 最初のトラックの - 埋め込み、追記先なら既存の代表)と同じとは限らない。忠実なのはトラックごとのサムネイル - `GET /api/inbox/:id/artwork/:hash` は、取り込みがあれば `PICTURE` にその sha256 を持つファイルを走査順に - 開き(openat2。symlink / 境界外 / 消失は次の候補へ)、埋め込み画像の内容の sha256 が一致するものを - 探す。見つからなければ 404(取り込みに無い hash、ファイルが消えた、画像が書き換わった)。見つかれば - `If-None-Match` が ETag `"<hash>-orig"` に一致すれば 304、でなければ原寸を返す(MIME はタグの値ではなく - 内容の sniff。sniff できなければ 404。`Cache-Control: public, max-age=31536000, immutable`)。 - **304 の判定は実体の照合の後**(DB に `PICTURE` が残っていても実体が無ければ 404)。取り込みの状態は見ない - (rejected / failed でも実体があれば返す。placed は実体が消えているので普通 404。**画面は配置済みの取り込みの - 画像を Library の画像置き場 `/api/artwork/:hash` から出す**(配置で同じ画像が登録されている。`lib/inbox.ts` の - `artworkUrl` / `trackPictureUrl` / `itemThumbUrl`))。セッション必須 - (allowlist 無し)。その他の I/O 失敗は 500。サムネイルは作らない(画面で縮小)。同梱の `cover.jpg` - 等は配置時に埋め込みより優先されるが(§7.1 の規則)、承認画面が見せるのは埋め込み画像だけ -- **採番**: 下書きの提案で TRACKNUMBER の無いファイルは、採用する album の active な `track_no` の最大 + 1 から - ファイル名順に振る(無ければ 1 から)。承認の検証に「採用する album の active なトラックと `(disc_no, track_no)` - が重ならない」を加え、配置で失敗する前に 400 で直させる。配置の登録トランザクションでも同じ検証をする - (承認と配置の間に足された分。外れたら `failed`) -- **サイドカー `spindle-inbox.json`**(§7.7、D-70): 取り込みのディレクトリにあれば `GET /api/inbox` が読み、 - `category` を提案の category(語彙に同じ canonical key があるときだけ)に、`files` の `verdict` / `message` / - `url` / `channel` を各トラックに付ける。走査は音声でないので無視し、配置の成功時に消す(Library へ - 持っていかない)。壊れていれば無いものとして扱い、取り込みの `warnings` に載せる。モジュールは `import/sidecar.rs` - (ダウンローダと CD の吸い出しが共有する) -- **CD の吸い出しの取り込み**(D-67 追記、P2-5): サイドカーの `rip`(`RipEntry`: CTDB 形式の `toc`、吸い出し開始時の - `metadata`(`DiscMetadata`。名前は空でもよい)、音声トラック順のファイル名 `files`、ドライブが読んだ `isrcs` / - `mcn`(P4-21。旧サイドカーには無い)、rip.log の名前 `log`、`report`(`RipReport`))を持つ。提案は `album_gain = true`(D-74。保存した下書きがあればそちら)。配置は - 下書きの各トラックを **basename で `files` の位置へ結びつけ**(`bind_rip`。大小文字・正規化の違いは同じ名前)、 - 記録の形(件数が音声トラック数と揃う、名前の重複なし)・取り込みのファイルとの 1 対 1・下書きの `disc_no` が 1 つに - 揃うこと(1 取り込み = 1 枚)を確かめ、外れたら配置せず `failed`(提案の `warnings` にも出す)。登録は - `register_item` と同じトランザクションで `source_type = 'cd_rip'`、`album_verifications`(`source = 'rip'`、 - `disc_no` は下書きの値、`job_id` は NULL、`drive_offset` はレポートの `read_offset`(PCM に当てた - 読み取りオフセット。遡及照合の行は NULL。D-83 追記 3)、`log_path` は移した rip.log の Library 相対。移せなければ NULL)/ - `track_verifications` / `tracks.verification`(写像は §7.3 と同じ)。全トラックに `rip` の記録が既にあれば - 書かない(commit の後に落ちて再配置したとき)。サイドカーは読んだ FD の inode / size / mtime / ctime を - 登録の直前と消す前に照合し、変わっていれば登録せず `pending`(登録後なら消さずに残す) -- **承認後にファイルが増えた取り込み**(同じ 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 健全性チェック(移行時 + 任意) - -``` -flac -t で検証 - ├ STREAMINFO MD5 が未設定(全ゼロ) → デコードした PCM MD5 を STREAMINFO に補填(再エンコードしない) - │ 一部の古いエンコーダや配信由来の FLAC で実際に起きる。 - │ 未設定だと同一性解決の第 2 手段が使えず、遡及照合も不可能 - ├ デコードエラー → 要対応としてフラグ - └ 正常 → そのまま -``` - -**圧縮レベルを揃えるための一括再エンコードは行わない。** -`flac -8` は圧縮率だけの違いでデコード結果は完全に同一であり、削減は -概ね 0.5〜1.5%。対価として 200GB を書き直すことになり、ZFS スナップショットが -旧ブロックを掴むため保持期間中は実効使用量が倍増する。 -`flac -8` は新規生成物(CD リップ、WAV 正規化)にのみ適用する。 - -MD5 補填のための書き換えでは `audio_version` を据え置く -(音声内容が変わらないため Derived の再生成は不要)。inode と mtime は変わるので -DB の追随は必要。補填は再エンコードではなく STREAMINFO の MD5 16 バイトだけを書き換える -(P1-5b、D-57 / D-59)。補填は編集バッチ(`edit_ops.kind = 'md5'`、`edits.key = 'audio_md5'`、 -値は hex で全ゼロ = 未設定。`new_value` は反映時に計算値を書く)として記録し、tagwrite ジョブが -tmp + rename で反映する。巻き戻しは全ゼロを書き戻す(`audio_md5` は NULL、`flac_check` は -`md5_missing` に戻る)。 - -検査(`flaccheck` ジョブ)は読むだけで、結果を `tracks.flac_check`(`ok` / `md5_missing` / -`decode_error`)に検査時の `audio_version` 付きで記録する。版が進めば結果は古い扱いになり、 -`[normalize].flac_verify_on_import` ならスキャン完了時に結果の無い FLAC を自動で検査する。 -手動は `POST /api/flaccheck { selection }`。一覧の固定フィルタ `flac_unchecked` / `flac_error`。 - ---- - -### 7.10 偽ハイレゾ検出(任意) - -配信で買ったハイレゾ音源には、CD 由来の AccurateRip / CTDB のような品質の裏付けが無い -(`verification` は `unverifiable` のまま)。44.1 kHz / 16 bit のマスターをアップサンプリング・ -ビット深度の水増しで「24/96」として売る例があり、移行データにも `Album/Hi-Res/01.m4a`(24/96)と -アルバム直下の 16/44 が別トラックとして両方入っている。どちらを残すかの判断材料として、 -可逆かつ **`sample_rate > 48000` または `bit_depth > 16`** のトラックを解析し、結果を表示と -絞り込みに出す。**判定を消費する自動処理は無い**(Derived・配布ビュー・RG・リネームは判定に -依らない。D-71)。非可逆由来の可逆(44.1 kHz の FLAC が 16 kHz で切れている等)は対象外。 - -``` -hirescheck ジョブ(読むだけ。track_id + audio_version、並列 = max(1, CPU コア数 / 2)) - root の dirfd で開き fstat を行と照合 → media::decode で PCM を流す(PcmSink) - ├ スペクトル(sample_rate > 48000 のとき) - │ チャンネルごとに Hann 窓 8192 点 / ホップ 8192 の FFT(rustfft)。パワーはフルスケール正弦波が - │ 0 dB になるよう正規化(|X_k|² × (2 / Σw)²)し、線形のまま累積して有音フレーム数で割る - │ RMS が -70 dBFS 未満のフレームは無音として捨てる - │ 終了時(有音フレームが 1 以上のとき): - │ P[k] = 平均線形パワー(未平滑)。dB は 10·log10(max(P, 1e-20))(下限 -200 dB) - │ S[k] = 各ビンを中心に ±1/6 オクターブ(= 1/3 オクターブ幅)の P を線形平均して dB。 - │ 窓は [0, Nyquist] で切る(端は片側だけ) - │ floor_db = max(Nyquist 直下 5% のビンの S の中央値, −150 dB)。完全ゼロの帯域で床が - │ −200 dB に張り付き、遠いサイドローブまで拾うのを防ぐ(−150 dB は 24 bit の - │ 量子化ノイズ(1 ビン約 −186 dB)より上、16 bit のディザの直下) - │ 候補 f_s = S[k] > floor_db + 10 dB を満たす最高ビンの中心周波数(S は窓の下端 - │ f_s / 2^(1/6) まで信号を引きずるので、これ自体は上へ最大 1/6 オクターブずれる)。 - │ 該当ビンが無ければ(上端まで信号がある) cutoff_hz = Nyquist、cliff_db = NULL - │ cutoff_hz = 平滑化前の P_dB に戻り、[f_s / 2^(1/3), f_s] の範囲で 200 Hz 幅の dB 平均の段差 - │ mean(P_dB[k−m, k)) − mean(P_dB[k, k+m)) が最大のビンの周波数(推定エッジ)。 - │ brickwall では段差が真のエッジで最大になり、Hann 窓の漏れの量に依らない - │ (合成信号で 22.05 kHz → 22,090 Hz、24 kHz → 24,035 Hz) - │ cliff_db = 10·log10(mean(P[cutoff−1 kHz, cutoff)) / mean(P[cutoff, cutoff+1 kHz]))。 - │ **平滑化前の P で、cutoff と同じ推定エッジを中心に測る**(S で測ると崖を自分で - │ ぼかす)。帯域は [0, Nyquist] で切り、どちらかの帯域にビンが無ければ(cutoff が - │ Nyquist 直下・1 kHz 未満)NULL。分母が 0 なら +200 dB に飽和 - │ チャンネルが複数なら cutoff が最大のチャンネルを採り、**cliff もその同じチャンネルの値**を対で - │ 採る(片側だけ本物なら本物。cutoff と cliff を別々に最大化しない) - │ 有音フレームが 0 なら cutoff_hz / cliff_db は NULL - ├ ビット(bit_depth ≤ 24 のとき) - │ round(sample × 2^(bit_depth−1)) を i32 に戻して全チャンネル・全サンプルを OR - │ effective_bits = bit_depth − OR の末尾ゼロビット数。OR = 0(全無音)なら NULL - │ 32 bit は f32 で正確でないので NULL - └ 判定(上から順に最初に当たったもの。計測しなかった側は NULL) - decode_error : デコード失敗(hires_check_error に stderr / メッセージの先頭 500 文字) - inconclusive : 計測できたものが無い(cutoff_hz と effective_bits がともに NULL。全無音、 - 32 bit かつ ≤ 48 kHz) - both : upsampled かつ padded - upsampled : cutoff_hz ≤ [hires].cutoff_hz かつ(cliff_db ≥ [hires].cliff_db または - cutoff_hz ≤ [hires].hard_cutoff_hz)。後者は 44.1 kHz の Nyquist + 余裕で、 - >48 kHz のファイルでそこから上が空なら崖の有無を問わない(D-71 追記) - padded : effective_bits ≤ 16 - inconclusive : cutoff_hz ≤ [hires].cutoff_hz だが崖が無い(cliff_db が NULL か閾値未満。 - 自然なロールオフ。本物の可能性あり) - ok : いずれでもない -``` - -カットオフだけで判定しない理由: 本物でも録音や マスタリングの都合で自然に減衰する帯域がある。 -SRC のローパスは 1 kHz 以内に段差(実測 12〜21 dB。エッジ直下の音楽の残りと上げた後の床の差で -決まり、30 dB には届かない)を作るので、それを条件に加えて誤検出を避ける。ただし 44.1 kHz の -Nyquist(22.05 kHz)の下で床に沈むものは、段差が無くても本物の >48 kHz 録音ではあり得ない -(実機の 106 本の本物はすべて 25.4 kHz 以上まで伸びていた)ので `hard_cutoff_hz` で拾う。 -22.5〜25 kHz の帯(48 kHz マスター)で崖の無いものは `inconclusive` として人が見る。 - -結果は `tracks.hires_check` に検査時の `audio_version` 付きで記録し(`hires_checked_at` / -`hires_check_version` / `hires_check_error`)、**計測値 `hires_cutoff_hz` / `hires_cliff_db` / -`hires_effective_bits` も残す**(しきい値を変えたときや目視の判断に使う。判定は検査時に確定し、しきい値の変更は既存の -結果を書き換えない。再判定は手動投入)。版が進めば結果は古い扱い(`stale`)。 -`[hires].check_on_import` ならスキャン完了時に結果の無い対象を自動で投入する(flaccheck と同型)。 -手動は `POST /api/hirescheck { selection }`(対象外・missing は `skipped`)。 -一覧の固定フィルタ `hires_unchecked` / `hires_suspect`(`upsampled` / `padded` / `both`)。 -DSL は `hirescheck`(文字列)、`cutoff`(数値、Hz)、`cliff`(数値、dB)、`effectivebits`(数値)。 - -開いた FD の fstat が行と一致しない・missing なら何も書かず `Outcome::Done` で終える(flaccheck と -同じ。API の `skipped` は投入時に対象外だった数で、ジョブの終端とは別)。次のスキャンで版が進めば -再投入される。`record` は `WHERE audio_version = ?` で、検査中に版が進んでいれば書かない。 - -### 7.11 端末への配信 - -Library の曲とプレイリストを端末へ写す(P5、D-95)。Android(Poweramp)へは spindle が USB の -ADB で直接書き、iPhone へは Mac の `spindle-agent` がミュージック.app へ反映し、その先は Finder -同期に任せる(Linux から純正ミュージックへ直接書く手段が無いため)。**自動では送らない**: -端末ごとに差分を出し、利用者が確かめてから計画として確定し、確定した計画だけを実行する。表は -§6「端末への配信の表」、ジョブは §8、API は §9、端末タブは §12、設定は §13 の `[devices]`、adb -サイドカーは §14。送る系統は端末ごとに選ぶ(既定は Android が `opus`、iPhone が `aac`。§7.6)。 - -``` -CD ─┐ ┌─ device_sync ─ adb ─ Android(Poweramp) - ├→ Inbox → 承認・配置 → Library → RG → Derived ─┬─ opus ─┘ -YT ─┘ └─ aac ─→ spindle-agent(Mac)→ ミュージック.app → iPhone - ↑ 端末ごとのマニフェスト(あるべき状態)を spindle が毎回計算する -``` - -| | Android(`transport = 'adb'`) | iPhone(`transport = 'agent'`) | -|---|---|---| -| 運び手 | spindle の `device_scan` / `device_sync` / `device_verify` ジョブ。adb サーバは host ネットワークのサイドカーで、Unix ソケットだけで待ち受ける(D-98) | Mac の `spindle-agent`(常駐しない CLI)。`/api/agent/*` を Bearer トークンで叩く(D-99) | -| 端末側の正本 | `<root>/.spindle/manifest.json` とジャーナル、実ファイル | エージェントの `state.json` とミュージック.app の track。spindle へは報告で届く | -| 差分の確認と確定 | 端末タブの差分表 →「同期」 | `spindle-agent sync` が差分を表示 → `y/N` → 確定 | -| キャッシュへの取り込み | 回復の後(各ジョブの最初・破棄)と同期の完了 | 報告・破棄の受理 | -| 接続の検出 | `adb track-devices` の常駐監視。つながると `device_scan`(回復と差分の計算だけ)を投入し、待っている同期・検証を前倒しする | 無い(利用者が Mac で `sync` を実行する) | -| 端末タブの「反映済み」 | 端末上 | **Mac に反映済み**(`~/Music/spindle` とミュージック.app まで。Finder 同期の結果は spindle から見えない) | - -**マニフェスト(あるべき状態)** - -保存せず、差分を見るたびに DB から計算し直す(D-78 と同じ流儀。`domain::device` の純粋関数、 -`db::devices::compute` が 1 つの読み取りトランザクションで入力を読む)。曲ごとに **desired / hold / remove** -のどれかに入れる。 - -| 項目 | 規則 | -|---|---| -| 選曲 | `selection = 'all'` は全曲、`'playlists'` は印(`device_playlists`)の付いたプレイリストの曲の和集合(スマートは materialize 済みの結果。D-54)。どちらも missing と、チャンネル数が 1・2 以外(不明を含む)の曲は対象外(= remove)。印の付いたプレイリストは `all` でも端末へ書き出す | -| 循環の禁止 | `on_device` / `device_pending`(端末の状態を引く DSL のフィールド)を使うスマートプレイリストには印を付けられない(400)。印の付いたプレイリストのルールの保存でも同じ検査をする(同期のたびに選曲が変わるため。D-95 の P5-2 追記) | -| 評価待ち | スマートプレイリストの再評価は debounce される(D-54)ので、印の付いたスマートプレイリストがある `playlists` の端末は、評価待ちの間は確定を 409 `pending_reevaluation` で断る。差分画面には各プレイリストの評価時刻(`playlists.evaluated_at`)と評価待ちの印を、エージェントの manifest には評価待ちの印を出す | -| 送る元 | 系統(`devices.variant`)の Derived の行があり、`src_audio_version = audio_version`、かつ系統が有効なら `audio_profile` が設定と一致 → **Derived**(タグ版だけ古い `stale_tags` でも送る。タグの上書きでトークンが変わり、更新として送り直す)。系統が `opus` で原本が非可逆 → **Library の原本**(D-8。`aac` は非可逆原本も Derived に入るので原本は送らない)。それ以外 → **待ち**(理由は「Derived 未生成」、`aac` で RG が揃わなければ「RG 未解析」、音声版か profile が違えば「Derived の音声が古い」)。系統が設定に無い・凍結中は行の profile をそのまま有効とみなす(D-75 の凍結と同じ) | -| 送る元のハッシュ | `source_hashes` に送る元の**意味トークンが同じ行**があるときだけ送れる。原本はさらに行の物理同一性(inode・size・mtime_ns・ctime_ns)が `tracks` の現在の値と一致すること(外部ツールが同じタグで再保存した原本を「変化なし」としないため)。無ければ「待ち: ハッシュ計算中」で、差分を計算した側が `source_hash` ジョブを投入する(下記) | -| 端末上のパス | Library の `rel_path` と同じ相対パスで、拡張子だけ送る元に合わせる(`J-Pop/YOASOBI/THE BOOK/1-01 群青.opus`)。Derived と原本が 1 つの root に並ぶので、m3u8 を root 相対で書ける | -| パスの検査 | 端末の root を前置きした長さが 240 UTF-16 単位(§5)を超えれば「エラー: パスが長すぎる」。前置きは adb が `/storage/<volume>/<root>/`(内部ストレージは `/storage/emulated/0/<root>/`)、agent が `Music/spindle/`。別の曲と `dest_path_key`(`canonical_key` = casefold + NFD)が重なれば**どちらも**「エラー: パス衝突」(黙って片方を勝たせない。Library のリネームで解消する)。行き先を「パスを変えない管理下の曲」(保留中の曲を含む)が占めていれば、その曲も「パス衝突」で保留にし、不動点まで繰り返す(入れ替えの片側が保留になれば、もう片側も保留。D-95 の P5-1 追記) | -| プレイリスト | 印の付いたプレイリストを、衝突を解いた後の desired と hold から組み立てる。載せるのは**同期の後に端末に実在する曲**だけ: desired は今回のパス、hold で既存の写しがある曲は `device_items` のパス。adb は `Playlists/<名前>.m3u8`(`#EXTM3U` と `../<root 相対パス>` の行、UTF-8)、agent はミュージック.app の「spindle」フォルダ直下の同名のプレイリスト。名前の `canonical_key` が重なる・予約名(`.spindle`)と同じものはエラーで、端末上の既存のものに触らない | - -| 集合 | 条件 | 端末上の既存の写し | -|---|---|---| -| **desired** | 選曲に入り、送る元とハッシュが揃い、エラーが無い | 追加 / 更新 / 移動 / 更新 + 移動 / 変化なし | -| **hold** | 選曲に入るが、待ち(上の理由)かエラー(パス衝突・パス長) | **触らない**(古い写しがあればそのまま残る) | -| **remove** | 選曲から外れた・missing・マルチチャンネル | 削除 | - -**トークン**は構成要素の正準 JSON(キー昇順・空白なし・スキーマ版 `v` を含む)の SHA-256 で、 -解析しない不透明な値: - -- **意味トークン**: 送る元の版。Derived は行に記録した版(`variant`・`src_audio_version`・ - `src_tag_version`・`src_artwork_id`・`src_rg_scanned_at`・`audio_profile`・`tag_profile`)、原本は - `audio_version`・`tag_version`。`source_hashes` がどの版のハッシュかを示す -- **配信トークン**: 意味トークン + sha256。端末の中身の同一性と、エージェントのファイル取得の強い - ETag はこれで判定する(版が進まないまま外部ツールがバイト列を変えても、更新として送り直す) -- プレイリストのトークン: `playlist_id`・名前・生成したバイト列の sha256 - -**差分**は desired と `device_items` を `track_id` で突き合わせる。トークンもパスも同じなら変化なし、 -トークンだけ違えば**更新**、パスだけ違えば**移動**、両方違えば**更新 + 移動**、`device_items` に -無ければ**追加**、remove に入る既存の写しが**削除**。hold は件数と理由だけを出す。プレイリストは -`device_playlist_state` と突き合わせて追加 / 更新(中身か置き場所が違う。改名を含む)/ 削除。 -各曲の端末の状態(`synced` / `pending` / `waiting` / `error` / `removing`)は一覧・詳細と DSL の -`device_pending` に使う(D-95 の P5-2 追記)。 - -**`source_hash` ジョブ**(曲 × 送る元の単位、dedup `source_hash:<track_id>:<source>`、 -CPU の共通並列予算の対象で並列度 2。D-73): 送る元を root の dirfd から `openat2` で開き、開いた -FD の物理同一性を記録してから全体を読んでハッシュし、読み終えた後の `fstat` が開いた時と同じで、 -意味トークンも変わっていなければ保存する(途中で差し替わったら捨てて再試行)。原本は開いた FD が -`tracks` の物理同一性と食い違えば読まず、増分スキャンを投入して「スキャン待ち」で終える(同じ物理同一性の -間は再投入しない)。Derived の実ファイルが無ければ(D-51 の drift)`derived_files` の行を消して -transcode を投入する。投入は差分の計算(端末一覧と差分)のたびに、未完了と抑制中のものを除いて 1 つの -SAVEPOINT で行う(D-95 の P5-1・P5-2 追記)。初回は全曲分が走る。 - -**配信時の照合**(ADB の転送・エージェントのファイル取得の両方): 送る元を開いた FD の物理同一性が -`source_hashes` の行と一致しなければ送らない(ADB はその曲を項目のエラーにし、エージェントへは -412 `source_changed`。どちらも行を捨てて次の差分で取り直させる)。ADB は送りながら spindle -側でもハッシュを取り、期待値と違えば同じく取り直させる。Derived の差し替えや Library の外部更新と -競合しても、**開いた FD の中身とハッシュの組**がずれない。 - -**計画** - -- **確定**: 差分の応答は、`generation` と各操作(種類・track_id / playlist_id・from・to・トークン)を正準 - JSON にした `plan_token` を持つ。確定の要求はそれを添え、サーバが計算し直した差分の - `plan_token` と一致したときだけ、計画を `device_sync_plans` に**不変のまま**保存する(違えば - 409 `plan_changed`。画面・エージェントは差分を取り直して再確認させる)。差分を見てからボタンを押すまでに - 増えた削除を黙って実行しないため。adb は確定と `device_sync` の投入を 1 つのトランザクションで行う。 - agent は最初の副作用の前にエージェントが確定する -- **端末ごとに open な計画は 1 つ**。同じ `plan_token` の確定は同じ計画を返し(冪等)、違えば - 409 `open_plan_exists`。open な計画か待ち・実行中の `device_sync` がある間は、名前以外の PATCH・ - 印の変更(PUT)・pair コードの発行を 409 `open_plan` にする(`generation` を動かさないため)。 - 印のついたプレイリストの削除も同じ規則で、印のある端末のどれかに open な作業があれば 409 `open_plan` - (端末名を message に出す)。通れば印のある端末の `generation` を削除と同じトランザクションで進める - (D-102)。プレイリストの中身・名前・ルールの変更は止めない(次の差分で反映される) -- **開始済みの計画の再開**: 実行するのは計画の部分集合だけ。今の状態で満たされている操作は済みとして - 飛ばし、今の差分にも**同じ操作・同じトークン・同じパス**で残っているものだけを実行する。差分から消えた・ - 変わった操作と、計画に無い操作(とくに新しい削除)は実行せず、次の差分に回す。パス変更は入れ替え・ - 循環の成分ごとに全員残すか全員外す。例外は始まったパス変更のバッチ(下記)と、 - 旧パスの削除の後で切れたプレイリストの改名(追加として続ける。D-95 の P5-3a 追記) -- **終端**: adb はジョブの成功で `completed`。失敗・キャンセル・未接続では `open` のまま残り、 - 端末タブが「続きを実行」(同じ計画で同期を投入し直す)と「破棄」を出す。agent は報告の受理で - `completed`、エージェントの `abandon` で `abandoned`。終端した計画への報告は受けない(同じ報告の - 再送だけは `report_digest` の一致で成功を返す。D-99)。終端した計画は GC が保持期間後に消すので、それより - 遅れて届いた再送は 404 `no_plan` になる(エージェントは `plan_id` を外して止まり、次の sync が報告だけを - 取り直して回復する。端末ごとの最後の終端 1 件は残すので、通常の再送は影響を受けない。D-102) -- **端末が戻らないときの逃げ道**(D-98): 破棄に `force` を付けると、端末につながず回復もせずに計画を - `abandoned` にし、待ちの同期を取り消す(実行中の同期・端末のロック中は 409 `busy`)。adb で安全なのは、 - 次につないだときの回復が計画と無関係に端末のジャーナルから封印済みバッチを完遂・取り消すため。iPhone の - Mac が失われたときもこれで閉じる。端末の削除も open な計画があっても通す(端末のジョブが実行中か - ロック中なら 409 `busy`。待ちのジョブは取り消す)。印のついたプレイリストの削除が `open_plan` で止まるときも、これで開く - -**Android(ADB)** - -- **接続**: adb のサブコマンドは `shell`(shell v2)だけを使う。pty を割り当てなければ stdin / - stdout がバイナリ安全で終了コードも返る(`exec-in` / `exec-out` は終了コードを返さない)。 - ジャーナルの行と転送する中身は stdin で流し、端末側のスクリプトに埋め込むパスは - 1 か所の関数で単引用符クォートする(`sh -c` はローカルでは使わない)。未接続は adb クライアントの文言か - `get-state` で判定する(D-95 の P5-3a 追記、D-98) -- **登録**(端末タブの「未登録の Android」): 保存先は内部共有ストレージか SD カードの - `Music/spindle`。**root が無いか空のときだけ**登録でき、`.spindle/manifest.json` を作る(空でなければ - 409 `not_empty`。既存のファイルの扱いを推測しない。片付けるのは、中身が `.spindle` の下だけで - **回復に要る物が無い**(`.spindle/moving` の下にファイルが無く、ジャーナルが無いか空)前の登録の失敗の残りで、 - manifest が無い・読めない・空のものだけ。それ以外は 409 `not_empty`。曲かプレイリストを指す manifest は捨てず 409 `adoptable` で - 引き継ぎを案内する。対象は `volume` が登録先と同じで、uuid が DB に無いものに限る)。 - 保存先は PATCH で変えない(削除して登録し直す)。Poweramp の - スキャン範囲はそのフォルダ設定なので、保存先はその下に置く -- **引き継ぎ**(DB 全損後。`POST /api/devices` の `adopt: true`。D-103): 未登録の一覧は、root の manifest が - 読めて、`volume` が一致し、uuid が DB に無く(32 桁の小文字 16 進)、曲かプレイリストを指す - (空の manifest は引き継げない)ボリュームを `adoptable` にする。プレビュー - (`GET /api/devices/adb/adopt-preview`)は書かずに付け替える曲・外れる曲・プレイリスト・管理外の件数と - `library_busy` を返す(ジャーナルは回復しないので件数は目安)。確定は `register_lock` の下で、 - Library の `scan` ジョブが待ち・実行中なら 409 `library_busy`(DB の作り直し途中だと大半が外れる)→ - manifest を読む(無い・読めない・`volume` 違い・空・uuid の形が不正・回復で manifest の不整合が - 見つかったときは 409 `not_adoptable`、uuid が DB にあれば 409 `uuid_registered`。端末との通信の失敗は - 既存のエラー応答の 502 `device_failed` / 409 `not_connected`)→ - `scan` が待ち・実行中でないことと remap の入力(Library の曲とプレイリスト)を 1 つの読み取り - トランザクションで読む(スキャン中なら 409 `library_busy`。別々に読むと間に始まったスキャンの途中の曲で - remap し得る)→ 回復(`recover`。DB は見ない)→ remap → `compact` で manifest を書き直す → - 1 トランザクションで manifest の uuid の行を作り、付け替えたプレイリストに印を付け直す → - `device_scan` を投入する。`generation` は作成時の既定のまま(印の復元で進めない)。印を付け直すのは、 - 端末に m3u8 があるのはそのプレイリストに印が付いていたからで、付けないと `selection=playlists` の端末の - 最初の差分が全削除になるため。remap は曲を manifest の `rel_path` の `canonical_key` で今の tracks - (missing を除く)から引いて `track_id` を付け替え、プレイリストは `Playlists/<名前>.m3u8` の名前で付け替える。 - 1 つのキーに複数の曲、1 曲に複数の項目が当たるものは採らない。**採らなかった項目は manifest から外して管理外に - する(ファイルは消さない)**。曲のトークンは `ADOPTED_TOKEN`(`"adopted"`)、プレイリストは `STALE_TOKEN` - (回復が先に `STALE_TOKEN` にした曲は変えない)。remap は冪等で、途中で落ちてもやり直せば同じ結果になる。 - Android 以外の transport は 400。Mac は `pair --adopt` で、同じ付け替えの規則をサーバで計算する - (下の「iPhone」の「引き継ぎ」、D-104) -- **端末側の正本**: manifest は `format`・`device_uuid`・`volume` と、曲(track_id・path・配信トークン・ - size・sha256・`rel_path`・`source`)とプレイリスト(playlist_id・path・トークン)の一覧。知らない `format`、id とパスの重複、 - root 外のパス、10 万件超は読まずに止める。`device_uuid` と `volume` が端末の行と揃わなければ止める(別の - 端末のカード・カードの差し替え)。**manifest に無いファイルには触らない**(手で置いたものは - 「管理外」として件数を出し、置き先を塞いでいれば「管理外のファイルと衝突」でその曲を保留にする)。曲の `rel_path` は送った元の Library の - `tracks.rel_path`、`source` は送る元の種類(`SourceKind::as_str()` の `master` / `opus` / `aac`)で、 - どちらも必須(引き継ぎの付け替えの鍵。ジャーナルの曲の意図にも任意で載せ、回復で取り込む)。`format` は 1 - のまま、この 2 項目の無い旧 manifest は読めない(リリース前の変更。保存先を消して登録し直す) -- **ジャーナル**(`.spindle/journal`、1 行 1 レコードの NDJSON): 各操作は**意図を耐久化してから**副作用を - 起こす。追記 → `sync` の後に副作用、ファイルの置き換えは同じディレクトリの tmp → `sync` → `mv -f` → - `sync`、圧縮は manifest を書き直して確定させてからジャーナルを空にする(逆だと回復できない)。 - バッチ以外の意図は `put` と `rm` だけ。完了は次の意図と同じ追記に載せ、manifest の書き直しは完了 - 50 件ごとと同期の最後に行う(1 曲ごとに 2 回 `sync` すると初回の全曲転送が数時間延びる。D-95 の P5-3a - 追記) -- **パス変更(移動・更新 + 移動)は計画の全件を 1 つのバッチ**にし、大域的な 2 段で進める(入れ替え・ - 循環・大小文字だけの改名で上書きしないため。Library の一括リネームと同じ考え方): メンバー全員を記録して - `sealed` → 更新 + 移動の新しい内容を一時ファイルへ転送して照合(`prepared`)→ 全員の旧パスを空ける - (`vacating` → `vacated`)→ 全員の行き先を埋める(`placing` → `done`)。一時ファイルは - `.spindle/moving/<batch_id>-<op_id>`(`.new`)。`prepared` 以前は副作用が一時ファイルだけなので破棄・ - 縮小できる(先に `batch_abort` を耐久化)。 - **`vacating` 以降はキャンセルも `generation` の不一致も効かせず、記録した内容のまま完遂する**(一部だけ - 止めると入れ替えが壊れる) -- **回復**(各ジョブの最初と破棄。DB は見ない): manifest とジャーナルを読み、完了の無い `put` は置き先の - sha256 が意図と一致すれば反映済みとして取り込む。封印済みで `vacating` 以降のバッチは、メンバーの位置を - ファイルの在否で判定して前進で完遂し(`from` にも `staging` にも無いメンバーは手で消されたとみなし、 - manifest から外す)、`prepared` 以前のバッチは破棄する。実ファイルが無い・サイズが違う曲は manifest から - 外さずトークンを空(`STALE_TOKEN`)にする(外すと管理外になって上書きも削除もできない。次の差分で「更新」に - 戻る)。ただし実ファイルを見失った曲は、`.spindle/moving/` に残ったファイルと sha256 で照合できれば元の - パスへ戻す。最後に manifest を書き直してジャーナルを空にし、その結果で `device_items` / - `device_playlist_state` を全置換する(D-95 の P5-3a 追記) -- **同期**(`device_sync`、payload は端末と計画の id、dedup は端末ごと、最大 3 回): - 端末ごとの排他(`job_mutexes` の `device:<id>` とプロセス内のロック)を取り、計画がまだ - open かをロックの後で確かめ直し、回復してから再開の規則で実行する計画の部分集合を決める。順序は - **削除 → パス変更のバッチ → 更新 → 追加 → プレイリスト**(先に空きを作る。プレイリストは旧パスの - 削除を全部先に行ってから書く)。始める前に、計画の順に見た追加量の最大値 + プレイリストと manifest の - 大きさ + 64 MiB を空き(`stat -f`)と比べ、足りなければ送らず失敗する。転送は送る元の FD を stdin に - 繋いで端末の `<to>.spindle-tmp` へ書き、端末の `sha256sum` で照合してから `mv -f` する。`vacating` より - 前の書き込みの直前にキャンセルと `generation` を確かめる(不一致は失敗で、再試行しない)。完了で manifest - からキャッシュを、この同期のエラーから `device_errors` を全置換し、計画を `completed` にする(同期の途中で - 計画が閉じられていたら、キャッシュも `last_synced_at` も進めずに失敗) - - **進捗**(D-109): 項目の始めと終わりに `progress` を呼ぶ。件数は `JobEvent` の `done` / `total`、 - SSE の `job` イベントの detail は `{"kind":"device_sync","phase","current","bytes_done","bytes_total","rate"}` - (`phase` は `delete` / `move` / `send` / `playlist`、`current` は送り先のファイル名(削除は元のファイル名)、プレイリストは - そのファイル名)。`bytes_total` は端末へ中身を送る曲(`Add` / `Update` / `UpdateMove`)の `size` の和で、 - 引き継ぎの照合で済む曲と純粋な `Move` は含めない。`bytes_done` は曲を 1 曲処理し終えるごとに増やし - (曲の途中では増えない)、保留・失敗になった曲の `size` も処理し終えた量として足す。パス変更のバッチは - 失敗した曲を外してやり直すので、バッチの中では送り終えた量の最大値を見せて減らさず、バッチを終えたら - 全体を確定させる。バッチの仕上げ(空ける・置く)でもメンバーごとに `current`(送り先のファイル名)を出し、 - 置くたびに件数を 1 進め、終えたら件数を「バッチの開始時 + バッチの項目数」に確定させる。`rate` は直近 5 秒の標本(基準は 5 秒前以前で最新の標本、無ければ 0)からの - バイト差 ÷ 経過秒で、`JobControl` が持つ。detail は SSE にだけ流し DB には書かない(DB への進捗の書き込みは - `JobContext` が 250 ms に間引く) -- **引き継いだ写しの近道**: `Update` でパスが同じ(`from` 無し)、book の項目のトークンが `ADOPTED_TOKEN`、 - `sha256` と `size` が操作と一致する曲は、転送もジャーナルも省いてトークンだけを操作のものに書き換える - (信頼の水準は日常の差分と同じ。manifest の `sha256` は転送時に端末上で測って照合した値)。ただし送る元は - 通常の転送と同じく開いて identity を照合し、変わっていれば項目エラー + ハッシュの取り直し、開けなければ - 項目エラーにしてトークンを進めない。この曲は転送量の - 見積もりと空き容量の確認から外し、差分では op `adopted`(UI は「引き継ぎの照合」)として分ける。回復や - `device_verify` が付けた `STALE_TOKEN` が優先されるので、壊れた写しは送り直す。同期の終わり(最後の - `compact` の前)に、パスのキーが今の desired と一致する全項目の `rel_path` / `source` を今の値へ書き直す - (移動の操作が出ない `rel_path` の変化があっても新しく保つため) -- **Poweramp**: 同期の最後に `am broadcast -a com.maxmpz.audioplayer.ACTION_SCAN_DIRS` を API - レシーバへ明示(`-n`)で送る。extras は付けない(パスを指定する extra は無く、 - `eraseTags` は全タグ消去の副作用がある)。Poweramp が無ければ何もせず、失敗は警告に留めて同期は成功扱い - (D-98) -- **内容の検証**(`device_verify`): 日常の差分は**サイズまでしか見ない**(全曲の - sha256 を毎回取ると数十分かかる)ので、同じサイズの破損や端末上の書き換えは日常の差分では見つからない。 - 端末タブの「内容を検証」が全曲の `sha256sum` を取り、食い違った・無くなった曲を `STALE_TOKEN` にして - manifest を書き直し、`device_items` も全置換する(次の差分で「更新」になる)。 - 回復済み(ジャーナルが空)の正本にだけ行う -- **未接続**: 同期・検証は attempts を数えずに 300 秒後へ回し(`RequeueAfter`)、 - `track-devices` がその端末の `device` を見たら前倒しする。`device_scan` は待たずに終わる(次の接続で - 投入される)。端末のロックが取れなければ 15 秒後に回す。`[devices].adb_server` が空なら ADB の操作は - 503 `adb_disabled`(強制破棄と端末の削除は動く。D-98) - -**iPhone(Mac の spindle-agent)** - -エージェントはリポジトリ内の別クレート `agent/`(ワイヤ型は serde だけに依存する -`agent-proto` で本体と共有)。導入・前提・困ったときは `agent/README.md`、エンジンの判断は D-100、 -ミュージック.app と配布の判断は D-101。 - -- **コマンド**: `pair <URL> <コード> [--insecure-http] [--adopt]`(端末タブで発行した 10 分・1 回限り・ - 5 回まで試せるワンタイムコードでトークンを受け取る。発行すると旧トークンは失効。`--adopt` は下の - 「引き継ぎ」)、`sync`、`status`(引き継ぎが途中なら `adopt_pending` を出す)、 - `resolve`、`abandon`。多重起動は state のディレクトリの `lock`(`flock`)で防ぐ。平文の http は - `--insecure-http` なしでは拒む -- **保存先の所有権**: ローカルの root は `~/Music/spindle`(起動時にシンボリックリンクを解いた - 実パスにする。ミュージック.app が track の場所をそう持つため。root の中のリンクは辿らない)。 - pair のときに**無いか空のときだけ**使い、`.spindle-device` に `device_uuid` と nonce を書いて - sync のたびに照合する。ミュージック.app 側はエージェントが作った「spindle」フォルダで、衝突しない一時名 - `spindle-setup-<nonce>` で作ってから改名する(既存の同名フォルダは採らずに止める)。 - **管理対象は state と `pending_ops` / `pending_batches` にある persistent ID とパスだけ**で、管理外の - ファイル・同名のプレイリストは上書きも削除もせず「管理外と衝突」で保留する -- **前提の検査**(副作用の前): ミュージック.app の「ファイルを[ミュージック]フォルダにコピー」は - **オフ**が前提。外から読めないので、`add` が返した track の location が期待パスと違うことで検出し、 - その track を消して止める。root かその祖先がミュージックのメディアフォルダ(直下に - `Automatically Add to Music.localized` か `.Media Preferences.plist` がある)なら pair も - sync も止める(メディアフォルダの中の曲は削除でファイルまで消え、コピー設定が ON でも複製されず、 - 前提が崩れる。D-101) -- **sync の順序**: 前提の検査 → root の印と pair の初期化の続き → `pending_batches` と - `pending_ops` の回復 → 途中の引き継ぎ(`adopt_pending`)の続き → manifest の取得 → **再発見**(ミュージック.app の file track のうち location が - root の下を指すものを state と突き合わせる)→ open な計画の照合 → 差分の表示と `y/N` → 確定 → 反映 → - 報告。バッチの途中は location が `.moving/` を指すので、回復を再発見より先に行う。 - open な計画を再開するのは、自分が `y/N` を経て確定し state に `plan_id` を記録した計画だけ(それ以外は - 実行せずに報告して閉じる)。回復か再発見で state が変われば、何も実行しない計画を確定して報告だけを - 行い、表示する差分をエージェントの実状態に合わせる(D-100) -- **受け取り**: `GET /api/agent/files/:track_id` に常に `If-Match: "<配信トークン>"`(強い ETag)を付け、 - 続きは同じ `If-Match` に `Range` を足す(`If-Range` は不一致で全文が返り、途中のファイルと混ざりうるので - 使わない)。版が変わっていれば 412 で、その操作は次の差分に回す。受け取ったら size と sha256 を確かめ、 - 同じディレクトリの tmp → fsync → **上書きしない rename**(macOS は `renameatx_np(RENAME_EXCL)`。 - 対応しない FS では止める)→ 親の fsync -- **ミュージック.app への反映**は JXA(`osascript -l JavaScript`。trait のメソッド 1 回 = 起動 1 回、既定 - 120 秒で打ち切り)。追加は `add`、更新は同じパスへ差し替えて `refresh`(再生回数を保つ)、削除は - track を消してからファイルを消す。パス変更は ADB と同じバッチと相で、旧ファイルを - `.moving/<batch_id>-<op_id>` へ動かして location を付け替える(track は残す)。 - プレイリストの中身は一時プレイリスト `.tmp-<op_id>` を作って埋め、旧を消して改名する(途中で落ちても空や - 重複のものが本名で残らない)。state の書き込みは tmp → fsync → rename → 親の fsync で、 - 1 つの遷移で変わるもの(曲の更新・バッチの相・`pending_*` からの除去)は 1 回の書き込みに含める -- **外で変わった写し**: size・sha256 が state と違う曲は track を残してトークンを空にし(ADB の - `STALE_TOKEN` と同じ)、ミュージック.app から track が消えた曲は state から外して自分のファイルも消す - (次の差分で追加になる)。コピー設定 ON のまま `add` の直後に落ちたときの複製は件数にかかわらず自動では - 消さず、候補を表示して `resolve <op_id> --delete-track <persistent_id>` か `--no-copy-created` を明示で - 選ばせる -- **state の曲の出どころ**: state(`state.json`)の曲は persistent ID・パス・トークン・size・sha256・ - inode / mtime に加えて、`rel_path`(送った元の Library の `tracks.rel_path`)と `source`(送る元の種類。 - `master` / `opus` / `aac`)を必須で持つ(引き継ぎの付け替えの鍵。§7.11「端末側の正本」の manifest と同じ)。 - 値は manifest(`GET /api/agent/manifest` の `items[].rel_path` / `source`)の同じ `track_id` の項目から取り、 - manifest に無い曲の追加・更新は項目のエラー(「出どころが無い(manifest に無い曲)」)にする。曲の - `pending_ops` には任意で、パス変更のバッチのメンバーには必須で持たせ、回復で写す。`STATE_VERSION` と - `PROTO_VERSION` は 1 のまま、この 2 項目の無い旧 state は読めない(リリース前の変更。作り直す) -- **進捗**(D-108): `spindle-agent sync` は確認の後の実行中(削除 → パス変更 → 更新 → 追加 → プレイリスト)の - 進捗を、Mac の端末とサーバに出す。件数は 1 件を扱い終えるたびに進め(見送り・エラーも数える)、バイト数は - 受け取りが要る項目の `size` の和に対する受信量(失敗・見送りは残りを足して合計に届かせる)。速度は直近 5 秒の - 移動平均、残り時間は残りバイト ÷ 速度。サーバへは `POST /api/agent/plans/:id/progress` を 2 秒に 1 回まで送る - (件数が変わらなくても速度と生存のために送る。項目の始めにも送る機会を作り、CLI では別スレッドの心拍が、 - 1 項目が長く掛かる間も前回の送信から 2 秒空けば最新の値を送り直す。間隔・失敗の数・打ち切りはエンジンと共有し、 - 前の計画の送信の結果は次の計画に記さない。速度は送る時点で計り直す。最後の 1 回は心拍の送信の終わりを待って送る)。**サーバはメモリだけに持つ**(端末ごとに 1 件。再起動で消え、次の - 送信で戻る)。計画が閉じる(報告・破棄・強制破棄)と消して `device_progress`(`progress: null`)を流す。 - 端末の応答の `sync_progress` は、保持している進捗の `plan_id` が今の open な計画と一致するときだけ出す。 - 受理後に計画を確かめ直し、閉じていたら消して null を流す(閉じる側の消去との競合)。端末の削除でも消す。 - 送信の間隔は前回の送信が終わってから数える。送信の失敗(通信エラー・タイムアウト・5xx)は同期を止めず、 - その回を捨てて間隔を 2 秒・4 秒・8 秒…(上限 60 秒)と延ばし、3 回続けて失敗したらその計画では以後送らない - (詰まったサーバのタイムアウトで受信を止めないため)。404 / 409 を受けたらその計画では以後送らない(古いサーバの - 404 もこれ)。次の計画(再開した計画の後の新しい計画)ではまた送る。終わりの要約(`完了: …`)は最後まで実行した - ときだけ出し、途中で止めた(`Stop`)ときは進捗の行を消して止めた理由だけを出す。 - Android(adb)はこの経路を使わず、ジョブの `job` イベントの detail で流す(下の「同期」の進捗、D-109) -- **引き継ぎ**(`pair --adopt <URL> <コード>`。DB 全損後。D-104): DB を作り直して端末タブで作った新しい - 行の pair コードで、この Mac の保存先とミュージック.app の track をそのまま引き継ぐ。 - 1. 前提(コードを使う前): 通常の pair と同じ検査(コピー設定・メディアフォルダ・Automation・トークンの - 置き場)、前の登録の state(初期化が済み、サーバの情報がある)があること(無ければ「引き継ぐ前の登録が - ありません」で止める)、root の `.spindle-device` が state の uuid と一致すること、ローカルの回復 - (サーバを使わない)で `pending_ops` / `pending_batches` が空になること(残れば止める) - 2. `POST /api/agent/pair` に `adopt_uuid`(state の uuid)を付ける。サーバは 32 桁の小文字 16 進でなければ - コードに触らずに 400 `bad_request`。コードの消費と同じトランザクションで、行が端末の状態を一度も持って - いない(`last_synced_at` が NULL、計画が 1 件も無い、`device_items` と `device_playlist_state` が空)うえ - `adopt_uuid` が他の行に無いときだけ行の uuid を差し替える。行の uuid が既に `adopt_uuid` なら、報告も - 計画も無い(`last_synced_at` が NULL、計画が 1 件も無い)ときだけ差し替え済みのやり直しとして通す - (前の adopt が `device_items` などを書いた後で落ちたやり直しを含む)。報告か計画があれば DB を失って - いない端末なので断る。それ以外は 409 `not_adoptable` で、コードは消費せず、予約した試行の枠も返す - (断られても 5 回の試行に数えない) - 3. エージェントは応答の uuid が state と同じことを確かめ、サーバの URL・名前と `adopt_pending = true` を - 保存してから、トークンを保存する(逆順だと間で落ちたとき、新しいトークンと付け替える前の state が - 組み合わさる。この順なら次の `sync` は古いトークンで止まり、`pair --adopt` のやり直しで続く)。続く adopt - はこの新しいトークンで送る - 4. `POST /api/agent/adopt` に state の曲(`track_id`・パス・`rel_path`・`source`・size・sha256・ - トークンが `STALE_TOKEN` か)とプレイリスト(`playlist_id`・名前)を送り、付け替え表(外れたものは - `to: null`)を受け取る。付け替えは Android と同じ規則(曲は `rel_path`、プレイリストは名前の - `canonical_key`。曖昧なものは採らない)。サーバはそのまま `device_items`(トークンは `stale` なら - `STALE_TOKEN`、それ以外は `ADOPTED_TOKEN`)・`device_playlist_state`(`dest_path` は名前、トークンは - `STALE_TOKEN`)を全置換し、付け替えたプレイリストに印を付け足し(既存の印は外さない)、`device_errors` を空にする。 - `generation` と `last_synced_at` は進めない。冪等で、同じ入力を送り直しても同じ結果になる - 5. エージェントは state の `tracks` / `playlists` のキーを付け替え、曲のトークンを `ADOPTED_TOKEN` - (`STALE_TOKEN` の曲はそのまま)、プレイリストのトークンを `STALE_TOKEN`(サーバと同じ。最初の同期で - 中身を同じ名前のまま書き直す)にし、`plan_id` を消し、`needs_report` と `adopt_pending` を下ろして - 1 回で保存する。**外れた曲・プレイリストは state から外すだけで、ファイルもミュージック.app の track も - 消さない**(管理外になる) - 3 と 5 の間で落ちたら、次の `sync` が回復の後・manifest の取得の前に 4 と 5 をやり直す(`pair --adopt` の - 打ち直しでもよい。サーバの差し替えはやり直しを受け付ける)。`--adopt` 無しの pair で uuid が state と - 食い違ったときは今どおり止め、文言で `pair --adopt` を案内する(同じ端末の pair し直しと区別できないので、 - コードを使う前には検出しない) -- **引き継いだ曲の近道**: `Update`(`from` 無し)で、state の曲のトークンが `ADOPTED_TOKEN`、パスの鍵・ - size・sha256 が計画の項目と一致すれば、受け取りもミュージック.app の操作もせず、トークン(と `rel_path` / - `source`)だけを書き換える。報告はそのトークンで行う(計画に含まれるので検証を通る)。受け取らないので - 実行前の空き容量の確認からも外す。信頼の水準は日常の差分と同じ(state の sha256 は再発見の inode / - mtime のキャッシュと読み直しで保たれている) -- **報告**(`POST /api/agent/report`): `generation`・`plan_id`・再発見で確かめた state 全体・ - 失敗した項目を送る。サーバは計画がその端末の open な計画で、`generation` が一致し、各項目が「現在の - desired」「既存の `device_items` と同じ値」「その計画で許された遷移の結果」のどれかに完全一致すること - (トークンが空の曲は `(track_id, パスの鍵)` の一致だけ)、件数(10 万)とパスの形を確かめ、 - **1 件でも外れれば全体を 400 で拒否**する。通れば 1 つのトランザクションでキャッシュと `device_errors` を - 全置換し、計画を `completed` にして `report_digest` を記録する(D-99)。`abandon` も同じ検証を通り、 - `pending_ops` と `pending_batches` が空のときだけ受ける -- **トークンの保管**は macOS の Keychain(サービス `spindle-agent`、アカウント `token`)。ssh - 越しでは使えないので、pair はコードを使う前に保存できるかを確かめる。`SPINDLE_AGENT_SECRETS=file` で - state のディレクトリのファイル(0600)に置ける。サーバはトークンのシークレットを - SHA-256 で持つ(256 bit の乱数なので総当たりに耐え、ファイル取得のたびに argon2id を払わない。D-99) -- **配布**: macOS(arm64)のバイナリは CI で作り、**署名・公証せず** tar.gz を GitHub - Release に置く(README に `xattr -d com.apple.quarantine` と Automation の許可を書く。D-101) - -**不変条件との関係(明示する例外)** - -(DB にしかない端末の設定・印・計画・トークンの扱いは §3・§14。) - -CLAUDE.md の不変条件と禁止事項は Library(ユーザデータ)を対象にしている。端末上の写しと Mac の -`~/Music/spindle` は **Library から再生成できる複製**なので、次を例外とする(D-95)。 - -- **端末の写しの削除は物理削除でよい**(`missing_since` の猶予を持たない)。Library の曲が - missing になれば次の同期で端末から消え、missing が解ければ追加で戻る。ただし spindle が消すのは端末側の - 正本(manifest / state)にある管理下のものだけで、手で置いたファイルには触らない -- **端末への書き込みは `edit_batches` / `edits` に載せない**。巻き戻しは「Library を巻き戻して同期し - 直す」。中断からの安全は、意図を先に耐久化する端末側のジャーナル(Android)と state の - `pending_*`(Mac)、計画の再開の規則で保つ(不変条件 6) -- **「ファイルが正」は端末にも当てはめる**: 端末側の正本と実ファイルが正で、`device_items` などは - キャッシュ。食い違えば端末側を採る(回復・再発見・内容の検証の結果で全置換する) -- **生 SQL の禁止は変わらない**。DSL の `on_device` / `device_pending` は端末名を値としてバインドし、 - 未反映の集合は JSON 配列をバインドして `json_each` で展開する(D-95 の P5-2 追記) - ---- - -## 8. ジョブシステム - -| type | 並列度 | 冪等キー | -|---|---|---| -| `scan` | 1 | 固定 | -| `rip` | **1**(物理ドライブ1台) | discid | -| `verify` | 2 | album_id | -| `rg` | CPU コア数 | album_id | -| `rgwrite` | 1 | 固定(`rgwrite`。開始時にキーを外すので、実行中の投入は次のジョブになる)。`rg_write_due` の行を album ごとの編集バッチにする(D-97) | -| `transcode` | CPU コア数 - 1 | track_id + variant + audio_version(variant は P4-7 から。§7.6) | -| `tagwrite` | 4 | track_id + tag_version(`edit_batch_id` でバッチに紐づく) | -| `rename` | 1 | batch_id(バッチ 1 つに 1 ジョブ。2 phase の順序を守るため直列。D-43) | -| `normalize` | 2 | track_id + op_id(同じトラックの直列化は track_locks) | -| `thumbnail` | 4 | artwork_id | -| `flaccheck` | CPU コア数 | track_id + audio_version(版付き。D-57) | -| `hirescheck` | max(1, CPU コア数 / 2) | track_id + audio_version(版付き。§7.10、D-71。rg / transcode / flaccheck と重なる分を抑える) | -| `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「同期」) | -| `device_verify` | 2(同上) | `device_verify:<device_id>`(全曲の sha256。§7.11「内容の検証」) | -| `gc` | 1 | 固定(scan と同じ排他 `library` を取れなければ Requeue。D-56) | -| `backup` | 1 | 固定 | - -- **CPU 系の共通予算**(`rg` / `transcode` / `flaccheck` / `hirescheck` / `source_hash`。D-73、P4-1): - 種別の並列度に加えて共有の予算(= CPU コア数)を取ってから走る。種別単独なら今までどおり、複数種別が - 同時に走るときだけ実行中の合計がコア数に収まる。取得順は種別 → 共通で、共通が取れなければ claim せず次の周回で試す - (ジョブは queued のまま。種別内の順序は変えない)。予算を分け合う種別は 1 件ずつラウンドロビンで - claim する(次の周は最後に claim した種別の次から。1 種別が予算を独占しない)。`GET /api/jobs` の `cpu_budget` -- **端末ジョブ**(`device_scan` / `device_sync` / `device_verify`。D-98): 端末ごとの排他(`job_mutexes` の - `device:<id>` とプロセス内のロック)を取ってから、最初に端末側の回復を行う(別の端末なら並べてよい)。 - ロックが取れなければ 15 秒後、未接続なら `device_sync` / `device_verify` は attempts を数えずに 300 秒後へ - 回し(`RequeueAfter`)、`adb track-devices` がその端末を見たら `run_after` を前倒しする。`device_scan` は - 未接続なら待たずに `DoneWith("未接続")` で終わる(次の接続でまた投入される)。`generation` の不一致と空き不足は - 再試行しても変わらないので直ちに `failed`。端末が削除されていれば何もせずに終わる。詳細は §7.11 -- 起動時リカバリ: `running` を `queued` へ戻し、`track_locks` / `derived_path_locks` / `job_mutexes` を - **全件削除**する - (ロックはプロセス生存中しか意味を持たない)。単一インスタンス前提。同じ DB を - 複数プロセスで開くことは想定しない(compose で replicas を増やさない) -- **終端遷移の書き込みの再試行**(D-76、P4-9): ハンドラの結果を DB に書く終端トランザクション - (進捗の flush・ロック解放・`done` / `failed` / `cancelled` / 再キュー)が失敗したら、種別の許可と - CPU 予算を**持ったまま** 1, 2, 4, 8, 16, 32 秒の間隔で再試行する(6 回、約 1 分。ディスク満杯や - 一時的なロックなら自力で復帰し、仕事の結果を失わない)。使い切ったら `failed` としての記録を 1 度 - 試み、それも駄目なら `running` のまま手放す(下の稼働中の回収が拾う) -- **稼働中の回収**(起動時リカバリのループ版。D-76、P4-9): ワーカーは周回ごとに、DB で `running` - だが**プロセス内で実行中でない**行(終端を書けなかった行)を `queued` へ戻し(`cancel_requested_at` - が立っていれば `cancelled`)、その行の `track_locks` / `derived_path_locks` / `job_mutexes` を消す。 - `started_at` は NULL、`attempts` / `run_after` は触らない(起動時リカバリと同じ)。回収した旨を - `last_error` とログに残す。実行中の登録は claim と同じスケジューラの流れで spawn の前に行い、 - 「claim 済みだが未登録」の瞬間を作らない -- `dedup_key` は **`queued` / `running` の間だけ**一意(partial unique index)。 - 列 UNIQUE にすると `done` / `failed` 後に同じキー(`scan` の固定キー、同 version の - 手動再試行)を永久に投入できない。キーは `type` を含めて構成する - (`tagwrite:<track_id>:<tag_version>` 等) -- 失敗は `attempts` をインクリメントし、`run_after` に次回時刻を書いて指数バックオフ - (再起動を跨いでも待ち時間が保たれる)。`attempts >= max_attempts` で `failed`。再試行しても変わらない - 失敗(`JobError::Fatal`。対応していない URL 等)はバックオフせず直ちに `failed`(D-70) -- 終端の行の片付け(P4-18、D-81): `DELETE /api/jobs/:id`(failed / done / cancelled だけ。queued / running は - 409)、`DELETE /api/jobs?state=failed`(失敗をまとめて)。上部バーの赤丸は `summary.failed > 0` なので、 - 確認済みの失敗を消せば消える。保持期間を過ぎた終端の行は GC が消す(`[gc].jobs_done_days` / - `jobs_failed_days`) -- キャンセルは `cancel_requested_at` を立てる協調方式。ハンドラは進捗更新のたびに - 確認して自発的に止め、`cancelled` へ遷移する。外部プロセス(ffmpeg 等)は - 子プロセスグループごと kill し、tmp の成果物を消す -- 進捗は SSE で配信。DB にも永続化してリロードに耐える -- 同一トラックに対する競合ジョブは `dedup_key` と track 単位の - advisory lock テーブルで直列化。複数トラックを掴むジョブ(album 単位の `rg` 等)は - `track_id` 昇順に取得し、1 つでも取れなければ全解放して再キュー(デッドロック回避) -- 版を持つジョブ(`tagwrite` / `transcode`)は開始直前に payload の版と現在値を比較し、 - 古ければ no-op で `done`(§7.5 stale ジョブ) -- **編集バッチは coordinator + 子ジョブ。** `tagwrite` ジョブは track 単位、`rename` ジョブは - バッチ単位で投入し `jobs.edit_batch_id` でバッチに紐づける。バッチ自体はジョブではなく、 - 終端状態は子ジョブ・op の結果から集計する(最後に終端になった子ジョブが集計する)。 - 起動時リカバリ・キャンセルはバッチ配下の子ジョブに対して行う - ---- - -## 9. HTTP API - -``` -GET /health 認証の外。{ status: "ok" | "locked", version, ytdlp } - version = ビルド時に焼いた版(CI の git describe --tags --always。 - `spindle --version` と同じ。P4-12、D-79)、ytdlp = 起動時診断で - 取った yt-dlp の版(取れなければ null) -GET /api/tracks?filter=&sort=&cursor=&limit= カーソルページング(D-39) - filter = URL エンコードした JSON(下記)、 - sort = album(既定) | title | artist | album_title | - albumartist | date | duration | codec | rel_path | id - (`-` 前置で降順)、limit = 1..=1000(既定 100) -GET /api/tracks/:id セッション有りは一覧と同じ行に detail(タグ全部・物理属性・ - verifications = 手法ごとの最新の照合。D-58 / D-96)を足す。 - trusted_cidrs からのセッション無しは限定フィールド(D-27 / D-39) -PATCH /api/tracks/batch 一括編集(dry_run フラグ) -POST /api/tracks/batch/preview 変更プレビュー -POST /api/rename/preview テンプレート適用結果(selection_token を発行) -POST /api/rename/apply token の集合をリネームバッチとして記録 -POST /api/normalize/preview ロスレス → FLAC 正規化の宛先(selection_token を発行) -POST /api/normalize/apply token の集合を正規化バッチとして記録(§7.4) -POST /api/rg { selection }。rg ジョブを投入(D-47)。`albums.album_gain` が on の - album は album 単位(rg:album:<id>)、それ以外は track 単位(rg:track:<id>。D-74) -POST /api/rg/write { selection, description?, skip_pending? }。解析値を - タグとして書く編集バッチを記録(§6、D-48)。 - preview 段階は無い(値は DB から決まる)。解析後は - rgwrite が自動で書くので、これは自動で書かれなかった - 行(外部で書き換えた・巻き戻した等)の書き直し用(D-97) -POST /api/flaccheck { selection }。active な FLAC ごとに flaccheck ジョブを - 投入(§7.9、D-57。読むだけで preview は無い) -POST /api/hirescheck { selection }。対象(可逆かつ >48 kHz または >16 bit)ごとに - hirescheck ジョブを投入(§7.10、D-71。読むだけで preview は無い) - → 202 { tracks, skipped, duplicates, job_ids } | 409 no_changes -POST /api/md5fill { selection, description?, skip_pending? }。flac_check = md5_missing - の FLAC に md5 op の編集バッチを記録(§7.9、D-59) - → 201 { batch_id, affected, skipped, pending_excluded } - → 409 pending | no_changes | md5_fill_disabled -POST /api/verify { selection }。selection のトラックが属する album ごとに - verify ジョブ(遡及照合)を投入(§7.3、D-13 / D-63。読むだけ) - → 202 { albums, duplicates, job_ids } - → 409 no_changes - -GET /api/albums / :id 全件(ページングなし)。track_count / duration_ms は active のみ。 - `?filter=`(/api/tracks と同じ JSON)で一致するトラックを持つ album だけ(P4-6) - /api/tracks の行と /api/tracks/:id には artwork_hash(トラック自身の - 埋め込み画像。無ければ null。D-61)。行に album_gain(D-74) -PATCH /api/albums/:id { album_gain }。album gain の属性(D-74)。true → album 単位の rg を投入、 - false → rg_album_* を NULL にして未書込に戻し、Derived の追随を投入。 - 同じ値なら何もしない。200 { album, job_id | null, derived_jobs }。 - 404 not_found / 400 bad_request -GET /api/categories, POST /api/categories 統制語彙 { "items": [{ id, name, albums, genres, subscriptions, inbox_drafts }] } - (4 つは使用数。削除できるのは全部 0 の語彙。D-106)。POST は { name }(重複は 409)。 - CD 取り込みの確定フォームの category に使う(D-67) -DELETE /api/categories/:id 使われていない語彙だけ消す(D-92)。204 / 404 / 409 `in_use` - (active な album・GENRE の写像・購読・Inbox の下書きのどれかが - 使っている。`message` に理由)。missing の album は SET NULL -GET /api/tags/keys ライブラリにあるタグのキーと、そのキーを持つ曲数 → { items: [{ key, tracks }] } - (曲数の多い順、同数はキーの順。多値も 1 曲、missing の曲と PICTURE は数えない)。 - タグのキーの入力欄の前方一致の候補(D-113) -GET /api/search?q= FTS5 trigram(3 文字未満は LIKE)。/api/tracks と同じ - レスポンス形で、filter / sort / cursor / limit も受ける - -GET /api/stream/:id Range 対応。原本 -GET /api/stream/:id?transcode=opus オンザフライ変換 -GET /api/stream/:id?transcode=aac AAC(スマホ版の試し聴きの退避。§11、§12.7、D-115) -GET /api/artwork/:hash?size= size = 256 | 768 で WebP のサムネイル、無しで原画像 - (元の MIME)。hash は albums.artwork_hash。未生成なら - 原画像へ倒す(no-cache)。ハッシュアドレスなので immutable -POST /api/artwork/upload 生の画像バイト列(Content-Type: image/*、上限 32 MiB。形式は - ヘッダで判別。JPEG / PNG / WebP 以外は 400 unsupported_image)を - ArtworkStore と artwork 行に置き thumbnail ジョブを投入 - → 201 { sha256, mime, width, height, bytes }(D-60) -POST /api/artwork/embed { selection, sha256, description?, skip_pending? }。selection の - active 全行の埋め込み画像をその 1 枚に差し替える tags op - (PICTURE)の編集バッチを記録(§7.5、D-60) - → 201 { batch_id, affected, unchanged, pending_excluded } -POST /api/artwork/from-caa { release_id } か { release_group_id }(どちらか片方。D-93)。release_id は MBID - (大小無視)か musicbrainz.org の /release/<MBID> の URL、release_group_id は - MBID。Cover Art Archive の front 画像(500px)を - 取り、upload と同じく置く → 201(upload と同じ応答)。両方・どちらも無し・形が - 違えば 400 bad_request。画像の無い盤は 404、 - 上流の失敗は 502 lookup_failed、未構成は 503 coverart_unavailable(D-86) - → 404 artwork_not_found、409 pending | no_changes - -GET /api/playlists, POST, PATCH, DELETE プレイリストの CRUD(D-53)。DELETE は印のある端末に open な作業があれば - 409 open_plan(D-102)。POST / PATCH に rule があれば - スマート(D-54。評価結果は playlist_items に書く)。並びは - /api/tracks?filter={"playlist_id":N}&sort=position -POST /api/playlists/:id/items { selection, sort? } を末尾に追加(同じトラックは 1 回) -DELETE /api/playlists/:id/items { track_ids?, selection? } を外す -POST /api/playlists/:id/items/move { track_ids, before } before の直前(null は末尾)へ -POST /api/playlists/preview { rule } スマートルールの検証と評価件数(保存しない。D-54) -POST /api/playlists/:id/refresh スマートを今の DB で再評価(項目を書き直す) -GET /api/playlists/:id/export?profile= m3u8 の本文(trusted CIDR で認証スキップ) -POST /api/playlists/:id/export?profile= Playlists/<profile>/<name>.m3u8 へ書き出し。応答に - count / skipped_missing / stale_tags(delivery のタグ追随待ち) -GET /api/playlists/import, POST Playlists root 下の m3u8 の一覧 / { path, name? } で取り込み -GET /api/playlists/:id/fb2k_query foobar Autoplaylist 用の { query, sort, notes }(smart のみ。D-55) - -GET /api/devices 端末の一覧 → { items: [Device] }(下の「レスポンス形」。P5-2、D-95)。ハッシュの無い送る元の - source_hash をここと差分で投入する(§7.11) -POST /api/devices { name, transport: adb | agent, variant: opus | aac, selection: all | playlists, serial?, volume? } - → 201 Device。agent は行を作るだけ。adb は serial と volume(`emulated` か SD の UUID)が要り、 - 接続を確かめ、保存先 `Music/spindle` が無いか空(曲もプレイリストも指さない空の manifest の - 前の登録の失敗の残りは片付ける)なら manifest を作って device_scan を投入する(§7.11「登録」)。 - `adopt: true`(adb のみ。他の transport は 400)は既存の保存先の引き継ぎで、manifest の uuid の - 行を作る(§7.11「引き継ぎ」、D-103)。400 bad_request、 - 409 duplicate(名前)| serial_registered | not_empty | not_connected | - adoptable(引き継げる manifest がある。引き継ぎを案内)| not_adoptable | uuid_registered | - library_busy(scan が待ち・実行中)、502 device_failed、503 adb_disabled -PATCH /api/devices/:id { name?, selection?, variant? } → 200 Device。名前以外は open な計画か待ち・実行中の - device_sync がある間 409 open_plan。409 duplicate。保存先は変えない(削除して登録し直す) -DELETE /api/devices/:id → 204。端末上のファイルには触らない。open な計画も行と一緒に消え、待ちの端末ジョブは - 取り消す。404 / 409 busy(端末のジョブが実行中か端末のロック中。D-98) -PUT /api/devices/:id/playlists { playlist_ids }(印の全置換)→ 200 Device。400 bad_request(無いプレイリスト)| - cycle(on_device / device_pending を使うスマート)、409 open_plan -GET /api/devices/:id/diff 差分・見積もり・評価時刻と plan_token(下の「レスポンス形」) -GET /api/devices/:id/estimate?selection=&playlist_ids=1,2 - 選曲タブの見積もり(保存しない)→ { tracks, bytes, estimated, unhashed }(bytes は確定と - 推定の和、estimated = 推定で数えた曲数、unhashed = 推定もできず - bytes に含まない曲数。D-107) -GET /api/devices/adb/unregistered 接続中で未登録の Android → { items: [{ serial, model, state, volumes: [{ volume, path, free, - state: missing | empty | nonempty | adoptable }], error }] }(volumes は state = device の端末だけ - 調べる。adoptable = 読める manifest が曲かプレイリストを指し、volume が一致し、uuid が DB に無い)。503 adb_disabled -GET /api/devices/adb/adopt-preview?serial=&volume= - 引き継ぎのプレビュー(読むだけ。ジャーナルは回復しないので件数は目安)→ { device_uuid, - tracks_adopted, tracks_dropped, playlists_adopted, playlists_dropped, unmanaged, library_busy } - (playlists_adopted は件数)。409 not_adoptable | uuid_registered、503 adb_disabled -POST /api/devices/:id/sync { plan_token }(adb のみ)。計画の確定と device_sync の投入を 1 トランザクションで行う → - 202 { job_id }(同じ plan_token の open な計画があればそれで投入し直す。未接続でも 202 で、 - ジョブが接続を待つ)。409 plan_changed(本文に今の plan_token)| open_plan_exists | - pending_reevaluation | plan_unreadable、400(agent の端末)、503 adb_disabled -POST /api/devices/:id/plans/open/resume adb の open な計画の同期を投入し直す → 202 { job_id }。404 no_open_plan、409 plan_unreadable -POST /api/devices/:id/plans/open/abandon { force? }。adb は接続して回復し、キャッシュを置き換えてから計画を abandoned にする → - 200 Device。409 not_connected | busy、404 no_open_plan。agent は force 無しでは 400 - (Mac の spindle-agent から破棄する)。force: true は端末につながず回復もせずに閉じる - (agent も可。adb_disabled でも動く。409 busy。D-98 / D-99) -POST /api/devices/:id/verify adb のみ。device_verify を投入 → 202 { job_id } -POST /api/devices/:id/pair-code agent のみ。10 分・1 回限り・5 回まで試せるコードを発行 → 201 { code, expires_at } - (旧トークンと旧コードは即失効)。400 not_agent、409 open_plan -// Android の操作(登録・未登録の一覧・同期・再開・通常の破棄・検証)は `[devices].adb_server` が空なら -// 503 adb_disabled(強制破棄と端末の削除は adb を使わないので動く。D-98) - -// エージェントの経路(認証は下の「認証」。セッション・CSRF・trusted_cidrs は通らない。D-99) -POST /api/agent/pair { code, adopt_uuid? }(Bearer なし。端末タブで発行した `<selector>.<secret>`)→ 200 { device_uuid, - device_name, token }。不一致・期限切れ・試行超過・引けないセレクタは一律 401 invalid_code。 - adopt_uuid(`pair --adopt`。32 桁の小文字 16 進。形の不正はコードに触らず 400 bad_request)は - コードの消費と同じトランザクションで行の uuid を差し替える。行が端末の状態を持つか(uuid が - 既に adopt_uuid なら報告か計画があるか)、uuid が他の行にあれば 409 not_adoptable(コードは消費せず、試行にも数えない。§7.11「引き継ぎ」、D-104) -GET /api/agent/manifest → { device_uuid, device_name, generation, plan_token, pending_reevaluation, - items: [{ track_id, dest_path, token, size, sha256, rel_path, source }], - playlists: [{ playlist_id, name, token, tracks: [track_id] }], - diff: { items: [{ op, track_id, from, to, token, size, sha256 }], - held: [{ track_id, reason, waiting, has_copy }], - playlists: [{ op, playlist_id, from, to, token }], playlist_errors: [{ playlist_id, reason }] } } - (差分は UI と同じスナップショットから作る。D-99) -GET /api/agent/files/:track_id If-Match: "<配信トークン>"(強い ETag)必須、続きは Range を足す(HEAD も可)→ 200 / 206。 - 428 if_match_required、412 precondition_failed(トークン不一致・弱い ETag・`*`・並記・ - ハッシュ未計算)| source_changed(送る元が変わった。行を捨てて次の差分で取り直させる)、 - 404(その端末の desired に無い)、416、503 files_unavailable。パスはクライアントから受け取らない -POST /api/agent/plans { plan_token } → 201 Plan(新しく確定)| 200 Plan(同じ plan_token の open な計画)。 - 409 plan_changed(本文に今の plan_token)| open_plan_exists | pending_reevaluation | plan_unreadable -GET /api/agent/plans/open → 200 Plan | 404 no_open_plan | 409 plan_unreadable -POST /api/agent/plans/:id/abandon Report に { pending_ops, pending_batches }(件数)を足した本文 → 200 {}。報告と同じ検証を通り、 - キャッシュを全置換して計画を abandoned にする。409 pending_work(どちらかが 0 でない)、 - ほかは report と同じ -POST /api/agent/report Report → 200 {}(計画を completed にする。同じ報告の再送も 200)。400 invalid_report - (1 件でも外れれば全体を拒否。§7.11「報告」)| bad_request、404 no_plan、409 generation_mismatch | - plan_closed | plan_unreadable。本文の上限は report と abandon と adopt だけ 64 MiB -POST /api/agent/plans/:id/progress { generation, done, total, bytes_done, bytes_total, rate, phase, current? } → 204。実行中の進捗を - メモリだけに置く(DB には書かず、SSE `device_progress` で流す。§7.11「進捗」、D-108)。phase は - delete | move | fetch | add | playlist、rate はバイト / 秒、current は 1,024 バイトまで。 - 404 no_plan(その端末の open な計画でない: 閉じた・破棄済み・別の端末・無い)、 - 409 generation_mismatch、400 invalid(done > total・bytes_done > bytes_total・未知の phase・ - 負の値・current 超過)。本文の上限は 4 KiB。エージェントは 404 / 409 で以後送らない -POST /api/agent/adopt { tracks: [{ track_id, path, rel_path, source, size, sha256, stale }], - playlists: [{ playlist_id, name }] } → 200 { tracks: [{ from, to }], playlists: [{ from, to }] } - (`to` は付け替え先の id、外れたものは null)。DB 全損後の `pair --adopt` で、付け替えた状態を - キャッシュへ直接書く(`generation` と `last_synced_at` は進めない。冪等。§7.11「引き継ぎ」、D-104)。 - 400 bad_request(件数の上限・パスの形・未知の source・id とパスの鍵の重複・空のプレイリスト名・ - sha256 が小文字 16 進 64 桁でない(空は `stale` のときだけ許す)・size が i64 を超える)、 - 409 library_busy(スキャン中)| open_plan | not_adoptable(既に報告を受けた端末) -// Report = { generation, plan_id, state: { tracks: [{ track_id, dest_path, token, size, sha256 }], -// playlists: [{ playlist_id, name, token }] }, errors: [{ kind: track | playlist, ref_id, reason }] } -// Plan = { plan_id, generation, plan_token, items: [{ op_id, op, track_id, from, to, token, size, sha256 }], -// playlists: [{ op_id, op, playlist_id, from, to, token }] } -// op = add | update | move | update_move | delete(プレイリストは add | update | delete) -// ワイヤ型は agent-proto クレートで本体とエージェントが共有する - -POST /api/auth/login, POST /api/auth/logout -GET /api/auth/session - -GET /api/cd/status { state: unknown | no_drive | no_disc | tray_open | not_ready | disc_ok, - toc: CTDB 形式の文字列 | null, isrcs: [音声トラック順。無ければ null], - mcn: JAN/UPC | null, error: 直近の失敗 | null, checked_at, - tracks: [{ number, length_ms }], rip_job: 進行中の rip ジョブ | null, - drive: { model, offset, offset_source: manual | learned | table | unknown } | null - (型番はディスクが無くても読む。D-83 追記 2)} - (P2-1。ポーラの状態で、ドライブは叩かない。TOC は下の lookup に渡す - 文字列と同じ形。ドライブ未配線なら 503 cd_unavailable) -POST /api/cd/lookup { toc, isrcs?, mcn?, release?, catno?, barcode?, refresh? }。TOC 文字列(CTDB 形式 0:13915:…:leadout か - MusicBrainz 形式 1 12 leadout+150 offset+150…)から各種 DiscID を出し、 - MusicBrainz に照会(P2-3、D-21 / D-64)。isrcs / mcn は status が読んだもの - (null は捨てる)、release は貼ったリリース URL か MBID。catno / barcode は - ユーザが盤から読んで入れた品番(英数字・ハイフン・空白、32 文字まで)と - JAN / UPC(数字 8・12・13 桁。mcn が無いときだけ使う)。D-94。→ 200 { discid, - mb_toc, accuraterip_id, ctdb_toc_id, exact, candidates: [リリース × medium。 - matched_by: [catno | discid | release | isrc | barcode | toc] を強い順に持ち、その順に並ぶ。 - media: [{ position, format, track_count }](リリース全体の収録構成)], - notes: [候補に入れられなかった理由], tracks: [{ number, length_ms }](TOC の - 音声トラック。手入力フォームの行。D-65)}。同じ入力の照会結果は - 10 分覚えていて MusicBrainz を引き直さない(D-64 追記 3)。 - refresh: true で捨てて引き直す(画面の「MusicBrainz に照会」)。 - 400 bad_request(TOC・ISRC・MCN・品番・JAN の形)、502 lookup_failed(届かない・応答が壊れている)、 - 503 musicbrainz_unavailable(再試行しても 503 の負荷制限、または未構成) -POST /api/cd/library { toc, release_id? }。いまの盤がライブラリにあるか(§12.6 CD の帯、D-85)。 - DiscID はサーバが TOC から出し、active なトラックの MUSICBRAINZ_DISCID - タグで引く。当たらず release_id があれば active な album の mb_release_id - (大文字小文字を区別しない。D-84)で引く。DB だけを読む(MusicBrainz は - 引かない)→ 200 { discid, disc: AlbumRef | null, release: AlbumRef | null } - (AlbumRef = { album_id, rel_dir, album, albumartist }。複数あれば id の - 小さいもの。disc が当たれば release は引かない)。400 bad_request(TOC) -GET /api/cd/cover/{release_id} 候補のジャケット。Cover Art Archive の front(500px)の中継(D-82)。 - 画像の無い盤は 404、上流の失敗は 502 lookup_failed、未構成は 503 coverart_unavailable -GET /api/cd/release-group/{id}/releases リリースグループの版(D-93)。MusicBrainz の browse - (ws/2/release?release-group=…&inc=media+labels、先頭 100 件)→ 200 { releases: - [{ release_id, title, disambiguation, date, country, status, formats: [媒体の形式], - label, catalog_number, front: CAA に表の画像があるか }](front のある版が先、 - 同じなら日付の古い順), total }。知らないグループは 404、MBID でなければ 400、 - 502 lookup_failed、503 musicbrainz_unavailable(負荷制限・未構成) -POST /api/cd/rip { toc, metadata }(P2-5)。metadata は CD 画面の下書き(DiscMetadata。名前は空でもよい)。 - 盤がドライブにあり TOC が一致するときだけ rip ジョブを投入 → 202 { job_id }。 - 400 bad_toc / bad_metadata、409 disc_mismatch(盤が違う・無い)/ duplicate - (進行中の吸い出しがある。ドライブは 1 台)、503 cd_unavailable。進捗は SSE の - job イベントの detail(RipProgress。§7.2)、結果の一行はジョブの note -POST /api/cd/eject トレイを開けて状態を見直す → 204。失敗は 500 eject_failed(理由付き)。 - 先に CDROM_LOCKDOOR 0 で扉のロックを外す(外さないとドライブが - CHECK CONDITION で拒む機種がある)。吸い出し中(rip が running)は 409 ripping - -GET /api/jobs, POST /api/jobs/:id/cancel, POST /api/jobs/:id/retry -DELETE /api/jobs/:id 終端(failed / done / cancelled)のジョブ行を消す(P4-18。queued / running は 409 not_terminal) -DELETE /api/jobs?state=failed 失敗をまとめて消す → { "deleted": N }(state=failed 以外は 400) -GET /api/config 読み込んだ config.toml の原文 { "path", "text" }(設定画面 §12.6。秘密は config に無い) -GET /api/archive?state=held|restored|deleted|all&limit=&cursor= - 退避台帳 { "items": [ archived_files の行 + "batch_id" ], "next_cursor", "counts" } - (新しい順。`state` 省略は held、`limit` は 1..=500・省略 100、`cursor` は - `id < cursor`。`counts` は状態ごとの件数。復元は batch の巻き戻し。D-106) -GET /api/logs?kind=all|retro|rip&album_id=&limit=&cursor= - 照合・吸い出しのログの実行ごとの一覧 { "items", "next_cursor" }。`limit` は - 1..=200・省略 50、`cursor` は不透明(offset)。実行は照合なら - (album_id, source, job_id)、`job_id` が無い吸い出しなら (album_id, source, - verified_at) でまとめ、key は `retro-<album>-j<job>` / `rip-<album>-t<verified_at>`。 - `log` は latest | superseded | none(照合ログは album ごとに最新 1 回分) -GET /api/logs/{key}/text?disc=N ログ本文(text/plain)。root の dirfd から O_NONBLOCK で開き、通常ファイル以外 - (ディレクトリ・FIFO)と ENOTDIR は 404 `log_not_found`、1 MiB 超は 413 - `log_too_large`、key が不正なら 400、`data/verify` を開けなければ 503 - `logs_unavailable`(D-106) -POST /api/scan {"kind": "incremental" | "deep"}。scan ジョブを投入 -GET /api/gc/preview GC の dry-run(区分ごとの件数・バイト数・先頭 50 件、`jobs: { done, failed }` は掃除するジョブ行の数、`plans` は消す終端した計画の数。何も消さない。D-56、D-102) -GET /api/inbox 承認キュー { "items": [{ id, rel_dir, state, detected_at, error, placed_album_id, - 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, 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 の - 項 { source, url, channel, verdict, message, subscription_id?, position? } | null。 - destination.numbers は宛先の既存の番号 [[disc, track], …](昇順)。subscriptions は - 取り込みのトラックが参照する購読の直近の同期の要約(last_result の align から。1 回で読む)) - 承認済み(approved / placing / placed)の件には stages: [{ key, label, status: done | running | - todo | na, done, total }](配置 → RG → 系統 → 端末の段。§12.6、P5-2) -GET /api/inbox/:id/files/:name 取り込みの吸い出しファイル本文(`rip*.log` / `disc*.cue` / `disc*.toc` だけ。 - `GET /api/inbox` の各項目の `rip_files` が名前の一覧。D-106) -GET /api/inbox/:id/artwork/:hash 取り込みのファイルの埋め込み画像(PICTURE の sha256 で実体を照合してから ETag / 304。 - 原寸、MIME は sniff、immutable。取り込みに無い / 実体消失 / 不一致は 404。状態非依存。 - セッション必須。§7.8、P4-4) -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 + - ジョブ投入。件の再判定ジョブが 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/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, - video_url?, located?: { location: library|inbox, path }, list_id?, subscription?: { id, albumartist, album } }] } - (YouTube 画面 ① の照合。DB だけ。`list=` 付きは再生リスト。D-87) -POST /api/ytmusic/playlist { url }(再生リスト)→ { list_id, title, entries, unavailable, in_library, in_inbox, new, truncated, subscription? } - | 400 | 502 ytdlp_failed(yt-dlp で列挙して所在ごとに数える。読むだけ。同時 2 本。D-87) -GET /api/ytmusic/subscriptions → { items: [Subscription] }(albumartist / album 順。P4-16、D-78) -POST /api/ytmusic/subscriptions { url, albumartist, album, category?, align? = true, enabled? = true, max_enqueue? = 50 } - → 201 Subscription | 400(YouTube の list= 付き URL でない、空、max_enqueue が 1〜1000 外) - | 409 { error: "duplicate_list" | "duplicate_target" } -PATCH /api/ytmusic/subscriptions/:id { albumartist?, album?, category? (null で消す), align?, enabled?, max_enqueue? } - → 200 Subscription | 404 | 409 { error: "duplicate_target" | "sync_running" } - 追記先を変えると album_id は NULL に戻る。同期が queued / running の間は sync_running -DELETE /api/ytmusic/subscriptions/:id → 204 | 404 | 409 sync_running(PATCH と同じ。Library のファイルは消えない) -POST /api/ytmusic/subscriptions/:id/sync → 202 { job_id } | 404 | 409 { error: "duplicate" }(走行中。latch は残るので終わった後に走る) -GET /api/ytmusic/subscriptions/:id/progress → { sync: Job | null, items: [Job] } | 404(直近の playlist_sync と、それより後に作られた(jobs.id が大きい)その購読の ytdl を position 昇順で。created_at は秒単位で前回の同期の終わった件と同じ秒になりうるので、境界は id で取る。投入が走行中の古い ytdl に dedup されて新しい行ができない同期もあるので、この購読の ytdl で queued / running のものは作られた順によらず含める。スマホ版の購読の「完了」の判定に使う。§12.7) - [ytmusic].enabled でなければすべて 404 -// Subscription = { id, list_id, url, album_id, albumartist, album, category, align, enabled, max_enqueue, -// created_at, updated_at, last_attempted_at, last_synced_at, sync_requested_at, -// last_result: { state, error?, synced_at, title?, entries, playlist_count?, album_id?, in_library, in_inbox, -// elsewhere: [{position, id, track_id, rel_path}], enqueued: [position], running: [position], -// deferred, unavailable: [{position, id, kind}], align?: { moved, unchanged, blocked: [{track_id, -// position, current_no, reason}], outsiders, unnumbered, tags?, renamed, rename? } } | null } - → 202 { job_ids }。`[ytmusic].enabled` でなければ 404(§7.7、D-70) -POST /api/gc gc ジョブを投入(未完了があれば 409) - (202 + job_id。queued / running があれば 409 duplicate) -GET /api/events SSE: ジョブ進捗・ライブラリ変更 - -GET /api/history, POST /api/history/:batch/revert - 巻き戻しは新バッチとして記録(§7.5)。 - 終端状態のバッチのみ。pending 衝突・二重 revert は 409 -POST /api/history/:batch/cancel 反映中バッチのキャンセル -``` - -- 一括編集は必ず preview → apply の 2 段階 -- SSE は単一チャネル。イベント種別で多重化 - -### レスポンス形(UI が依存するもの) - -```jsonc -// GET /api/tracks?filter=...&sort=title&cursor=...&limit=100 -// filter はホワイトリストのキーだけを持つ JSON(未知キーは 400。D-39): -// { "category": "J-Pop", // ツリー: categories.name -// "albumartist": "…", // ツリー: tracks.albumartist の完全一致 -// "album_id": 12, // ツリー -// "playlist_id": 3, // プレイリスト所属 -// "flags": ["missing", "pending"], // 固定フィルタ(AND): unverified | duplicate | missing | -// // no_rg | rg_unwritten | pending | conflict | hardlink | -// // flac_unchecked | flac_error(§7.9)| hires_unchecked | hires_suspect(§7.10) -// "q": "情緒", // 検索語(3 文字以上 FTS5 / 未満 LIKE) -// "device_pending": 2 } // 端末に未反映(端末 id。追加・更新・移動・更新 + 移動で、前回の失敗も含む。DSL の device_pending と同じ定義。§7.11) -// cursor は前ページの next_cursor をそのまま返す不透明文字列(キーセット)。sort が変わったら -// 捨てる(別ソートで発行したカーソルは 400)。 -// total はフィルタに一致する全件数(同じ読み取りスナップショットで数える) -{ "items": [ { "id": 1, "title": "...", "artist_display": "...", "album": "...", "albumartist": "...", - "track_no": 1, "disc_no": 1, "date": "2024", "category": "J-Pop", - "duration_ms": 280000, "codec": "flac", "lossless": true, - "verification": "verified_ctdb", "rg_scanned_at": 1, "rg_written_at": 1, - "derived": { "opus": { "codec": "opus", "stale_tags": false }, "aac": null }, - // 系統ごと。無ければ null(§7.6) - "flac_check": { "status": "ok", "checked_at": 1700000000, "stale": false, "error": null }, - // 未検査なら null(§7.9) - "hires_check": { "status": "upsampled", "checked_at": 1700000000, "stale": false, - "error": null, "cutoff_hz": 22050, "cliff_db": 48.3, "effective_bits": 16 }, - // 対象外・未検査なら null(§7.10) - "pending_batch_id": 42, // または null - "conflict_batch_id": 41, // または null - "duplicate_group": "a1b2…", // audio_md5 hex または null - "hardlink": false, "missing_since": null, - "rel_path": "J-Pop/…/01 ….flac", - "devices": [ { "device_id": 2, "state": "synced", "synced_at": 1700000000 }, - { "device_id": 3, "state": "pending", "op": "add", "reason": null } ] } ], - // devices は端末ごとの状態(P5-2。その曲が端末の対象外で端末にも無いときは、その端末の要素を省き、端末が 1 台も無ければキーごと - // 省く)。state = synced { synced_at } | pending { op: add | update | move | update_move, reason } - // | waiting { reason, has_copy } | error { reason, has_copy } | removing。バッジ列とプロパティの「端末」が使う - "next_cursor": "…", "total": 61234 } - -// 選択は 2 形。Ctrl+A はフィルタ形で送る(ID 列挙にしない) -// { "ids": [1, 2, 3] } または { "filter": "<選択時点のフィルタ式>", "exclude_ids": [7] } -// フィルタ形は「選択した時点のフィルタ」を immutable に保持する(表示中のフィルタとは別物) - -// POST /api/tracks/batch/preview { "selection": {...}, "ops": [...], "sort": "title" } -// サーバは selection を解決して対象 track_id と各行の tag_version / 事前条件を -// スナップショットに保存し、selection_token(TTL 15 分)を返す(D-33)。 -// ops は上から順に適用する操作の配列(D-42): -// { "op": "set", "key": "TITLE", "value": "…" | ["…", "…"] } // 空は削除 -// { "op": "ref", "key": "ALBUMARTIST", "template": "%artist%" } // 先頭値で展開 -// { "op": "replace", "key": "TITLE", "pattern": "…", "replacement": "$1" } -// { "op": "number", "key": "TRACKNUMBER", "start": 1, "pad": 0 } // sort 順に連番 -// { "op": "delete", "key": "COMMENT" } -// { "op": "set_rows","key": "SOURCE_URL", "rows": { "<track_id>": "…" | ["…"] } } -// 行ごとに違う値(API 専用。UI には出さない。rows に無い行は変更なし。1〜10,000 行。P4-14) -// items は値が変わる行だけ。反映待ちの行は評価せず pending_excluded に数える -{ "selection_token": "…", "count": 1207, - "changed": 1180, "unchanged": 24, "pending_excluded": 3, - "items": [ { "id": 1, "changes": { "TITLE": { "old": ["…"], "new": ["…"] } } } ] } - -// PATCH /api/tracks/batch { "selection_token", "ops", "description", "skip_pending": false } -// 対象は token のスナップショット集合だけ。preview 後にスキャンで増えた行は含まれない。 -// スナップショット時と tag_version が変わった行は skipped_conflict になる -// 201 { "batch_id": 42, "affected": 1180 } -// 409 { "error": "pending", "track_ids": [ … ], "count": 3 } // skip_pending=true で除外して続行 -// 409 { "error": "preview_stale" } // token 期限切れ・ops 不一致 -// 409 { "error": "no_changes" } // 値が変わる行が無い -// 409 の応答では token を消費しない(同じ token でやり直せる)。201 で消費する - -// POST /api/rename/preview { "selection": {...}, "sort"? } -// selection を解決して固定し selection_token を返す(D-33)。各行に [layout] のテンプレートを -// 適用した宛先を返す。items は宛先が変わる行と衝突した行だけ(変更なし・反映待ちは件数) -{ "selection_token": "…", "count": 980, - "changed": 975, "unchanged": 2, "conflict": 2, "pending_excluded": 1, - "items": [ { "id": 1, "old": "old/a.flac", "new": "J-Pop/花譜/魔法/03 過去を喰らう.flac" }, - { "id": 2, "old": "old/b.flac", "new": null, "reason": "同名の別リリースと衝突(…)" } ] } - -// POST /api/rename/apply { "selection_token", "description"?, "skip_pending": false } -// token の集合で計画を取り直してバッチを記録する。衝突した行と preview の後にタグが変わった行は -// skipped_conflict の op として記録だけする(affected に含む) -// 201 { "batch_id": 43, "affected": 977, "conflict": 2 } -// 409 { "error": "pending" | "preview_stale" | "no_changes" } // 規則は PATCH /api/tracks/batch と同じ - -// POST /api/normalize/preview { "selection": {...}, "sort"? } -// rename と同型。items は宛先が決まる行(old / codec / new)と衝突した行(new: null, reason)。 -// 既に FLAC・非可逆は unchanged に数える -{ "selection_token": "…", "count": 7572, - "changed": 7570, "unchanged": 0, "conflict": 2, "pending_excluded": 0, - "items": [ { "id": 1, "old": "J-Pop/…/01 ….m4a", "codec": "alac", "new": "J-Pop/…/01 ….flac" } ] } -// POST /api/normalize/apply { "selection_token", "description"?, "skip_pending": false } -// 201 { "batch_id": 44, "affected": 7572, "conflict": 2 } -// 409 { "error": "pending" | "preview_stale" | "no_changes" | "normalize_disabled" } - -// GET /api/history -{ "items": [ { "id": 42, "created_at": 1, "description": "…", "kind": "tags", - "state": "partial", "affected": 312, "applied": 309, "conflict": 3, "failed": 0, - "reverts_batch_id": null, "reverted_by": 39, "finished_at": 1 } ] } -// reverted_by は reverted_at が立っているときだけ(戻した逆バッチの id)。reverted_at も返す -// GET /api/history/:id → 上 + "ops": [ { "id", "track_id", "kind", "result", "error", "rel_path", -// "edits": { "TITLE": { "old": [...], "new": [...] } }, -// "current": { "TITLE": [...] } } ] // skipped_conflict のときだけ -// (編集キーの現在値 = ファイルの再読込結果。rename は rel_path) -// POST /api/history/:id/revert { "description"? } → 201 { "batch_id": 43, "affected": 50, "conflict": 1 } -// → 404 | 409 { "error": "not_terminal" | "already_reverted" | "pending" } -// POST /api/history/:id/cancel → 202 | 404 | 409 { "error": "not_cancellable" } - -// GET /api/jobs?type= // type(任意): 種別で items を絞る(`ytdl` 等。上限も種別内で数える。YouTube 画面)。 - // summary / by_type は全件のまま。不明な種別は 400 -{ "items": [ { "id", "type", "state", "progress", "done", "total", "attempts", "last_error", - "run_after", "edit_batch_id", "created_at", "started_at", "finished_at", - "note", // 完了時の結果 1 行(ハンドラが返す。ytdl: "Inbox に置いた: <path>" / - // "プラグインが skip: <理由>" / "取り込み済み(Library|Inbox): <パス>" / - // "再生リストを展開した: N 件を投入、M 件は取り込み済み"。 - // verify: 手法ごとの結果("CTDB 全 12 曲一致(信頼度 34) / AccurateRip - // 12 曲中 10 曲一致"、複数ディスクは "disc N: " 付きで「。」区切り。D-96)。 - // playlist_sync: 同期の要約。無ければ null) - "subject" } ], // subject = 対象の表示用文字列(track_id → Library のパス、album_id → ディレクトリ、 - // batch_id → 説明、transcode は " [<variant>]" 付き、scan は kind、ytdl は url、 - // thumbnail は "artwork #id"、端末ジョブは "device #id"(source_hash は track_id と同じ)。 - // 行が消えていれば "track #id"、対象の無い種別は null) - // 状態ごとに上限付き(`limits`): 実行中・待ち 1,000(実行中 → 待ち。取り出し順 = priority 降順・ - // 作成順・id 昇順)→ 完了 300(新しい順)→ 失敗・取り消し 300(新しい順)。1 本の並びで切ると - // 待ちが数千件のとき完了・失敗が届かない - "summary": { "running": 3, "queued": 12, "done": 15300, "failed": 0, "cancelled": 0, "pending_ops": 1204 }, - "by_type": { "transcode": { "queued": 6470, "running": 11, "done": 1089, "failed": 0, "cancelled": 0 }, … }, - // 種別ごとの全件集計(0 件の種別は無し) - "concurrency": { "scan": 1, "rg": 12, "transcode": 11, … }, // 種別ごとの並列度(§8) - "cpu_budget": 12, // CPU 系の共通予算(= コア数。D-73) - "limits": { "active": 1000, "done": 300, "failed": 300 } } // items の状態ごとの上限 -// POST /api/jobs/:id/cancel → 202(queued は即 cancelled、running は cancel_requested_at を立てる) -// → 404 | 409 { "error": "not_cancellable" } // 既に終端 -// POST /api/jobs/:id/retry → 202(failed / cancelled を attempts=0 で queued に戻す) -// → 404 | 409 { "error": "not_retryable" | "duplicate" } // D-36 -// DELETE /api/jobs/:id → 204(failed / done / cancelled の行を消す。P4-18) -// → 404 | 409 { "error": "not_terminal" } -// DELETE /api/jobs?state=failed → 200 { "deleted": N }。state=failed 以外は 400 { "error": "invalid_state" } - -// SSE /api/events event 種別: job | batch | library | resync | device_progress -// job: { "id", "state", "progress", "done", "total" } -// batch: { "id", "state", "applied", "conflict", "failed" } -// library: { "scan_run_id", "kind": "ids", "track_ids": [ … ] } // 変更が 200 行以下 -// { "scan_run_id", "kind": "bulk" } // それ以上。ページを無効化 -// scan の完了時に 1 回(commit 後)。変更行 0 件なら流さない(D-39) -// device_progress: { "device_id", "progress": SyncProgress | null } // agent 端末の同期の進捗(null は消去: 計画が閉じた) -// resync: { "skipped": n } // サーバ側で取りこぼした。一覧(jobs / history / 表示ページ)を再取得 -// クライアントは SSE を開いてから一覧を取得する(逆順だと開く前のイベントを失う。D-36) -``` - -`library` イベントを受けたクライアントは、**表示中のページ(クエリ + カーソル)を無効化して -再取得**する。行の差し替えだけでは、変更行がフィルタに出入りしたりソートキーが変わったり -カーソル境界を跨いだときに集合と順序が壊れる。`ids` のときは表示中に含まれる id があれば -再取得、`bulk` は無条件に再取得。選択(`selection`)はイベントで変えない。 - -`pending_batch_id` / `conflict_batch_id` / `duplicate_group` / `hardlink` は一覧の -バッジ列(§12.2)が直接使う。`GET /api/tracks` は 1 クエリで返す: `edit_ops` の pending と -最新 op は LEFT JOIN、重複は `audio_md5` 索引への相関 EXISTS で行ごとに引く -(`duplicate_groups` ビューを JOIN すると毎回 GROUP BY の実体化が走る。D-39)。 - -// GET /api/albums -{ "items": [ { "id": 1, "rel_dir": "J-Pop/…/…", "category": "J-Pop", "albumartist": "…", "album": "…", - "date": "2024", "original_date": null, "edition": null, "mb_release_id": null, - "disc_count": null, "artwork_id": null, "artwork_hash": null, // SHA-256 hex - "track_count": 12, "duration_ms": 2800000, "missing_since": null } ] } -// GET /api/albums/:id → 上の 1 要素 | 404 - -```jsonc -// GET /api/devices → { "items": [Device] }。POST / PATCH / PUT playlists / 破棄も Device を返す(端末タブ。§12.6) -{ "id": 2, "name": "Xperia", "transport": "adb", "variant": "opus", "selection": "playlists", - "generation": 5, - "connected": true, // adb: 監視が見た状態が device か(adb 無効なら false)。agent は null - "adb_state": "device", // adb の生の状態(unauthorized / offline …)。見えていない・agent は null - "adb_volume": "E1C6-6113", "adb_root": "Music/spindle", // agent は null - "counts": { "add": 3, "update": 0, "move": 1, "delete": 0, "waiting": 12, "error": 0, "synced": 210 }, - // 曲ごとの状態の件数。更新 + 移動は move に数える。ナビの黄色い件数は - // add + update + move + delete + error の全端末の合計(待ちは含めない) - "last_synced_at": 1700000000, // adb は同期の完了、agent は報告の受理 - "playlist_ids": [1, 4], // 印(device_playlists) - "playlist_states": [ // プレイリストごとの同期の状況(表示用のスナップショットから。少し古いことがある) - { "playlist_id": 1, "state": "synced", "pending": 0, "waiting": 0, "errors": 0, "reason": null }, - { "playlist_id": 4, "state": "pending", "pending": 3, "waiting": 1, "errors": 0, "reason": null } ], - // 選曲の各プレイリストと、外したが端末に残るもの(removing)。state は - // error(送れない収録曲があるか、ファイル自体のエラー。reason)> pending(次の同期で - // 送る収録曲・準備待ちの収録曲・ファイルの追加か更新のどれかがある)> synced の順に - // 決める。pending は送る曲、waiting は準備待ち(Derived 未生成など)、errors は送れない - // 曲の数(ライブラリから消えた曲は数えない) - "open_plan": false, // open な計画か待ち・実行中の device_sync がある(設定を変えられない) - "plan_open": false, // open な計画がある(「続きを実行」「破棄」の対象) - "sync_job": null, // 待ち・実行中の device_sync { id, state } か null - "sync_progress": null } // agent の同期の進捗か null。open な計画の plan_id と一致するものだけ(下記) - -// SyncProgress: { "plan_id", "done", "total", "bytes_done", "bytes_total", "rate", "phase", "current", -// "updated_at", "age" } // age = サーバが受けてからの秒数(画面は端末の時計に頼らない) - -// GET /api/devices/:id/diff -{ "generation": 5, "plan_token": "…", // 同期(adb)と計画の確定(agent)に添える - "pending_reevaluation": false, // 印の付いたスマートの評価待ち(確定は 409) - "items": [ { "op": "add", "track_id": 1, "title": "…", "artist": "…", "from": null, - "dest_path": "J-Pop/…/1-01 群青.opus", "reason": null, "size": 8123456, "has_copy": false } ], - // op = add | update | move | update_move | delete | adopted | waiting | error。waiting / error は hold - // (reason に理由、has_copy は既存の写しが残るか)と、反映済みで前回の同期・報告が失敗した曲。 - // adopted = 引き継いだ写しの照合(トークンの書き換えだけで転送しない。estimate に含めない) - "playlists": [ { "op": "update", "playlist_id": 4, "name": "通勤", "dest_path": "Playlists/通勤.m3u8", - "reason": null } ], // op = add | update | delete | error - "estimate": { "transfer_bytes": 25000000, "peak_bytes": 24000000, "free": 51200000000 }, - // 送る量、実行順に見た「今より増える量」の最大値、端末の空き(adb が最後に測った値。無ければ null) - "evaluations": [ { "playlist_id": 4, "name": "通勤", "evaluated_at": 1700000000, "pending": false } ], - // 印の付いたスマートプレイリストの評価時刻 - "counts": { … } } // Device の counts と同じ -``` - -### 認証 - -LAN 限定でも必須とする。攻撃者対策というより事故対策で、この API は 300GB の -ライブラリをリネーム・削除・タグ上書きできる。認証がないと、別タブのスクリプト、 -家族の端末、うっかり有効にしたポートフォワードがそのまま破壊的操作に届く。 - -- 単一パスワード。argon2id でハッシュして DB に保存 -- **初期パスワードは環境変数 `SPINDLE_INITIAL_PASSWORD`** で与える(D-28)。DB に - パスワードが無い初回起動でハッシュ化して保存し、以後は無視する。環境変数も DB も - 無い場合は**ロックモード**で起動し、SPA の配信を含めて `/health` 以外は 503 で - 短い JSON(設定手順)を返す。`/health` は `{"status":"locked"}` を返して機械可読にする。 - 「先着で設定できる初回セットアップ画面」は置かない。compose のサンプルは - `${SPINDLE_INITIAL_PASSWORD:?...}` 形式で、値を与えないと起動できないようにする -- セッション Cookie(HttpOnly / SameSite=Lax / TLS 時のみ Secure)。Cookie 値はランダム - 32 バイトで、DB には **SHA-256 のみ**保存(`sessions.token_hash`)。失敗ログインは - IP 単位でレート制限 -- **変更系リクエスト(POST / PATCH / DELETE)の CSRF 検証**: `Origin` ヘッダが**あれば** - scheme / host / port が自分自身と完全一致することを必須にする。`Origin` が無い場合だけ - `Sec-Fetch-Site` が `same-origin` / `none` であることを要求し、それも無ければ拒否する。 - **`Host` の一致は判定に使わない**(クロスサイト POST でも Host は送信先になるため防御に - ならない)。reverse proxy 越しの外部 origin は `trusted_proxies` からの `X-Forwarded-Host` / - `X-Forwarded-Proto` だけで構成する -- CORS は全面禁止(SPA は同一オリジンで配信するため不要) -- `trusted_cidrs` からの接続は **route allowlist だけ認証をスキップ**する(D-27): - `GET /api/stream/:id`、`GET /api/artwork/:hash`、`GET /api/tracks/:id`(限定フィールド)、 - `GET /api/playlists/:id/export`。それ以外(一覧・検索・SSE・history・jobs・設定・session)は - CIDR 内でもセッション必須。変更系は当然セッション必須。用途は他プレイヤーや curl からの - ストリーム参照であり、履歴やジョブのエラー文(パスを含む)を LAN 全体に見せる理由はない。 - 判定に使うのは接続元 socket のアドレスで、`X-Forwarded-For` は `trusted_proxies` に列挙した - proxy からのものだけ採用する -- `/health` と `/api/agent/*` 以外の全ルート(SSE / stream / artwork 含む)が同じ認証ミドルウェアを通る -- **エージェントの経路**(`/api/agent/*`。Mac の `spindle-agent`、D-99): セッションの認証ミドルウェアの - **外**に置き、`Authorization: Bearer <selector>.<secret>` だけで通す専用のミドルウェアを載せる。 - Cookie・CSRF・`trusted_cidrs` はどれも見ず、逆にセッション側は Bearer では通らない(同じ経路に分岐を - 足すと、どちらかの検査が緩む事故が起きやすい)。セレクタで端末の行を 1 つ引き、シークレットの SHA-256 を - 定数時間で比べる(256 bit の乱数なので argon2id は要らない)。外れれば 401 `unauthenticated` と - `WWW-Authenticate: Bearer`。トークンは端末タブの「pair コードを発行」(`POST /api/devices/:id/pair-code`。 - セッション必須)で出したワンタイムコード(`<selector>.<secret>`、10 分・1 回限り、argon2id で保存、 - 試行は検証の前に予約して 5 回まで)を `POST /api/agent/pair` で引き換えて受け取る。pair だけは Bearer を - 要らない明示的な例外。コードを発行し直すと旧トークンは即失効する。ロックモードでは pair を含めて 503。 - トークンとコードはログに出さない -- Subsonic 非対応が確定したため、salt+md5 方式との併存を考慮する必要はない - ---- - -## 10. プレイリストとエクスポート - -### スマートプレイリスト DSL - -foobar2000 風の文法を採用し、`pest` でパースして AST(JSON) を DB に保存、 -実行時にパラメータ化 SQL へ変換する。生 SQL を保存しないのは、スキーマ変更で -全ルールが壊れることと、インジェクション面を抱え込むことを避けるため。 - -``` -%albumartist% IS ヰ世界情緒 AND %verification% IS verified_ctdb - AND NOT %category% IS _Unsorted -ORDER BY %date% DESC LIMIT 100 -``` - -- 演算子: `IS` / `HAS`(部分一致) / `GREATER` / `LESS` / `MATCHES`(正規表現) / - `MISSING` / `PRESENT` -- 論理: `AND` / `OR` / `NOT` / 括弧 -- 拡張フィールド: `verification` `lossless` `codec` `samplerate` `bitdepth` - `channels` `category` `added` `duration` `has_derived` `missing` `hirescheck` `cutoff` `cliff` - `effectivebits`、端末の状態を引く `on_device` / `device_pending`(値は端末名、`IS` だけ。端末名はバインドし、 - 未反映の集合は JSON 配列をバインドして `json_each` で展開する。これらを使うスマートプレイリストは端末の - 選曲に載せられない。§7.11、docs/DSL.md) -- 独自拡張(foobar に無い): `MATCHES` / `LIMIT` / `ORDER BY random` -- SQL 生成はホワイトリスト列へのマッピング。任意タグは - `EXISTS (SELECT 1 FROM track_tags ...)` に展開。値は全てバインドパラメータ -- 評価結果(ORDER BY / LIMIT 適用後)は `playlist_items` に書く(D-54)。表・書き出しは手動と同じ経路。 - ライブラリ変更をトリガに常駐タスクがデバウンス後に再評価する - -### エクスポート形式 - -| 出力先 | 形式 | 内容 | -|---|---|---| -| foobar(静的) | `.m3u8` | 評価結果のトラック一覧。UTF-8 / BOM なし | -| foobar(動的) | クリップボード | **クエリ文字列とソートパターン**。Autoplaylist 作成時に貼る(`.txt` は作らない。D-55) | -| Android | `.m3u8` | 配布ビュー(Derived 優先)で解決したパス。タグ追随待ちの Derived は件数(`stale_tags`)で示す | -| 汎用 | `.pls` | 任意(未実装。`export_profiles.format` に列挙だけしてある) | - -`.fpl` は非対応。非公開のバイナリ形式で foobar 1.x と 2.x で構造が異なり、 -書き損じると foobar 側の状態を壊す。 - -### foobar クエリへの変換 - -1. **フィールド名の写像。** foobar はスペース区切り: `ALBUMARTIST` → `%album artist%`、 - `TRACKNUMBER` → `%tracknumber%`。写像表を持つ(docs/DSL.md)。技術情報(`codec` `samplerate` - `bitrate` `channels` `bitdepth` `duration`)は foobar の技術フィールドへ、spindle 固有 - (`verification` `category` `source_type` `lossless` `added` `has_derived` `missing` `hirescheck` - `cutoff` `cliff` `effectivebits` `on_device` `device_pending`)と `MATCHES` は - 変換不能としてその項を落とし `notes` に出す -2. **`ORDER BY` は分離。** foobar の Autoplaylist はソートをクエリに書かず、 - 別欄のタイトルフォーマット文字列で指定する。「クエリ」「ソートパターン」の - 2 本を出力する -3. **`LIMIT` と `random`、降順は変換不能。** `notes` に明示する - -`HAS` は foobar でも部分一致で、技術情報フィールドの `PRESENT` / `MISSING` も効く -(2026-09-19 に実機で確認。D-55)。 - -### パスマッピング(エクスポートプロファイル) - -NAS 上の `/library/...` をそのまま書いても foobar からは開けない。 -出力先ごとにプロファイルを持つ(`export_profiles` テーブル)。3 つは固定で、CRUD の API は -持たない。`foobar` の `prefix` だけ `[export].fb2k_prefix` を正として起動時に揃える(D-55)。 - -| プロファイル | source | path_style | prefix | sep | -|---|---|---|---|---| -| `foobar` | master (Library) | absolute | `\\TRUENAS\music\` | `\` | -| `android` | delivery (Derived 優先) | relative | — | `/` | -| `internal` | master | relative | — | `/` | - -書き出し先は `Playlists/<profile>/<name>.m3u8`(プロファイルごとにディレクトリを分ける。D-53)。 -`Playlists/<profile>/` は `Library/` と同じ深さなので、相対パスは `../../Library/...` で -正しく解決される。旧ライブラリから移した `Playlists/m3u8/` は取り込み元として残す。 -スマートプレイリストはライブラリ変更をトリガに、 -デバウンス(既定 30 秒)を挟んで自動再エクスポートする。 - ---- - -## 11. 再生とトランスコード - -| コーデック | ブラウザ | 方針 | -|---|---|---| -| FLAC | Chrome / Firefox / Safari 対応 | 直送 | -| Opus | Chrome / Firefox / Safari 18.4+ 対応 | 直送(Opus 不可のブラウザ向けの AAC 変換は作らない。D-52。スマホ版の試し聴きは例外で `?transcode=aac` を使う。D-115) | -| AAC (m4a) | 全対応 | 直送 | -| WAV | 全対応 | 直送(サイズ大) | -| **ALAC** | **Safari のみ** | **既定で Opus へ変換** | -| ハイレゾ FLAC | 再生可だが帯域大 | 既定の Derived(Opus 48 kHz)再生で吸収。「原本」を選んだときはそのまま送る(D-52) | - -- `GET /api/stream/:id` は原本を Range で直送。`?transcode=opus` は `delivery` が Derived を指せば - Derived を Range で直送し、無いときだけ ffmpeg を `stdout` パイプで起動して chunked で返す(D-52) -- `?transcode=aac` は `aac` 系統の Derived(m4a)が現在値なら Range で直送し、無いときは ffmpeg で AAC を ADTS として - chunked で返す(`audio/aac`。Range なし。シークは `?start=`)。非可逆の原本は `opus` / `aac` のどちらでも変換せずそのまま - 返す(世代劣化させない)。スマホ版の試し聴きが、Ogg/Opus を再生できない端末で使う(§12.7、D-115) -- 変換中のシークは該当位置から ffmpeg を再起動(`?start=` → `-ss`) -- 変換結果はキャッシュしない(Derived がそのキャッシュ) -- クライアント能力は起動時に `canPlayType()` で判定し、再生時に URL で選ぶ(サーバへは通知しない)。 - 可逆はブラウザが再生できても既定で Derived の Opus(設定「原本」で直送) - ---- - -## 12. UI - -デスクトップブラウザ専用(スマホ用の別ページは §12.7)。レスポンシブ・アニメーションは対象外。配色はライト / ダーク -(設定「表示」。既定は OS に従う。P4-10、D-58 追記)。キーボードは Ctrl+A / Delete / Enter / Esc と -一覧のカーソル移動(§12.2「キーボード」)。 - -### 12.1 骨格(上部バー + 3 ペイン) - -``` -┌──────────────────────────────────────────────────────────────────────────────────────┐ -│ spindle ライブラリ アルバム CD YouTube Inbox❷ 端末❸ │ ⏮ ▶ ⏭ ■ 0:38 ━━━━━━━━ 3:37 曲名 — アーティスト │ RG track ☐原本 🔊━ │ ☰ │ -├──────────────┬───────────────────────────────────────────────────────────────────────┤ -│ [検索 _____] │ プロパティ │ 一括編集 │ 操作 選択 1,204 件 ▴ │ ← 右パネル -│ ▾ All Music │ Metadata │ Location │ -│ ▾ J-Pop │ Artist Name … │ File path … │ -│ ▸ 藍井 │ … │ General … │ -│ ▸ Game ├───────────────────────────────────────────────────────────────────────┤ -│ │ 表示 9,099 件 列 │ -│ プレイリスト │ ☐ │ Artist/album │ # │ Title / track artist │ 長さ │ バッジ │ │ -│ ♪ 通勤 │ ☑ │ … │ 1 │ … │ │ ✔ RG │ │ -│ ⚙ 未検証 │ … (6 万行、仮想スクロール) │ -│ フィルタ │ │ -│ 未検証 重複 │ │ -│ ┌──────────┐ │ │ -│ │ cover │ │ │ -│ └──────────┘ │ │ -└──────────────┴───────────────────────────────────────────────────────────────────────┘ -``` - -- **上部バー**(ナビとプレイヤーを 1 本に。D-58 追記): 左に画面の切替、中央に ⏮ ▶ ⏭ ■ とシーク、 - 曲名(無ければ Not playing)、右に RG モード / 原本 / 音量、☰(ジョブ / 記録 / 設定 / ログアウト。 - SSE の接続状態の点もここ) -- **ナビの並び**(P4-20): `ライブラリ / アルバム / CD / YouTube / Inbox / 端末`。ジョブ・記録・設定は ☰ の中。 - **タブは取り込みの流れ順には並べない。** ものは 入力(CD / YouTube)→ Inbox → ライブラリ と流れるが、 - ライブラリが日常の入口なので先頭(ホーム)に置く。導線は「取り込みタブのすぐ右に Inbox があり、 - そこに赤い数字が増える」ことで示す(CD を取り込み終えたら右隣の Inbox を見る)。 - Inbox のバッジは承認待ちの件数(`GET /api/inbox/summary`)で、失敗があれば赤点も出す。 - ジョブが ☰ に入るので、実行中 + 待ちの件数は ☰ のメニュー内の「ジョブ」に添え、失敗の赤点は - ☰ のボタン自体に出す(SSE の点と合わせて 2 つまで。3 つ並べると読めない)。 - **端末**(P5-2、D-95)は取り込みの流れの終点として Inbox の右に置き、全端末の未反映(追加 + 更新 + 移動 + - 削除 + エラー。待ちは含めない。同じ曲が 2 端末で未反映なら 2)の件数を黄色で出す(`GET /api/devices` の - `counts` の合計。一覧は画面に関わらず取る) -- **左サイドバーを出す画面**(P4-20): ツリーの選択が表の絞り込みに効く画面(ライブラリ / アルバム)だけ。 - CD / YouTube / Inbox / 端末 / ジョブ / 記録 / 設定では左カラムごと畳んで全幅にする(ツリーが何にも効かないため。 - 左カラム下のアルバムアートも一緒に隠れるが、曲名は上部バーに残る) -- **検索ボックス**は左サイドバーの先頭(スクロールしない)。表の `filter.q`(入力から 250ms 後に - 反映。3 文字以上で FTS、未満は LIKE。D-39 / D-40) -- **左サイドバー**: 3 区画(ツリー / プレイリスト / フィルタ)。どれを選んでも - **中心の表の集合を差し替えるだけ**で、列・ソート・選択の仕組みは共通 - - ツリー: Category → AlbumArtist → Album(`GET /api/albums` から構築。選ぶと scope を置き換える) - - プレイリスト: 手動 / スマート。表からドラッグで追加。**選曲の近道**(P5-2、D-95): 印の付いた端末を行に - 端末ごとの 📱 で出す。同期の状況(Device の `playlist_states`)を記号で添える: 同期済みは 📱 だけ、 - 同期待ち・同期中は 📱⏳、送れないものがあれば 📱⚠、選曲から外したが端末に残るものは薄い 📱🗑。 - 「同期中」は同期の実行中(Android は device_sync ジョブが running、iPhone は `sync_progress` が届いている間)の - 同期待ちのうち、送る曲かファイルがあるものだけ(送れないもの・準備待ちだけのものは実行中の同期で片付かない)。 - 端末名と状況はホバーで出す(名前が長くても印は切らず、名前のほうを省略する)。 - 右クリックと … のメニューの「端末へ送る」に端末ごとのチェックを並べる - (端末タブの選曲と同じ `PUT /api/devices/:id/playlists` を触る。端末が 1 台も無ければ追加を案内する。 - 同期が途中の端末は変えられない) - - フィルタ(固定): 未検証 / 重複 / missing / RG なし / 反映待ち / conflict / hardlink。 - トグルで AND、ツリーの絞り込みと組み合わせられる(D-40)。端末があれば「端末に未反映 ▾」で端末を選ぶと - `filter.device_pending` で絞る(追加・更新・移動・更新 + 移動。前回の失敗も含む。DSL の `device_pending` と - 同じ定義。選んでいた端末が消えたら外す) -- **右パネル**: 2 タブ(一括編集 / 選択の詳細)。折りたたみ可、幅は永続化 -- **下部バー**: 左が再生(再生・停止・シーク・音量・RG の off / track / album)、右がジョブ要約 - 「実行中 N · 反映待ち M · 失敗 K」。右側クリックでジョブ画面へ - -### 12.2 トラック一覧 - -**列**(既定。順・幅・表示は localStorage に永続化): -選択 / # / Title / Artist / Album / AlbumArtist / Date / Category / 長さ / Codec / バッジ / -rel_path(既定非表示) - -**バッジ列**(1 セルに複数アイコン、ホバーで文言): - -| バッジ | 条件 | 出典 | -|---|---|---| -| 検証 | `verification` を検証済み / 不一致 / 検証不能 / 未検証の 4 つの見え方で出す。`verified_ctdb` と `verified_ar` は同じ「検証済み」(どちらで一致したかはプロパティの General の CTDB / AccurateRip 行。D-96)。`unverifiable` は「未検証」と別の見え方 | tracks | -| 可逆 / 非可逆 | `lossless` | tracks | -| RG | `rg_scanned_at` の有無。書き込み未反映(`rg_written_at < rg_scanned_at`)は半透明 | tracks | -| Derived | 系統ごとに 1 つ: **O** = `derived_files` の `opus` 系統あり(= 配布ビュー)、**A** = `aac` 系統あり。この順に並べ、`stale_tags` は点付き(O• / A•)。プロパティの Location 列にも系統ごとの行を出す(§7.6、D-112) | delivery | -| 反映待ち ⏳ | `edit_ops.result = 'pending'` がある | edit_ops | -| conflict ⚠ | このトラックの**最新の op**(`edit_ops` を `id DESC` で 1 件)が `skipped_conflict` | edit_ops | -| 重複 | `duplicate_groups` に属する | view | -| hardlink | `nlink > 1` | tracks | -| missing | `missing_since` あり。行全体をグレー | tracks | -| 端末 | 端末ごとに 1 つ(ホバーで端末名): 📱 反映済み / 📲 未反映(追加・更新・移動・更新 + 移動)/ ⏳ 待ち(理由)/ ⚠ エラー(理由)。対象外(removing)と、その端末に関係しない曲は出さない(P5-2、D-95 の可視化 D) | `/api/tracks` の `devices` | - -**選択**: クリック / Shift 範囲 / Ctrl 追加 / Ctrl+A(フィルタ結果全件)/ キーボード(下記)。 -全件選択は ID 列挙ではなく**選択した時点のフィルタ式**をサーバに渡す(`selection.filter`)。 -選択は immutable で、その後にソートを変えても**選択集合は変わらない**。表の集合(サイドバーの -scope・固定フィルタ・検索語・ルール編集中の DSL)が変わったら**選択を解除する**。右パネル(プロパティ・ -一括編集・操作)の対象は表示中の表の行だけにし、前の表示で選んだ行を持ち越さない(D-40)。 -プレビューでサーバが集合をスナップショットし(`selection_token`)、適用はその集合だけに -効く。右パネル頭に「選択 N 件(うち反映待ち M 件)」、表の上に「表示 K 件」。 - -**反映待ちの行**は編集不可。右パネルの [適用] は「M 件を除外して適用 / 待つ」の 2 択に -なる(API の 409 をここで吸収する)。 - -**キーボード**(P4-17、D-80。表にフォーカスがあるとき。クリックした行がカーソルになる): - -| キー | 動き | -|---|---| -| ↓ / ↑ | カーソルを 1 行動かし、その行だけを選択する | -| PageDown / PageUp | 1 画面ぶん動かす(同上) | -| Home / End | 読み込み済みの先頭 / 末尾へ(同上) | -| Shift + 上記 | カーソルを動かし、選択を **anchor からカーソルまでの範囲そのもの**に置き換える(戻れば縮む。クリックの Shift が「足す」のと違う)。anchor が無ければ(Ctrl+A / Esc の直後)移動前のカーソル行が anchor | -| Ctrl + 上記 | カーソルだけ動かす(選択は変えない) | -| Space | カーソル行を選択にトグル(Ctrl+クリックと同じ) | -| Ctrl+A / Esc / Delete | 全件選択 / 解除 / プレイリスト scope で選択を除外(従来どおり) | - -カーソルは**読み込み済みの行の中**でだけ動く(未読込の骨組み行には id が無く選択に入れられない)。末尾に -着くと次のページが読まれるので、End を繰り返せば先へ進める。カーソルは選択とは別の見た目上の状態で、 -ソートを変えても選択集合は変わらない(表の集合が変われば上記のとおり解除する)。 - -**インライン編集**: セルをダブルクリック → 1 件のバッチとして同じ経路(プレビュー省略)。 - -**プロパティタブの Metadata**(D-58 / D-72、P4-3): 値のダブルクリックで選択全体への `set`(`;` で多値)。 -foobar2000 の Properties と同じく、行末の × か右クリックのメニューで**フィールドの削除**(選択全体への -`delete` op。値の無い行は消せない)、表の下の「フィールドを追加」で**新しいキー**に `set`(キーは大文字化、 -`=` と制御文字は不可、`PICTURE` 不可、表に既にあるキーはその行の編集を案内、空の値は追加しない)。キーの入力欄は Inbox の「タグを追加」・一括編集の -操作のキーと同じく、入力のたびに**前方一致の候補**(`GET /api/tags/keys` のキーを曲数の多い順に、ライブラリに -無いよく使うキーを後ろに。既にあるキーは除く)を出し、↑↓ と Enter / Tab かクリックで選ぶ(`TagKeyInput`、D-113)。 -どれも 1 op のバッチ(preview → apply、履歴から巻き戻せる)。サーバの変更は無い。 - -**プロパティタブの「端末」節**(P5-2、D-95 の可視化 A): Location の下に、端末が 1 台でもあれば端末ごとに -1 行: `✓ 反映済み(日時)` / `未反映(追加・更新・移動・更新 + 移動)`(前回の失敗があれば理由)/ -`待ち: <理由>` / `エラー: <理由>`(既存の写しがあれば「(古い版が端末にあり)」)/ `対象外` -(次の同期で端末から消えるものは「次の同期で端末から削除」)。複数選択は他の行と同じく集計し、 -全部反映済みなら `✓ 反映済み`(日時は畳む)。 - -### 12.3 右パネル: 一括編集 - -``` -操作リスト(上から順に適用) - 1. TITLE 正規表現置換 /\s*\(Official.*\)$// - 2. ALBUMARTIST フィールド参照 %artist% - 3. TRACKNUMBER 連番 開始 1、現在のソート順に - 4. COMMENT 削除 - [+ 操作を追加] -[プレビュー] → 表の該当セルに 旧値→新値 の差分表示。変更なしは薄く。 - 頭に「変更 1,180 / 変更なし 24 / 反映待ちで除外 3」 -[適用] → 説明文(任意)→ バッチ作成 → 下部バーの反映待ちが動く -``` - -操作: 固定値代入 / フィールド参照(`%albumartist%`)/ 正規表現置換 / トラック番号連番 / -タグ削除。プレビューは `POST /api/tracks/batch/preview` の結果を表に重ねる。別表は開かない。 -操作リストは選択を変えても残る(同じ操作を別の集合へ繰り返し適用できる)。 - -**導線**(D-87。ユーザ要望・モックで合意): 一括編集タブと操作タブは CD / Inbox と同じ番号付きの段を -**横に 4 列**並べる(パネルは表の上で低く横長なので。コンテナ幅が狭ければ 2 列 → 1 列)。 - -- 一括編集: **① 対象**(選択件数・アルバム・反映待ち。`PanelTargetStep`)→ **② 操作**(上の操作リスト)→ - **③ プレビュー**(変更 / 変更なし / 反映待ち除外の件数。選択・操作・ソートを変えると「古い」になり枠が - 警告色)→ **④ 適用**(説明と「N 曲に適用」。③ が済むまで押せない。済んだらバッチ #、巻き戻しは履歴) -- 操作: **① 対象** → **② 操作を選ぶ**(ファイル / 音量 / 検査 / 画像・プレイリストの群から 1 つ。選んだものは - localStorage に残す)→ **③ 確かめる / プレビュー**(選んだ操作の説明と「巻き戻せる変更 / ジョブ / 読むだけ / - すぐ反映」の印。リネーム・正規化はパスのプレビュー、アートワークは画像の選択、album gain はチェックボックス、 - プレイリストは追加先)→ **④ 実行**(見出しは適用 / 投入 / 反映。押せない理由を添える。結果の 1 行と - 反映待ちの「除外して適用」もここ) - -### 12.4 記録(編集履歴 / 退避ファイル / ログ) - -☰ の「記録」(旧「履歴」)は 3 つのタブを持つ: **編集履歴 / 退避ファイル / ログ**。設定にあった退避ファイルと、 -操作タブが実ファイルのパスで案内していた照合ログをここに集める(D-106)。 - -- **退避ファイル**: `archived_files` の一覧と復元。既定は保持中(`held`)で 100 件ずつ(「もっと見る」で - `cursor`)。状態ごとの件数は `counts`。GC で実体を消した記録(`deleted`)は既定では出さない -- **ログ**: 照合(`retro`)と吸い出し(`rip`)の実行ごとの一覧(50 件ずつ。`kind` で絞る)。照合ログは - album ごとに最新 1 回分しか残らないので、古い実行は `superseded`(「上書きされた」)と出す。吸い出しログは - DB の `log_path` を使わず、その disc の active な曲がいまあるディレクトリから `rip.log` / `rip<N>.log` を引く - (リネームに追随する)。行を開くと本文を読み、コピーとダウンロードができる。Inbox の承認画面でも CD の - 取り込みの `rip*.log` / `cue` / `toc` を読める(`GET /api/inbox/:id/files/:name`) -- **編集履歴タブ**: 以下 - -``` -#42 09-16 14:03 "Official を除去" tags 1,204 件 applied [巻き戻す] -#41 09-16 13:50 "アーティスト統一" tags 312 件 partial (conflict 3) [巻き戻す] [conflict を見る] -#40 09-16 13:20 "リネーム J-Pop/…" rename 980 件 applying 640/980 [キャンセル] -#39 09-15 … (#38 の巻き戻し) tags 50 件 applied ↩ #38 [巻き戻す(=やり直し)] -#38 09-15 … "…" tags 50 件 applied reverted — -``` - -- 行を開くと op 一覧(トラック / result / error)。conflict の op は「ファイルを再読込した」旨と - 現在値を表示 -- [巻き戻す] は終端状態のみ有効。`reverted_at` 済みは無効化して「#39 で戻し済み」と表示 -- 巻き戻しも新バッチなので同じ一覧に現れる(`reverts_batch_id` を ↩ で表示) -- [キャンセル] は `prepared` / `applying` のみ - -### 12.5 ジョブ - -種別ごとの並列度と待ち行列(待ち / 実行中 / 完了 / 失敗。件数は `by_type` の全件集計。一覧は上限付きなので -画面で数えない)、一覧の各行に**対象**(`subject`。どのファイル / アルバム / バッチかが id だけでは分からない)、 -実行中の進捗(`done / total`)、失敗の `last_error` と [再試行] / [キャンセル]、終端の行の [消す](確認済みの -片付け。失敗・取り消しタブには [失敗をすべて消す]。P4-18)。一覧のタブは -**実行中・待ち / 完了 / 失敗・取り消し / すべて**で、各タブに `summary` の件数を添える(待ち → 実行中 → 完了と -数が移っていくのが分かる)。一覧は状態ごとに切り出す(実行中・待ちはキューの順で 1,000 件、完了と失敗・取り消しは新しい順で -各 300 件。`limits`)。表示中のタブが上限に達していれば「表示は最新 N 件まで」と添える。種別表の下に CPU 系の実行中の合計と共通予算(`cpu_budget`。D-73)。編集バッチ由来のジョブは `edit_batch_id` で記録画面(編集履歴)へリンク。 -SSE `/api/events` で更新し、リロードしても DB の値で復元する。 - -**結果**列は完了したジョブの `note` の 1 行目を出す(`transcode` は実行中から処理の名前を出す。§7.6)。`note` かエラーのある行は、クリックで開いて全文を読む -(verify の行には「ログを見る」で、記録 > ログをその album で絞って開き、最新の実行の本文を開く)。GC・スキャン・バックアップは複数行の `note` を残す: -GC は削除件数・バイト数と区分ごとの内訳・見送りと失敗・掃除したジョブ行と計画の数、スキャンは見たファイル数・新規・更新・ -移動・missing・エラーの件数と、読めなかったファイルと対象外のパス(対象外は 20 件まで)、バックアップは作った -ファイルと大きさ・保持する世代数・消した件数。ジョブ行と同じ保持期間 -(done 7 日)で消える(D-106)。 - -### 12.6 その他の画面(骨格のみ) - -- **アルバム**(P1-3): サムネイルグリッド(`/api/artwork/:hash?size=256`。missing は出さない)→ - クリックで表を `album_id` に絞る(一覧へ戻る)。**表と同じ絞り込み**(ツリーの選択・プレイリスト・検索語・ - 編集中のルール)が効き、`GET /api/albums?filter=` で「一致する active なトラックを持つ album」を出す - (P4-6、D-58 追記)。件数は絞り込み中「全 M 件中 N 件」。ツリーは全件の一覧から組む(絞り込みとは別に取る) -- **操作タブの「アートワーク」**(P1-3 書き側、D-60): ファイル選択 → `POST /api/artwork/upload` → - 256px のプレビュー(形式・寸法)→ 「選択 N 件の埋め込み画像を差し替え」(`POST /api/artwork/embed`。 - 反映待ちの 409 は他の操作と同じ「除外して適用」)。履歴画面の `PICTURE` 値はサムネイルで出す -- **CD**(P2、P4-20): **ドライブに CD が入っている前提の画面**。左カラム(ツリー)は出さない(§12.1)。 - - **導線**(D-85。ユーザ要望): 上から番号付きの枠で **① 挿入中の CD → ② 認識したトラック → ③ 候補を - 選ぶ → ④ 取り込む**。各枠の見出しの右に要約(① は「表示のみ」のバッジ、② は曲数、③ は照会の要約 - `lookupHeadline`)と ⓘ。**長い説明は本文に並べず ⓘ のツールチップ**(`components/Hint.tsx`。ホバーと - キーボードのフォーカスで出る)に畳む(照会の段の順序、写す範囲、CD 以外の媒体、「さらに広げて探す」、 - 「どれも違う」、ドライブの型番とオフセット)。③ の候補の一覧は枠の中でスクロールし(高さの上限)、 - ④ を画面の遠くへ押し出さない。④ の横の一行は「『アルバム』として取り込む」/「候補を選ばずに取り込む」 - - **ライブラリにあるかの帯**(D-85): ツールバーの下。`POST /api/cd/library`(TOC と選択中の候補の - リリース)で、その盤そのもの(DiscID)が当たれば緑の「✓ この盤はライブラリにある: 『…』(…)」、 - リリースだけ当たれば黄の「同じリリースの album がライブラリにある(この盤は未取り込み)」と - 「ライブラリで開く」(表を album に絞る)。どちらも無ければ出さない。失敗しても画面は止めない - - **ドライブ**(P2-1): 画面を開いている間 `GET /api/cd/status` を 2 秒間隔で取り(`useCdDrive`)、 - 状態の一行(ディスクなし / トレイが開いている / ディスクあり(N トラック・総時間)/ ドライブが無い - (理由))と「照会し直す」「取り出す」。新しいディスクの TOC が出たときだけ(同じディスクの間は 1 回。 - `lib/cdDrive.ts` の `newDiscToc`)照会を自動で始める。自動の照会はサーバが覚えている結果を使い、 - ボタンからの照会は `refresh` で引き直す - - **この画面では編集させない**(P4-20 追記。ユーザ要望)。吸い出したものは Inbox を通すので - (D-67 追記)、値を直すのは Inbox の承認画面に一本化する。入力欄が 2 か所にあると、どちらで直すのか - 分からなくなる。CD 画面は「何が入っていて、どの盤として取り込むか」を確かめる場所 - - **トラック表**: TOC の音声トラックと 1:1 で、**照会の前から出る**(`GET /api/cd/status` の `tracks`。 - 番号と長さはサーバが TOC から出す)。列は `# / タイトル / アーティスト / 長さ`、取り込み中だけ右端に - `進捗`。**読み取り専用**で、見た目は一覧の表に寄せる(sticky なヘッダ、`--row-h` の行高、行のホバー、 - 番号と長さは右寄せ)。名前の入っていない行は**取り込んだときに付く名前**(`Track NN` / - アルバムアーティスト)を薄字で見せ、候補を選ぶと実名に置き換わる。ISRC は列にせず `#` の - ツールチップ。コンポーネントは一覧の `TrackTable` とは別(あちらは仮想スクロールと行選択を持つ) - - **アルバムの要約**(① 挿入中の CD。`CdAlbumSummary`): 読み取り専用。**すべてラベル付き**の 2 列の - 定義リストで、アルバム(強調)/ アルバムアーティスト / 日付は常に(空は「—」、アルバムは「(候補を - 選んでいない)」)、ディスク番号(複数枚組のときだけ)/ レーベル / カタログ番号 / JAN/UPC は値のある - ときだけ。名前は Inbox で入れる旨は ⓘ に。その下に**品番と JAN/UPC の入力欄**と「品番で照会」 - (D-94。盤の帯・背から読む版の識別子。入れた値はすぐ下書きに重なり、盤が替わったら消える。 - 照会のボタンすべてに添える)。 - **`category` は CD 画面に無い**(Inbox の承認時に選ぶ)。トラックリスト貼り付け(P2-4、D-65)も - CD 画面からは外し、Inbox の承認画面へ移した(§12.6 の Inbox、P2-10)。TOC の貼り付け - (CTDB 形式 / MusicBrainz 形式 / `cdrecord -toc` の出力)・各種 ID・用語の凡例は「詳細」に残す - (編集ではなく照会の入力。ドライブの無い環境とデバッグ用。P2-3、D-64) - - **候補**: 左にジャケット(`GET /api/cd/cover/{release_id}`。無ければ空の枠。D-82)、右に候補の - ラジオ一覧。MusicBrainz のリリースへのリンク、収録構成(`mediaSummary`。`CD + Blu-ray の 1 枚目` / - `CD 2 枚組の 1 枚目`)、ディスクとの長さ差(`lengthDiffMs`)で見分ける。CD 以外の medium - (デジタル配信・DVD・Blu-ray)は既定で畳み、「CD 以外の媒体に当たった候補も表示」で出す - (`isCdMedium` / `splitByMedium`)。バッジは経路(品番一致 / DiscID 一致 / 指定 / ISRC / バーコード / TOC 近似。 - 複数可)、見出しは段と経路(`stage`。DiscID が登録済みのまま下の段に落ちたときは「未登録」と言わない)、 - `notes` は赤字。入力した品番が選んだ候補と食い違えば注記(`catnoMismatch`。別の版として取り込む)。 - 品番で当たった候補が 1 件なら既定でそれを選ぶ(`initialSelection`)。DiscID 未登録なら「MusicBrainz に DiscID を登録」リンク(`discidSubmissionUrl`)。 - 応答の `can_widen` が真なら**「さらに広げて探す」**(`widen`。TOC 近似まで引き直す。D-64 追記 4) - - **写す範囲**(D-72、P4-2)は**既定で「全部写す」**(D-72 追記 2、P4-20。レーベル・カタログ番号・ - JAN/UPC・`MUSICBRAINZ_RELEASEGROUPID`・各トラックのタイトル / アーティスト・MB id・ISRC まで写す)。 - 「識別用の最小限」に切り替えると盤を見分けるのに要るものだけ(アルバム・アルバムアーティスト・ - 日付・ディスク番号 / 枚数・`MUSICBRAINZ_ALBUMID`。`MUSICBRAINZ_DISCID` / `TRACKTOTAL` は吸い出し時に - TOC から付く。トラック行は番号と長さだけの空行)になる。範囲を切り替えると選択中の候補を写し直す。 - 候補ゼロ件でも、また「どれも違う(候補を使わない)」を選んでも取り込める(P2-4、D-21 / D-65。 - 名前は Inbox で入れる) - - **「確定」の段は無い**(P4-20)。「取り込む」で吸い出しが始まる(吸い出しは P2-5。それまでは - ボタンを無効にしておく)。遷移は `lib/cdState.ts` の reducer: フォームは TOC が読めた時点ででき、 - 消えるのは別のディスクに替わったとき(`set_disc` / `set_toc` / `reset`)だけ。**照会の開始と - 失敗ではフォームを消さない**(表は照会の前から出ているため) -- **Inbox**(P2-10、D-68): 左に取り込み(= `[paths].inbox` の音声ファイルのあるディレクトリ)の一覧 - (状態バッジ・ファイル数・コーデック・検出時刻・失敗理由)と「今すぐ確認」(`POST /api/inbox/scan`)、 - 右に選んだ取り込みの補正フォーム: アルバムアーティスト / アルバム / 日付 / category - (`components/CategoryField.tsx`)。**CD で吸い出したものもここへ来る**(D-67 追記)ので、 - 盤の名前を直すのもこの画面と、トラックごとの disc / # / タイトル / アーティスト(ファイル名・コーデック・長さは - 表示のみ)。初期値はタグからの提案(`proposal`)、承認済み・失敗の取り込みは保存した下書き。検証はサーバと - 同じ規則(`lib/inbox.ts` の `validateDraft`)で、問題が無いときだけ「承認して配置」が押せる。 - 「却下」はファイルを残したまま取り込みを却下にし(一覧には「却下」のバッジで残る)、「下書きに戻す」で pending に戻る。 - 却下した取り込みには「削除」(確認ダイアログ → 破棄待ち。一覧に「削除待ち」のバッジ、承認画面に「削除待ち: <期限> 以降の - GC でファイルを消す」と「削除を取り消す」。期限は `GET /api/inbox` の `discard_retention_days` から。D-90)。placed の取り込みは 24 時間 - 残り、「アルバムを開く」で表を `album_id` に絞る。inbox ジョブの完了で一覧を取り直す。 - `destination` があれば「宛先: 既存の『…』(N 曲)に追加。番号は <max+1> から」と出す。**購読から落とした曲** - (`source.subscription_id` あり)を含む取り込みは番号が再生リストの位置(同期が空けた番号)に入るので、「番号 165 に入る - (再生リストの位置。空けてある番号)」と出し、その下に購読の直近の同期の要約(「同期(日時)で既存の 14 曲の番号を - 揃え … 165 を空けた(バッチ #14 / #15)。承認しても既存の曲は動かない」。直近の同期で揃え直しが無ければその旨)を - 出す。下書きの番号が `destination.numbers` と重なれば赤で警告する(承認はサーバが 400 で止める。P4-22) - 追記先の曲がどれもディスク番号を持たない(`destination.uses_disc = false`)取り込みで、下書きの `disc_no` の最大が 1(人が 2 以上にしたら書く。 - CD の取り込みを除く)なら、配置で - `DISCNUMBER` を書かず取り込みのファイルにあれば消し(宛先に合わせる。DB の `disc_no` も NULL になる)、宛先の文言に - 「ディスク番号は付けない(宛先の曲に無い)」を足し、③ の disc 列を空で見せる(下書きの値は 1 のまま。D-70 追記) - (配置済みの取り込みは `destination` を引かないので、宛先も同名の警告も出ない。引くと自分が置いた album に当たる)。 - **同名の警告**(P4-19)はタイトル欄の下に「⚠ Library に同名: <ファイル名>(長さ)/この曲 <長さ>」(取り込みの曲の長さも - 並べる。P4-22)、取り込みの一覧に「同名 N」のバッジ(承認は止めない。長さで別テイクと見分ける)。ツールバーに周期監視の「最後に確認: HH:MM:SS(N 秒ごと)」(P4-18)。`source` のあるトラック行は判定バッジ - (ok / 未判定)を出し、行を開くと `message`(参照実装ならルールの足し方)と URL が読める(D-70)。 - 「album gain を計算する」のチェックボックス(既定 off。`destination` があればその現在値が初期値。D-74)。 - **忠実表示**(P4-4、D-70): ARTIST が多値のファイルの行は元の値をチップで見せ、「ファイルの多値をそのまま - 保つ」のチェック(提案は on。on の間は欄が `"; "` 結合の表示で編集不可、外すと欄が編集できて 1 値で書く - 旨を示す)。見出しの横と**取り込みの一覧の各取り込みの左**(48px。無い取り込みは同じ寸法の空枠。D-85)に取り込みの代表画像 - (各ファイルの代表 = `PICTURE` の先頭、の最頻)、トラック表に小さなサムネイル列 - (`GET /api/inbox/:id/artwork/:hash`。無ければ空)。 - **導線**(D-86。ユーザ要望・モックで合意): 承認画面は CD 画面と同じ番号付きの段(`components/Step.tsx`)で - **① 取り込むファイル**(出どころ CD / YouTube / 手置き・ディレクトリ・検出時刻・追記先・ファイルのタグで足りないもの - = `warnings`)→ **② アルバム情報** → **③ トラック** → **④ 確認して配置**。見出しに取り込みの代表画像 - (下書きを当てた後の各曲の画像の最頻)・出どころ・状態・「変更 N 件」(`draftChangeCount`)。 - **② アルバム情報**: 先頭に画像の欄(`InboxCover`)、その下にライブラリのプロパティと同じ操作の 2 列の表 - (`InboxAlbumProps`)。Metadata(アルバムアーティスト / アルバム / 日付 / category / album gain)は最初から - 入力欄にせず、行をクリックで選び、ダブルクリック(Enter / F2)で入力欄、Enter で確定、Esc で取り消し、↑↓ で - 行を移る。category は統制語彙の選択欄(`CategoryField` の inline)、album gain はダブルクリックで切り替わる。 - 提案(album gain は追記先の現在値)と違う行に「変更」、行のツールチップに元の値。Info(MusicBrainz リリース / - ディスク・トラック数 / 追記先)は表示のみ。 - **画像の欄**: 全曲が同じ画像なら 1 枚(クリックかドロップで差し替え=全曲)、画像なしは点線の枠、**曲ごとに - 違う(YouTube)なら既定で曲ごとの画像を保ち**、サムネイルを並べて「画像の無い N 曲に入れる」「全曲を 1 枚に - そろえる」を明示的な操作にする。CD の取り込みでリリースが決まっていれば、表の画像を Cover Art Archive から - 自動で取って初期値に入れてある(D-91。そのリリースに無ければリリースグループの代表画像。D-93。欄に - 「Cover Art Archive の表の画像(…自動で取った)」、「画像を外す」で - 外せる。`lib/inbox.ts` の `usesCaaPicture`)。手動の「Cover Art Archive から取る」 - (`POST /api/artwork/from-caa`)も残す(リリースを引き直した後など)。**「別のリリースから取る…」**(D-93。 - 曲ごとに違う画像のときは出さない)は、メタデータを取り込むリリースのまま表の画像だけを別の版から取る - (`components/CaaReleasePicker.tsx`): 下書きの `release_group_id` があれば同じグループの版 - (`GET /api/cd/release-group/{id}/releases`)のうち表の画像のある版をジャケット(`/api/cd/cover/{id}`)・ - 日付・国・形式・レーベル・カタログ番号付きで並べ(取り込むリリースに印。画像の無い版は件数だけ)、クリックで - その画像を全曲に入れる。「リリースグループの代表画像を取る」と、別のグループに登録された版のための - MusicBrainz のリリースの URL / MBID の貼り付けもある(この 3 つと「Cover Art Archive から取る」は `POST /api/artwork/from-caa` - で置く)。選んだ・ドロップしたファイルは `POST /api/artwork/upload` で置く。どちらも置いた画像を下書きの `picture` に入れる - (`hooks/useArtworkUpload`、`lib/inbox.ts` の `pictureState` / `applyPicture` / `resetPictures`)。 - **③ トラック表は全項目**(D-85 / D-86): ライブラリの表のように列で並べ、枠の中で横スクロールする - (`InboxTrackGrid`)。セルをクリックで選び、ダブルクリック(Enter / F2)で入力欄、↑↓←→ で直せるセルを移る。 - disc / # / タイトル / アーティストはその行だけ、**画像**はその曲だけ差し替える(ファイルを選ぶかドロップ)、 - アルバム / アルバムアーティスト / 日付は**アルバム単位**(どの行で直しても全行と ② に反映。列を薄く塗る)、 - **ファイルの全タグ**(取り込みのファイルと下書きで足したキーのうち、固定列が出している TITLE / ARTIST / ALBUM / - ALBUMARTIST / DATE / TRACKNUMBER / DISCNUMBER と PICTURE、種類・除外の列が出す TRACK_TYPE / PLAYLIST_EXCLUDE を - 除いたもの。ABC 順、多値は `"; "` で結合)は - その行だけ直し、空にするとそのタグを消す(`setTrackTag`)。表の下の「タグを追加」で新しいキーの列を足す - (`newTagKeyProblem`)。長さ / codec / ファイル / 判定 / category と、**🔒 の付いた同一性のタグ** - (`SOURCE_URL` / `MUSICBRAINZ_*`)と DISCTOTAL は直せない。変更したセルは色で示し、ツールチップに元の値。 - ARTIST が多値の行はセルに「多値を保つ」のチェック(D-70)。disc / # / 画像 / タイトルは横スクロールしても - 左端に残す(`stickyColumns`)。アーティストの右に**種類**(`TRACK_TYPE`。曲(空)/ カラオケ / BGM / ドラマ / トークの - 選択欄)と**除外**(`PLAYLIST_EXCLUDE=1` のチェック)の列を置き、どちらもその行の下書きのタグとして配置のときに - 書く。種類が空の行には、タイトルとアルバム名からの候補(`suggestTrackType`: instrumental / off vocal / カラオケ / - 歌無し → カラオケ、ドラマ / ボーナス・トラック → ドラマ、MC / ラジオ / トーク / コメント → トーク、サントラの - アルバムで一番多いアーティスト(作曲者)の曲 → BGM)を「候補: …」のボタンで出し、表の上の「種類の候補をすべて - 適用」でまとめて入れる。候補は自動では入れない(D-113)。列の組み立ては `inboxColumns` / `extraTagKeys`。「列」メニューで - disc / # / タイトル以外を隠せ、隠した列は localStorage(`inbox.columns.hidden`)に覚える。 - **④ 確認して配置**: 赤(`validateDraft` の問題。あると承認できない)/ 黄(判定できなかった曲・Library に - 同名・画像の無い曲)/ 緑(タグを直す曲数・画像を差し替える曲数)の一覧、配置先の見込み - (`POST /api/inbox/:id/preview` を下書きの変更から 400ms 後に引く。`hooks/useInboxPreview`)、 - 「承認して配置」「却下」「下書きに戻す」。 - **MusicBrainz の引き直し**(P4-21、D-84): CD の取り込み(`GET /api/inbox` の `rip` がある取り込み)だけ、アルバムの欄の - 下に節を出す。ボタンで `POST /api/cd/lookup { toc, isrcs, mcn, release?, refresh?, widen? }`(CD 画面と同じ)を - 引いて候補を並べ、選ぶと下書きの `release_id` / `release_group_id` を写し、ディスク番号を候補の medium の位置に - し、空欄の名前(と `Track NN`)だけ埋める(`lib/inbox.ts` の `applyCandidate`)。結果は保存しない。 - **トラックリスト貼り付け**(P2-10、D-65 / D-65 追記 2): トラック表の下の畳んだ節。テキストを - `lib/tracklist.ts` で行解析し、`lib/inbox.ts` の `applyTracklist` で**選んだディスクの行へトラック番号で** - 写す(複数枚組のときだけ「写す先」のディスクを選ぶ)。アーティストの無い行は既存の値を保ち、 - アーティストを貼った行は「そのまま保つ」を外す。取り込みに無い番号・同じ番号の行が複数あるもの・行数の違い・ - 未設定の行と、解析の警告(飛ばした見出し・番号の重複 / 飛び)を節の中に出す。写した後もフォームで直せる -- **Inbox の段**(P5-2、D-95 の可視化 B): 承認した件(approved / placing / placed)の見出しの下に、その後の段を - 横並びで出す: `配置 → 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 を超えたら絞るよう促す)。アルバム画面は無く - アルバム一覧は表を絞るだけなので、切り替えはここに置く -- **YouTube**(P3-3 → P4-13、D-70 追記): 上部バーの独立した画面(取り込み元は Inbox / CD / YouTube で - 横並び、結果は Inbox に集まる)。 - **導線**(D-87。ユーザ要望・モックで合意): 見出しの切り替えで「ダウンロード」と「購読」。ダウンロードは - 番号付きの段 **① URL を貼る**(入力が止まって 400ms で `POST /api/ytmusic/lookup` を引き、行ごとに種類と - 状態(新規 / ライブラリにある / Inbox で承認待ち / YouTube 以外 / 取れない)を表で出す。再生リストは URL - ごとに 1 回 `POST /api/ytmusic/playlist` で本数と新規の数を出す)→ **② 取り込み方**(行き先・判定・展開・ - 画像の説明。未購読の再生リストがあれば「購読にする →」で購読の ① へ URL を渡す)→ **③ ダウンロード** - (飛ばす行を除いた「N 件をダウンロード」。投入後は今回のジョブ = 返ったジョブと、その再生リストの展開で増えた子(payload の - `parent_job_id`)を追う)→ - **④ Inbox で承認**(今回のジョブの note から置いた取り込みを並べ、「Inbox で開く」でその取り込みを選んで開く。取り込みが - まだ一覧に無ければ現れるまで待ち、人が別の取り込みを選んだらやめる)。 - これまでのジョブは折りたたみ。購読は **① 再生リスト**(list_id・題名・本数。購読済みなら止める)→ - **② アルバムとして登録**(題名が取れたら空のアルバム名に入れる)→ **③ 登録して同期**(「登録して今すぐ同期」と - 「登録だけ」。定期同期は既定で無いので、登録だけなら後から一覧の「同期」)、その下に登録済みの一覧。以下は各部の中身。(1) 1 行 1 URL のテキストエリアと「ダウンロード」(`POST /api/ytmusic/ - download`。再生リストは動画ごとに展開し、Library / Inbox に `SOURCE_URL` のある動画は投入しない)、 - (2) ytdl ジョブの一覧(`GET /api/jobs?type=ytdl` を新しい順。URL・結果(待ち / ダウンロード中 / 完了は - `note` = Inbox に置いた・プラグインが skip・再生リストを展開した N 件 / 失敗の理由)・時刻・取り消し / - 再試行・Inbox に置いた行だけ「Inbox で確認」)、 - (3) 購読(P4-16、D-78。ジョブの表は長くなるので購読の節はその上に置く): 一覧表(アルバムアーティスト / アルバム(category、束ねた album #)・再生リスト - (list_id、リンク)・有効・揃える(その場で PATCH)・最終同期・結果 1 行(走行中なら「同期中 / 同期待ち / - 再試行待ち」を `GET /api/jobs?type=playlist_sync` から、無ければ `last_result` の要約)・「同期」「編集」 - 「削除」)。結果に詳細があれば「詳細」で行を開く(取れない一覧(非公開 / 削除 / 取れない)・別の album に - ある・走行中・持ち越し・揃えられない(理由付き)・番号 / 改名のバッチ id と適用 / 衝突 / 失敗)。追加 - フォーム(URL(`list=` が無ければその場で注意)/ アルバムアーティスト / アルバム / category(語彙から - 選択、空 = 未分類)/ 揃える / 上限)。編集はアルバムアーティスト / アルバム / category をその行で。 - 削除は確認ダイアログ。SSE job のたびに取り直す。 - `/youtube?url=<URL>` で開くと欄に入れた状態で開く(ブックマークレットの - 受け口。同一 origin の GET なので CORS / CSRF を触らない。http / https 以外は受けない。USERGUIDE §11.3)。 - 操作タブの YouTube 節は廃止 -- **端末**(P5-2〜P5-4、D-95 / D-98 / D-99。§7.11): 左カラム(ツリー)は出さず、代わりに左に端末の一覧 - (名前・接続状態の点(adb のみ)・Android / iPhone・未反映 / 待ちの件数、未接続や USB デバッグの許可待ちの - 一言)と「端末を追加 ▸ iPhone(Mac 経由)/ Android(USB)」、右に選んだ端末のサブタブ **差分 / 選曲 / 設定** - (選んだタブは localStorage)。一覧は 60 秒ごとに、一覧と差分はジョブの状態が変わるたびに(間引いて) - 取り直す(ハッシュ計算・変換・評価の完了で差分が変わり、SSE の job イベントには種別が無いため) - - **追加**: iPhone は名前・系統(既定 AAC)・選曲で行を作るだけ(Mac は設定タブの pair で結ぶ)。Android は - `GET /api/devices/adb/unregistered` を前の要求の 3 秒後ごとに取り直し、許可待ちの端末には「端末で許可して - ください」、`device` の端末には名前(機種名)・系統(既定 Opus)・選曲・保存先(内部共有ストレージ / - SD カード。空き容量付き。空でないものは選べない)のフォームを出して登録する。Poweramp の音楽フォルダの - 下に置くよう添える - - **差分**: 上に送る量・一時的に増える量・端末の空き、評価待ちの警告と印の付いたスマートの評価時刻。表は - 操作(追加・更新・移動・更新 + 移動・削除・待ち・エラー)/ 曲 / 理由で、待ちとエラーに既存の写しがあれば - 「保留: 既存の写しは残す」を添える(先頭 1,000 行まで)。下にプレイリストの差分。Android は未接続・許可待ちの - 一言と「同期(N 件・M バイト)」ボタン(`plan_token` を添えて `POST /api/devices/:id/sync`。差分が無い・ - 同期が待ち / 実行中・途中の計画があれば押せない)、途中の計画があれば「前回の同期が途中です」に - 「続きを実行」「破棄」、未接続か通常の破棄が `not_connected` なら「強制破棄」。iPhone はボタンを出さず - 「Mac で `spindle-agent sync` を実行する」の案内とエージェントの最終報告時刻、途中の計画があれば Mac で - 続けるかの案内と「強制破棄」(進捗が 30 秒以内に届いている間は Mac が同期中なので、どちらも出さない。D-108) - - **同期の進捗**(agent。D-108): 「差分」の同期中の表示の下に、`sync_progress` があれば(`plan_open` は見ない。 - サーバは open な計画の進捗だけを返し・流し、計画の確定はイベントを流さないので `plan_open` は一覧の取り直しまで - 古い。非 null の `device_progress` を受けたら `plan_open` も真にする)進捗バー(`bytes_total > 0` - ならバイト、無ければ件数)、`12 / 92 件 · 85.3 MB / 634.8 MB · 12.4 MB/s · 残り 0:42`、段の動詞と今の項目を出す。 - `device_progress` で差し替え、一覧の応答より新しい SSE の値は古い応答で上書きしない(前後は時刻ではなく - イベントの到着順の番号で比べる。要求と同じ 1 ms に届いたイベントも負けない)。null の `device_progress` - (計画が閉じて進捗を消した)を受けたら一覧を取り直し、`plan_open` を合わせる。`age`(応答時点)と受信後の - 経過から 30 秒を超えたら速度と残り時間を消して「応答なし(最後の進捗: 1 分前)」と出す(1 秒ごとに描き直す)。 - 無ければ従来どおり(古いエージェント) - - **同期の進捗**(Android。D-109): `sync_job` の job の進捗を `job` イベントで受けているとき(一覧の state が queued のままでも。 - 進捗の表は running の間だけ項目を持つ)、 - 上と同じ進捗の行(件数・バイト・速度・残り時間・段の動詞と今の項目)を「差分」の同期中の表示に出す。 - 段は `delete`(削除)/ `move`(パスの変更)/ `send`(送信中)/ `playlist`。Mac の「受信中」は使わない。進捗が - 無ければ従来どおり「同期中…」。ページを開き直した直後は、次の `job` イベントが届くまで「同期中…」のまま - (DB の件数は足さない)。「応答なし」(30 秒)は出さない(ジョブの生死はワーカーが管理し、パスの変更のバッチは - 間が空く) - - **選曲**: 全曲 / プレイリストの切り替えと、プレイリストのチェック一覧(スマートは ⚙、曲数)。選び方を - 変えるたびに `GET /api/devices/:id/estimate` で合計曲数と容量(推定で数えた曲数と、容量の分からない - 曲数を添える。D-107)を出す。保存は印を先に、選び方を後に(プレイリスト選曲へ切り替えるとき空の選曲の差分を作らない)。 - 途中の計画があれば保存できない - - **設定**: 名前・系統(途中の計画があれば系統は変えられない)、Android は保存先の表示(変えるには削除して - 登録し直す)と「内容を検証」、iPhone は「pair コードを発行」(確認のうえ発行し、コードと期限と - `spindle-agent pair <この画面の origin> <コード>` をコピーできる形で出す。「この Mac に前の登録の保存先が - 残っていれば、代わりに次を実行すると引き継げます」と `spindle-agent pair --adopt <origin> <コード>` も - コピーできる形で添える(§7.11「引き継ぎ」、D-104)。コードは画面の state にだけ持つ)。最後に「端末を削除」(端末上のファイルは消さない。途中の計画があればそれも破棄する旨を確認に出す) -- **設定**: 「表示」(配色: OS に従う / ライト / ダーク。localStorage に保存。P4-10、D-58 追記)、 - `config.toml` の閲覧、再スキャン / deep scan / GC dry-run のボタン、 - category(統制語彙)の表(使用数 = アルバム / ジャンル / 購読 / Inbox の下書き。使用中は削除ボタンを無効にする)。 - 退避 WAV(`archived_files`)の一覧と復元は記録画面(§12.4)へ移した(D-106) - -### 12.7 スマホ版(`/m/`、D-115) - -外出先で眺める・探す・試し聴きする・誤字を直す・プレイリストを手直しする・購読を同期する、ための別ページ。 -一括編集・巻き戻し・CD・Inbox の承認・端末同期・ジョブ・設定は PC で行う(スマホ版には置かない)。 - -- **PC 版に手を入れない**: コードは `web/src/mobile/`、ビルドは PC と別の vite 設定(`web/vite.mobile.config.ts`)で - `web/dist/m/` に出す(`npm run build` が PC のビルドの後に実行)。入口を足すと共通チャンクが変わり PC の JS が - 変わるため。`web/src/components/` `hooks/` `App.tsx` `main.tsx` `index.css` は import も編集もしない。`api/` と `lib/` は - 編集せず import だけする。`dist/m/` 以外の出力が変更前と同一であることを `scripts/pc-dist-hash.sh` で確かめる。 - 配色はライト / ダークとも PC の `index.css` と同じ値を持ち、テーマの保存値は PC と共有する。指で押す要素は 44px 以上 -- **入口と振り分け**(`src/api/spa.rs`): `/` を `User-Agent` に `Mobi` / `Android` / `iPhone` を含むときだけ `/m/` へ 302 で - 飛ばす(iPad は Mac の UA を名乗るので PC 版)。スマホ版のメニューの「PC 版を開く」は Cookie `spindle_view=pc` を立てて - `/` を開き、以後は振り分けない。PC 版からの戻り導線は PC を触らないと作れないので `/m/` を直接開く。`/m/` を開くと - Cookie を消す。`/m/` 以下の未知のパスは mobile の HTML を返す。認証と CSRF は PC と同じ(未ログインはスマホ版のログイン画面) -- **4 タブ**(下部。再生中だけタブの上に小さな再生バー): - - **ライブラリ**: 検索欄(`GET /api/tracks` の検索)と「アーティスト(albumartist)/ アルバム / 曲」の切り替え(最後の見方を - localStorage に覚える)。1 段ずつ降り、一覧は仮想スクロール。行は曲名・アーティスト・アルバムと形式のバッジ - - **フォルダ**: パンくずと、カテゴリ → アルバムアーティスト → アルバム → 曲の木(`GET /api/albums` からクライアントで組む) - - **プレイリスト**: 普通とスマートの一覧(曲数・長さ付き)。普通は中身の外す・並べ替え。スマートは条件を - 「フィールド / 演算子 / 値」で組み立てる(AND / OR、条件ごとの NOT、ORDER BY、LIMIT。入力のたびに - `POST /api/playlists/preview` で検証と件数)。組み立て画面で表せない規則(入れ子、AND と OR の混在)は開いた時点から、 - それ以外も「文で編集する」でルール文の編集へ移る(組み立てから表せなければ文のまま)。値の `"` と `\\` はエスケープして - 書く(DSL.md)。新規作成・名前の変更・削除(確認あり。409 `open_plan` はその旨を出す)ができる - - **購読**: 先頭に Inbox の未確認の件数(0 件なら出さない。確認は PC)。購読ごとのカード(最後の同期、「同期」)。 - 同期は**手動のまま**(自動にしない)。「完了」は `playlist_sync` の終了ではなく**投入した ytdl のダウンロードがすべて - 終わった時点**で、それまで進捗バー(`GET /api/ytmusic/subscriptions/:id/progress`、SSE のジョブイベントで取り直す)。 - 結果は 1 行(新しい曲の数・非公開・失敗と「再試行」)、カードの詳細に曲ごとの状態。購読の追加・変更・削除は PC -- **曲のシート**: 曲をタップで下から出る。半分は ジャケット・曲名・再生・バッジ・主要タグ、引き上げると全タグ・ファイル情報・RG。 - 「プレイリストに追加」(`POST /api/playlists/:id/items`)。戻るボタンで シート → 一覧 → 上の階層 -- **タグの修正**: 1 曲だけ。値の修正のみで、フィールドの追加・削除と空にすることはしない。PC と同じ - `batch/preview` → `apply`(`ids: [id]`)を通り、`edit_batches` に載るので PC の「履歴」から巻き戻せる。pending / conflict - のときは保存させない -- **試し聴き**: 開いている間だけ(バックグラウンド再生・ロック画面・キューは無い)。LTE + Cloudflare 経由のため原本は流さず - `?transcode=opus`(Derived があればそれ、無ければその場で変換。非可逆の原本はそのまま)。Opus が鳴らない端末(iOS Safari の - 判定は当てにならない)では、再生前にエラーになったとき**1 回だけ** `?transcode=aac` で同じ曲を引き直す。シートに使った - コーデックを出す。ReplayGain は掛けない - ---- - -## 13. 設定 - -```toml -[server] -listen = "0.0.0.0:8080" # 待ち受けアドレス。省略時はこの値。ポート公開は compose 側で行う - -[paths] -library = "/library" -derived = "/derived" -archive = "/archive" -inbox = "/inbox" -playlists = "/playlists" -data = "/data" - -[layout] -multi_disc = "{category}/{albumartist}/{album}/{disc}-{track:02}. {title}" -single_disc = "{category}/{albumartist}/{album}/{track:02}. {title}" -unsorted = "_Unsorted/{albumartist}/{album}/{track:02}. {title}" - -[rip] -device = "/dev/sr0" -drive_offset = "auto" # auto | 整数 -retry_on_mismatch = 2 -prefer_ctdb = true - -[encode] -flac_compression = 8 - -[encode.derived.opus] # Derived の opus 系統(§7.6、D-75)。Android の同期・Web 再生・配布ビュー -enabled = true -bitrate = 256 # opusenc --vbr --music --bitrate(D-9 追記) - -[encode.derived.aac] # Derived の aac 系統(§7.6、D-75)。Mac のミュージック.app へ取り込む Apple 向け -enabled = true # 節を省略すると false(RG 全件解析 → 有効化の順序のため) -bitrate = 256 # ffmpeg -c:a aac -b:a -lossy_sources = true # 非可逆原本も AAC へ(D-8 の例外。aac 原本も再エンコード) -multi_value_separator = " & " # 多値フィールドの結合 - -[replaygain] -reference_lufs = -18.0 # 内部表現。Opus のみ書き出し時に -23 へ変換 -write_tags = true - -[normalize] -wav_to_flac = true -flac_verify_on_import = true -flac_fix_missing_md5 = true -flac_recompress_all = false # 圧縮レベル統一のための一括再エンコードはしない - -[scan] -deep_interval_days = 30 # tag_hash / audio_md5 を全件再計算する deep scan の間隔。0 で自動実行なし - -[inbox] -poll_interval_secs = 60 # Inbox の確認間隔(秒。変化があったときだけ走査を投入)。0 で自動なし(UI の「今すぐ確認」だけ) - -[gc] -retention_days = 7 # missing_since / 退避 WAV / Derived 孤児 / 却下して削除した Inbox の取り込みを物理削除するまでの日数(終端した同期の計画の回収にも使う) -jobs_done_days = 7 # 終端のジョブ行(done / cancelled)を消すまでの日数。0 で消さない -jobs_failed_days = 30 # failed のジョブ行を消すまでの日数。0 で消さない - -[auth] # 認証は常に有効(無効化する設定は無い) - # 初期パスワードは環境変数 SPINDLE_INITIAL_PASSWORD(初回起動時のみ) -session_days = 30 -trusted_cidrs = [] # 例: ["192.168.1.0/24"]。stream / artwork / tracks/:id / playlist export だけ認証スキップ -trusted_proxies = [] # ここからの X-Forwarded-* だけを信用する - -[backup] -interval_hours = 24 -retention_generations = 14 - -[export] -autoexport_debounce_sec = 30 -fb2k_prefix = "\\\\TRUENAS\\music\\" - -[musicbrainz] -user_agent = "spindle/0.1 (contact@example.com)" -rate_limit_per_sec = 1 -url = "https://musicbrainz.org/ws/2/" # 省略可。テストと自前ミラー用 -address_family = "auto" # auto | ipv6 | ipv4。片方の経路が塞がれている環境で選ぶ -cover_art_url = "https://coverartarchive.org/" # 省略可。候補のジャケット(D-82) - -[verify] # 遡及照合 / リップ検証の照会先。UA は musicbrainz.user_agent を共用 -accuraterip_url = "http://www.accuraterip.com/accuraterip/" -ctdb_url = "http://db.cuetools.net/lookup2.php" - -[ytmusic] -enabled = true -metadata_command = ["/usr/local/bin/spindle-ytmusic-meta", "metadata"] # メタデータプラグイン(D-69)。引数配列 -metadata_timeout_secs = 30 -download_timeout_secs = 900 # yt-dlp のダウンロード 1 件の上限(D-70) -# yt-dlp に毎回付ける追加引数(省略時は無し)。UA / Referer は付けない。 -# youtube:lang=ja: 言語を指定しないと、投稿者が翻訳タイトルを付けた動画は英語のタイトルが返り、日本語の曲名が -# 取れない(メタデータプラグインのルールにも合わない)。チャンネル名も言語で変わる -ytdlp_args = ["--extractor-args", "youtube:lang=ja"] -# YouTube に弾かれるときは extractor-args を足す(同じキーは ; で並べる)か cookies を渡す -# ytdlp_args = ["--extractor-args", "youtube:lang=ja;player_client=web_safari"] -# ytdlp_args = ["--extractor-args", "youtube:lang=ja", "--cookies", "/data/cookies.txt"] -sync_interval_hours = 0 # 再生リストの購読を定期同期する間隔(時間)。0 = 手動と承認の後続だけ(D-78) - -[hires] # 偽ハイレゾ検出(D-71) -check_on_import = true # スキャン完了時に未検査の対象(可逆かつ >48 kHz または >16 bit)を自動投入 -cutoff_hz = 25000 # カットオフがこれ以下なら「上げただけ」の疑い(44.1k の 22.05 kHz と 48k の 24 kHz を余裕込みで拾う) -cliff_db = 10.0 # カットオフ前後 1 kHz の落差がこれ以上なら SRC の崖とみなす(実 SRC は 12〜21 dB) -hard_cutoff_hz = 22500 # カットオフがこれ以下なら崖に関わらず「上げただけ」(44.1k の Nyquist + 余裕) - -[bin] -ffmpeg = "ffmpeg" -flac = "flac" -opusenc = "opusenc" -cdparanoia = "cd-paranoia" -cdrdao = "cdrdao" -ytdlp = "yt-dlp" -adb = "adb" - -[devices] -# adb サイドカー(deploy/compose.yaml の adb サービス)のソケット。空なら Android の同期を無効にする(D-98) -adb_server = "localfilesystem:/run/adb/adb.sock" -adb_timeout_secs = 60 -adb_transfer_timeout_secs = 3600 -``` - -実体は `deploy/config.example.toml`。両者は一致させる。 - -端末への配信(§7.11)の設定(P5-3b、D-98): - -- `[bin].adb`: adb クライアント。イメージに platform-tools の adb(版と sha256 を固定)を同梱し、compose の adb - サイドカーも同じイメージの adb をサーバとして使う(クライアントとサーバの版を揃える)。省略時は `adb`、空と前後の - 空白は起動時に拒否する。子プロセスには `HOME=<data>/adb` を渡す(`$HOME/.android` を作れないと adb が止まる) -- `[devices].adb_server`: adb サーバの場所(`ADB_SERVER_SOCKET`)。`localfilesystem:/絶対パス`(サイドカーの - Unix ソケット。既定の構成)か `tcp:ホスト:ポート`。**空(節ごと省略を含む)なら Android の同期を無効**にし、 - ADB を使う操作(作成・未登録の一覧・同期・再開・通常の破棄・検証)は 503 `adb_disabled`(強制破棄と端末の削除、 - iPhone、PATCH / PUT playlists / 差分 / 見積りは動く)。前後の空白は起動時に拒否する -- `[devices].adb_timeout_secs`: 1 回で終わる adb のコマンド(状態の確認・manifest の読み書き・1 曲の - `sha256sum` など)の上限(秒。既定 60、1 以上) -- `[devices].adb_transfer_timeout_secs`: 1 曲の転送と、端末上の全ファイルの一覧(数千件の `stat` で長く - かかる)の上限(秒。既定 3600、1 以上) - -`[bin].cdparanoia` の `cd-paranoia` は libcdio 版(Debian パッケージ cd-paranoia)で、xiph の `cdparanoia` ではない。 - -iPhone(Mac の `spindle-agent`)はサーバ側の設定を持たない(エージェントは pair のときに spindle の URL を -受け取る。§7.11、`agent/README.md`)。 - ---- - -## 14. デプロイ - -### イメージの配布(P4-12、D-79) - -- 置き場は GHCR `ghcr.io/akashisn/spindle`(public。pull にトークン不要)。`linux/amd64` のみ -- CI(`.github/workflows/ci.yml`)は web → rust → docker → publish の順(Mac の `spindle-agent` の - `agent-macos` も publish の前提。下記)。docker ジョブ(PR でも走る。 - `contents: read` だけ)が**イメージを 1 回だけビルド**し(`docker/build-push-action` の `load`)、起動確認 - (`--version`、`/health` の `version` / `ytdlp`、ロックモード、ログイン、CSRF、ジョブ API、同梱 SPA)に - 通ったものを `docker save` して成果物に渡す。publish ジョブ(push イベントだけ。書き込み権限 = GHCR / - Release はここだけ)がそれを skopeo で GHCR へ複製する(二度ビルドしない = 確認した digest と配布する - digest が同じ。`docker push` は load したイメージで「unknown blob」になる)。タグは `docker/metadata-action`: - `main` への push → `edge` と `sha-<7 桁>`、`vX.Y.Z` タグ(この形だけ。他の `v*` は CI が拒む)→ `X.Y.Z` / - `X.Y` / `latest`(+ GitHub Release。自動生成のノートにイメージのタグと digest を添える)。PR はビルドと - 起動確認だけで push しない -- 版は build context に `.git` を入れないので、CI が `--build-arg SPINDLE_VERSION=$(git describe --tags - --always)` で渡し、`build.rs` が `cargo:rustc-env` で焼く(環境変数 > 作業ツリーの `git describe`(HEAD / - その ref / packed-refs / index の変化で再計算。`--dirty` は付けない)> `dev`)。OCI ラベル(`org.opencontainers.image.{source,revision,version,created,…}`)は metadata-action -- メタデータプラグイン(`spindle-ytmusic-meta`)はイメージに焼かず実行時マウントのまま(D-70) -- adb(Android の platform-tools。版と sha256 を Dockerfile で固定)はイメージに同梱し、spindle のクライアントと - compose の adb サイドカーのサーバが同じものを使う(D-98) -- **Mac の `spindle-agent`**(P5-4c、D-101): CI の `agent-macos` ジョブ(`macos-latest`)が clippy・test - (偽のミュージック.app の `fake` 機能)・release ビルドを行い、`spindle-agent-<git describe>-aarch64-apple-darwin.tar.gz` - (バイナリと `agent/README.md`)と `.sha256` を成果物にする。publish ジョブはこれにも依存し(edge のイメージも - macOS ジョブの成功が要る)、`vX.Y.Z` タグでは Release に添付する。macOS(arm64)だけで、 - **署名・公証はしない**(Developer ID が無い。必要になったら入れる)。導入は README の - `xattr -d com.apple.quarantine` と、初回の Automation(ミュージック.app の操作)の許可 -- **`edge` は開発用で DB の互換(マイグレーションの前進)以外は約束しない。** 本番は `latest` - (= 最新の `vX.Y.Z`)か `X.Y` を指す。マイグレーションは起動時に自動で前進のみ = 戻すときは - バックアップから(OPERATIONS) -- 依存の更新は Renovate(`renovate.json`。GitHub App): Dockerfile の `FROM`(node / rust / debian / - deno)、GitHub Actions、Cargo / npm(minor / patch は週 1 のまとめ)、**yt-dlp**(`ARG YTDLP_VERSION` - を `# renovate:` 注釈の custom manager で github-releases から追い、schedule の例外で 1 件ずつ即時)。 - マージは手動。Dependency Dashboard の issue に保留中の更新と手動実行のチェックボックスが出る。 - マージすれば `edge` が作り直され、本番へは次の `vX.Y.Z` で届く(yt-dlp だけの更新でもパッチ版を切る) -- スカッシュ(`db/migrations` を `0001` に畳む)は最初の `vX.Y.Z` より前に 1 回だけ行った(2026-09-24、D-88)。 - 公開イメージで DB を作った後は既存ファイルを書き換えられないので、以後は連番を足すだけ - -### TrueNAS Custom App (compose) - -```yaml -services: - spindle: - image: ghcr.io/akashisn/spindle:latest - init: true # PID 1 を tini にする(取り消しで kill した孫プロセスをゾンビにしない) - devices: - - /dev/sr0:/dev/sr0 # CD ドライブ。ioctl / SG_IO とも sr0 に直接通る(/dev/sg* は不要) - group_add: - - "24" # host の cdrom グループ GID - device_cgroup_rules: - - 'b 11:* rmw' # sr (block) - user: "1000:1000" # 既存ライブラリの所有者に合わせる - volumes: - - /mnt/ssd/media/Library:/library - - /mnt/ssd/media/Derived:/derived - - /mnt/hdd/media/Archive:/archive - - /mnt/ssd/media/Inbox:/inbox - - /mnt/ssd/media/Playlists:/playlists - - /mnt/ssd/apps/spindle:/data - - adb-run:/run/adb:ro # adb サイドカーの Unix ソケット(D-98)。:ro は外さない(下記) - - /mnt/ssd/apps/spindle/bin/spindle-ytmusic-meta:/usr/local/bin/spindle-ytmusic-meta:ro # メタデータプラグイン(D-70) - ports: - - "8080:8080" - restart: unless-stopped - - adb: # adb サーバだけを動かすサイドカー(D-98)。Android の同期を使わないなら不要 - image: ghcr.io/akashisn/spindle:latest - network_mode: host - init: true - entrypoint: ["/bin/sh", "-c", "umask 000 && exec /usr/local/bin/adb -L localfilesystem:/run/adb/adb.sock nodaemon server"] - volumes: - - /dev/bus/usb:/dev/bus/usb - - adb-run:/run/adb - - /mnt/ssd/apps/spindle/adb-keys:/root/.android # USB デバッグの許可の鍵。消すと端末で許可し直す - device_cgroup_rules: - - 'c 189:* rmw' # USB(/dev/bus/usb) - restart: unless-stopped - -volumes: - adb-run: {} -``` - -実体は `deploy/compose.yaml`(PID 1 を tini にする `init: true` などのコメントもそこ)。 - -**注意点:** - -- USB 接続だとデバイス再列挙でノード番号が変わり得る(sr0 → sr1)。 - `/dev/disk/by-id/...` を指すか、SATA 接続を推奨 -- UID/GID が既存ライブラリの所有者と一致しないとタグ書き込みが全滅する -- udev はコンテナに届かないため、ディスク挿入検知はポーリング -- ホストにドライブが無いと `devices` の行で compose が起動に失敗する。ドライブの無い機体では - `devices` / `group_add` / `device_cgroup_rules` を消す。アプリ側は `[rip].device` を開けなくても - 起動し、CD 画面に「ドライブが無い」と出す - -**adb サイドカー**(Android の同期。§7.11、D-98): - -- **`network_mode: host`**: 独自のネットワーク名前空間では、挿し直した端末を拾わない(USB のホットプラグの - 通知 = netlink の uevent が届かない。実機で確認)。代わりに **TCP は開かず**、名前付きボリューム `adb-run` 上の - Unix ソケット(`-L localfilesystem:/run/adb/adb.sock`)だけで待ち受ける(`-a` で 5037 を開くと LAN から端末を - 操作できてしまう)。`umask 000` はサイドカー(root)が作るソケットに spindle(`user: 1000:1000`)が繋げるようにするため -- **spindle 側はソケットのボリュームを `:ro` でマウントする**: `localfilesystem:` を指定しても adb クライアントは - 「サーバに繋がらなければ同じパスで自前のサーバを起こす」。サイドカーが落ちている間にそれが起きると、戻った - サイドカーとサーバが 2 つになり、端末の監視が USB を持たない方に繋がったままになる。読み取り専用なら connect() - は通り、自前のサーバの待ち受けの作成だけが `Read-only file system` で失敗する(未接続として扱われる) -- **鍵の置き場**: USB デバッグの許可の鍵(`/root/.android`)を `/mnt/ssd/apps/spindle/adb-keys` に残す。消すと - 端末で許可し直しになる。USB は `/dev/bus/usb` の bind と `c 189:* rmw` -- spindle 側は `[devices].adb_server = "localfilesystem:/run/adb/adb.sock"`(§13)。サイドカーを置かない構成では - 空にする(Android の同期だけが無効になる)。spindle 本体の権限は変えない - -### ZFS データセット - -```bash -COMMON="-o casesensitivity=insensitive -o normalization=formD" # 作成時のみ指定可能。utf8only=on が強制される -zfs create ssd/media -zfs create $COMMON -o recordsize=1M -o compression=lz4 -o atime=off ssd/media/Library -zfs create $COMMON -o recordsize=1M -o compression=lz4 -o atime=off ssd/media/Derived -zfs create $COMMON -o recordsize=1M -o compression=lz4 -o atime=off ssd/media/Inbox -zfs create hdd/media -zfs create $COMMON -o recordsize=1M -o compression=lz4 -o atime=off hdd/media/Archive -zfs create -o recordsize=16K -o compression=lz4 -o atime=off ssd/apps/spindle -``` - -| dataset | recordsize | snapshot | 備考 | -|---|---|---|---| -| ssd/media/Library | 1M | 毎日 + 一括編集前 | 唯一の正 | -| ssd/media/Derived | 1M | なし | 再生成可能。レプリケーション対象外 | -| hdd/media/Archive | 1M | 週次 | 追記のみ。FLAC 正規化で退避するロスレスの受け皿(D-45) | -| ssd/media/Inbox | 1M | なし | 承認前の一時領域 | -| ssd/apps/spindle | 16K | 毎日 | SQLite。ページサイズに合わせる | - -**`casesensitivity` と `normalization` はデータセット作成時のみ指定可能で、 -後から変更できない。** このためライブラリは既存データセットの rename ではなく、 -新規作成 + ファイルコピーで移行する(D-19)。 - -- `casesensitivity=insensitive`: Windows の foobar2000 が `Cover.jpg` を、 - spindle が `cover.jpg` を作る事故を ZFS 層で潰す。spindle は自身の生成パスの - 一意性は保証できるが、他クライアントが作るファイルまでは制御できない -- `normalization=formD`: 日本語の濁点・半濁点には NFC(`が` 1 文字)と - NFD(`か` + 結合濁点)の 2 表現がある。macOS 由来のパスは NFD になりがちで、 - 正規化なしだと「見た目が同じで別ファイル」が発生する。目視では気づけない - -Inbox は Library と別データセットなので move は実コピーになるが、 -1 回あたりアルバム 1 枚(数百 MB〜数 GB)なので実用上の問題はない。 -Archive は別プールなので退避も実コピー(追記のみで速度は要らない)。 - -### 移行手順 - -手順は `docs/MIGRATION.md`、振り分けの判断は D-45。要点: - -- 事前チェックは `scripts/preflight.py`(衝突 / 不正 UTF-8 / 255 バイト超 / symlink / hardlink / - SMB 禁止名 / 容量)、振り分けは `scripts/migrate_plan.py`(`Opus/` と `Original/` の 1:1 対応を - 原本の形式で Library / Archive に割り、`rsync --files-from` の一覧を出す)。手書きの find / awk は - 置かない -- 旧 `Original/` の ALAC が Library の master(後で FLAC 正規化)、webm 由来は `.opus` が master で - webm は Archive、旧 `Opus/` のロスレス由来分は移さず Derived を再生成する -- `zfs send/recv` は不可(作成時プロパティが継承される)。**ACL は rsync で引き継げない** - (NFSv4 ACL と `rsync -A` の POSIX ACL は互換がない)ので TrueNAS の ACL エディタで適用し直す -- 旧データセットは `readonly=on` にして残し、初回スキャンが完走してトラック数が一致し、 - 数日間の運用で問題が出ないことを確認してから破棄する - -### 環境変数 - -| 変数 | 用途 | -|---|---| -| `SPINDLE_CONFIG` | `config.toml` のパス(既定 `/data/config.toml`) | -| `SPINDLE_INITIAL_PASSWORD` | 初回起動時の管理パスワード。DB にパスワードが無いときだけ読み、argon2id で保存して以後は無視する。未設定かつ DB にも無ければロックモード(`/health` 以外 503) | - -### バックアップ - -- SQLite は `backup` ジョブが `VACUUM INTO` でバックアップ(WAL 中の安全なコピー手段。 - 読み取りコネクションで走らせ、書き手を止めない)。tmp に書いて `quick_check` → fsync → - rename(`RENAME_NOREPLACE`。同名の確定済みを上書きしない)→ **`backup/` とその親も fsync** - してから世代 GC。空き容量が DB サイズ + 64 MiB を - 下回るなら書かずに失敗する(ジョブのバックオフで再試行) -- 周期は `[backup].interval_hours`(既定 24)。スケジューラが起動時と 10 分ごとに - 「`jobs` の最後の終端 `backup` からの経過」で due を判定して投入する(dedup key `backup`)。 - ファイルの mtime は見ない。復元直後は古い記録しか無いので、すぐ 1 世代取れる -- ファイル名は `spindle-<YYYYMMDDTHHMMSSZ>.db`(UTC、固定幅。名前順 = 時刻順)。 - 保持世代は `[backup].retention_generations`(既定 14)で、この名前形式のファイルだけを - 新しい順に残す。`apps/spindle` データセットのスナップショットと二重化 -- 復元: コンテナ停止 → `spindle.db`(と `-wal` / `-shm`)を差し替え → 起動。起動時スキャンが - ファイルとの差分を吸収する。未反映だった編集意図(pending)は失われるが、ファイルは - 旧値のままなので壊れない。手順は `docs/OPERATIONS.md`、復元ドリルは `tests/backup.rs` -- DB は「キャッシュ」だが、プレイリスト / 編集履歴 / 検証結果 / ジョブ履歴 / 端末の設定・印・計画・ - エージェントのトークンは DB にしかない(§3)。バックアップは任意ではない -- 古いバックアップへ戻すと、それより後に発行したエージェントのトークン(`devices.agent_selector` と `agent_secret_hash`)は - 失われ、その端末は pair し直しになる(`devices.uuid` も同様に、後から足した端末は消える) -- 古いバックアップへ戻すと `tracks.id` の採番も巻き戻る。バックアップより後に足した曲には起動時スキャンが - id を振り直し、別の曲に当たり得るので、バックアップより後に同期した端末は**同期する前に**引き継ぎし直す - (端末の名前・形式・送る範囲を控えて行を消し、同じ設定で Android は「前の登録の保存先」、Mac は新しい行で - `pair --adopt`。範囲が分からなければ「全曲」。最初の差分に予期しない削除があれば確定しない。 - `docs/OPERATIONS.md`「端末は引き継ぎし直す」、D-105)。 - GC が最大 id の行を消さないこと(§6「論理削除」)は DB の中の再利用だけを防ぐ - ---- - -## 15. Rust モジュール構成 - -``` -src/ -├── main.rs -├── config.rs config.toml の読み込みと検証(D-34) -├── logging.rs tracing の初期化 -├── db/ -│ ├── mod.rs コネクション管理(write 単一 / read プール) -│ ├── migrations.rs リポジトリ直下 db/migrations/*.sql の埋め込みと適用 -│ ├── tracks.rs 一覧(キーセット)・検索・selection 解決・アルバム一覧(D-39) -│ ├── archive.rs archived_files 台帳(退避・復元・GC の根拠) -│ ├── categories.rs 統制語彙(canonical key で一意) -│ ├── inbox.rs inbox_items / inbox_files(承認キューの状態機械。D-68) -│ ├── subscriptions.rs playlist_subscriptions(再生リストの購読。追記先の CAS 束ねと同期の latch。D-78) -│ ├── devices.rs 端末の表(§6): マニフェストの入力の読み取りと全端末のスナップショット、計画の確定・終端、 -│ │ キャッシュの全置換、pair コードとトークン、source_hash の投入(§7.11、D-95 / D-99) -│ ├── stages.rs Inbox の承認済みの件の段(配置 → RG → 系統 → 端末。§12.6) -│ ├── playlists.rs jobs.rs history.rs -├── domain/ -│ ├── identity.rs inode / audio_md5 による同一性解決 -│ ├── filter.rs 一覧のフィルタ(JSON)/ ソート / カーソルの検証 -│ ├── selection.rs selection の 2 形、preview のスナップショット store(D-33) -│ ├── pathgen.rs テンプレート展開・正規化・衝突回避 -│ ├── tags.rs lofty ラッパ、正規化、多値処理、Derived への書き出し(Opus の VorbisComments / MP4 の ilst + フリーフォーム) -│ ├── replaygain.rs ebur128、フォーマット別変換 -│ ├── device.rs 端末への配信の純粋関数(選曲・送る元・パスの検査と衝突・desired / hold / remove・トークン・ -│ │ 差分・plan_token・曲ごとの状態・見積もり。§7.11、D-95) -│ └── category.rs 統制語彙、GENRE 写像 -├── edit/ -│ ├── mod.rs 編集バッチの coordinator(記録・DB 先行更新・反映・overlay 解消・ -│ │ キャンセル・起動時リカバリ。D-24 / D-41) -│ ├── rename.rs 一括リネームの計画・記録・2 phase 反映・album の追随(D-43) -│ ├── normalize.rs ロスレス → FLAC 正規化の計画・記録・反映・Archive 退避(D-46) -│ ├── md5fill.rs FLAC の MD5 補填(md5 op。D-59) -│ ├── picture.rs 埋め込み画像の差し替え(`PICTURE` の tags op。画像の読み出し・退避。D-60) -│ └── revert.rs 巻き戻し(対象集合・現在値の比較・逆バッチの記録。D-44) -├── media/ -│ ├── fingerprint.rs STREAMINFO MD5 / デコード PCM MD5 / パケット列ハッシュ -│ ├── decode.rs symphonia / ffmpeg フォールバック -│ ├── hires.rs 偽ハイレゾ検出の解析(PcmSink: FFT の累積とサンプル OR → 計測値(cutoff / cliff / 実効ビット)→ 判定。§7.10、D-71) -│ ├── encode.rs flac(ffmpeg デコード → flac -8)/ opus(ffmpeg デコード → opusenc)/ aac(ffmpeg 1 パス。RG 焼き込み・48 kHz 上限) -│ └── artwork.rs 同梱 / 埋め込み画像の選択、判別、ハッシュアドレスのキャッシュ(P1-3) -├── cd/ -│ ├── mod.rs TrackLayout(サンプル単位のトラック列)、照会用 HTTP クライアント -│ ├── device.rs ioctl(CDROM_DRIVE_STATUS / READ TOC / LOCKDOOR + EJECT)、SG_IO READ SUB-CHANNEL(ISRC / MCN)、 -│ │ Drive トレイト、DriveMonitor とポーラ -│ ├── toc.rs TOC の検証、各種 DiscID 算出、サンプル数からの再構成(§7.3) -│ ├── rip.rs cd-paranoia、オフセット、分割 -│ ├── accuraterip.rs ARv1/v2 CRC、DB の照会(dBAR-*.bin)、オフセット表 -│ ├── ctdb.rs CRC32、照会(lookup2.php)、パリティファイルの取得 -│ ├── repair.rs CTDB の修復(GF(2^16) RS: シンドローム表、オフセット探索、復号、適用。D-66) -│ ├── crctable.rs 1 回流して任意オフセットのトラック CRC を出す表(累積和と CRC32 combine) -│ ├── verify.rs DB の応答との照合(オフセット探索、トラックごとの一致、`tracks.verification` の写像) -│ ├── musicbrainz.rs ws/2/discid の照会(UA、1 req/s、503 の再試行)と候補(リリース × medium) -│ ├── metadata.rs 確定フォームの DiscMetadata(検証、タグ写像 + TRACKTOTAL / MUSICBRAINZ_DISCID。D-65 / D-67) -│ ├── riplog.rs RipReport と rip.log / disc.cue / disc.toc の描画、同梱ファイルの名前(D-67) -│ └── place.rs 配置(分割 MD5 → flac → pathgen::plan → library 排他 → tmp + RENAME_NOREPLACE → -│ 1 トランザクション登録 → rg / transcode。MD5 で冪等。D-67) -├── import/ -│ ├── scanner.rs -│ ├── placement.rs CD / Inbox 共通の配置(tmp + RENAME_NOREPLACE、album の解決と登録トランザクションでの -│ │ リリースキー再検証、行の登録 / 採用。D-67 / D-68) -│ ├── inbox.rs Inbox の走査(取り込み = ディレクトリ)、タグからの下書き、承認の検証、承認済みの配置 -│ │ (補正をタグに書いて pathgen::plan の宛先へ。source_type = download。D-68) -│ └── ytmusic/ metadata.rs(メタデータプラグインのプロトコル v1 と呼び出し。D-69)、 -│ downloader.rs(yt-dlp の dump / download、remux、タグ、Archive、Inbox への配置と -│ spindle-inbox.json。D-70)、sidecar.rs(spindle-inbox.json の読み書き。Inbox と共有)、 -│ playlist.rs(再生リストの列挙の解釈と番号揃えの計画。純粋。D-78) -├── device/ 端末への配信の実行側(§7.11。判定は domain::device、キャッシュは db::devices) -│ ├── remote.rs DeviceFs(端末の root 配下の操作。試験は偽の端末 FS) -│ ├── adb.rs adb による DeviceFs(shell v2 だけ、引数配列。ボリュームの探索) -│ ├── quote.rs 端末側のシェルへ渡すパスの検査と単引用符クォート(1 か所だけ) -│ ├── ondevice.rs journal.rs store.rs -│ │ 端末側の正本(manifest.json)・ジャーナル(NDJSON)・耐久化の順序 -│ ├── recover.rs 接続のたびの回復(DB は見ない) -│ ├── plan.rs 確定した計画と、開始済みの計画の再開(実行する部分集合) -│ ├── sync.rs 計画の実行(削除 → パス変更のバッチ → 更新 → 追加 → プレイリスト) -│ ├── verify.rs 内容の検証(全曲の sha256sum) -│ ├── track.rs runtime.rs -│ │ `adb track-devices` の解析と常駐監視、端末ごとのロック・空き容量(D-98) -│ ├── credential.rs エージェントのワンタイムコードとトークン(`<selector>.<secret>`。D-99) -│ └── report.rs エージェントの報告の検証と正準ダイジェスト(D-99) -├── jobs/ -│ ├── queue.rs worker.rs recovery.rs scheduler.rs(backup / gc の周期投入。inbox は handlers/inbox.rs、 -│ │ 購読の dispatcher は handlers/playlist_sync.rs) -│ └── handlers/ 種別ごと(playlist_sync.rs = 購読の同期: 列挙 → 追記先 → 揃え → 投入。D-78。 -│ device.rs = device_scan / device_sync / device_verify、source_hash.rs。§7.11) -├── gc/ -│ └── mod.rs 物理削除の唯一の経路。plan(判定・dry-run)と execute_*(6 区分。D-56 / D-90) -├── playlist/ -│ ├── dsl.rs pest 文法 → AST -│ ├── compile.rs AST → パラメータ化 SQL -│ ├── fb2k.rs AST → foobar クエリ + ソートパターン -│ └── export.rs m3u8 / pls / パスマッピング -├── api/ -│ ├── mod.rs tracks.rs albums.rs categories.rs selection.rs batch.rs rename.rs normalize.rs -│ │ history.rs stream.rs cd.rs inbox.rs events.rs ytmusic.rs subscriptions.rs -│ ├── auth.rs argon2id / セッション Cookie / CSRF / trusted_cidrs のミドルウェア -│ ├── devices.rs 端末 API(UI 向け。`/api/devices*`) -│ ├── agent.rs エージェント API(`/api/agent/*`)と Bearer のミドルウェア(auth::guard の外。D-99) -│ ├── state.rs error.rs AppState、`{ "error": code }` 応答 -└── web/ SPA を rust-embed で同梱 - -agent-proto/ ワークスペースのクレート。本体とエージェントが共有する `/api/agent/*` のワイヤ型 - (serde だけに依存する。D-99) -agent/ ワークスペースのクレート `spindle-agent`(Mac の CLI。§7.11、D-100 / D-101)。本体には依存しない -├── src/main.rs CLI(pair / sync / status / resolve / abandon) -├── src/sync.rs 各コマンドの段取り(state のディレクトリの lock を取り、state.json を読んでエンジンを呼ぶ) -├── src/pair.rs pair と冪等な初期化(`.spindle-device`、「spindle」フォルダ) -├── src/adopt.rs `pair --adopt`(DB 全損後の引き継ぎ)と、途中で落ちたときの続き(D-104) -├── src/state.rs ctx.rs state.json(エージェントの実状態の正本)と耐久化 -├── src/recover.rs rediscover.rs plan.rs exec.rs batch.rs playlist.rs -│ 回復・再発見・再開の照合・実行・パス変更のバッチ・プレイリストの入れ替え -├── src/local.rs pathkey.rs ローカルの root(`~/Music/spindle`)と canonical_key -├── src/failpoint.rs 試験の中断点(state の保存・ミュージック.app の操作・配置の前後で落とす。本番は何もしない) -├── src/music.rs + music/ ミュージック.app の trait と JXA 実装(`osascript -l JavaScript`)・偽実装 -├── src/music/music.js バイナリに埋め込む JXA(要求は argv の JSON、応答は 1 行の JSON) -├── src/server.rs + server/ `/api/agent/*` のクライアント(reqwest blocking)・偽実装 -├── src/secrets.rs + secrets/ トークンの保管(Keychain / ファイル) -├── tests/ エンジンの試験(偽のミュージック.app・サーバ、中断点からの回復。実機の JXA・Keychain は #[ignore]) -└── README.md 導入・前提・困ったとき -``` - ---- - -## 16. 実装順 - -| フェーズ | 内容 | 完了条件 | -|---|---|---| -| **P0** | データセット構築、移行、スキャナ、DB、表 UI、タグ一括編集、リネーム、編集履歴 | 既存ライブラリ全曲が表に出て、一括編集と巻き戻しができる | -| **P1** | ReplayGain、アートワーク、WAV 正規化、プレイリスト(m3u8 出力)、再生、Derived 生成・追随、GC | foobar2000 を開かずに日常運用が回る | -| **P2** | CD 取り込み(TOC → MB → rip → AR/CTDB → エンコード)、遡及照合 | 新規 CD が検証付きで取り込め、既存 FLAC が格付けされる | -| **P3** | ytmusic 移植(メタデータプラグイン + yt-dlp → Inbox)、配置直後のアートワーク解決、偽ハイレゾ検出 | ytmusic CLI を廃止できる | -| **P5** | 端末への配信(§7.11、D-95): 端末の表とマニフェストの計算、端末タブ・選曲の近道・可視化、Android の ADB 同期(ジョブ・常駐監視・adb サイドカー)、エージェント API と Mac の `spindle-agent` | Xperia(Poweramp)を USB でつなげば差分を見てからボタンで同期でき、Mac で `spindle-agent sync` を実行すれば iPhone に入れる曲とプレイリストがミュージック.app に反映される。取り込みから端末までの状態が画面で追える | - -P0 を先に置くのは、リップの出口(タグ付け・配置・RG)がすべて P0 の成果物であり、 -先に作った方が結果的に早いため。 - ---- - -## 17. 決定済み事項と残課題 - -### 決定済み - -| 項目 | 決定 | 根拠 | -|---|---|---| -| 名称 | **spindle** | CD を積むスピンドル。バイナリ名として扱いやすい | -| ライブラリ構造 | 役割別 3 層(Library / Derived / Archive) | フォーマット別だと CD の FLAC が保管物かつ再生対象で破綻する | -| Category 軸 | ジャンル別・統制語彙 | GENRE タグとは別フィールド | -| アルバム名 | `{album}` のみ、衝突時のみ年を付与 | | -| Derived | 可逆のみ変換 + 配布ビュー解決。Apple 向け `aac` 系統だけ非可逆も変換(D-75) | 非可逆の多重劣化を回避。ミュージック.app は Opus を読めない | -| ビットレート | Opus 256k VBR(当初 128k。D-9 追記)、AAC 256k | 再生成可能なので低リスクな決定 | -| WAV | FLAC へ正規化(MD5 照合付き)。元 WAV は Archive へ退避し GC 待ち | タグ・RG の互換性が低いため。即時削除は禁止事項と矛盾 | -| ジョブ dedup | `queued`/`running` の間だけ一意 | 列 UNIQUE だと完了後に同キーを再投入できない | -| 一括編集 | DB 先行更新 + `edit_ops.pending`(トラック単位)、pending 中の再編集は 409、スキャナは pending の論理値を巻き戻さない | ファイル反映待ちの窓で「ファイルが正」と衝突する | -| 配布ビュー | 音声版一致の Derived のみ。音声が古ければ原本へ | 再エンコード待ちの古い音声を配らない | -| スキャン | 4 相(inventory → 候補 → claim → commit)、`scan_runs` で claim、missing は completed でのみ | 走査途中では「移動元の消失」を判定できない | -| 一括編集の対象 | preview 時のスナップショット(`selection_token`) | 見ていない行を破壊的操作の対象にしない | -| 認証スキップ | `trusted_cidrs` は route allowlist のみ、CSRF は Origin 完全一致 | 全 GET 開放は履歴・パスが漏れる。Host は防御にならない | -| 記法 | foobar 風 DSL → AST → SQL | 生 SQL 保存はスキーマ変更で壊れる | -| 認証 | 単一パスワード + セッション Cookie | 事故対策。破壊的 API を裸で置かない | -| Subsonic | 非対応 | ファイル同期を継続。認証も argon2 単独で閉じる | -| メタデータ | MusicBrainz + 手入力の一級市民化 | 同人・VTuber 盤は未登録が常態 | -| マルチch | Derived 対象外・album RG 除外、個別オプトイン | | -| FLAC 再圧縮 | 一括再エンコードはしない。MD5 未設定のみ補填 | 削減 1% 未満に対し 200GB の書き直しとスナップショット肥大 | -| インポート | Inbox + 承認キュー方式 | 外部ツリーを直接スキャンすると規約とタグ品質が混入する | -| SMB | `casesensitivity=insensitive` / `normalization=formD` | 他クライアントが作るファイルと NFC/NFD 混在の事故を ZFS 層で潰す | -| 移行方式 | 新規データセット作成 + rsync コピー | 上記 2 プロパティは作成時のみ指定可能 | -| 外部メタデータ | MusicBrainz だけ。候補から写す範囲は選べる(CD 画面の既定は「全部写す」。D-72 追記 2)。Discogs / VGMdb は作らない | 表記揺れが激しく値は結局手で直す。外部の価値は盤の識別(D-72) | -| `.fpl` | 非対応(確定) | .m3u8 とクエリ文字列で足りる。非公開バイナリで 1.x / 2.x が違う(D-72) | -| album gain | album ごとの属性(既定 off、CD 取り込みは on、承認画面で選ぶ)。RG の一致判定は完全一致のまま | 育つコレクションに曲を足すたびに全曲の再解析と書き換えになる。運用はシャッフルが主(D-74) | -| ACL の再適用 | 移行後に TrueNAS の ACL エディタで手動(MIGRATION.md §2 のチェックリスト) | rsync は NFSv4 ACL を引き継げない。1 回しか使わないのでスクリプト化しない | -| 端末への配信 | spindle の管轄に入れる。Android は ADB 直結(host ネットワークの adb サイドカーが Unix ソケットで待ち受ける)、iPhone は Mac の `spindle-agent` がミュージック.app を中継し Finder 同期に任せる。端末ごとのマニフェストを毎回計算し、差分を確かめて確定した計画だけを実行する(自動では送らない)。端末の写しは Library から再生成できる複製なので物理削除と `edit_batches` の対象外を明示の例外にする(§7.11、D-95 / D-98〜D-101) | 端末に何が入っていて何が古いかが spindle から見えなかった。Linux から純正ミュージックへ直接書く手段が無い。配信ツリーの実体化は Derived と同量をもう一度持つ。差分の確認はユーザが選んだ | - -### 残課題 - -- [x] 印のついたプレイリストの削除(2026-10-09。印の変更と同じ規則: open な作業中は 409 `open_plan`、 - 通れば `generation` を進める。D-102) -- [x] 終端した計画の回収(2026-10-09。GC が `[gc].retention_days` 後に消す。端末ごとに最後の終端 1 件は残す。 - D-102) -- [x] Discogs / VGMdb 連携(2026-09-20。作らない。D-72) -- [x] `.fpl` 書き出し(2026-09-20。作らない。D-72) -- [x] `HAS` 等の演算子の foobar 実機との挙動突き合わせ(2026-09-19。部分一致で一致。D-55) -- [x] 偽ハイレゾ検出のしきい値設計(2026-09-20。カットオフ ≤ 25 kHz かつ(崖 ≥ 10 dB または ≤ 22.5 kHz)/ 実効 ≤ 16 bit。 - 実機 124 本で較正。計測値も保存。§7.10、D-71) -- [x] 移行後の NFSv4 ACL 再適用(2026-09-20。UI で手動。MIGRATION.md §2 のチェックリスト) -- [x] CPU 系ジョブ(rg / transcode / flaccheck / hirescheck)に共通の並列予算(2026-09-20。共通 Semaphore = - コア数を P4 で実装。D-73) -- [x] Inbox のポーリング間隔(2026-09-19。`[inbox].poll_interval_secs` 既定 60 秒 + 手動。D-68) -- [x] 一括リネームで album 全体を動かした後、旧ディレクトリに残る同梱ファイル(cover.jpg / - disc.cue / rip.log 等)の追随と空ディレクトリの扱い(2026-09-19。rename ジョブが commit 後に - 既知の名前を追随させ、空なら rmdir。D-67) -- [x] 端末への配信の実現性の確認(2026-09-30 / 10-01。Poweramp の再スキャンは `ACTION_SCAN_DIRS` を API レシーバへ - 明示で送り extras を付けない(D-98)。ミュージック.app の track の `location` は文字列の代入で付け替えられ、 - 移動でも track と再生回数を保てる(D-101)) -- [ ] ADB の転送の速さ: shell v2 の stdin 自体は約 38MB/s だが、1 曲ごとの意図・`sync`・sha256 の往復込みの実測は - 約 6.5MB/s で、全曲(約 9,000 曲)の初回はおよそ 2 時間。意図の追記や `sync` をまとめる高速化は持ち越し(D-98) -- [x] DB を失ったときの既存の保存先の引き継ぎ(Android。2026-10-13。P5-7): 端末タブの「前の登録の保存先(引き継げる)」。 - manifest の uuid の行を作り、曲の `rel_path` で `track_id` を付け替える。manifest に `rel_path` / `source` を - 足し、`ADOPTED_TOKEN` の近道で送り直さずにトークンだけ書き換える(§6、§7.11「引き継ぎ」。D-103) -- [x] 引き継ぎ(Mac の `pair --adopt`。2026-10-13。P5-8): 新しい行の pair コードで `pair --adopt` を実行すると、 - pair が行の uuid を state の uuid へ差し替え、`POST /api/agent/adopt` が state の曲を `rel_path` で付け替えて - キャッシュへ直接書く。state と manifest に `rel_path` / `source` を足し、`ADOPTED_TOKEN` の近道で送り直さずに - トークンだけ書き換える。ミュージック.app の track(再生回数)は保たれる。pending が残っていれば止める - (§6、§7.11「iPhone」の「引き継ぎ」。D-104) -- [x] id の再利用(2026-10-14。D-105): `tracks.id` / `playlists.id` は `INTEGER PRIMARY KEY` で `AUTOINCREMENT` ではないので、 - 最大 id の行を GC が消すと次の挿入が同じ id を得る。端末に写しのある曲の id が別の曲に再利用されると、 - 差分は `UpdateMove` になり、Android は収束するが、Mac ではミュージック.app の track(再生回数)が - 別の曲に引き継がれる。7 日以上同期しない場合に限る(D-102)。方針は **GC が `tracks` の最大 id の行を消さない** - (SQLite は `max(rowid)+1` を振るので再利用が起きない)。「token・sha256・パスがすべて違う写しを Delete + Add - にする」は却下(Library の一括のタグ修正と改名は日常操作で、そのたびに Mac の再生回数が消える)。 - プレイリストの id の再利用は受け入れる(Update = 改名として収束し、失う情報が無い。D-103) diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md deleted file mode 100644 index 3227953a..00000000 --- a/docs/USERGUIDE.md +++ /dev/null @@ -1,401 +0,0 @@ -# 操作手順 - -spindle の画面の使い方。起動・更新・バックアップは `OPERATIONS.md`、旧ライブラリからの移行は -`MIGRATION.md`、スマートプレイリストの文法は `DSL.md` を見る。画面の仕様そのものは `SPEC.md` §12。 - -## 1. 初回起動とログイン - -1. `deploy/compose.yaml` を TrueNAS のカスタムアプリとして起動する(作り方は `OPERATIONS.md` - 「イメージの更新」)。初回だけ環境変数 `SPINDLE_INITIAL_PASSWORD` を渡す。パスワードは - argon2id で DB に保存され、以後この環境変数は読まれない -2. ブラウザで `http://<ホスト>:8080/` を開き、パスワードを入れてログインする(ユーザ名は無い)。 - セッションは Cookie で保持される -3. ログイン後は「ライブラリ」画面が開く。起動時にライブラリのスキャンが自動で走るので、 - ☰ →「ジョブ」の `scan` が完了すると全曲が表に出る - -初期化が済んだら compose から `SPINDLE_INITIAL_PASSWORD` の行を消してよい(平文を残さないため。 -消しても DB のパスワードで起動する)。環境変数も DB もパスワードが無いと**ロックモード**で起動し、 -`/health` 以外は 503 を返す。そのときはログイン画面に「パスワードが未設定です」と出る。 - -ログインに何度も失敗すると IP 単位でしばらく拒否される(「試行回数が多すぎます」)。 - -## 2. 画面の骨格 - -``` -┌──────────────────────────────────────────────────────────────────────────────┐ -│ spindle ライブラリ アルバム CD YouTube Inbox❷ │ ⏮ ▶ ⏭ ■ シーク 曲名 │ RG 原本 🔊 │ ☰ │ ← 上部バー -├──────────────┬───────────────────────────────────────────────────────────────┤ -│ [検索] │ プロパティ │ 一括編集 │ 操作 選択 N 件 ▴ │ ← 右パネル -│ ▾ ツリー ├───────────────────────────────────────────────────────────────┤ -│ プレイリスト │ 表示 K 件 列 │ -│ フィルタ │ ☐ │ # │ Title │ Artist │ Album │ … │ 長さ │ バッジ │ │ ← 表 -│ [cover] │ (仮想スクロール) │ -└──────────────┴───────────────────────────────────────────────────────────────┘ -``` - -- **上部バー**: 左が画面の切替。「ライブラリ」がホームで、その右に取り込みの入口(CD / YouTube)、 - さらに右が「Inbox」。**取り込むと Inbox に赤い数字(承認待ちの件数)が増える**ので、次にどこを - 見ればよいか分かる。中央がプレイヤー(§8)。右端の ☰ に「ジョブ」「履歴」「設定」「ログアウト」 - (ジョブには実行中 + 待ちの件数が付き、失敗があれば ☰ 自体に赤点が付く)。☰ の横の点は - サーバとの SSE 接続状態(緑なら接続中。切れても自動で再接続する) -- **左サイドバー**: 検索ボックス、ツリー、プレイリスト、固定フィルタ、選択中の曲のアートワーク。 - どれを選んでも**中央の表の集合を差し替えるだけ**で、列・ソート・選択の仕組みは共通。 - ツリーが効かない画面(CD / YouTube / Inbox / ジョブ / 履歴 / 設定)では出ない -- **表**: 全曲(最大数万行)を仮想スクロールで出す。列は右上の「列」で表示 / 非表示と順序を変え、 - ヘッダのクリックでソート、ドラッグで並べ替え・幅変更。設定はこのブラウザに保存される -- **右パネル**: 「プロパティ」「一括編集」「操作」の 3 タブ。頭に「選択 N 件(うち反映待ち M 件)」。 - ▴ で折りたたみ、境界のドラッグで幅を変えられる(保存される) - -## 3. 一覧の使い方 - -### 3.1 絞り込み - -- **検索**: サイドバー先頭の検索ボックス。入力から 250ms 後に表へ反映する。3 文字以上は部分一致 - (全文検索)、2 文字以下は前後一致なしの LIKE(短い語は当たりにくい) -- **ツリー**: 既定は Category → AlbumArtist → Album。表示形式は組み込み 5 種(by folder structure / - by category / by artist / by album / by year)と、`%category%|%albumartist%|%album%` のような - パターンで自作できる(「新しい表示形式…」。`|` が階層の区切り)。節を選ぶとその範囲に絞る -- **プレイリスト**: 選ぶとその内容だけになる(§7) -- **固定フィルタ**: 未検証 / 重複 / missing / RG なし / RG 未書込 / 反映待ち / conflict / hardlink / - FLAC 未検査 / FLAC エラー / Hi-Res 未検査 / 偽ハイレゾ疑い。複数を押すと AND。ツリーや検索と - 組み合わせられる - -絞り込みを変えても**選択集合は変わらない**(§3.2)。 - -### 3.2 選択 - -一括編集も操作もすべて「選択」に対して行う。 - -| 操作 | 動き | -|---|---| -| クリック | その行だけを選択(カーソルも置く) | -| Shift + クリック | 直前の行からの範囲を**足す** | -| Ctrl + クリック / Space | 1 行をトグル | -| Ctrl+A | 表示中の絞り込み結果を**全件**選択(何万件でも即時。ID ではなくフィルタ式を持つ) | -| Esc | 選択解除 | -| ↓ ↑ / PageDown PageUp / Home End | カーソルを動かし、その行だけを選択 | -| Shift + 上記 | anchor からカーソルまでの範囲そのものに置き換える(戻せば縮む) | -| Ctrl + 上記 | カーソルだけ動かす(選択は変えない) | -| Delete | プレイリストを開いているとき、選択をそのプレイリストから除外 | - -選択は作った時点で固定される。その後に検索語やツリー、ソートを変えても選択集合は変わらないので、 -表に見えている行と選択が食い違うことがある。右パネルの「選択 N 件」が正。 - -### 3.3 バッジ列 - -1 セルに複数のアイコンが並ぶ。カーソルを合わせると文言が出る。凡例は表の上の「凡例」から。 - -| バッジ | 意味 | -|---|---| -| ✔ / ✘ / ○ / · | CD 由来の照合。検証済み(CTDB か AccurateRip のどちらかで一致。どちらかはプロパティ)/ 不一致 / 検証不能(配信・ダウンロード音源)/ 未検証 | -| L / l | 可逆 / 非可逆(コーデック名はツールチップ) | -| RG | ReplayGain 書き込み済み。薄い表示は計測済みだがタグ未書き込み | -| D / D• | Derived(配布用 Opus)あり。• はタグが古く追随待ち | -| F / ✘F | FLAC の STREAMINFO に MD5 が無い / デコードエラー | -| H | 偽ハイレゾ検出の疑い・判定できず・エラー(色で区別。• は結果が古い) | -| ⏳ | 編集の反映待ち(バッチが処理中)。この行は編集できない | -| ⚠ | conflict。外部の変更と衝突して最後の編集が反映されなかった(履歴画面で現在値を確認) | -| ⧉ | 同一音声の重複がある | -| ∅ | missing。ファイルが見つからない(行はグレー。7 日後に GC が消す) | - -### 3.4 その場で直す - -- セルをダブルクリックすると 1 件だけの編集になる(Enter で適用、Esc で取り消し)。裏では 1 件の - バッチとして同じ経路を通るので、履歴に載り巻き戻せる -- 行の先頭に出る ▶ で、その行から表示順に連続再生する(§8) - -## 4. プロパティタブ - -選択した行のタグ(Metadata)、ファイル情報(Location)、技術情報(General)を出す。複数選択なら共通の -値だけを出し、違う値は「<複数の値>」になる。 - -- 値をダブルクリックすると編集欄になり、Enter で**選択全体**にその値を書く(`;` で多値) -- 行末の × か右クリックで、そのフィールドを選択全体から削除する(値の無い行は消せない) -- 表の下の「フィールドを追加」で新しいキーに値を書く(キーは大文字化。`=` と制御文字、`PICTURE` は不可。 - 既にあるキーはその行を編集する) - -どれも 1 操作のバッチになり、プレビュー → 適用 → 履歴と同じ流れで反映される。 - -General の「CTDB」「AccurateRip」の行には、手法ごとの最新の照合結果が出る(一致なら信頼度とオフセット、 -ほかに不一致 / 登録なし / 検証不能)。バッジの ✔ はどちらかで一致すれば付くので、どちらで一致したかはここで見る。 - -## 5. 一括編集タブ - -選択した全曲に同じ操作の列を適用する。foobar2000 の Masstagger に相当。 - -1. 「追加する操作」から種類を選び、キーと値を入れる。上から順に適用される(上へ / 下へで並べ替え) - - **固定値**: キーに値を書く(`;` で多値。空なら削除) - - **フィールド参照**: `%artist%` のように他のフィールドの値を写す(テンプレート) - - **正規表現置換**: パターンと置換(`$1` で参照) - - **連番**: TRACKNUMBER などを現在のソート順に振り直す(開始番号を指定) - - **削除**: キーを消す -2. 「プレビュー」を押すと、表の該当セルに 旧値 → 新値 の差分が重なる。頭に「変更 N / 変更なし M / - 反映待ちで除外 K」 -3. 説明(任意。履歴に残る)を入れて「適用」。バッチが作られ、ジョブがファイルへ書く。表のバッジが - ⏳ → 消える、で完了。件数が多ければ「ジョブ」画面で進捗が見える - -操作リストは選択を変えても残るので、同じ操作を別の集合へ繰り返し適用できる。選択・操作・ソートを -変えたら**プレビューし直す**(プレビュー時の集合に対してだけ適用される)。 - -対象に反映待ち(⏳)の行があると適用できない。「M 件を除外して適用」か、待ってから再度プレビューする。 - -画像はタグ編集の対象外(アートワークは操作タブの「アートワーク」で差し替える)。 - -## 6. 編集履歴と巻き戻し - -☰ → 「履歴」。タグ編集・リネーム・正規化・MD5 補填・アートワーク差し替え・購読の番号揃えなど、 -ファイルを書き換えた操作はすべてバッチとしてここに並ぶ。 - -- 行を開くと、曲ごとの結果(applied / skipped_conflict / failed)と変更内容が見える。conflict の行は - 「ファイルを再読込した現在値」を並べて出す -- 「巻き戻す」は終端状態(applied / partial / failed)のバッチにだけ押せる。巻き戻しも新しいバッチとして - 同じ一覧に現れる(↩ で元バッチを示す)。戻したものを「巻き戻す (=やり直し)」でもう一度戻せる -- 戻し済みのバッチは「#N で戻し済み」と表示され、再度は戻せない -- 巻き戻しの時点でファイルが元バッチの結果と違っていれば(外部で書き換えた等)、その曲だけ conflict と - なり他は戻る。直してから再度巻き戻すと残りだけが対象になる -- 処理中(prepared / applying)のバッチは「キャンセル」できる - -外部の変更(foobar2000 で書いた等)は次のスキャンで取り込まれ、DB ではなく**ファイルの内容が採用**される。 -編集と外部変更がぶつかったときは、編集側が conflict になる。 - -## 7. 操作タブ - -選択した曲に対する、タグ編集以外の操作。ボタンごとにジョブか編集バッチが作られる。 - -### ファイル - -- **リネームをプレビュー**: `config.toml` の `[layout]` の規則でパスを組み直し、旧 → 新を一覧に出す。 - 生成できない行(必要なタグが無い、宛先が衝突)は理由付きで赤くなる。「リネームを適用」で - ファイルを移動する(履歴に載り巻き戻せる。Derived は自動で追随する)。同じ名前・同じ年の別の版(通常版と - Hi-Res 版など)が同じフォルダに当たるときは、区別したい側の曲に `EDITION` タグ(例: `Hi-Res`)を付けると - `アルバム名 (Hi-Res)` に分かれる(付けていない方は元の名前のまま) -- **正規化をプレビュー**: WAV / ALAC / AIFF を FLAC にする。適用すると元ファイルは `Archive/` へ退避 - され(すぐには消えない。設定画面の「退避ファイル」から復元できる)、変換前後の PCM が一致するときだけ - 置き換わる。既に FLAC の行や非可逆は「変更なし」 - -### ReplayGain / FLAC - -- **ReplayGain を解析**: 曲ごと(album gain が on の album は album 単位)にラウドネスを計測する。 - ファイルは書かない。バッジが薄い RG になる -- **解析値をタグに書く**: 計測済みの値をタグに書く編集バッチ(巻き戻せる)。Opus は R128 形式で書く -- **album gain**: 選択行が属する album ごとのチェック。on にすると album 単位で解析し直し、通し再生の - ゲインが付く。off にすると album の値を消す。既定は off(CD 取り込みは on) -- **FLAC を検査**: `flac -t` と MD5 の有無を確認する(読むだけ)。結果はバッジ F / ✘F と固定フィルタ -- **MD5 を補填**: 検査で MD5 無しだった FLAC の STREAMINFO に MD5 を書く(巻き戻せる) -- **偽ハイレゾを検出**: 可逆かつ 48 kHz 超または 16 bit 超の曲を解析し、アップサンプリングやビット深度の - 水増しを判定する(読むだけ。結果はバッジ H とプロパティの「Hi-Res check」) -- **遡及照合**: 44.1 kHz / 16 bit / 2ch の FLAC だけが対象。アルバム単位で CTDB / AccurateRip に照会し、 - CD 由来の音源かどうかを格付けする。終わったアルバムから結果(手法ごとの一致数と信頼度)が ④ に出る。 - 不一致は「要確認」で不良とは限らない(ログは `data/verify/`) - -### アートワーク - -画像ファイル(JPEG / PNG / WebP)を選ぶとプレビューが出る。「選択の埋め込み画像を差し替え」で選択した -全曲の埋め込み画像をこの 1 枚に置き換える(編集バッチ。巻き戻すと元の画像に戻る)。同梱の -`cover.jpg` 等は書かない(埋め込みに統一)。 - -### プレイリスト - -「プレイリストへ追加…」で選択を手動プレイリストの末尾に足す(表からのドラッグでも同じ)。 - -## 8. プレイリスト - -サイドバーの「プレイリスト」区画。 - -- **手動プレイリスト**: 「新しいプレイリスト」で作る。表の行をプレイリスト名へドラッグして追加、 - プレイリストを開いた状態で行をドラッグして並べ替え、「…から除外」ボタンか Delete キーで外す -- **スマートプレイリスト**(⚙): 「新しいスマートプレイリスト」でルールを書く。中央にエディタが出て、 - 入力しながら件数と表の結果が追随する(Ctrl+Enter で保存)。例: - `%albumartist% IS ヰ世界情緒 AND NOT %category% IS _Unsorted ORDER BY %date% DESC LIMIT 100`。 - 文法は `DSL.md`。ライブラリが変わると自動で再評価される(「再評価」で手動も可)。 - 項目の追加・並べ替えはできない -- **メニュー**(行末の「操作」): 改名 / 削除 / ルールを編集 / 再評価 / foobar クエリ / 書き出し -- **書き出し**: プロファイル(foobar / android / internal)を選ぶと `Playlists/<profile>/<名前>.m3u8` - に書く。パスの形はプロファイルごと(foobar は Windows のドライブレター、android は Derived への - 相対パス)。Derived のタグが古い曲があれば件数が通知に出る。一度書き出したプレイリストは、 - 内容が変わると自動で書き直される -- **foobar クエリ**: スマートプレイリストのルールを foobar2000 の Autoplaylist 構文に変換して表示する - (コピーして foobar 側に貼る。変換できない条件は脚注に出る) -- **取り込み**: 「Playlists/ の m3u8」から、Playlists ディレクトリにある m3u8 を手動プレイリストとして - 取り込む。行はパスとファイル名で照合し、見つからない行は未解決として件数に出る - -## 9. 再生 - -上部バー中央のプレイヤー。表の行の ▶ で、その行から表示順に連続再生する。 - -- ⏮ ▶ ⏭ ■ とシークバー、音量 -- **RG**: off / track / album。album は album gain が無ければ track にフォールバック -- **原本**: 既定では可逆の曲は Derived の Opus(48 kHz)を再生する。チェックすると原本を流す - (ハイレゾをそのまま聴きたいとき。ブラウザが再生できない形式は ffmpeg で変換される) -- 非可逆(Opus / MP3 / AAC)は常に原本 - -### 9.1 スマホで使う - -外出先のスマホからは、専用の簡易ページ `/m/` を使う。スマホのブラウザで spindle の URL(`/`)を開くと自動で `/m/` に移る -(iPad は PC 版が開く)。`/m/` を直接開いてもよい。ログインは PC と同じ。 - -- 下のタブは ライブラリ / フォルダ / プレイリスト / 購読。曲をタップすると下からシートが出て、タグやファイル情報を確かめ、 - 試し聴き(▶)とプレイリストへの追加ができる。タグの誤字は、シートのタグの行をタップして 1 曲だけ直せる。直した内容は - PC の「履歴」から巻き戻せる -- 試し聴きは通信量を抑えるため、原本ではなく軽い形式(Opus、鳴らなければ AAC)で流れる。音量の補正(RG)は掛からず、 - 画面を閉じると止まる -- 購読タブの「同期」は手動。ダウンロードがすべて終わると「完了」になる。Inbox の確認は PC で行う -- 一括編集・CD・Inbox の承認・端末同期・ジョブ・設定は PC 版だけ - -**PC 版に切り替える**: スマホ版のメニューの「PC 版を開く」を押す。以後そのブラウザでは `/` が PC 版になる。スマホ版に -戻るには `/m/` を開く(開くと PC 版の指定は解ける)。 - -## 10. アルバム画面 - -上部バーの「アルバム」。サムネイルのグリッドで、表と同じ絞り込み(ツリー・プレイリスト・検索語)が -効く。クリックすると一覧をその album に絞って戻る。missing だけの album は出ない。 - -アートワークは埋め込み画像か同梱画像(`cover.jpg` 等)から自動で拾う。曲ごとに画像が違う album は -曲ごとの画像を使う。 - -## 11. 取り込み - -取り込み元は Inbox / CD / YouTube の 3 つで、結果はすべて **Inbox** に集まり、人が承認して配置する。 - -### 11.1 Inbox - -**category**(配置先の最上位のフォルダ)の選択肢は、Library 直下のフォルダ名(Anime / J-POP など)から -スキャンで自動で作られる。新しい category を増やすときは、選択欄で名前を足すか、Library の直下に -フォルダを作ってアルバムを置けば次のスキャンで選択肢に出る(`_Unsorted` は category にならない)。 -自動で作られるのは追加だけなので、SMB で一時的に作ったフォルダの名前などが残ったら、設定画面の -「category」で消す(どのアルバム・購読・Inbox の下書きにも使われていない語彙だけ消せる)。 - -`config.toml` の `[paths].inbox` に音声ファイルの入ったディレクトリを置くと、1 つのディレクトリが 1 つの**取り込み**として -一覧に出る(定期の確認は変化があったときだけ走査する。「今すぐ確認」で即時)。 - -1. 左の一覧から取り込みを選ぶ。右にアルバム単位(アルバムアーティスト / アルバム / 日付 / category)と - トラック単位(ディスク / # / タイトル / アーティスト)の補正フォームが出る。初期値はファイルの - タグからの提案 -2. 直す。問題(必須項目が空、番号の重複など)が残っている間は「承認して配置」が押せない - - タイトル欄の下に「⚠ Library に同名」が出たら、同じ曲を二重に取り込もうとしている可能性がある - (Library 側とこの曲の長さが並ぶので、長さで別テイクか見分ける。Cover / Live ver. は別曲。承認は止めない) - - ARTIST が多値のファイルは元の値をチップで示す。「ファイルの多値をそのまま保つ」を外すと 1 値で書く - - 「album gain を計算する」: アルバム通し再生用。既定 off - - 「宛先: …(N 曲)に追加。番号は M から」が出ていれば、既存の album に追記される。再生リストの購読から - 落とした曲は「番号 165 に入る(再生リストの位置。空けてある番号)」と出る。後ろの曲をずらす処理は承認では - なく同期が先に済ませていて、その経緯(何曲をずらしたか・バッチ番号)が下に出る。承認しても既存の曲は動かない - - 追記先の album の曲にディスク番号が無ければ「ディスク番号は付けない」と出て、disc 列が空になる(追記した曲 - だけが並びの末尾に回らないよう、宛先に合わせてディスク番号を書かない) -3. 「承認して配置」。inbox ジョブが Library へ移し、DB に登録し、ReplayGain / Derived を投入する。 - 配置済みの取り込みは 24 時間残り、「アルバムを開く」で一覧に飛べる(画像は Library に登録された同じ画像を出す) -4. 「却下」はファイルを残したまま取り込みを却下にする(一覧に「却下」で残る)。「下書きに戻す」で承認前に戻せる -5. 要らない取り込みは、却下したうえで「削除」する。ファイルはすぐには消えず、`[gc].retention_days`(既定 7 日) - 経ったあとの GC が取り込みのディレクトリの音声・カバー画像などを消し、取り込みも一覧から消える(一覧に「削除待ち」、 - 承認画面に消す日時が出る)。それまでは「削除を取り消す」で却下に戻せる。削除待ちの間にそのディレクトリへ - ファイルが足されると、見ていないものを消さないよう削除は自動で取り消される - -承認後にファイルが変わると自動で pending に戻る。 - -### 11.2 CD - -上部バーの「CD」。吸い出しは未実装(`TASKS.md` P2-5)で、いまはディスクの読み取りとメタデータを -そろえるところまでが動く。**取り込んだものは Inbox に入る**(入力 → Inbox → ライブラリ)ので、 -この画面では値を直さない。 - -1. CD を入れる。上に**トラックの表**が出る(曲数と長さはディスクから。照会を待たない)。同時に - MusicBrainz へ自動で照会する。ドライブの無い環境では「詳細」の中に TOC の貼り付け欄がある -2. 下に候補が並ぶ。選ぶと表のタイトルが**実名に置き換わり**、左にジャケットが出る。まだ名前が - 入っていない行は `Track 01`… が薄く見えていて、そのまま取り込めばその名前になる -3. 照会は DiscID → ISRC / JAN・UPC → TOC 近似の順に広げ、**当たった段で止まる**。どれも違うときは - 「さらに広げて探す」で TOC の近い盤まで見る。候補に出ない盤は「詳細」のリリース URL 指定で当てる -4. 「候補から写す範囲」は既定が「全部写す」で、レーベル・カタログ番号・各トラックのタイトル / - アーティストまで入る。表記を自分で決めたいときは「識別用の最小限」(アルバム・アルバムアーティスト・ - 日付・ディスク番号 / 枚数・MB id だけ)に切り替える(トラック名は空のまま取り込まれ、Inbox で入れる)。候補が無ければそのまま手で入れる -5. 「取り込む」(P2-5 で有効になる)。**この画面は表示だけで、値は直せない。** 取り込んだものは - Inbox に入るので、名前や配置先(category)は Inbox の承認画面で直す。どの候補も違うときは - 「どれも違う(候補を使わない)」で、名前の無いまま取り込んで Inbox で入れればよい -6. 候補を選んで取り込んだ盤は、CD 画面に出ていたジャケット(Cover Art Archive の表の画像)が Inbox の承認画面の - 画像の欄に**最初から入っている**(取り込みの後の確認で一度だけ取りに行く)。そのまま承認すれば全曲に埋め込む。 - 要らなければ「画像を外す」、別の画像にするなら「差し替える…」。Cover Art Archive に画像の無い盤と、候補を - 選ばずに取り込んだ盤は画像なしのまま - -### 11.3 YouTube - -上部バーの「YouTube」。動画か再生リストの URL を 1 行 1 つ貼って「ダウンロード」。音声を Inbox に置く -(承認は Inbox)。再生リストは動画ごとに展開し、取り込み済み(`SOURCE_URL` が一致)の動画は飛ばす -ので、同じ再生リストを何度貼っても新しいものだけが落ちる。下の「ジョブ」表で進み具合と結果 -(Inbox に置いた / プラグインが skip / 失敗の理由)が見え、Inbox に置いた行は「Inbox で確認」で飛べる。 - -URL を貼る手間を減らすブックマークレット(ブックマークの URL 欄に貼る。`<spindle>` は -`http://truenas:8080` のような spindle の URL): - -- **この動画を spindle へ**: いま見ているページの URL を spindle の YouTube 画面に入れて開く - ``` - javascript:void(open('http://<spindle>/youtube?url='+encodeURIComponent(location.href))) - ``` -- **このページの動画リンクを全部集める**: チャンネルの動画一覧・検索結果・再生リストのページで - 動画の URL を集め、改行区切りでクリップボードへ(spindle の欄に貼る) - ``` - javascript:(()=>{const s=new Set([...document.querySelectorAll('a[href*="/watch?v="]')].map(a=>new URL(a.href).searchParams.get('v')).filter(Boolean));navigator.clipboard.writeText([...s].map(v=>'https://www.youtube.com/watch?v='+v).join('\n')).then(()=>alert(s.size+' 件をコピーした'))})() - ``` - -#### 再生リストの購読 - -アーティストごとに再生リストを 1 本ずつ持っているなら、URL を貼るかわりに「購読」に登録する -(再生リストの URL + 追記先のアルバムアーティスト / アルバム / category)。「同期」を押すと -(`config.toml` の `[ytmusic].sync_interval_hours` を 0 以外にすれば定期的にも)、再生リストを列挙して - -1. 既に Library にある曲の `TRACKNUMBER` とファイル名を再生リストの位置に揃え(履歴に載り巻き戻せる)、 -2. Library にも Inbox にも無い動画だけを位置付きでダウンロードして Inbox に置く(1 回の上限は既定 50 本)。 - -承認は今までどおり Inbox で行う(初期値の番号がそのまま位置)。配置されると自動でもう一度同期が走り、 -番号を揃え直す。非公開・削除になった動画は位置を占め続けるので、その番号は飛ぶ(購読の結果に「取れない」 -として出る。公開に戻れば次の同期で拾う)。`SOURCE_URL` の無い曲や再生リストに無い曲は触らない(その番号に -入るべき曲は「揃えられない」として出るので、手で直す)。「揃える」を外した購読は番号に触らない。 - -結果に詳細があれば「詳細」で行を開く。同期中は編集・削除できない。 - -`config.toml` の `[ytmusic].ytdlp_args` には `["--extractor-args", "youtube:lang=ja"]` を入れておく(サンプルの -既定)。言語を指定しないと、投稿者が翻訳タイトルを付けた動画は英語のタイトル(とチャンネル名)が返り、日本語の -曲名が取れない。 - -YouTube に弾かれるようになったら、まず yt-dlp を新しくする(イメージの更新。`OPERATIONS.md`)。それでも -駄目なら `[ytmusic].ytdlp_args` に `--extractor-args`(`youtube:lang=ja;player_client=…` のように `;` で並べる)や -`--cookies` を足す。 - -## 12. ジョブ画面 - -☰ →「ジョブ」。スキャン、タグ書き込み、ReplayGain、Derived 生成、ダウンロードなど裏で走る処理の -一覧。実行中 + 待ちの件数は ☰ のメニュー内の「ジョブ」に付き、失敗があれば ☰ 自体に赤点が付く。 - -- タブ: **実行中・待ち / 完了 / 失敗・取り消し / すべて**。各タブに件数 -- 種別表: 種別ごとの並列度と待ち / 実行中 / 完了 / 失敗の数。クリックで一覧をその種別に絞る。表の下に - CPU 系ジョブの共通予算(コア数) -- 各行: 対象(どのファイル / アルバム / バッチか)、進捗(done / total)、失敗理由。「再試行」 - 「取り消し」、終端の行は「消す」で一覧から片付ける。「失敗をすべて消す」で赤点も消える -- 編集バッチ由来のジョブは「編集履歴で開く」で履歴画面に飛ぶ - -ジョブは冪等で、コンテナの再起動や電源断のあとは中断したものが自動で再開される。 - -## 13. 設定画面 - -☰ → 「設定」。 - -- **表示**: 配色(OS に従う / ライト / ダーク)。このブラウザにだけ保存される -- **ライブラリ**: 「再スキャン」は stat の差分だけを見る(速い)。「deep scan」は全件のタグと音声 - ハッシュを読み直す(時間がかかる。外部で大量に書き換えた後や、DB を復元した後に) -- **GC**: 「消すものを確認(dry-run)」で、missing が保持期間(既定 7 日)を超えた行、退避期限を過ぎた元ファイル、 - Derived の孤児、参照の無いアートワーク、古いジョブ行の件数と容量を出す。確認してから「GC を実行(N 件)」。 - 定期実行は 1 日 1 回(物理削除はここだけ) -- **category**: 配置先の最上位のフォルダの一覧(Library 直下のフォルダ名からスキャンで自動で作られる)。 - 使われていないものは「削除」で消せる(使われていれば理由が出て消えない)。Library の直下に同じ名前の - フォルダがあってアルバムが入っていれば、次のスキャンでまた作られる -- **退避ファイル**: 正規化で置き換えた元ファイルの台帳。「復元」で元に戻せる(期限までに) -- **config.toml**: 現在の設定の閲覧。変更はファイルを編集してコンテナを再起動する(画面からは書けない) - -## 14. 困ったとき - -| 症状 | 見るところ | -|---|---| -| 曲が表に出ない | ジョブ画面の `scan` が完了しているか。`[paths].library` が正しいか(設定画面の config.toml) | -| ⚠ conflict が付いた | 履歴画面でそのバッチを開き「現在値」を確認。外部の変更を取り込んだ後に再適用する | -| ⏳ が消えない | ジョブ画面で `tagwrite` / `rename` が失敗していないか。失敗理由を見て「再試行」 | -| ∅ missing が付いた | ファイルを移動・削除した。戻せば次のスキャンで復帰する。7 日(`[gc].retention_days`)で GC が行を消す | -| YouTube の取り込みが失敗する | まずイメージを更新して yt-dlp を新しくする(`OPERATIONS.md`) | -| 再生が始まらない | 「原本」を外して Derived を使う。Derived が無ければ ジョブ画面の `transcode` を待つ | -| DB が壊れた | `OPERATIONS.md`「復元」。バックアップから戻して deep scan | diff --git a/docs/admin-guide/install.md b/docs/admin-guide/install.md new file mode 100644 index 00000000..5926e369 --- /dev/null +++ b/docs/admin-guide/install.md @@ -0,0 +1,74 @@ +# 導入 + +spindle を TrueNAS SCALE のカスタムアプリ(Docker Compose)として動かすまでの手順。 +既存の音楽ライブラリを持ち込むときは、先に [移行手順](migration.md) でデータセットを作ってファイルを移す。 +動かし始めた後の更新・バックアップは [運用手順](operations.md)。 + +## 前提 + +- TrueNAS SCALE(Docker で動くカスタムアプリ)。ほかの Linux の Docker Compose でも動くが、手順は TrueNAS を前提に書く +- ZFS のデータセット。Library / Derived / Archive / Inbox は `casesensitivity=insensitive` + + `normalization=formD` で**作成時に**作る(後から変えられない。[D-18](../decisions/D-018.md) / [D-19](../decisions/D-019.md))。 + 作り方は [移行手順](migration.md)「1. データセット作成」、置き場所の役割は [SPEC §5](../spec/05-library-layout.md) +- CD を取り込むなら光学ドライブ(`/dev/sr0`)。Android へ同期するなら USB でつないだ端末 + +## イメージ + +イメージは GHCR `ghcr.io/akashisn/spindle`(public。pull にトークン不要)。 + +| タグ | 中身 | +|---|---| +| `latest` / `X.Y` / `X.Y.Z` | リリース(`vX.Y.Z`)。普段はこれを指す | +| `X.Y.Z-rc.N` | リリース候補。`latest` / `X.Y` は付かない | +| `edge` / `sha-<7 桁>` | `main` の最新。開発用で、DB の互換は前進のマイグレーション以外は約束しない | + +外部バイナリ(`ffmpeg` `flac` `opusenc` `cd-paranoia` `cdrdao` `yt-dlp` `adb`)はイメージに入っている。 +YouTube のメタデータプラグイン(`spindle-ytmusic-meta`)は入っていないので、使うなら別に置く([D-70](../decisions/D-070.md))。 + +## カスタムアプリの作り方 + +TrueNAS の Apps → Discover → Custom App → **Install via YAML** に [`deploy/compose.yaml`](../../deploy/compose.yaml) を写す。 +そのまま使えない箇所: + +- `devices` / `device_cgroup_rules` / `group_add`: CD ドライブ(`/dev/sr0` と cdrom グループの GID。 + `/dev/sg*` は要らない)。ドライブが無い機体では 3 つとも消す(残すと compose が起動に失敗する。 + アプリ側はデバイスを開けなくても起動し、CD 画面に「ドライブが無い」と出す)。 + **ドライブのつなぎ先に注意**: 一部の SATA コントローラでは、複数セクタの READ CD の中のセクタ境界で + 音声が 16 バイト(4 サンプル)ずれ、C2 もエラーも出ないまま照合(AccurateRip / CTDB)が全曲通らない。 + AMD 600 シリーズのチップセット SATA(1022:43f6)で起き、同じドライブを USB-SATA 変換につなぐと + 消えた。PCIe カードの ASMedia ASM1062(1b21:0612)の SATA でも起きない(2026-09-25、P2-5)。 + 照合が一度も通らず rip.log の「ずれ」が毎回数百〜千回出るなら、まずつなぎ先を変える +- `user`: 既存ライブラリの所有者の UID:GID に合わせる(違うとタグ書き込みが全滅する) +- `volumes`: Library / Derived / Archive / Inbox / Playlists / data の実パスと、メタデータプラグイン + (`spindle-ytmusic-meta`)のマウント +- `adb` サービス: Android へ同期しないなら消してよい(spindle の `adb-run` のマウントも一緒に消し、 + `config.toml` の `[devices].adb_server` を空にする。[SPEC §13](../spec/13-config.md)) +- `SPINDLE_INITIAL_PASSWORD`: 初回だけ。カスタムアプリには `.env` が無いので、`${…:?…}` の行を + `- SPINDLE_INITIAL_PASSWORD=<生成したパスワード>` の値に書き換える(下記「初回起動」) +- **イメージは作る前に pull しておく。** ホストに同じタグ(`edge` など)の古いイメージが残っていると、 + カスタムアプリはそれをそのまま使う。`sudo docker pull ghcr.io/akashisn/spindle:<tag>` を先に打つか、 + 作った直後に `/health` の `version` を見て、違えば止める → `/data` の DB を消す → Pull image → 起動し直す + +## 設定 + +`SPINDLE_CONFIG`(既定 `/data/config.toml`)で設定ファイルを指す。例は +[`deploy/config.example.toml`](../../deploy/config.example.toml)、各項目の意味は [SPEC §13](../spec/13-config.md)。 +初回起動の前に `/data/config.toml` を置く(無いと既定値で起動する)。起動時に設定を厳格に検証し、 +不正なら起動しない([D-34](../decisions/D-034.md))。 + +- YouTube から取り込むなら `[ytmusic].ytdlp_args` に `["--extractor-args", "youtube:lang=ja"]` を入れる + (無いと翻訳タイトルのある動画は英語のタイトルとチャンネル名が返り、日本語の曲名が取れない) +- リバースプロキシ越しに https で使うなら `[auth].trusted_proxies` が要る([運用手順](operations.md) + 「外から https で使う」) + +## 初回起動 + +1. 初回起動では環境変数 `SPINDLE_INITIAL_PASSWORD` を読んで argon2id で DB に保存し、以後は無視する。 + 環境変数も DB もパスワードが無いとロックモード(`/health` が `{"status":"locked"}`、それ以外は 503)で起動する +2. ログに「SPINDLE_INITIAL_PASSWORD から パスワードを初期化した」と出て、ログインできることを確かめたら、 + アプリの Edit で `environment` ごと消す(平文を compose に残さないため。消しても DB のパスワードで起動する) +3. ブラウザで `http://<ホスト>:8080/` を開いてログインする。起動時にライブラリのスキャン(初回は deep)が + 自動で走り、ジョブ画面の `scan` が終わると全曲が表に出る(目安: 約 9,000 曲で 9 分) +4. 動いている版は `GET /health` の `version`(`spindle --version` と同じ)と `ytdlp` で確かめられる + +画面の使い方は [操作手順](../user-guide/README.md)。 diff --git a/docs/admin-guide/migration.md b/docs/admin-guide/migration.md new file mode 100644 index 00000000..38ee09b7 --- /dev/null +++ b/docs/admin-guide/migration.md @@ -0,0 +1,204 @@ +# 移行手順 + +既存の音楽ライブラリを spindle のデータセット構成(役割別の Library / Derived / Archive / Inbox。 +[SPEC §5](../spec/05-library-layout.md)、[D-1](../decisions/D-001.md))へ移す手順。振り分けの判断は [D-45](../decisions/D-045.md)。 +**`casesensitivity` と `normalization` はデータセット作成時のみ指定可能**で後から変更できない +ため、既存データセットの `zfs rename` による流用はしない([D-19](../decisions/D-019.md))。 + +この手順は作者の旧ライブラリ(`Opus/` と `Original/` が 1:1 で対になった構成。`Original/` に ALAC / webm / +mp3 などの原本、`Opus/` に配布用の Opus)を 2026-09 に移したときのもの。`scripts/migrate_plan.py` の振り分けは +この構成を前提にしている。構成が違うなら、振り分け一覧(rsync の `--files-from`)は自分で作り、 +データセット作成・コピーと検証・照合の手順だけを使う。以下の例のパスは [`deploy/compose.yaml`](../../deploy/compose.yaml) +に合わせて、SSD のプール `ssd`、HDD のプール `hdd`、旧ライブラリ `ssd/musics` としている。 + +| | 例 | +|---|---| +| 旧ライブラリ | `ssd/musics` → `/mnt/ssd/musics/{Opus,Original}` | +| 新データセット | `ssd/media/{Library,Derived,Inbox}`、`ssd/apps/spindle`、**`hdd/media/Archive`** | +| コンテナ | `user: 1000:1000`(`deploy/compose.yaml`) | + +作業はホストで行う。`scripts/preflight.py` と `scripts/migrate_plan.py` は標準ライブラリだけで +動くので、`scp` で `/tmp` へ置いて `sudo python3` で実行する。 + +## 0. 事前チェックと振り分け計画 + +```bash +# ブロッカー検出(衝突 / 不正 UTF-8 / 255 バイト超 / symlink / hardlink / SMB 禁止名 / 容量)。 +# Library へ入る 2 本の木を別々に見る。どちらも exit 0 になるまで進まない +sudo python3 /tmp/preflight.py /mnt/ssd/musics/Opus --dest /mnt/ssd --plan /tmp/rename-opus.sh +sudo python3 /tmp/preflight.py /mnt/ssd/musics/Original --dest /mnt/ssd --plan /tmp/rename-original.sh + +# 振り分け一覧(rsync --files-from 用)。unmatched が 0 で exit 0 になること +sudo python3 /tmp/migrate_plan.py /mnt/ssd/musics --out /tmp/plan +sudo cat /tmp/plan/summary.json # library_audio_total が初回スキャン後のトラック数 +sudo cut -f1,2 /tmp/plan/skip.tsv | sort | uniq -c # 移さないものの理由を目視 +``` + +`insensitive` + `formD` では、比較キーが一致する 2 ファイルは共存できない。判定は +「NFD 正規化 → casefold」した結果の一致で行う。ブロッカーがあれば `--plan` のリネーム案を +**目視のうえ**実行する(中身が同一の重複ならリネームではなく削除が正しい)。symlink は +実体に置き換え、hardlink は片方を実コピーにするか削除する([D-26](../decisions/D-026.md))。 + +`migrate_plan.py` は `Opus/` と `Original/` の 1:1 対応を前提に、原本の形式で振り分ける +(ALAC → Library、webm → その `.opus` が Library で webm は Archive、mp3 → Library、 +AIFF は同名 ALAC があれば Archive、`AAC/` とマーカーは移さない)。対応が取れない stem が +あれば exit 1 で止まるので、`skip.tsv` の `unmatched` を見て手で決める。 + +## 1. データセット作成 + +```bash +COMMON="-o casesensitivity=insensitive -o normalization=formD" # utf8only=on が強制される +sudo zfs create ssd/media +sudo zfs create $COMMON -o recordsize=1M -o compression=lz4 -o atime=off ssd/media/Library +sudo zfs create $COMMON -o recordsize=1M -o compression=lz4 -o atime=off ssd/media/Derived +sudo zfs create $COMMON -o recordsize=1M -o compression=lz4 -o atime=off ssd/media/Inbox +sudo zfs create hdd/media +sudo zfs create $COMMON -o recordsize=1M -o compression=lz4 -o atime=off hdd/media/Archive +sudo zfs create -o recordsize=16K -o compression=lz4 -o atime=off ssd/apps/spindle +sudo mkdir -p /mnt/ssd/media/Playlists/m3u8 +``` + +| dataset | recordsize | snapshot | 備考 | +|---|---|---|---| +| ssd/media/Library | 1M | 毎日 + 一括編集前 | 唯一の正 | +| ssd/media/Derived | 1M | なし | 再生成可能。レプリケーション対象外 | +| hdd/media/Archive | 1M | 週次 | 追記のみ。FLAC 化で退避する原本の受け皿 | +| ssd/media/Inbox | 1M | なし | 承認前の一時領域 | +| ssd/apps/spindle | 16K | 毎日 | SQLite。ページサイズに合わせる | + +- `casesensitivity=insensitive`: Windows の foobar2000 が `Cover.jpg` を、spindle が + `cover.jpg` を作る事故を ZFS 層で潰す +- `normalization=formD`: 日本語の濁点・半濁点には NFC と NFD の 2 表現がある。macOS 由来の + パスは NFD になりがちで、正規化なしだと「見た目が同じで別ファイル」が発生する + +Inbox は Library と別データセットなので move は実コピーになるが、1 回あたりアルバム 1 枚 +なので実用上の問題はない。Archive は別プールなので退避も実コピー(追記のみで速度は要らない)。 + +## 2. コピーと検証 + +```bash +# 1. 旧ライブラリを固定してから作業する(ブロッカー解消のリネームは readonly の前に済ませる) +sudo zfs set readonly=on ssd/musics +sudo zfs snapshot ssd/musics@pre-migration + +# 2. 振り分け一覧に従ってコピー(zfs send/recv は不可。作成時プロパティが継承されてしまう) +R="sudo rsync -aHX --info=progress2 --from0" +$R --files-from=/tmp/plan/library-original.list /mnt/ssd/musics/Original/ /mnt/ssd/media/Library/ +$R --files-from=/tmp/plan/library-opus.list /mnt/ssd/musics/Opus/ /mnt/ssd/media/Library/ +$R --files-from=/tmp/plan/archive-original.list /mnt/ssd/musics/Original/ /mnt/hdd/media/Archive/ +$R --files-from=/tmp/plan/playlists.list /mnt/ssd/musics/Opus/Playlists/ /mnt/ssd/media/Playlists/m3u8/ + +# 3. 検証(チェックサム比較。何も出なければ成功)。-O はディレクトリの時刻を比べない: +# Original と Opus の 2 本を同じディレクトリへコピーするので、ディレクトリの mtime は必ず違う +V="sudo rsync -aHXnO --checksum --itemize-changes --from0" +$V --files-from=/tmp/plan/library-original.list /mnt/ssd/musics/Original/ /mnt/ssd/media/Library/ +$V --files-from=/tmp/plan/library-opus.list /mnt/ssd/musics/Opus/ /mnt/ssd/media/Library/ +$V --files-from=/tmp/plan/archive-original.list /mnt/ssd/musics/Original/ /mnt/hdd/media/Archive/ + +# 4. 設定とメタデータプラグインを置く(初回起動より前。config.toml が無いと既定値で起動する) +sudo install -m 644 config.toml /mnt/ssd/apps/spindle/config.toml +sudo install -D -m 755 spindle-ytmusic-meta /mnt/ssd/apps/spindle/bin/spindle-ytmusic-meta + +# 5. 所有者をコンテナの実行 UID/GID に合わせる +sudo chown -R 1000:1000 /mnt/ssd/media /mnt/hdd/media /mnt/ssd/apps/spindle + +# 6. spindle の初回スキャン完了と数日の運用までは旧ライブラリを破棄しない +``` + +`config.toml` は `deploy/config.example.toml` を元にする。`[encode.derived.aac]` を最初から有効にして +よい(aac は RG の解析が済んだ行から作られる。§4)。プラグインはイメージに入っていない([D-70](../decisions/D-070.md))。 +`[ytmusic].ytdlp_args` に `["--extractor-args", "youtube:lang=ja"]` を入れる(無いと翻訳タイトルのある動画は英語の +タイトルとチャンネル名が返り、日本語の曲名が取れない)。 + +コピーと検証が済んだら、[導入](install.md)「カスタムアプリの作り方」でアプリを作って起動する。 +起動すると初回スキャン(deep)が走る(目安: 約 9,000 本で 9 分)。 + +**ACL は rsync で引き継げない。** TrueNAS の SMB データセットは NFSv4 ACL を使うが、 +`rsync -A` が扱うのは POSIX ACL であり互換がない。コピー後に TrueNAS の ACL エディタで +新データセットにプリセットを適用し直す(SMB ユーザとコンテナの `1000:1000` の両方が書ける形)。 +1 回しか使わないのでスクリプト化はしない([D-72](../decisions/D-072.md) と同日に決定)。手順: + +1. Datasets → `ssd/media` → Permissions → Edit(`hdd/media` も同じ) +2. Owner / Group を `1000` / `1000`、Preset は **Restricted** を基点に、`owner@` と `group@` に + FULL_CONTROL、SMB で書くユーザ(またはそのグループ)にも FULL_CONTROL の ACE を足す +3. **Apply permissions recursively** と **Apply permissions to child datasets** にチェックして保存 +4. 確認: `nfs4xdr_getfacl /mnt/ssd/media/Library` に `owner@:rwxpDdaARWcCos:fd-----:allow` 相当の + 行が出て、コンテナ(`1000`)と SMB ユーザの両方で `touch` できること + (`sudo -u '#1000' touch /mnt/ssd/media/Inbox/.acl-test && rm` と、SMB クライアントからの新規ファイル) + +定期スナップショットも UI で上の表どおりに設定する。 + +## 3. 移行後の照合 + +```bash +# Library の音声一覧が計画と一致すること +sudo python3 /tmp/preflight.py /mnt/ssd/media/Library --manifest /tmp/after.tsv --audio-only +tail -n +2 /tmp/after.tsv | wc -l # = summary.json の library_audio_total +diff <({ tr '\0' '\n' < /tmp/plan/library-original.list; tr '\0' '\n' < /tmp/plan/library-opus.list; } \ + | grep -Ev '\.(png|jpe?g|webp)$' | sort) \ + <(tail -n +2 /tmp/after.tsv | cut -f1 | sort) +``` + +初回スキャン完了後、DB のトラック数が `after.tsv` の行数(ヘッダ除く)と一致することを +確認する。音声拡張子の判定は `preflight.py` の `AUDIO_EXT` が唯一の定義で、スキャナの +受け付ける拡張子もこれと揃える。一致しない場合はタグ読み取り失敗を疑う。 + +あわせて、webm 由来の曲を数件選び、`Archive/` の `.webm` と `Library/` の `.opus` を `ffprobe` で +比べてコーデック(opus)とストリーム長が一致すること(remux であり再エンコードでないこと)を +確認する。これが「webm 由来の曲は Library の `.opus` が master、`Archive/` の webm は生データ」 +という前提の根拠になる。ALAC 由来の曲は `.m4a` がそのまま master なので照合は不要。 + +## 4. 移行後にやること + +- `backup` ジョブが 1 世代以上取れていること(`/mnt/ssd/apps/spindle/backup/`。起動時に 1 世代取る) +- 旧 m3u8 を手動プレイリストとして取り込む(プレイリスト画面「Playlists/ の m3u8」、または + `GET /api/playlists/import` の各 `path` を `POST /api/playlists/import`)。未解決が 0 であること +- ReplayGain を全件解析する(`POST /api/rg {"selection":{"filter":""}}`、または全選択で「ReplayGain を解析」)。 + `[replaygain].write_tags = true` なら解析と同時にタグへ書く(別の「解析値をタグに書く」は要らない)。 + aac の Derived は RG が揃った行から作られるので、解析が終わるまで aac は増えない +- ロスレス → FLAC 正規化(P1-4)で ALAC を FLAC にする(全選択で「正規化をプレビュー」→ 適用)。 + 退避した ALAC は `Archive/` に 7 日(`[gc].retention_days`)置かれてから GC される。正規化は `audio_version` を上げないので、 + Derived の生成と並行してよい(作り直しは起きない)。目安: 2 並列で約 7,500 本に約 5 時間 +- 一括編集を 1 件行い、巻き戻しが通ること(P0 の完了条件) +- Derived は Library から再生成される(起動時スキャンの後に自動で投入。[SPEC §7.6](../spec/07-pipeline/06-derived.md))。旧 `Opus/` は + 移していない。目安: 約 9,000 曲で opus + aac に約 130G 使うので、始める前に空きを見る + +## 5. パスを `[layout]` の書式に揃える(任意) + +旧ライブラリのパスは spindle の書式(`[layout]`。[SPEC §5](../spec/05-library-layout.md)「パステンプレート」)と表記が違うことがある。 +リネームは巻き戻せるが数千件のファイル移動になるので、ReplayGain の解析と Derived の生成が終わってから一度だけ行う。 + +1. **同名の別版に `EDITION` を付ける**。同じアルバム名の別版(Hi-Res 版など)は、`EDITION` が無いと通常版と + 同じパスになり、リネームのプレビューで「降格に必要な値が無い: edition」になる。付ければ通常版は元の名前、 + 別版は `… (Hi-Res)` のように分かれる([D-43](../decisions/D-043.md) 追記)。**付けた後に増分スキャンを 1 回走らせてから + プレビューする**(album の edition はスキャンの最後に構成トラックの `EDITION` から埋まる。タグを書いただけでは + album 側が NULL のままで、「同名の別リリースと衝突」が出る) +2. 全選択で「リネームをプレビュー」。作者のライブラリでは約 9,100 曲中 約 5,200 曲が変わり、衝突 0 だった。 + 変わるのは表記の違い(アルバム名の `/` を `/` にする、複数枚組のファイル名 `1.01.` → `1-01.`、`[layout]` に無い + 中間の階層が無くなる、など)。**`_Unsorted/` 行きが 0 であること**(category は初回スキャンで直下のフォルダ名から + 付く。[D-92](../decisions/D-092.md))と、「パスを生成できない」が 0 であることを確かめてから適用する +3. 適用後、Derived は移動に追随し、m3u8 の書き出しは自動で書き直される。foobar2000 など外部の + プレイヤーのライブラリはパスが変わるので読み直しが要る + +## 6. YouTube 由来の曲に `SOURCE_URL` を付ける(任意) + +YouTube の再生リストから取り込んでいた曲を、spindle の購読(YouTube 画面の「同期」。[D-78](../decisions/D-078.md))へ +引き継ぐときの一度きりの手順。旧パイプラインが「再生リスト名 = アルバム名、リスト内の位置 = `TRACKNUMBER`」で +並べていたなら、`scripts/backfill_source_url.py` で既存の行に `SOURCE_URL` を付けてから購読を登録すると、 +同期が既存の曲を「持っている」と判定して二重に取り込まない(P4-14 / P4-15)。 + +1. `scripts/backfill_source_url.py --playlists … --spindle … --out …` で計画 CSV を出し、目視 + (`title-mismatch` / `no-track` / `extra-track` / `kept` と、位置推定の `verified-by-neighbors` を + 確認)→ `--apply` で `SOURCE_URL` を書く(アルバムごとに 1 バッチ。巻き戻し可)。位置推定は区間内の + 入れ替えを検出できないので、CSV で確かめてから `--include-inferred` を付けて書く。既に + `SOURCE_URL` を持つ行は上書きしない +2. 再生リストに無い曲は、プロパティタブの Metadata で「フィールドを追加」して `SOURCE_URL` を手で付ける +3. YouTube 画面で再生リストを購読に登録し「同期」する。同期は先に既存の行の `TRACKNUMBER` とファイル名を + 再生リストの位置に揃え、`SOURCE_URL` の無いものだけを Inbox に入れる。承認画面で「⚠ Library に同名」が + 出たら、件の長さと Library 側の長さを比べる。**長さが一致するものは同じ音源の二重取り込み**なので承認せず、 + 既存行の `SOURCE_URL` を件の動画の URL に付け替える。長さが違えば別テイク(Cover / Live ver.)なのでそのまま承認してよい + +## 7. 旧ライブラリの破棄 + +正式運用で数日間問題が出ないことを確かめてから、旧ライブラリ(と `@pre-migration` スナップショット)を破棄する。 +作者の環境では、旧ライブラリは別プールへ退避してから元のデータセットを消した。 diff --git a/docs/OPERATIONS.md b/docs/admin-guide/operations.md similarity index 74% rename from docs/OPERATIONS.md rename to docs/admin-guide/operations.md index 4921e4a2..374a2e81 100644 --- a/docs/OPERATIONS.md +++ b/docs/admin-guide/operations.md @@ -1,37 +1,13 @@ # 運用手順 -移行後の日常運用で、コードを読まずに済ませたい手順をまとめる。移行そのものは -`docs/MIGRATION.md`。 +動かし始めた後の日常運用で、コードを読まずに済ませたい手順をまとめる。最初に動かすまでは +[導入](install.md)、既存ライブラリの持ち込みは [移行手順](migration.md)。 ## イメージの更新(TrueNAS カスタムアプリ) -イメージは GHCR `ghcr.io/akashisn/spindle`(public。pull にトークン不要)。タグは `latest`(最新の -リリース `vX.Y.Z`)/ `X.Y` / `X.Y.Z`、リリース候補の `X.Y.Z-rc.N`(`vX.Y.Z-rc.N` タグ。`latest` / `X.Y` は -付かない)と、開発用の `edge`(`main` の最新)/ `sha-<7 桁>`。本番は `latest` か `X.Y` を指す。`edge` は DB の互換を(前進のマイグレーション以外)約束しない。 - -### カスタムアプリの作り方 - -TrueNAS の Apps → Discover → Custom App → **Install via YAML** に `deploy/compose.yaml` を写す。 -そのまま使えない箇所: - -- `devices` / `device_cgroup_rules` / `group_add`: CD ドライブ(`/dev/sr0` と cdrom グループの GID。 - `/dev/sg*` は要らない)。ドライブが無い機体では 3 つとも消す(残すと compose が起動に失敗する。 - アプリ側はデバイスを開けなくても起動し、CD 画面に「ドライブが無い」と出す)。 - **ドライブのつなぎ先に注意**: 一部の SATA コントローラでは、複数セクタの READ CD の中のセクタ境界で - 音声が 16 バイト(4 サンプル)ずれ、C2 もエラーも出ないまま照合(AccurateRip / CTDB)が全曲通らない。 - 実機(AMD 600 シリーズのチップセット SATA、1022:43f6)で起き、同じドライブを USB-SATA 変換につなぐと - 消えた。PCIe カードの ASMedia ASM1062(1b21:0612)の SATA でも起きない(2026-09-25、TASKS P2-5)。 - 照合が一度も通らず rip.log の「ずれ」が毎回数百〜千回出るなら、まずつなぎ先を変える -- `user`: 既存ライブラリの所有者の UID:GID に合わせる(違うとタグ書き込みが全滅する) -- `volumes`: Library / Derived / Archive / Inbox / Playlists / data の実パスと、メタデータプラグイン - (`spindle-ytmusic-meta`。イメージには入っていない)のマウント -- `SPINDLE_INITIAL_PASSWORD`: 初回だけ。カスタムアプリには `.env` が無いので、`${…:?…}` の行を - `- SPINDLE_INITIAL_PASSWORD=<生成したパスワード>` の値に書き換える。ログの「SPINDLE_INITIAL_PASSWORD から - パスワードを初期化した」とログインを確かめたら、アプリの Edit で `environment` ごと消す -- **イメージは作る前に pull しておく。** ホストに同じタグ(`edge` など)の古いイメージが残っていると、 - カスタムアプリはそれをそのまま使う(2026-09-24 のリハーサルで旧 compose の `edge` が起動した)。 - `sudo docker pull ghcr.io/akashisn/spindle:<tag>` を先に打つか、作った直後に `/health` の `version` を見て、 - 違えば止める → `/data` の DB を消す → Pull image → 起動し直す +イメージのタグは [導入](install.md)「イメージ」。本番は `latest` か `X.Y` を指す。 + +初めて作るときは [導入](install.md)「カスタムアプリの作り方」。 ### 更新 @@ -56,7 +32,7 @@ TrueNAS の Apps → Discover → Custom App → **Install via YAML** に `deplo 1. `main` の確かめたいコミットに `vX.Y.Z-rc.N` タグを打って push する。CI が `X.Y.Z-rc.N` のイメージと pre-release の Release を作る -2. 更新前のスナップショットを取る(例 `zfs snapshot ssd/apps/spindle@pre-vX.Y.Z-rc.N-<日付>`) +2. 更新前のスナップショットを取る(例 `zfs snapshot <プール>/apps/spindle@pre-vX.Y.Z-rc.N-<日付>`) 3. `sudo docker pull ghcr.io/akashisn/spindle:X.Y.Z-rc.N && sudo docker tag ghcr.io/akashisn/spindle:X.Y.Z-rc.N ghcr.io/akashisn/spindle:latest && sudo midclt call -j app.redeploy spindle` 4. `/health` の `version` が `vX.Y.Z-rc.N` になったことを見て、画面で変更点を確かめる 5. 問題なければ**同じコミット**に `vX.Y.Z` を打つ。CI が `latest` を公開したら @@ -70,13 +46,12 @@ TrueNAS の Apps → Discover → Custom App → **Install via YAML** に `deplo プロキシが spindle へ http で転送するときは、`config.toml` の `[auth].trusted_proxies` にプロキシの接続元を入れる。 入れないと spindle は `X-Forwarded-Proto: https` を信用せず、自分を `http://<host>` とみなすので、ブラウザの `Origin: https://<host>` と一致せず**ログインなど変更系の要求が CSRF 検査で 403 になる**(Cookie にも Secure が付かない)。 -接続元は spindle のログの `ログイン失敗 ip=` などで分かる。実機は cloudflared が host ネットワークから -`172.16.100.24:80` へ転送するので、接続元は NAS 自身の `172.16.100.5`(`trusted_proxies = ["172.16.100.5/32"]`。 -2026-09-26)。変えたら再起動する +接続元は spindle のログの `ログイン失敗 ip=` などで分かる。例えば cloudflared を NAS の host ネットワークで +動かして spindle の公開ポートへ転送するなら、接続元は NAS 自身のアドレス(`trusted_proxies = ["<NAS のアドレス>/32"]`)。 +同じホストのコンテナから Docker のブリッジ経由で届くなら、接続元はそのゲートウェイのアドレスになる。変えたら再起動する -nginx(Nginx Proxy Manager など)を挟むときは、その接続元も `trusted_proxies` に入れる(実機は -`172.16.100.20`。2026-10-15)。nginx は既定で応答を溜める(`proxy_buffering`)ので、SSE(`/api/events`)が -届かず、右上の点が赤のまま曲が 1 件も出なくなる(画面は SSE が開いてから一覧を取る。D-36)。spindle は +nginx(Nginx Proxy Manager など)を挟むときは、その接続元も `trusted_proxies` に入れる。nginx は既定で応答を溜める(`proxy_buffering`)ので、SSE(`/api/events`)が +届かず、右上の点が赤のまま曲が 1 件も出なくなる(画面は SSE が開いてから一覧を取る。[D-36](../decisions/D-036.md))。spindle は `/api/events` の応答に `X-Accel-Buffering: no` を付けてこれを避けている。それより古い版や、ヘッダを捨てる プロキシでは、プロキシ側で `proxy_buffering off;` にする @@ -84,11 +59,11 @@ nginx(Nginx Proxy Manager など)を挟むときは、その接続元も `tr まずイメージを更新する(yt-dlp が古いと YouTube の抽出が壊れる。新版は Renovate が PR にし、マージ すると `edge`、次のリリースで `latest` に届く)。`/health` の `ytdlp` で版を確認する。それでも駄目なら -`config.toml` の `[ytmusic].ytdlp_args`(`USERGUIDE.md` §11.3「YouTube」)。 +`config.toml` の `[ytmusic].ytdlp_args`([操作手順](../user-guide/README.md) §11.3「YouTube」)。 ## バックアップ -DB(`/data/spindle.db`)は原則キャッシュで、ファイルから再構築できる(SPEC §3)。 +DB(`/data/spindle.db`)は原則キャッシュで、ファイルから再構築できる([SPEC §3](../spec/03-principles.md))。 ただし **プレイリスト / 編集履歴 / 検証結果 / ジョブ履歴** は DB にしか無い。 バックアップは任意ではない。 @@ -151,7 +126,7 @@ docker compose -f /path/to/compose.yaml start spindle - `GET /api/jobs` で起動時 `scan` が `done` になり、`backup` が投入される(復元した DB には古い記録しか無いので、すぐ 1 世代取れる) - トラック数がライブラリの音声ファイル数と一致する(移行時の照合と同じ。 - `docs/MIGRATION.md` §3) + [移行手順](migration.md) §3) - 編集履歴(`GET /api/history`)とプレイリストがバックアップ時点の内容で見える ### 何が失われるか @@ -174,7 +149,7 @@ inode)が合えば続きを反映し、合わなければ `skipped_conflict` id を振り直すが、元と同じ id になるとは限らない。端末の写し(Android の manifest、Mac のエージェントの state)は `track_id` で曲を指すので、バックアップより後に同期した端末をそのまま同期すると、 別の曲への更新・移動になる。Mac ではミュージック.app の track の再生回数・レート・追加日が -別の曲に移る(D-105)。 +別の曲に移る([D-105](../decisions/D-105.md))。 だから復元したら、**同期する前に**、バックアップより後に同期した端末(普通はすべて)を引き継ぎし直す。 端末の行を消しても、端末上のファイルとミュージック.app の track には触らない。 @@ -184,9 +159,9 @@ state)は `track_id` で曲を指すので、バックアップより後に同 使っていた設定を入れる。送る範囲が分からなければ「全曲」にする(Library にある曲を端末から消すことが無い。 後から絞れる) 2. 端末タブで端末を削除し、引き継ぐ - - **Android**: 「前の登録の保存先(引き継げる)」から、控えた名前・形式・範囲で引き継ぐ(SPEC §7.11「引き継ぎ」) + - **Android**: 「前の登録の保存先(引き継げる)」から、控えた名前・形式・範囲で引き継ぐ([SPEC §7.11](../spec/07-pipeline/11-device-delivery.md)「引き継ぎ」) - **Mac**: 控えた名前・形式・範囲で新しく行を作り、その pair コードで `spindle-agent pair --adopt <url> <code>` - を実行する(SPEC §7.11「iPhone」の「引き継ぎ」) + を実行する([SPEC §7.11](../spec/07-pipeline/11-device-delivery.md)「iPhone」の「引き継ぎ」) 3. 引き継いだ後の最初の差分(Android は端末タブの差分表、Mac は `spindle-agent sync` の表示)を、確定する前に 確かめる。**予期しない削除があれば確定しない**(Android は同期しない、Mac は `N`)。範囲が狭すぎると、 引き継いだ曲が削除になり、Mac ではミュージック.app の track ごと消える。範囲を直してから差分を取り直す @@ -204,16 +179,17 @@ state)は `track_id` で曲を指すので、バックアップより後に同 ## ロスレスの FLAC 化(正規化) -Library の WAV / ALAC / AIFF を FLAC に置き換える(SPEC §7.4、D-45 / D-46)。移行で取り込んだ -ALAC 7,572 本が主対象。UI は未着手なので API を直接叩く。変換は `normalize` ジョブ(並列 2)が +Library の WAV / ALAC / AIFF を FLAC に置き換える([SPEC §7.4](../spec/07-pipeline/04-lossless-normalize.md)、[D-45](../decisions/D-045.md) / [D-46](../decisions/D-046.md))。移行で取り込んだ +ALAC が主な対象。画面では全選択 → 操作タブの「正規化をプレビュー」→ 適用で行える +([操作手順](../user-guide/README.md) §7)。以下は API を直接叩く手順。変換は `normalize` ジョブ(並列 2)が 1 曲ずつ行い、元ファイルは `Archive/` の同じ相対パスへ退避される(`archived_files` 台帳、 -既定 7 日後に GC が回収。それまでは履歴画面の [巻き戻す] で元に戻せる)。 +既定 7 日後に GC が回収。それまでは記録画面の編集履歴の [巻き戻す] で元に戻せる)。 -前提: Archive(`/mnt/hdd/media/Archive`)に退避ぶんの空き(ALAC 全件で約 220G)がある。 +前提: Archive に退避ぶんの空き(対象の ALAC などと同じ量)がある。 `config.toml` の `[normalize].wav_to_flac = true`。 ```bash -BASE=http://truenas:8080 +BASE=http://<ホスト>:8080 # 1. ログイン(Cookie を保存) curl -s -c cookie.txt -H 'Content-Type: application/json' -H 'Sec-Fetch-Site: same-origin' \ -d '{"password":"…"}' "$BASE/api/auth/login" @@ -225,13 +201,13 @@ curl -s -b cookie.txt -H 'Content-Type: application/json' -H 'Sec-Fetch-Site: sa TOKEN=$(jq -r .selection_token preview.json) curl -s -b cookie.txt -H 'Content-Type: application/json' -H 'Sec-Fetch-Site: same-origin' \ -d "{\"selection_token\":\"$TOKEN\",\"description\":\"ALAC → FLAC\"}" "$BASE/api/normalize/apply" -# 4. 進み具合はジョブ一覧か履歴画面で(batch_id の applied / conflict / failed) +# 4. 進み具合はジョブ一覧か記録画面で(batch_id の applied / conflict / failed) curl -s -b cookie.txt "$BASE/api/jobs" | jq .summary ``` - 一部だけ試すなら `"selection":{"ids":[…]}`(表で選んだ id)で 1 アルバムぶんから - `conflict` は宛先(`.flac`)を別のトラックが占有している行。`failed` は PCM MD5 の不一致か - ビット深度非対応で、元ファイルは無傷のまま残る。履歴画面の op の `error` を見る + ビット深度非対応で、元ファイルは無傷のまま残る。記録画面の編集履歴で op の `error` を見る - 変換中(op が pending)のトラックはタグ編集・リネームが 409 になる。バッチ単位のキャンセルは `POST /api/history/:batch/cancel`(変換中の 1 曲は止め、配置を始めた曲は完了を待つ) - 変換後の再スキャンで差分が出ないことを確認する(`new` / `moved` / `missing` が 0) diff --git a/docs/decisions/D-001.md b/docs/decisions/D-001.md new file mode 100644 index 00000000..27847e47 --- /dev/null +++ b/docs/decisions/D-001.md @@ -0,0 +1,11 @@ +# D-1 ライブラリ構造は役割別(フォーマット別ではない) + +**決定**: `Library`(正)/ `Derived`(生成物)/ `Archive`(生データ)/ `Inbox`(一時) + +**理由**: 旧構成の `Opus/` と `Original/` は実質「配布用」と「保管用」の +役割分担だった。CD の FLAC は保管物であると同時に再生対象でもあるため、 +フォーマットで切るとどちらに置くべきか決まらない。 + +**却下**: 同一ディレクトリにフォーマット混在(他プレイヤーから重複に見える)。 +CD イメージを `Archive` に二重保持(300GB の重複。`disc.cue` + `disc.toc` + +per-track FLAC があればイメージを再構成でき、検証可能性は失われない)。 diff --git a/docs/decisions/D-002.md b/docs/decisions/D-002.md new file mode 100644 index 00000000..65294963 --- /dev/null +++ b/docs/decisions/D-002.md @@ -0,0 +1,9 @@ +# D-2 ファイルが正、DB はキャッシュ + +**決定**: DB を消してもファイルから再構築できること。外部変更はファイルが勝つ。 + +**理由**: SMB 経由で foobar2000 や他プレイヤーが同じファイルを読み書きする。 +DB だけを真実にすると、外部から触られた瞬間に破綻する。 + +**例外**: プレイリスト、編集履歴、検証結果、ジョブ履歴は DB にしか存在しない。 +個別にバックアップする。 diff --git a/docs/decisions/D-003.md b/docs/decisions/D-003.md new file mode 100644 index 00000000..9045c83e --- /dev/null +++ b/docs/decisions/D-003.md @@ -0,0 +1,13 @@ +# D-3 同一性は inode と audio_md5 で解決する + +**決定**: `(dev, inode)` → `audio_md5` → `rel_path` の優先順位。 + +**理由**: リネームとタグ編集が日常操作であり、パスもバイト列も変わる。 +ZFS では rename とタグ書き換えで inode が不変。FLAC の STREAMINFO には +非圧縮音声の MD5 があり、タグ変更で不変かつデコード不要で読める。 + +**既知の限界**: 非可逆音源には強い識別子がないため `(size, mtime, path)` の +ヒューリスティックに落ちる。これは割り切る。 + +**補足**: どの識別子も実体の一意 ID ではない。各段での候補の検証規則は [D-30](D-030.md)、 +重複音声の扱いは [D-29](D-029.md)、hardlink は [D-26](D-026.md)。 diff --git a/docs/decisions/D-004.md b/docs/decisions/D-004.md new file mode 100644 index 00000000..c78afe6a --- /dev/null +++ b/docs/decisions/D-004.md @@ -0,0 +1,6 @@ +# D-4 音声とタグを別に版管理する + +**決定**: `audio_version` と `tag_version` を分ける。 + +**理由**: これがないと、数千件の一括タグ編集のたびに Derived の再エンコードが +走り数時間失われる。タグだけの変更は Derived 側もタグ上書きで済む。 diff --git a/docs/decisions/D-005.md b/docs/decisions/D-005.md new file mode 100644 index 00000000..e5d69e8a --- /dev/null +++ b/docs/decisions/D-005.md @@ -0,0 +1,8 @@ +# D-5 タグ書き込みは tmp + rename + +**決定**: 一時ファイルに書いて fsync してから rename する。 + +**理由**: 電源断でファイルが壊れないことを優先する。 + +**代償**: inode が変わるため、書き込み成功時に DB の `(dev, inode)` を +必ず更新する必要がある。忘れると次回スキャンで全件が新規トラックになる。 diff --git a/docs/decisions/D-006.md b/docs/decisions/D-006.md new file mode 100644 index 00000000..93fb0405 --- /dev/null +++ b/docs/decisions/D-006.md @@ -0,0 +1,9 @@ +# D-6 Category と GENRE タグは別フィールド + +**決定**: パス決定には統制語彙の `category` を使い、`GENRE` タグとは分離する。 + +**理由**: (a) GENRE は多値を取り得るがパスは単一値 (b) MusicBrainz 由来の +ジャンル文字列は表記揺れが激しくディレクトリ名が乱立する +(c) ジャンルは後から変わり、パス直結だと数千ファイルが動く。 + +語彙にマッチしないものは `_Unsorted/` に置き、取り込みを止めない。 diff --git a/docs/decisions/D-007.md b/docs/decisions/D-007.md new file mode 100644 index 00000000..ee11a71b --- /dev/null +++ b/docs/decisions/D-007.md @@ -0,0 +1,8 @@ +# D-7 アルバムフォルダ名に年を入れない + +**決定**: `{album}` のみ。衝突時のみ `{album} ({year})` へ降格。 + +**注意**: DATE / ORIGINALDATE タグは必ず保持する(パスに出さないだけ)。 +配置前に MusicBrainz Release ID または DiscID で同一リリース判定を行う(→ [D-67](D-067.md) 追記 3 で DiscID を外した。 +DiscID は 1 枚ごとの値で、複数枚組のリリースの鍵にならない)。 +**同名 ≠ 同一リリース**であり、パス衝突をマージ扱いにしてはならない。 diff --git a/docs/decisions/D-008.md b/docs/decisions/D-008.md new file mode 100644 index 00000000..cb971767 --- /dev/null +++ b/docs/decisions/D-008.md @@ -0,0 +1,14 @@ +# D-8 Derived は可逆のみ、配布ビューで解決 + +**決定**: `delivery_path = 可逆 ? Derived : Library`(`delivery` ビュー)。 + +**理由**: 非可逆音源を再エンコードすると世代劣化する。Derived にコピーすると +二重管理になる。ビューで解決すれば m3u8 生成側は Derived の有無を意識しない。 + +**例外**: 容量逼迫時のみトラック単位で `force_transcode` を許可。 +ただし元が 256kbps 以上の場合のみ。 + +**追記(2026-09-20。P4-8)**: Apple 向けの `aac` 系統([D-75](D-075.md))だけは非可逆原本(opus / ogg / mp3)も +AAC へ変換する。ミュージック.app が Opus を読めず、配布ビューで原本へ倒しても Apple 側で再生できない +ため、世代劣化を承知で全曲を揃える。`opus` 系統と配布ビュー(Android / Web)はこの決定のまま。 +原本が AAC でも同じ経路で再エンコードする(焼き込みのため)。 diff --git a/docs/decisions/D-009.md b/docs/decisions/D-009.md new file mode 100644 index 00000000..c13dce6c --- /dev/null +++ b/docs/decisions/D-009.md @@ -0,0 +1,14 @@ +# D-9 Opus 128kbps VBR + +**決定**: Derived は Opus 128k VBR。 + +**理由**: 事実上透過で、可逆 200GB 分でも約 25GB。96k はポータブルでは十分だが +有線イヤホンで差が出る境界。160k 以上は体感差がほぼなく容量だけ増える。 + +**補足**: Derived は再生成可能なので、この決定は低リスクであり後から変えられる。 + +**追記(2026-09-20。P4-7)**: `opus` 系統の既定を **256 kbps** に上げる(`--vbr --music` は当初から)。 +可逆 237 GB(7,570 本)で 29 GB → 約 58 GB。あわせて系統ごとの設定 `[encode.derived.<variant>]` に +改め、**行の `audio_profile`(codec / bitrate / サンプルレート規則 / 焼き込み方式の版を並べた文字列)が +設定と食い違えば再エンコード**の判定を足す(これが無いと設定を変えても音声版が同じ既存の Derived は +作り直されない。[D-75](D-075.md))。既存の 128k(`opus:128:v1`)はこの判定で全件作り直す。 diff --git a/docs/decisions/D-010.md b/docs/decisions/D-010.md new file mode 100644 index 00000000..34d56d9e --- /dev/null +++ b/docs/decisions/D-010.md @@ -0,0 +1,18 @@ +# D-10 WAV は FLAC へ正規化する + +**決定**: 取り込み時に可逆変換し、PCM MD5 の一致を確認してから元 WAV を +`Archive/` へ退避する。物理削除は GC ジョブ(既定 30 日後、`archived_files` 台帳を +根拠に)のみが行う。 + +**理由**: WAV は RIFF INFO / ID3 のどちらを使うかがソフトごとに異なり、 +ReplayGain タグの互換性も低い。**音声情報は**可逆変換なので失われない。 + +**意図して捨てるもの**: PCM MD5 が保証するのは音声サンプルの同一性だけで、 +RIFF INFO / ID3 / 未知チャンク / コンテナのバイト列は FLAC から再生成できない。 +保持期間後にこれらを不可逆に捨てることを受け入れる。WAV の付随メタデータに +価値があるライブラリなら `[gc]` の保持期間を延ばすか、GC を止めて無期限保持にする。 + +**却下**: MD5 一致確認後の即時削除(初版の案)。「ユーザデータの物理削除は GC のみ」 +という禁止事項、および「破壊的操作はバッチ単位で巻き戻せる」不変条件と矛盾する。 +情報が失われないのは事実だが、例外を 1 つ作ると原則の検査が実装ごとの判断になる。 +代償は一時的な容量 2 倍(GC までの 30 日間)で、設計レビュー(2026-09-15)で指摘。 diff --git a/docs/decisions/D-011.md b/docs/decisions/D-011.md new file mode 100644 index 00000000..c74c6029 --- /dev/null +++ b/docs/decisions/D-011.md @@ -0,0 +1,10 @@ +# D-11 FLAC の圧縮レベル統一はしない + +**決定**: `flac -8` は新規生成物(CD リップ、WAV 正規化)にのみ適用。 + +**理由**: `flac -8` は圧縮率だけの違いでデコード結果は完全に同一。 +削減は 0.5〜1.5% に対し、200GB の書き直しと、ZFS スナップショットが旧ブロックを +掴むことによる実効使用量の倍増が発生する。 + +**ただし**: STREAMINFO の MD5 が未設定の FLAC は再エンコードで補填する。 +未設定だと同一性解決の第 2 手段が使えず、遡及照合も不可能。 diff --git a/docs/decisions/D-012.md b/docs/decisions/D-012.md new file mode 100644 index 00000000..3a2d2e61 --- /dev/null +++ b/docs/decisions/D-012.md @@ -0,0 +1,7 @@ +# D-12 CD は全ディスクを 1 本の PCM として吸う + +**決定**: トラック単位ではなく `cd-paranoia '1-' -` で一括取得し、 +オフセット適用後に分割する。 + +**理由**: 読み取りオフセット補正がトラック境界をまたぐため、 +トラック単位で吸うと補正が正しく適用できない。 diff --git a/docs/decisions/D-013.md b/docs/decisions/D-013.md new file mode 100644 index 00000000..c6795722 --- /dev/null +++ b/docs/decisions/D-013.md @@ -0,0 +1,10 @@ +# D-13 CTDB を主、AccurateRip を補助 + +**決定**: 照合は CTDB を優先する。 + +**理由**: CTDB は公開 HTTP API があり、確認だけでなくパリティによる修復データも +返る。AccurateRip の DB 利用は本来 Illustrate 社の許諾前提というグレーさがある。 + +**重要**: 不一致は「不良」を意味しない。ドライブオフセット差、ギャップ処理差、 +隠しトラック、データトラックの存在で普通に外れる。`mismatch` は「要確認」として +扱い、警告色で表示しない。 diff --git a/docs/decisions/D-014.md b/docs/decisions/D-014.md new file mode 100644 index 00000000..931db56a --- /dev/null +++ b/docs/decisions/D-014.md @@ -0,0 +1,7 @@ +# D-14 検証状態と可逆性は別軸 + +**決定**: `source_type` / `lossless` / `verification` の 3 属性は直交。 + +**理由**: ハイレゾや配信音源はそもそも TOC が存在せず照合対象外。 +これは `unverifiable` であって「未検証」ではない。1 つのフラグで表すと +いつまでも気持ち悪いリストが残る。 diff --git a/docs/decisions/D-015.md b/docs/decisions/D-015.md new file mode 100644 index 00000000..ca7cb027 --- /dev/null +++ b/docs/decisions/D-015.md @@ -0,0 +1,7 @@ +# D-15 Subsonic API 互換は実装しない + +**決定**: Android へは Derived + m3u8 のファイル同期を継続。 + +**理由**: 実装すれば既存クライアントが使えるが、Subsonic の認証は +パスワードの salt+md5 を送る方式で、argon2 ハッシュのみを保存する設計と +両立しない(別クレデンシャル保管が必要になる)。 diff --git a/docs/decisions/D-016.md b/docs/decisions/D-016.md new file mode 100644 index 00000000..aae20767 --- /dev/null +++ b/docs/decisions/D-016.md @@ -0,0 +1,11 @@ +# D-16 スマートプレイリストは DSL → AST → SQL + +**決定**: foobar 風文法を pest でパースし、AST(JSON) を DB に保存。 +実行時にパラメータ化 SQL へ変換する。 + +**却下**: 生 SQL を保存する案(スキーマ変更で全ルールが壊れ、 +インジェクション面も抱える)。UI の条件ビルダのみの案(foobar に慣れた +ユーザには遅く、複雑な条件が組めない)。 + +`rule_source`(原文)と `rule_ast` を両方保存するのは、AST から DSL を +逆生成すると整形が変わりユーザの記述意図が失われるため。 diff --git a/docs/decisions/D-017.md b/docs/decisions/D-017.md new file mode 100644 index 00000000..0722fb9e --- /dev/null +++ b/docs/decisions/D-017.md @@ -0,0 +1,7 @@ +# D-17 認証は LAN 限定でも入れる + +**決定**: 単一パスワード + argon2id + セッション Cookie。 + +**理由**: 攻撃者対策というより事故対策。この API は 300GB のライブラリを +リネーム・削除・タグ上書きできる。認証がないと、別タブのスクリプト、 +家族の端末、うっかり有効にしたポートフォワードがそのまま破壊的操作に届く。 diff --git a/docs/decisions/D-018.md b/docs/decisions/D-018.md new file mode 100644 index 00000000..bb489958 --- /dev/null +++ b/docs/decisions/D-018.md @@ -0,0 +1,9 @@ +# D-18 SMB は insensitive + formD + +**決定**: `casesensitivity=insensitive`, `normalization=formD`。 + +**理由**: (a) Windows の foobar2000 が `Cover.jpg` を、spindle が `cover.jpg` を +作る事故を ZFS 層で潰す。spindle は自身の生成パスの一意性は保証できるが、 +他クライアントが作るファイルは制御できない +(b) 日本語の濁点には NFC / NFD の 2 表現があり、macOS 由来のパスは NFD に +なりがち。正規化なしだと「見た目が同じで別ファイル」が発生し、目視で気づけない。 diff --git a/docs/decisions/D-019.md b/docs/decisions/D-019.md new file mode 100644 index 00000000..66b973c3 --- /dev/null +++ b/docs/decisions/D-019.md @@ -0,0 +1,12 @@ +# D-19 移行は新規データセット作成 + rsync + +**決定**: 既存データセットの `zfs rename` による流用はしない。 + +**理由**: `casesensitivity` と `normalization` は**作成時のみ指定可能**で +後から変更できない。rename で流用すると Library だけ `sensitive` のまま残る。 + +**代償**: 300GB の実コピーと、一時的に 2 倍の空き容量が必要。 +`zfs send/recv` も不可(作成時プロパティが継承されてしまう)。 + +**注意**: NFSv4 ACL は rsync で引き継げない(`rsync -A` は POSIX ACL)。 +コピー後に TrueNAS の ACL エディタで適用し直す。 diff --git a/docs/decisions/D-020.md b/docs/decisions/D-020.md new file mode 100644 index 00000000..dcf0927a --- /dev/null +++ b/docs/decisions/D-020.md @@ -0,0 +1,7 @@ +# D-20 インポートは Inbox + 承認キュー + +**決定**: スキャン対象ディレクトリを増やす方式は採らない。 + +**理由**: 外部ディレクトリを直接スキャン対象にすると、そのツリーのパス規約と +タグ品質がそのまま Library に混入する。承認キューがないと `_Unsorted` が +際限なく育つ。 diff --git a/docs/decisions/D-021.md b/docs/decisions/D-021.md new file mode 100644 index 00000000..2bcb0770 --- /dev/null +++ b/docs/decisions/D-021.md @@ -0,0 +1,7 @@ +# D-21 メタデータは MusicBrainz + 手入力の一級市民化 + +**決定**: 照会結果ゼロでも CD 取り込みウィザードが完走できることを必須要件とする。 +トラックリスト貼り付け(テキストを行解析して割り付け)を実装する。 + +**理由**: 同人・VTuber・インディーズの国内盤は MusicBrainz 未登録が常態。 +プロバイダを増やすより、手入力経路の品質を上げる方が効果が大きい。 diff --git a/docs/decisions/D-022.md b/docs/decisions/D-022.md new file mode 100644 index 00000000..d6df3b5f --- /dev/null +++ b/docs/decisions/D-022.md @@ -0,0 +1,7 @@ +# D-22 マルチチャンネルは切り捨て + +**決定**: 2ch 以外は Derived 対象外、album RG の集計からも除外。 +トラック単位でステレオダウンミックスをオプトイン可能。 + +**理由**: 5.1 が混ざると album gain の集計でも Opus 変換でも例外処理になる。 +DSD / SACD ISO は非対応。 diff --git a/docs/decisions/D-023.md b/docs/decisions/D-023.md new file mode 100644 index 00000000..f671518d --- /dev/null +++ b/docs/decisions/D-023.md @@ -0,0 +1,11 @@ +# D-23 ジョブの dedup は未完了の間だけ効かせる + +**決定**: `jobs.dedup_key` は `queued` / `running` の行に対する partial unique index。 +列 UNIQUE にしない。バックオフの待ち時間は `run_after` 列に永続化する。 + +**理由**: 列 UNIQUE だと `done` / `failed` / `cancelled` 後に同じキーを永久に投入できない。 +`scan` / `inbox` の固定キーは一生 1 回しか作れず、同じ `tag_version` の手動再試行も不能。 +バックオフをメモリだけで持つと、再起動直後に失敗ジョブが一斉再実行される。 + +**却下**: 完了時に `dedup_key` を NULL に書き換える案。履歴から「どのキーで走ったか」が +消え、二重実行の事後調査ができなくなる。 diff --git a/docs/decisions/D-024.md b/docs/decisions/D-024.md new file mode 100644 index 00000000..b26f98a8 --- /dev/null +++ b/docs/decisions/D-024.md @@ -0,0 +1,32 @@ +# D-24 一括編集は DB 先行更新 + pending 記録、スキャナは pending を巻き戻さない + +**決定**: 編集バッチは 1 トランザクションで「旧値・新値・事前条件(dev / inode / mtime / +rel_path)を `edit_ops` / `edits` に記録 → DB 更新 → track 単位の tagwrite ジョブ投入」を +行い、ファイル反映は非同期。**ファイル反映の単位はトラック(op)**で、同一トラックの +全差分を 1 回の tmp+rename で書く。DB の値は確定値ではなく**書き込み意図の +オーバーレイ**と定義し、UI に pending を表示する。 +`pending` の op があるトラックは、スキャナがその op の所有する論理フィールドを +再評価しない(物理的な所在は追随する)。**pending 中の再編集は 409 で拒否**する。 +巻き戻しは終端状態のバッチに限り、実際に applied になった op(既存の逆バッチで戻し済みの +ものを除く)だけを反転する。**結果は op 単位**で、1 フィールドでも現在値が元バッチの新値と +違えば op 全体を conflict にする(フィールド単位の部分適用はしない)。元バッチの +`reverted_at` は逆バッチが対象を全件 applied にしたときだけ立てる。 +op が applied 以外の終端になるときは、同じトランザクションで DB 値をファイルの現在値へ +戻す(overlay の解消。次回スキャン任せにしない)。事前条件には `ctime_ns` と `tag_hash` +を含める。 + +**理由**: 「ファイルが正」と「DB を先に更新して UI へ即時反映」は、反映待ちの窓で +衝突する。この窓でスキャンが走ると編集が DB から巻き戻され、後続の tagwrite と +食い違う(設計レビューで指摘)。pending を一級の状態として持てば、スキャナ・tagwrite・ +巻き戻し・起動時リカバリが同じ表を見て判断でき、クラッシュ点ごとの結果が決まる。 + +**却下**: ファイル書き込み後に DB を確定する案。6 万件の一括編集で UI が数分間 +古いまま残り、その間の再編集が前の編集を見ない状態で行われる。 + +**却下(P0 では)**: pending 中の再編集を許し後続バッチが先行 intent を統合する案 +(`superseded`)。先行バッチの集計・スキャナ抑止の解除・リカバリの定義が必要で、 +P0 の価値に対して複雑すぎる。`edit_ops.result` に値だけ予約し、必要になったら足す。 +409 の代償は「反映中の数分間はそのトラックを触れない」ことで、UI がバッジで示す。 + +**却下**: フィールド単位で tmp+rename する案。1 件目で inode が変わり 2 件目以降の +事前条件が必ず外れる(再レビューで指摘)。 diff --git a/docs/decisions/D-025.md b/docs/decisions/D-025.md new file mode 100644 index 00000000..d06b7109 --- /dev/null +++ b/docs/decisions/D-025.md @@ -0,0 +1,9 @@ +# D-25 配布ビューは版が一致する Derived だけを返す + +**決定**: `delivery` は `derived_files.src_audio_version = tracks.audio_version` の +ときだけ Derived を返し、音声版が古ければ Library 原本へフォールバックする。 +タグ版だけ古い場合は Derived を返し `stale_tags = 1` を付ける。 + +**理由**: 存在だけで採用すると、音声差し替え(修復・再リップ)後の再エンコード待ちの間、 +古い音声を正常品として配ってしまう。タグ版まで一致を要求すると、一括タグ編集のたびに +全曲が一時的に可逆原本の配布へ落ちて Android 同期が肥大する。 diff --git a/docs/decisions/D-026.md b/docs/decisions/D-026.md new file mode 100644 index 00000000..4ba58a92 --- /dev/null +++ b/docs/decisions/D-026.md @@ -0,0 +1,12 @@ +# D-26 hardlink は移行ブロッカー、Library 内では作らない + +**決定**: `preflight.py` は `nlink > 1` のファイルを「解消必須」に分類する。 +移行後に Library 内で見つかった場合(`nlink > 1`、または同じ走査で同一 inode を複数パスが +持つ場合)は **inode 段も md5 段も飛ばし `rel_path_key` だけで解決**し、警告バッジを出す。 +spindle 自身は hardlink を作らない。 + +**理由**: hardlink は「1 トラック = 1 ファイル」と `(dev, inode)` 同一性の両方を壊す。 +同じ inode を 2 パスが共有すると、片方へのタグ書き込みが他方を黙って書き換える。 + +**却下**: 各パスを別トラックとして登録する案。二重書き込みのリスクを受け入れる +ことになり、原則 4(巻き戻し)が破れる。 diff --git a/docs/decisions/D-027.md b/docs/decisions/D-027.md new file mode 100644 index 00000000..205362ec --- /dev/null +++ b/docs/decisions/D-027.md @@ -0,0 +1,19 @@ +# D-27 trusted_cidrs は route allowlist だけ認証をスキップする + +**決定**: `trusted_cidrs` からの接続は **route allowlist**(`/api/stream/:id`、 +`/api/artwork/:hash`、`/api/tracks/:id` の限定フィールド、`/api/playlists/:id/export`)だけ +認証なしで通す。一覧・検索・SSE・履歴・ジョブ・設定はメソッドが GET でもセッション必須。 +変更系は CIDR 内でもセッション Cookie を要求し、`Origin` があれば完全一致、無ければ +`Sec-Fetch-Site` で判定する(`Host` は使わない)。接続元の判定は socket のアドレスで、 +`X-Forwarded-*` は `trusted_proxies` からのものだけ採用する。 + +**理由**: Cookie なしで破壊 API が通る経路があると、SameSite も CORS も CSRF の防御に +ならない(別サイトの JS から LAN IP への単純 POST は送信され、応答が読めないだけ)。 +一方、他プレイヤーや curl からのストリーム参照にセッションを要求すると運用が面倒。 +読み取りだけ許すのが両立点。 + +**却下**: 設定項目の削除(ストリーム参照に毎回セッションが要る)。 +全 API で維持 + カスタムヘッダ必須(curl から使うたびにヘッダを付ける手間、実装増)。 +メソッドベース(全 GET を開ける): 履歴・ジョブのエラー文・rel_path・設定が LAN 全体に +見える。他プレイヤーの用途に SSE や履歴は要らない。 +`Origin || Host` の論理 OR: クロスサイト POST でも Host は送信先になるので防御にならない。 diff --git a/docs/decisions/D-028.md b/docs/decisions/D-028.md new file mode 100644 index 00000000..809598a2 --- /dev/null +++ b/docs/decisions/D-028.md @@ -0,0 +1,17 @@ +# D-28 初期パスワードは環境変数で与える + +**決定**: `SPINDLE_INITIAL_PASSWORD` を、DB にパスワードが無い初回起動時だけ読み、 +argon2id でハッシュして保存する。以後は無視する。環境変数も DB も無ければ +ロックモード(`/health` 以外 503)で起動し、先着で設定できる画面は置かない。 + +**理由**: 「最初にアクセスした人がパスワードを決める」画面は、起動直後の窓が無防備になる。 +LAN 内・単一ユーザでもコンテナの再作成のたびにその窓が開くので、明示的に与える方が +事故が少ない。 + +**代償**: compose.yaml に平文が残り得る。設定後に行を消す運用を README に書く。 +サンプルは `${SPINDLE_INITIAL_PASSWORD:?...}` 形式にし、既知の既定値(`change-me` 等)を +書かない。既定値があると、そのまま起動した利用者が既知パスワードで初期化される。 +ロックモードでは SPA の配信も止める(セットアップ画面が無いので出しても操作不能)。 + +**却下**: 起動ログに one-time token を出す案(`docker logs` を見る手順が要り、 +TrueNAS の UI からだと一手間)。 diff --git a/docs/decisions/D-029.md b/docs/decisions/D-029.md new file mode 100644 index 00000000..545cb591 --- /dev/null +++ b/docs/decisions/D-029.md @@ -0,0 +1,9 @@ +# D-29 同一音声の重複は自動マージしない + +**決定**: `audio_md5` が一致する active トラックが複数あっても別トラックとして扱う。 +スキャンで「移動」と判定するのは候補がちょうど 1 行で、かつ移動元が消えている場合だけ。 +重複は `duplicate_groups` ビューで UI に見せ、統合するかはユーザが決める。 + +**理由**: `audio_md5` は音声内容の識別子であってトラック実体の ID ではない。 +コピー元が残っている・ベスト盤に同一マスターが収録されている・無音トラック、で +普通に衝突する。自動マージすると片方のタグ・プレイリスト所属が黙って消える。 diff --git a/docs/decisions/D-030.md b/docs/decisions/D-030.md new file mode 100644 index 00000000..3f8b94d2 --- /dev/null +++ b/docs/decisions/D-030.md @@ -0,0 +1,26 @@ +# D-30 同一性の各段で候補を検証する + +**決定**: 走査は **4 相**(inventory 固定 → 候補生成 → 決定的 claim → 単一トランザクションで +commit)で、判定は inventory 全体が揃ってから行う。(1) inode 段は inventory 内でその inode を +持つパスが 1 つだけ、`nlink = 1`、未 claim、`size` か `mtime_ns` の一致を要求し、両方違えば +`audio_md5` の一致を要求する。(2) audio_md5 段は候補がちょうど 1 行で、その旧 `rel_path_key` +が inventory に無いことを要求する。(3) rel_path_key 段は未 claim を要求する。取り合いは +`rel_path_key` 昇順で決着する。claim は `scan_runs.id`(`tracks.seen_run_id`)で管理し、 +`missing_since` は run が `completed` になった finalize でだけ立てる。DB の path / key 更新は +予約済み一時 key 経由の 2 段階で行う(swap / 循環で UNIQUE を踏まない)。 + +**却下**: 走査しながら逐次判定する方式。コピー先を先に訪問すると、未訪問のコピー元を +「消失」と誤認して移動扱いにし、その後コピー元を新規登録する。並列走査では結果が順序依存。 +`scan_generation` = 開始時刻を `seen_at` に兼用する案(epoch 秒では同秒の再実行と区別不能)。 + +**理由**: inode は削除後に再利用され、別ファイルが同じ inode を得る。並列走査では +2 パスが同じ行を取り合う。検証なしの一致採用は「見た目は正常だが別の曲に紐づく」 +最悪の壊れ方をする。 + +**変更検出**: 最速パスの比較に `ctime_ns` を含める(mtime を保存する上書きの検出)。 +版は `tag_hash` と音声フィンガープリントの**実差分**があるときだけ進める。音声 +フィンガープリントは可逆が `audio_md5`、非可逆が**エンコード済みパケット列の SHA-256** +(`audio_fp`、demux のみ)。非可逆の `size` 変化を音声の変化とみなす案は却下: タグ書き換えで +コンテナサイズは普通に変わり、不変条件 3 に反する。spindle 自身の tagwrite / rename / +RG 書き込み / MD5 補填は音声不変が既知なので再計算せず `audio_version` を据え置く。 +ZFS rollback は ctime も戻すので検出できず、`deep scan`(既定 30 日、手動可)で吸収する。 diff --git a/docs/decisions/D-031.md b/docs/decisions/D-031.md new file mode 100644 index 00000000..279a51de --- /dev/null +++ b/docs/decisions/D-031.md @@ -0,0 +1,18 @@ +# D-31 パスは canonical key で比較し、dirfd 基準で開く + +**決定**: `rel_path` / `rel_dir` に対して `casefold(NFD(...))` の canonical key 列を持ち +UNIQUE にする。ファイルは root の dirfd から `openat2(RESOLVE_BENEATH | +RESOLVE_NO_SYMLINKS)` で開き、symlink は辿らない。一時ファイルは対象と同じ +ディレクトリに `O_EXCL` で作る。外部コマンドは引数配列 + `--`(非対応なら `./` 前置)。 + +**理由**: ZFS は `insensitive` + `formD`([D-18](D-018.md))だが SQLite の UNIQUE は BINARY 比較で、 +同じファイルを指す 2 つの文字列を別と見る。canonicalize → prefix 比較は symlink の +差し替えに負ける。先頭 `-` のファイル名は外部ツールにオプションとして解釈される。 + +**限界**: `casefold(NFD())` は spindle 側の保守的な同値規則で、OpenZFS の `u8_textprep` +と同一の保証はない(ß、トルコ語 I、合字、Unicode 版差)。DB の key は事前判定に留め、 +最終判定は `O_EXCL` / `RENAME_NOREPLACE` の失敗で行う。`openat2` が無い環境は起動時に +落とす(フォールバックを設けない。非 Linux 開発機は結合テストを skip)。 + +**却下**: SQLite の `COLLATE NOCASE`(ASCII しか畳まない)。全角・半角の同一視 +(NFKC の領域で、ZFS の formD とも別物。同一視しない)。 diff --git a/docs/decisions/D-032.md b/docs/decisions/D-032.md new file mode 100644 index 00000000..9cc98361 --- /dev/null +++ b/docs/decisions/D-032.md @@ -0,0 +1,12 @@ +# D-32 album の同一性は構成トラックと MBID / DiscID で引く + +**決定**: ディレクトリ rename 後の album は、`mb_release_id` / `discid` が**ちょうど 1 件** +(→ [D-67](D-067.md) 追記 3 で `discid` の列ごと外し、`mb_release_id` だけになった) +一致し旧 rel_dir が消えているもの → 構成トラックの過半数が属していた album(旧 rel_dir が +消えているもの)の順で既存行を引き当て、`rel_dir` を書き換えて id を維持する。候補が +複数なら自動では寄せない。分割は移った側が新規、統合は最多の album が id を維持し、 +構成 0 になった album は `albums.missing_since` を立てて行を残す(削除すると +`album_verifications` が CASCADE で消える)。`rel_dir` の更新もトラックと同じ 2 段階。 + +**理由**: album を `rel_dir` で作り直すと `album_verifications` / `artwork_id` / +プレイリストのアルバム参照が失われる。「パスは識別子ではない」は album にも適用する。 diff --git a/docs/decisions/D-033.md b/docs/decisions/D-033.md new file mode 100644 index 00000000..f21c475e --- /dev/null +++ b/docs/decisions/D-033.md @@ -0,0 +1,13 @@ +# D-33 一括編集の対象はプレビュー時のスナップショットに固定する + +**決定**: `POST /api/tracks/batch/preview` でサーバが selection を解決し、対象 `track_id` と +各行の `tag_version` / 事前条件を短期保存して `selection_token`(TTL 15 分)を返す。 +`PATCH /api/tracks/batch` はこの token の集合だけを対象にする。スナップショット後に +`tag_version` が変わった行は conflict になる。 + +**理由**: フィルタ形の selection を preview と apply で別々に評価すると、その間のスキャンで +増えた(ユーザが一度も見ていない)行まで破壊的操作の対象になる。「プレビューで見たもの +だけに効く」は一括編集の基本保証。ID を HTTP で列挙しない要件とは両立する。 + +**却下**: apply 時にフィルタ式 + `scan_run_id` を再送して一致を要求する案。スキャンが +走るたびに 409 になり、6 万件の適用中に必ず踏む。 diff --git a/docs/decisions/D-034.md b/docs/decisions/D-034.md new file mode 100644 index 00000000..9c9cc6aa --- /dev/null +++ b/docs/decisions/D-034.md @@ -0,0 +1,26 @@ +# D-34 起動時に設定を厳格に検証し、不正なら起動しない + +**決定**: `config.toml` は未知キーをエラーにし、値の範囲(`flac_compression` 0..=8、 +`gc.retention_days` ≥ 1、`reference_lufs` は有限の負数、`flac_recompress_all = false` 固定、 +`musicbrainz.rate_limit_per_sec = 1` 固定など)を起動時に検証する。`[paths]` の各ルートは +絶対パスで、**互いに同じでも入れ子でもなく**(Derived が Library の下にあるとスキャナが +Derived を原本として拾う)、**ディレクトリとして存在**しなければならない。同一性は字面に +加えて `(dev, inode)` でも見る(symlink / bind mount の別名を弾く)。入れ子は字面に加えて +symlink 解決後(`canonicalize`)のパスでも見る。いずれかを満たさなければプロセスは起動せず、 +理由をログに出して終了する。 +待ち受けアドレスは `[server].listen`(既定 `0.0.0.0:8080`、省略可)で持つ。 + +保証の範囲: 検出できるのは「無い」「同じ」「symlink 解決後まで含む入れ子」まで。 +**bind mount で Library の子ディレクトリを別パスに見せた入れ子**は `canonicalize` にも +`(dev, inode)` にも現れないので検出しない(`/proc/self/mountinfo` の解析が要り、compose の +volumes でそれを書く事故は考えにくい)。**空の別ディレクトリが誤ってマウントされている** +ケースも区別できない(それはスキャナ側の "全曲 missing" 閾値の話で、P0-6 以降で扱う)。 + +**理由**: マウント忘れで空の `/library` を走査すると、次の finalize で全曲に `missing_since` +が立つ。復旧は可能(再発見で復活する)だが、GC の猶予やバッジの意味が壊れる。typo で +「設定したつもり」になるのも同種の事故で、黙って既定値に倒すより起動時に落ちる方が安い。 +listen をハードコードしないのは、開発機で複数インスタンスを並べるため。ポート公開は +compose 側の責務なので、既定値はコンテナ内で使う値に固定する。 + +**却下**: 存在しないルートを自動作成する案。ホスト側のマウント漏れを隠してしまう。 +`[paths]` の存在確認を warn に留める案。全曲 missing の後始末の方が高くつく。 diff --git a/docs/decisions/D-035.md b/docs/decisions/D-035.md new file mode 100644 index 00000000..9aba10ce --- /dev/null +++ b/docs/decisions/D-035.md @@ -0,0 +1,19 @@ +# D-35 認証まわりの固定値と境界 + +**決定**: ログインのレート制限は同一 IP から 15 分に 10 回で、超えると 429(設定には +出さない)。枠は argon2id の検証に入る**前に原子的に予約**する(並行送信で上限を超えさせない)。 +表はハード上限 1024 IP で、超えたら期限切れを掃除し、それでも一杯なら最古の窓を追い出す。 +`X-Forwarded-For` は trusted proxy から受け取ったチェーンを右から辿り、trusted proxy の hop を +飛ばした最初の IP をクライアントとする(それより左は自己申告)。壊れた値はクライアント不明。期限切れセッションの掃除は起動時とログイン成功時に行い、周期タスクは置かない。 +SPA の静的配信(`/api` 以外の GET)はセッション不要で通す(ログイン画面を出すため)。 +`POST /api/auth/login` も CSRF 検証の対象にする。`GET /api/auth/session` はセッションが +無ければ 401(SPA はこれでログイン画面へ切り替える)。`OPTIONS` は変更系でも SPA でも +ないのでセッション必須扱いになり、結果として CORS プリフライトは常に失敗する。 + +**理由**: 単一ユーザ・LAN 内なので制限値を調整する場面が無く、設定項目を増やすだけ損。 +セッションはログインでしか増えないので、ログイン時の掃除で溜まらない。curl からログイン +したい場合は `Sec-Fetch-Site: none` を付ければ通る(trusted_cidrs の用途は stream 参照で、 +ログインは想定しない)。 + +**却下**: 周期的な掃除タスク(P0-4 のジョブ基盤に載せる方が筋だが、それまで待つ理由が +ない程度の量しか溜まらない)。 diff --git a/docs/decisions/D-036.md b/docs/decisions/D-036.md new file mode 100644 index 00000000..f8dba769 --- /dev/null +++ b/docs/decisions/D-036.md @@ -0,0 +1,71 @@ +# D-36 ジョブ基盤の固定値と境界 + +**決定**: 仕様([SPEC §8](../spec/08-jobs.md))が定めていない値と境界を次のとおり固定する。設定には出さない。 + +- バックオフは失敗 n 回目で `10 × 2^(n-1)` 秒、上限 1 時間。`attempts` は**失敗のたびに** + 進める(claim 時ではない)。`attempts >= max_attempts` で `failed`。既定の `max_attempts` + はスキーマの DEFAULT と同じ 5 +- 手動 `retry` は `failed` / `cancelled` だけを対象にし、`attempts` を 0、`run_after` / + `cancel_requested_at` / 進捗を NULL に戻す。`last_error` は前回の理由として残す。同じ + `dedup_key` の未完了ジョブがあれば 409 `duplicate`。`done` / `queued` / `running` の retry は + 409 `not_retryable` +- `cancel` は `queued` なら即 `cancelled`(ハンドラを経ない)、`running` なら + `cancel_requested_at` を立ててプロセス内の CancellationToken も倒す。終端は 409 + `not_cancellable`。どちらも 202 で返す(実際の遷移は SSE で観測する) +- **cancel 要求は再実行より優先する。** `cancel_requested_at` が立った行は、 + (a) 起動時リカバリで `running` / `queued` とも `cancelled` に送る、 + (b) ワーカーが claim の前に `queued` を掃除して `cancelled` に送る(バックオフ中の cancel)、 + (c) ハンドラが token を見ずに失敗 / 再キューを返しても `cancelled` にする(試行回数は数えない)。 + 例外は**完了**で、ハンドラが `Done` を返せば cancel が後から来ていても `done`(仕事は済んで + いる。API は 202 を返しているが、最終状態は SSE / 一覧で分かる) +- 進捗は SSE には毎回流し、DB には 250ms に 1 回(および `done >= total` のとき)だけ書く。 + 未書き込みの最後の値は**終端遷移と同じトランザクション**で書く。DB の `cancel_requested_at` + は永続化のたびに読み、立っていれば token を倒す(API 経由でない要求も拾う) +- **版付きジョブ(tagwrite / transcode)は基盤が payload の `track_id` をロックしてから、同じ + トランザクションで stale 判定する。** ロック待ちの間に版が進んだジョブを実行しないための + 順序。同じトランザクションで先にトラックの存在を見て、**無ければロックを取らずに no-op + `done`**(`track_locks` は `tracks` への FK なので、消えたトラックはロックできない)。 + ロックが取れなければ試行回数を数えずに再キュー、版が古ければロックを返して no-op `done`。 + ハンドラは既にロックを持った状態で始まり、追加のロックを取った後は `JobContext::is_stale()` + で再確認できる。版付き種別の payload に整数の `track_id` と版が無いものは**投入時に拒否**し、 + DB に直接入った不正行は再試行せずに `failed` にする(ゲートを迂回して実行しない) +- `track_locks` が取れずに `Outcome::Requeue` で戻したジョブは `run_after = now + 1` 秒で + 再度対象になる。試行回数は数えない。ロックはジョブの終端遷移と同じトランザクションで + 全解放する +- ハンドラの panic はそのジョブの失敗(バックオフ対象)に変換し、ワーカーは止めない。 + 終端遷移の書き込み自体が失敗(DB 障害)したときも `running` に固着させず、失敗として + 記録を試み、それも駄目なら次回起動のリカバリに任せる(**追記 2026-09-20**: 記録の前に + バックオフ付きで再試行し、駄目なら稼働中の回収に任せる。[D-76](D-076.md)) +- **停止は共有の CancellationToken 1 つで行う。** シグナルハンドラがそれを倒し、HTTP サーバの + graceful shutdown・ワーカー・SSE ストリームが同時に止まる(SSE を閉じないと axum が接続の + 終了を待ち続け、コンテナの stop timeout で SIGKILL される)。ワーカーはループの先頭と + 各 claim の直前で token を見て新規 claim を止め、claim の DB 往復中に停止が来た分は起動せずに + `queued` へ戻す。実行中タスクは**破棄**する。DB 上は `running` のまま残り、次回起動のリカバリで `queued` に + 戻る(ジョブは冪等なのでこれで良い) +- `GET /api/jobs` は未完了を先頭に最大 1000 件で、一覧と `summary` は同じ読み取り + スナップショットで取る。SSE の keep-alive は 15 秒間隔、broadcast の容量は 1024。 + **購読者が遅れて取りこぼしたときは、読み飛ばした後続イベントより前に `event: resync` を + 流す。** クライアントは resync を受けたら一覧を再取得する。接続直後の取りこぼしを避けるため、 + クライアントは**SSE を開いてから**一覧を取得する(逆順だと開く前のイベントを失う) +- 外部プロセスは `process_group(0)` で自分のグループのリーダーとして起動し、キャンセル時は + グループへ SIGTERM → **`killpg(pgid, 0)` でグループが空になったかを見て** 2 秒待ち、残っていれば + グループへ SIGKILL。leader の終了だけでは判定しない(TERM を無視する孫が残る)。待つ間も + leader を `try_wait` で回収し続ける(zombie はグループの一員として数えられ、消えたことに + ならない)。グループに 1 つでもプロセスが残る限りその pgid は再利用されない(pgid として + 参照中の pid は割り当てられない)ので、`killpg` での判定と送信は leader 回収後も安全。 + wait を経ずに drop された(panic / 停止時の abort)ときはグループへ SIGKILL。tmp は `TempGuard`(drop で削除)で持ち、 + キャンセル・失敗・panic のどの経路でも残さない + +**理由**: 単一ユーザ・LAN 内で調整する場面が無く、設定項目を増やすだけ損。バックオフの +基数を 10 秒にしたのは NAS の一時的な I/O 詰まりを跨ぐには十分で、UI の「失敗」表示が +1 時間以上遅れることもないため。進捗の間引きは 1 万件スキャンで進捗のたびに WAL へ +書かないため。停止時に実行中を待たないのは、コンテナの stop timeout(既定 10 秒)内に +数分単位のジョブが終わる保証が無く、待っても SIGKILL されるだけだから。cancel を再実行より +優先するのは、ユーザが止めたジョブがバックオフ後や再起動後に勝手に動き出すのが破壊的 +操作(tagwrite / rename)では取り返しがつかないため。 + +**却下**: claim 時に `attempts` を進める案(プロセス kill の繰り返しで `failed` に達する +ジョブが出るが、その状況は電源断であってジョブの失敗ではない)。停止時に実行中ジョブへ +キャンセル要求を出す案(`cancelled` は「ユーザが止めた」の意味であり、再起動で再開すべき +ものと区別がつかなくなる)。Lagged を SSE の切断で伝える案(再接続のたびにイベントを +失う窓ができ、resync 1 行を流す方が単純で確実)。 diff --git a/docs/decisions/D-037.md b/docs/decisions/D-037.md new file mode 100644 index 00000000..c241d18e --- /dev/null +++ b/docs/decisions/D-037.md @@ -0,0 +1,66 @@ +# D-37 パス安全層・同一性解決・フィンガープリントの固定値と境界 + +**決定**: 仕様([SPEC §5](../spec/05-library-layout.md) / §6)が定めていない値と境界を次のとおり固定する。設定には出さない。 + +- **canonical key は `NFD → full casefold → NFD`。** casefold の結果は NFD とは限らない + (U+1F88 → U+1F00 U+03B9 のように分解が必要な文字がある)ので末尾で NFD を再適用し、 + `key(key(x)) == key(x)` を保つ。casefold は Unicode の full case folding(`caseless`)で、 + `ß` と `ss`、`fi` と `fi` は同じ key になる。これは spindle 側の**保守的な**同値規則で、ZFS が + 別名とみなす組を同名と見ることはあっても逆はない想定。差の実測は `tests/zfs_corpus.rs` + (`#[ignore]`)で対象 NAS 上で行い、結果をここに追記する +- 相対パスの要素は、`/` 区切り・先頭 `/` なし・`.` / `..` なし・NUL / `\` なしに加え、 + SMB / exFAT 禁止文字 `< > : " | ? *` と制御文字を含まず、末尾がドット・スペースでなく、 + Windows 予約名(CON PRN AUX NUL COM1–9 LPT1–9。**拡張子付きも予約**、大小文字無視)でなく、 + 255 バイト以下。パス全体の長さ上限(240 UTF-16 単位)は検証ではなく**生成側**(P0-11)で + 切り詰める。先頭 `-` のファイル名は正当で、外部コマンドへ渡す側で無害化する +- root は `O_DIRECTORY` で開いた dirfd を保持し、以後は `openat2(RESOLVE_BENEATH | + RESOLVE_NO_SYMLINKS)` だけで解決する。**`O_NOFOLLOW` は付けない**(`O_PATH` と組むと末尾の + symlink 自体が開けてしまい、親ディレクトリの解決で symlink を通す穴になる。全段の拒否は + `RESOLVE_NO_SYMLINKS` の ELOOP に任せる)。stat は親を `O_PATH` で開いてから + `statat(AT_SYMLINK_NOFOLLOW)`、rename / unlink も親 dirfd 基準。tmp は + `.spindle-tmp-<16 hex>` を `O_EXCL` で作り、EEXIST なら 8 回まで引き直す。 + 起動時に全 root(library / derived / archive / inbox / playlists)を開き、 + `openat2` が ENOSYS ならそこで落ちる。非 Linux では `RootDir` は空実装で `open` が常に + `Openat2Unsupported` を返す(crate はビルドでき、実ファイルの結合テストは + `#![cfg(target_os = "linux")]` で Linux の CI だけが走らせる) +- 外部コマンドの既定タイムアウトは 600 秒(エンコード等は呼び出し側で伸ばす)。stderr は + **読みながら**末尾 16 KiB だけを保持し(全量を溜めない。長時間の ffmpeg でもメモリは有界)、 + 失敗時は warn、成功時は debug でログに出す。leader が終了した時点でプロセスグループに + 子孫が残っていれば(leader が fork して先に exit した)グループごと掃除してから返す。 + 放置すると継承した stdout / stderr パイプの EOF が来ず呼び出し側が固まるか、孫が漏れる。`--` 方式では最初のパスの前に + `--` を 1 度だけ置くので**オプションはパスより前に並べる**。`./` 方式は相対パスで先頭 `-` の + ものだけ前置し、絶対パスは触らない。タイムアウトはキャンセルと同じ経路(プロセスグループ + SIGTERM → 2 秒 → SIGKILL、[D-36](D-036.md))で止め、エラー型で区別する +- 同一性解決は純関数(DB を触らない)で、inode 段 → md5 段 → path 段の順に**各段を全エントリ + について** `rel_path_key` 昇順で回す。エントリごとに 3 段を回す方式は、rename 後の旧パスに + 別ファイルが置かれたとき、先に来た旧パスの path 一致が後のエントリの inode 一致から行を + 奪うので採らない。DB 側で同じ `(dev, inode)` を複数行が持つ(過去の hardlink)場合は曖昧 + として inode 段を飛ばす。同じ md5 の新パスが複数あり旧パスが消えている場合は key 昇順の + 先頭だけが移動、残りは新規(duplicate_groups に出る)。`changed` は + `(dev, inode, size, mtime_ns, ctime_ns)` のいずれかの差で、md5 段の採用は常に `changed` +- `decoded_pcm_md5`(ALAC / WAV)は FLAC エンコーダが STREAMINFO に書くのと同じ流儀 + (チャンネルインターリーブ、リトルエンディアン、bps を 8 の倍数に切り上げたバイト数)で + 取るので、同じ PCM の WAV / ALAC / FLAC は同じ `audio_md5` になる(WAV → FLAC 正規化と + ALAC からの移行で同一性が保たれる)。ALAC の bit depth は demuxer から来ないので magic + cookie(`extra_data`、`frma` / `alac` atom 前置あり)の 6 バイト目から読む +- **非可逆の `audio_fp` は Opus を含めて symphonia の demuxer で取る。** symphonia が非対応 + なのは Opus の**デコード**であり、Ogg の Opus パケットは取り出せる。ffmpeg 経由にすると + 外部プロセスと ffmpeg の版差(remux 時のヘッダ生成)に結果が依存する。ハッシュはパケットの + データをそのまま連結した SHA-256(長さ前置なし。demuxer の packetization が変わっても + バイト列が同じなら同じ値)。MP3 / Opus / AAC(MP4) / Vorbis でタグ書き換え後に不変であることを + テストで固定する +- `tag_hash` はキーを大文字化、値を NFC、キー順に整列(同一キーの多値は入力順を保持)した + `(key, value)` 列を、それぞれ 8 バイト LE の長さ前置で連結した SHA-256。埋め込み画像は + `PICTURE` キーに `<mime>:<画像バイト列の SHA-256 hex>` として入れる(カバー差し替えも + `tag_version` を進め、Derived のタグ追随対象になる) + +**理由**: 設定に出しても調整する場面が無い。key の規則を保守的にするのは、DB の UNIQUE が +FS より緩い(別と見て同名を作ろうとする)方が、FS より厳しい(同じと見て正当な名前を拒む) +より危険なため(前者は `O_EXCL` / `RENAME_NOREPLACE` が最終防衛線になるが、黙って上書きに +近い挙動に見える)。PCM MD5 を FLAC と揃えるのは、正規化・移行で `audio_md5` が変わると +Derived の再エンコードと重複検出の両方が空回りするため。 + +**却下**: `char::to_lowercase` による簡易 casefold(`ß` などが畳まれず、ZFS の挙動との差が +増える方向)。SQLite の `COLLATE NOCASE`([D-31](D-031.md))。パケット列のハッシュに長さを前置する案 +(packetization 差で値が変わる)。`bits_per_sample` が無い ALAC をバッファ幅(32 ビット)で +ハッシュする案(FLAC と一致しなくなる)。 diff --git a/docs/decisions/D-038.md b/docs/decisions/D-038.md new file mode 100644 index 00000000..035f6bbb --- /dev/null +++ b/docs/decisions/D-038.md @@ -0,0 +1,77 @@ +# D-38 スキャナの固定値と境界 + +**決定**: 仕様([SPEC §7.1](../spec/07-pipeline/01-scan.md) / §6)が定めていない値と境界を次のとおり固定する。設定には出さない。 + +- **スキャンの起動経路**は `POST /api/scan`(`{"kind": "incremental" | "deep"}`、dedup key は固定の + `scan`)と、**起動時に incremental を 1 件投入**する(停止中の外部変更を拾う。既に queued / + running なら dedup で何もしない)。incremental は、完了した deep scan が + `[scan].deep_interval_days` より古い(または一度も無い)とき deep に**昇格**する。`0` なら + 昇格しない。deep は全既存行を「変更あり」として扱い、`tag_hash` と音声フィンガープリントを + 再計算する。**実差分があれば版を進める**(deep は stat に見えない変更 — ZFS rollback など — + を拾うためのもので、「再計算だから据え置く」ではない) +- 走査対象は拡張子で決める(flac / opus / m4a・mp4・aac / mp3 / wav / ogg・oga / wv / ape / + aiff・aif)。`.` で始まるファイルは黙って飛ばす(macOS の `._x`)。symlink と、SMB / exFAT + 制約に反する名前(`RelPath` が拒否するもの、UTF-8 でない名前)は**対象外として一覧に出す**。 + 対象外のディレクトリは配下ごと飛ばす。walk の途中でディレクトリを読めなければ run は + `failed`(`completed` は「root を開けて walk がエラーなく完了」なので、途中欠落を missing に + しない)。音声として読めないファイル(壊れた FLAC 等)は `errors` に数え、既存行なら物理属性 + だけ更新し、新規なら登録しない。run 自体は `completed` にする +- **`.spindle-tmp-*` の回収**は mtime が 1 時間より古いものだけ(並走中の tagwrite の tmp を + 消さないための猶予) +- **`artist_display`** は多値 ARTIST を `", "` で連結(foobar2000 の `%artist%` と同じ見え方)。 + `albumartist` キャッシュ列は ALBUMARTIST が無ければ ARTIST の先頭値で代用する。`track_no` / + `disc_no` は先頭の整数(`"3/12"` → 3) +- **`category_id` の推定**(album 単位): 先頭ディレクトリ名が `categories.name` と canonical key で + 一致すればそれ → 構成トラックの GENRE を `genre_category_map` で引く → どちらも無ければ NULL + (`_Unsorted` への配置はパス生成側 P0-11 の判断)。album のメタデータ(albumartist / album / + date / original_date / mb_release_id / disc_count)は構成トラックの**最頻値**(`discid` は [D-67](D-067.md) 追記 3 で外した) + (同数なら文字列順で先)。`edition` は P0-6 では設定しない +- **album 照合の候補順**は MBID が 1 件一致(DiscID は [D-67](D-067.md) 追記 3 で外した)→ 構成トラックの過半数が直前まで属していた + album → そのディレクトリに既にある album → 新規。既存 album を別のディレクトリへ寄せてよいのは + 「旧 rel_dir が inventory に無い」か「**旧 rel_dir の現在の構成の過半数が別の album に属して + いる**」ときで、後者が 2 ディレクトリの swap を id 維持で解く鍵になる(仕様の「旧 rel_dir が + inventory に無ければ」の字義どおりだと swap は両方新規になる)。ディレクトリを別の album に + 明け渡した album が誰にも claim されなかったときは `rel_dir` を予約 key(`\0displaced:<id>`)へ + 退避して UNIQUE を避け、構成 0 なので finalize で missing になる。変更のないディレクトリ + (最速パスだけ)は照合を省く +- パスの 2 段階更新の一時 key は `\0track:<id>` / `\0album:<id>`(NUL は `RelPath` が拒否するので + 実 key と衝突しない)。外部 rename の**宛先 key を今回 claim されなかった別の行**(missing 行を + 含む)が占有していれば、同じトランザクションでその行の key を `\0vacated:<id>` へ明け渡させる。 + 行は残り、finalize で missing になる([SPEC §7.1](../spec/07-pipeline/01-scan.md))。inventory 内で canonical key が重複する + ファイル(case-sensitive な FS で大小文字だけ違う 2 ファイル)は後から見つかった方を対象外 + (`DuplicateKey`)にして UNIQUE を踏まない +- **pending op は commit トランザクションの中で読み直す。** Phase 2 のスナップショットは長い + Phase 3 の間に編集バッチ(DB 先行更新 + pending)に追い越され得るので、スナップショットの + 値で判断すると編集意図をファイル値で巻き戻す([D-24](D-024.md) 違反)。さらに **rel_path と物理属性が + スナップショットから変わっている行(tagwrite の tmp + rename や rename ジョブが Phase 3 中に + 完了した)は、今回の inventory と読み取りが古いので何も適用せず `seen` だけ更新する** + (`overtaken` として報告。次回スキャンで整合する)。追い越された行は album 照合の + ディレクトリ集計にも入れない(所在も所属も DB の現在値が正しい)。scan は track_locks を + 取らないので tagwrite / rename と並走する前提 +- pending の rename op があるトラックの `rel_path` は op が所有する(overlay で先に変わっていて + よい)。外部移動の判定は DB の `rel_path` ではなく **`edit_ops.expected_rel_path`(記録時点の + 物理パス)** と inventory のパスを key で比べ、違えば `skipped_conflict`。同じなら物理属性と + タグ(rename op はタグを所有しない)は通常どおり追随する +- 音声の変化は同種のフィンガープリントの差に加え、**種類が変わった**(同じパスで ALAC ⇔ AAC + など可逆 ⇔ 非可逆の差し替え)ときも `audio_version` を進める。旧値が片方しか無いので同種比較 + では見えない +- root 直下へ移ったトラックは `album_id` / `album` を NULL にする(album = ディレクトリ。root は + album を持たない) +- pending の `tags` op があるトラックはタグ・キャッシュ列・`tag_hash`・`tag_version` を触らない。 + pending の `rename` op があるトラックで外部 rename を検出したら `rel_path` を据え置き、op を + `skipped_conflict`(error に新パス)にする。物理属性はどちらも追随する([D-24](D-024.md)) +- Phase 3(タグ読込 + フィンガープリント)の並列度は CPU コア数(`available_parallelism`)。 + 進捗は Phase 3 の件数で報告し、DB への永続化は `JobContext::progress` の間引きに任せる +- **性能の参照値**(`tests/scanner.rs::perf_10k_synthetic_tree`、`--release`、1 万件 = FLAC 8 割 / + Opus 2 割 / アルバム 12 曲、2 回目は warm cache): 2026-09-16、TrueNAS ホスト(AMD Ryzen 5 7600、 + 12 論理コア、`/tmp` = tmpfs、DB も tmpfs)で **1 回目 2.87 秒(new=10000)、2 回目 0.15 秒 + (unchanged=10000)**。ZFS 上の実ライブラリでは 1 回目はタグ読込の I/O が支配的になるが、 + 2 回目は stat だけなので同程度に収まる想定 + +**理由**: 起動時スキャンは「DB はキャッシュ」の原則の実装で、停止中の外部変更を UI を開く前に +拾う。tmp の猶予 1 時間は tagwrite が 1 ファイルに要する時間の上限を大きく超える値で、短すぎて +書き込み中の tmp を消す事故を避ける。swap の扱いを字義より広げたのは、「パスは識別子ではない」 +を album にも適用する [D-32](D-032.md) の意図に沿うため。 + +**却下**: ffmpeg で全ファイルの音声属性を取る案(lofty の properties で足りる。外部プロセス +1 万回は遅い)。`.spindle-tmp-*` を無条件に回収する案(並走する tagwrite の tmp を消す)。 diff --git a/docs/decisions/D-039.md b/docs/decisions/D-039.md new file mode 100644 index 00000000..d1679f68 --- /dev/null +++ b/docs/decisions/D-039.md @@ -0,0 +1,73 @@ +# D-39 トラック一覧 API の固定値と境界 + +**決定**: 仕様([SPEC §9](../spec/09-http-api.md) / §12)が定めていない値と境界を次のとおり固定する。設定には出さない。 + +- **フィルタ式は URL エンコードした JSON 1 文字列**で、キーはホワイトリスト(未知キーは 400): + `category`(`categories.name`)/ `albumartist`(`tracks.albumartist` 完全一致)/ `album_id` / + `playlist_id` / `flags`(`unverified` `duplicate` `missing` `no_rg` `pending` `conflict` `hardlink`。 + 複数は AND)/ `q`(検索語)。サイドバーの 3 区画(ツリー・プレイリスト・固定フィルタ)と + 検索ボックスをそのまま表せる。`selection.filter` も同じ文字列。P1-7 のスマートプレイリスト + DSL は同じ JSON に `dsl` キーとして足す(DSL を一覧のフィルタ式に採用して P1-7 を前倒しする案は + 「タスクをまたいで先回りしない」に反し、プレイリスト所属も表せないので却下) +- `unverified` は `verification = 'not_attempted'` だけ。`unverifiable`(TOC が無い音源)と + `mismatch` は別の意味を持つバッジなので混ぜない([SPEC §6](../spec/06-data-model.md)) +- **ソートはホワイトリスト**(`album`(既定。albumartist, album_id, disc_no, track_no)/ `title` / + `artist` / `album_title` / `albumartist` / `date` / `duration` / `codec` / `rel_path` / `id`。 + `-` 前置で降順)。**キーセットページング**で、カーソルは「発行時のソート `s` / 最終行のソートキー値 + `k` / `id`」の JSON オブジェクトを base64url にした不透明文字列。ソート列は NULL を許すが行値比較は + NULL で不定になるので、 + ソート式は `coalesce(col, '' | 0 | -1)` に畳み、同じ式の**式索引**を `0001_init.sql`(旧 `0002_tracks_sort_indexes.sql`。[D-88](D-088.md) で統合) + に持つ(NULL は昇順で先頭、降順で末尾)。SQLite は式索引に行値比較の範囲最適化を掛けないため、 + 述語は「先頭キーの `>=` / `<=`(索引の入口)AND 全キー + id の行値比較(絞り込み)」の 2 段にする。 + **カーソルは発行時のソート(向き込み)を持ち**、デコード時にキー数と値の型(TEXT / INTEGER)を + ソートキーの型並びと照合する。型が違う値をバインドすると SQLite の storage class 順序で比較が + 常に真になり先頭ページが再掲されるため、キー数だけの照合では足りない。**リクエストの `sort` と + 発行時のソートが違う(向き違いを含む)カーソルは `Query::from_params` で 400**、DB 層に直接 + 届いた不一致は空ページ(二重防御。エラーにしない)。`limit` は 1..=1000、既定 100 +- **バッジ列は行ごとの索引検索で引く**: pending は `edit_ops` の partial UNIQUE を LEFT JOIN、 + 最新 op は `max(id)` の相関サブクエリを LEFT JOIN、重複は `idx_tracks_md5` への相関 EXISTS。 + `duplicate_groups` ビューを LEFT JOIN すると毎回 GROUP BY の実体化と自動索引(temp B-tree)が + 走るので使わない([SPEC §9](../spec/09-http-api.md) の「LEFT JOIN で引く」は「1 クエリで返す」の意味に読み替える)。 + `total` は同じ読み取りトランザクションで `count(*)` を取る +- フィルタ `pending` / `conflict` は行ごとの相関サブクエリではなく **`edit_ops` 側から集合を作って + `IN` で引く**(該当行は少数で、全行を歩くと 6 万回の索引検索になる。計測で 124ms → 0.2ms)。 + `duplicate` は相関 EXISTS のまま(ビューの `IN` は 2 倍遅い) +- **フィルタ付きソートの temp B-tree は許容する。** 索引の無い組み合わせ(`category` で絞って + `album` 順など)では planner が絞り込み側の索引を選んで ORDER BY を temp B-tree で解く。 + 絞り込んだ集合のソートなので 100ms 以内に収まる(下の計測)。無フィルタの全ソートキーと + バッジ用 JOIN、`total` に temp B-tree が出ないことはテストで固定する +- 検索は **3 文字以上(文字数。バイト数ではない)で FTS5 trigram**、未満は 4 列(title / + artist_display / album / albumartist)の `LIKE ... ESCAPE '\'`。FTS には入力全体を 1 フレーズ + (`"` は `""`)として渡し、演算子・構文文字を解釈させない。`GET /api/search?q=` は一覧と同じ + レスポンス形(`filter.q` を差し替えるだけ。表の集合を差し替える [SPEC §12.1](../spec/12-ui/01-skeleton.md) の作りに合わせる) +- **`selection_token` の保存はプロセス内メモリ**(TTL 15 分、上限 16 件で最古を追い出す)。 + DB に置かないのは、再起動で消えてよい短期の状態で、apply 側が 409 `preview_stale` で preview から + やり直せるから。token は 32 バイトの乱数(base64url)で、**乱数源が読めなければ発行しない** + (時刻などで代用しない)。apply は `take`(期限確認と削除を同じロックの中で行う)で token を + 原子的に消費し、同じ token の並行 apply は 1 つだけ通る。スナップショットは `track_id` / + `tag_version` / `audio_version` / 事前条件(dev / inode / size / mtime_ns / ctime_ns / tag_hash / + rel_path)と preview 時の `ops` を持つ。`ids` 形の解決は `json_each` で 1 パラメータに畳む + (数万個のプレースホルダを並べない) +- `GET /api/tracks/:id` を trusted_cidrs から**セッション無し**で引いたときの限定フィールドは + id / title / artist_display / album / albumartist / track_no / disc_no / date / duration_ms / + codec / lossless。パス・編集状態(pending / conflict)・重複・missing・検証結果は含めない([D-27](D-027.md)) +- `GET /api/albums` はページングしない(数千件。ツリーの構築に全件を使う)。`track_count` / + `duration_ms` は active なトラックだけを数える +- SSE `library` は **scan ジョブの完了時に 1 回**流す。変更行(新規・更新・移動・復活・明け渡し・ + missing 確定・pending rename の conflict 化・読めなかったファイルの物理属性更新)が 200 件以下なら + `ids`、超えたら `bulk`、0 件なら流さない。基準は「表の表示値(バッジ含む)が変わった行」。 + commit 後に流すのでクライアントが再取得すれば新しい値が見える +- **性能の参照値**(`tests/tracks_perf.rs`、`--release`、6 万件 / 5,000 album / 履歴 10 万 op / + 重複 5% / pending 50 件、warm cache、5 回の中央値): 2026-09-16、TrueNAS ホスト(AMD Ryzen 5 7600、 + 12 論理コア、DB は tmpfs)で **既定ソート 1 ページ目 0.75ms、title 昇順 1.2ms、599 ページ目 + 1.2ms、`category` 絞り込み 12ms、`duplicate` 32ms、`conflict` 19ms、`no_rg` + `-date` 21ms、 + FTS で全行に当たる語 53ms(最悪)、LIKE 13ms**。フィルタ形 selection の全件解決は 27ms + (ID 列挙なし)。すべて `total` とバッジ列込み + +**理由**: 一覧は P0-8 の表が 6 万行を仮想スクロールで流す土台なので、ページ取得の遅さがそのまま +体感になる。キーセットにしたのは OFFSET が深いページで線形に遅くなるため(599 ページ目でも +1.2ms)。フィルタ形式を JSON にしたのはパーサを持たずにホワイトリストで SQL を閉じられ、 +サイドバーの操作と 1 対 1 に対応するから。 + +**却下**: OFFSET ページング。`duplicate_groups` ビューの LEFT JOIN(実体化のコストが毎回かかる)。 +token を DB に保存する案(短期状態に永続化は過剰。P0-9 の `edit_batches` が apply 後の正)。 diff --git a/docs/decisions/D-040.md b/docs/decisions/D-040.md new file mode 100644 index 00000000..64ed3fb8 --- /dev/null +++ b/docs/decisions/D-040.md @@ -0,0 +1,68 @@ +# D-40 表 UI の固定値と境界 + +**決定**: 仕様([SPEC §12](../spec/12-ui/README.md))が定めていない値と境界を次のとおり固定する。設定には出さない。 + +- **行の読み込みはカーソル順に積む。** `GET /api/tracks` はキーセットなので N ページ目を単独では + 取れない。1 ページ 500 件で先頭から順に取り、仮想スクロールが未読込の index を描くときにその + index まで続けて取る(末尾へのジャンプは全ページを順に読む。6 万件 = 120 リクエストで 0.8 秒)。 + 未読込の行は骨組みで描く。TanStack Table には**表示中の窓の行だけ**を渡す(6 万件の Row + オブジェクトをページのたびに作り直さない)。仮想化の count は `total` +- **無効化(SSE `library` / `batch` / `resync`)は「表示に必要な件数まで先頭から取り直し、揃ってから + 差し替える」。** 1 ページずつ差し替えると途中で行数が縮んでスクロール位置が飛ぶ。表示範囲より + 後ろの行は捨て、スクロールで再び要求されたら読む。`library` の `ids` は読み込み済みの行に含まれる + id があるときだけ、`bulk` は無条件。`batch` も取り直す(反映待ち / conflict バッジが変わる)。 + ソート・フィルタの変更は積んだ行を全部捨てて最初から +- **一覧は SSE を開いてから取る**([D-36](D-036.md))。`onopen` を初回取得のトリガにする。**再接続の `onopen` + では表示範囲・ジョブ要約・アルバムを全部取り直す**(サーバの SSE は broadcast の購読で event id + による再送が無く、切断中の job / batch / library は `resync` としても届かない)。SSE の `onerror` + ではセッションを確かめる(`GET /api/auth/session`、5 秒に 1 回)。401 で閉じられた SSE は + `apiFetch` の 401 境界を通らないので、これがログイン画面へ戻る経路になる +- **表の集合 = サイドバーの scope + 上部ナビの検索語 `q`。** ツリー(category / albumartist / + album_id)とプレイリストは scope を**置き換え**、固定フィルタ(`flags`)は**トグルで AND**(ツリーの + 絞り込みと組み合わせられる)。検索語は 250ms 遅らせて反映する。どれを変えても同じ表コンポーネント + の filter が変わるだけ +- **filter 形の選択を持ったまま表示フィルタを変えた後は、Ctrl / Shift による除外・解除を無視する** + (素のクリックで ids 形に置き換えるのは常に効く。2026-09-29 の改訂で表示フィルタを変えると選択を + 解除するようになったので、今はこの状態に入らない。lib/selection の防御としては残す)。表示中の行が選択集合に入っているかを client で + 判定できないので、除外 id を増やすと「total − 除外数」の件数が合わなくなる。Ctrl+A の時点で + 1 ページ目が届いておらず total が無かったときは、同じフィルタの total が届いた描画で一度だけ確定する +- **選択は immutable な値として表と別に持つ**(`lib/selection.ts`)。ids 形(クリック / Shift 範囲 / + Ctrl トグル)と filter 形(Ctrl+A。選択した時点の filter 文字列 + 除外 id)。Shift の範囲は表示中の + 行順で解決し、anchor がソート・フィルタで表示から消えていれば素のクリック扱い。filter 形での Ctrl + クリックは除外、Shift は除外の解除、素のクリックは ids 形に戻す。ソート・SSE では選択は変えない。 + **表の集合(scope・固定フィルタ・検索語・ルール編集中の DSL)が変わったら選択を解除する** + (2026-09-29 改訂。以前は表示フィルタでも選択を保っていたが、左のツリーで移動した後も前の表示の + 「選択 1 件」が右パネルに残り、プロパティ・一括編集・操作が見えていない行に効くのが分かりにくかった。 + 右パネルの対象は表示中の表の行だけにする) +- **filter 形の件数は「Ctrl+A 時点のサーバの total − 除外数」**、「うち反映待ち」は選択時のフィルタに + `pending` を足して `GET /api/tracks?limit=1` の `total` で数える(除外した行の反映待ちは読み込み済み + で判定できる分だけ差し引く)。集合は immutable でも中の行の pending はバッチの進行で変わるので、 + 選択時に加えて **batch / library(ids の該当有無に関わらず)/ resync / SSE 再接続**のたびに数え直し、 + 応答は世代で守る(`lib/pendingCount.ts`)。ハイライトは**表示フィルタが選択時のフィルタと同じときだけ**出す + (違うときは表示中の行が集合に入っているかを client では判定できない。件数は右パネルで見せる) +- 列の表示・順・幅は TanStack Table の状態を localStorage(`spindle:columns.*`)に持つ。 + 右パネルの折りたたみ・幅(220〜640px)・タブ、も同様。読めない・壊れている値は既定に倒す。 + `rel_path` 列は既定で非表示。`#` 列はトラック番号(2 枚目以降のディスクだけ `2-03` と前置) +- **反映待ちの行**(`pending_batch_id` あり)は `aria-disabled` + グレー背景、missing の行は + 打ち消し線。編集 UI 自体は P0-10 なので「編集不可」はまだ見え方だけ +- 401 はどの API でもログイン画面へ戻す(`GET /api/auth/session` で初回判定)。ログイン以外の画面 + (アルバム / CD / ジョブ / 履歴 / 設定)は骨格のプレースホルダ +- 純粋ロジック(filter の正規化、選択、ページローダ、バッジの条件、書式)は vitest で固定する + (`cd web && npx vitest run`)。React コンポーネントの単体テストは持たない(ブラウザで確認する) +- **性能の参照値**(2026-09-16、TrueNAS ホスト AMD Ryzen 5 7600、headless Chromium via agent-browser、 + 6 万件の合成行 + 実ファイル 9 件、release ビルドの同梱 SPA、1280×577): 6 行/フレームの連続 + スクロール 5 秒で **60.2 fps、フレーム間隔 p95 16.7ms、20ms 超のフレーム 0**、30 行/フレームでも + 60.3 fps / 0 落ち。末尾へのジャンプ(120 ページ順読み)0.8 秒。ソート変更後・Ctrl+A・除外・ + 表示フィルタ変更後に選択が維持されること(当時の仕様。2026-09-29 に解除へ改訂)、スキャンで行が増えたときに SSE でリロードなしに + `total` が 60,009 → 60,010 になることを同じセッションで確認した。Chrome 安定版の DevTools + Performance での再計測は実機の UI 確認時に行う + +**理由**: 6 万行を表に出す土台なので、行データの持ち方(順に積む・窓だけ渡す)とスクロール位置を +保つ無効化が体感を決める。選択を表と別の immutable な値にしたのは、Ctrl+A の集合を ID で +持たない要件([SPEC §12.2](../spec/12-ui/02-track-list.md))と「ソート・SSE では選択が変わらない」を同じ型で満たすため。表の集合が +変わったときの解除は `Selection` の値の側ではなく、App がその値を `NO_SELECTION` に置き換えて行う +(2026-09-29 改訂。以前は「表示を変えても選択は変わらない」も同じ型で満たしていた)。 + +**却下**: react-query 等のデータ取得ライブラリ(キーセット + 順読みの都合を吸収できず、依存が +増えるだけ)。TanStack Table の rowSelection(ID 列挙前提で filter 形を表せない)。無効化で表示中の +ページだけをそのカーソルで取り直す案(行が増減するとページ境界がずれて重複・欠落する)。 diff --git a/docs/decisions/D-041.md b/docs/decisions/D-041.md new file mode 100644 index 00000000..c8b0c68d --- /dev/null +++ b/docs/decisions/D-041.md @@ -0,0 +1,84 @@ +# D-41 編集履歴の記録機構の固定値と境界 + +**決定**: 仕様([SPEC §7.5](../spec/07-pipeline/05-tagwrite.md) / §8)が定めていない値と境界を次のとおり固定する。設定には出さない。 + +- **外部 rename の追随と ctime。** Linux の rename は対象 inode の ctime を進めるので、事前条件に + `ctime_ns` を含めたままでは「rel_path のみ不一致(外部 rename)は tags op なら追随」([SPEC §7.5](../spec/07-pipeline/05-tagwrite.md))が + 成立しない。**DB の `rel_path` が記録時点の `expected_rel_path` と違う(= スキャナが `(dev, inode)` で + 外部 rename を追随した)ときに限り、`ctime_ns` だけの不一致は rename によるものとみなして続行する。** + dev / inode / size / mtime_ns / tag_hash のどれかも違えば conflict。rel_path が同じなら ctime の + 不一致は従来どおり conflict(`touch -r` を伴う in-place 更新の検出)。宛先 key の占有はスキャナが + 移動を記録する時点で解決済み(`rel_path_key` UNIQUE)なので、tagwrite 側では再判定しない +- **事前条件の不一致でも、ファイルの全フィールドが op の新値と一致すれば `applied` として確定する。** + クラッシュ前に rename まで済んでいた op の確定([SPEC §7.5](../spec/07-pipeline/05-tagwrite.md) リカバリ)を、起動時の特別処理ではなく + 通常の `apply_op` の経路で行う。再投入されたジョブが同じ判定を通るので、リカバリの手順は + 「pending の op に track ジョブを再投入する」だけになる +- **overlay の解消はファイルの現在値が原則、読めないときだけ記録値。** ファイルが消えている・ + 壊れていて読めない op は `skipped_conflict` にし、`edits.old_value` と事前条件の物理属性 + (dev / inode / size / mtime_ns / ctime_ns / tag_hash)へ戻す。物理属性を記録時点へ戻すのは、その後 + ファイルが外部で変わっていれば次回スキャンが差分として拾い直せるようにするため(`rel_path` は + 戻さない。所在は常にスキャナが追随する) +- **キャンセルは書き込みロックの外でファイルを読む。** 1 つ目のトランザクションで子ジョブに + cancel 要求を立て(queued は即 `cancelled`)、pending の op のうちジョブが `running` でないものを + 集める。その後 op ごとにファイルを開いて現在値を読み、`failed`(error = `cancelled`)に閉じて + overlay を解消する。running のジョブはそのまま完了し、そのジョブが集計する。queued のジョブは + 1 つ目のトランザクションで `cancelled` になっているので、その後に claim されることはない +- **バッチの `cancelled` は「error = 'cancelled' の failed op が 1 件以上ある」で判定する。** cancel を + 要求しても全 op が既に進行中で完了した場合は `applied` / `partial` になる(キャンセルは何もしていない) +- **tagwrite ジョブはファイル操作を始める前だけ cancel を見る。** 始めた後は完了まで走り、`Done` を + 返す(進行中の op は完了を待つ。[SPEC §7.5](../spec/07-pipeline/05-tagwrite.md))。手を付ける前に cancel 要求があれば op を `failed` + (`cancelled`)に閉じて `Cancelled` +- **最終試行の失敗では op を `failed` に閉じる。** バックオフの上限(`max_attempts`)に達する失敗を + ハンドラが検知し、overlay を解消してから `Failed` を返す。op を pending のまま残すと再起動まで + そのトラックを編集できず、再起動後は同じ失敗を繰り返す +- **起動時リカバリは `jobs::recovery::run` の後・ワーカー起動の前。** prepared / applying のバッチの + pending op について、対応するジョブが `cancelled` なら op を閉じ、ジョブが無い・`done`・`failed` なら + 現在の `tag_version` で track ジョブを再投入する(dedup key `tagwrite:<track_id>:<tag_version>` で + 二重にはならない)。pending が無いのに開いているバッチは集計する +- **prepare は値が変わらないトラックを op にしない。** 全フィールドが現在値と同じトラックは + `unchanged` に数えるだけで `tag_version` も動かさない。全件が unchanged ならバッチを作らず + `NoChanges`。同じトラックが入力に 2 回あれば拒否する。値はキー大文字化・NFC 正規化してから + 比較・記録する(`edits.new_value` と反映後の読み戻しが同じ表記になる) +- **書いた内容は同じ FD から読み戻して `tag_hash` を確定し、編集キーが意図どおりでなければ反映 + しない。** lofty が書いた結果をスキャナと同じ関数で読み、編集した全キーが `edits.new_value` と + 一致することを確かめてから rename する。一致しなければ(その形式で表現できない値)tmp を捨てて + op を `failed` に閉じる(再試行しても直らない)。hash と fstat(rename 後)を DB に書く。 + rename は ctime を進めるので rename の後に fstat する。rename 後に親ディレクトリを fsync する +- **rename の直前に宛先を開き直して stat を再確認する。** 事前条件の確認から rename までの窓 + (コピー + タグ書き込み。大きいファイルでは秒単位)で外部が書き換えていれば、tmp を捨てて + `skipped_conflict`(overlay はその時点のファイルから解消)。dev / inode / size / mtime_ns / + ctime_ns が確認時点の FD と同じなら内容も同じ(書けば ctime が進む)。再確認と rename の間の + 窓は排他ロックを持たない以上は残り(不変条件 5)、そこはスキャンが調停する +- **tmp には元ファイルの mode / 所有者 / xattr を写す。** mode(`fchmod`)は必須で、失敗したら + 反映しない。所有者(`fchown`)と xattr(`user.*` / `system.*` を含む全部。TrueNAS SCALE の + NFSv4 ACL は xattr として見える)は best-effort で、権限や FS の都合で写せなければ警告して続行する + (タグ編集のためにアクセス制御を変えない。それでも写せない属性は運用でディレクトリ継承に任せる) +- **外部の実体を採用する経路では音声属性とフィンガープリントも確定する。** 事前条件不一致で + `applied`(全フィールドが新値)/ `skipped_conflict` になるとき、および overlay 解消・cancel で + ファイルを読むときは、スキャナと同じ規則(`import::scanner::read_fingerprint` / + `audio_changed`)で `audio_md5` / `audio_fp` を比べ、変わっていれば `audio_version` を進め、 + codec / 音声属性も置き換える。物理属性を新ファイルに揃えると次回スキャンには「変更なし」と + 見えるので、ここで確定しないと音声の差し替えを deep scan まで拾えない。自分の tagwrite の + 結果(音声不変が既知。[SPEC §6](../spec/06-data-model.md))では計算しない +- **generic 形式(ID3 / APE 等)は全タグブロックに同じ変更を当てる。** 読み側は複数ブロック + (ID3v2 + ID3v1 など)を **primary 優先で集約し、副ブロックからは primary に無いキーだけを + 補う**(連結すると同値が多値として二重になる。P0-6 の読み側もこの規則に揃えた)。 + 書き側が primary だけ書くと副ブロックの旧値が残るので、全ブロックに当てる +- **generic Tag に写像できないキーは書かない。** MP4 / ID3 / APE 等では lofty の `ItemKey` に + 写像できるキー(Vorbis Comment 名)だけを書き、写像できないキーは警告して落とす(読み側も + 名前を持たない項目を落とす。P0-6) +- **`tracks.album`(albums の複製)と album メタは prepare では更新しない。** ALBUM タグの編集は + `track_tags` とキャッシュ列 `title / artist_display / albumartist / track_no / disc_no / date` に + 反映され、album 側は tagwrite 後の次回スキャン(物理属性が変わるので dirty group になる)で + 再計算される。UI からの編集(P0-10)で即時性が要るなら、そこで album メタの再計算を足す + +**理由**: 事前条件は「ユーザが見たものと同じファイルか」を保証するための道具であり、rename が +進める ctime を conflict にすると日常操作(フォルダ整理中の一括編集)が全部 conflict になる。 +タグ内容は同じ FD の `tag_hash` で確認しているので、rename 追随時に ctime を緩めても書き込みの安全性は +落ちない。overlay の解消をファイル読みで行うのは「ファイルが正」を守るためで、記録値に戻すのは +読めないときの次善策にとどめる。キャンセルで 6 万ファイルを書き込みロックの中で読まない。 + +**却下**: 起動時に rename 済み op を専用の手順で確定する案(`apply_op` の経路と二重になり、 +片方だけ直す事故が起きる)。キャンセルで op を記録値へ戻す案(pending 中の外部変更を DB が +見失い、物理属性が一致したままだと次回スキャンも拾わない)。pending のまま失敗し続ける op を +残す案(そのトラックが永久に 409 になる)。 diff --git a/docs/decisions/D-042.md b/docs/decisions/D-042.md new file mode 100644 index 00000000..e5c746f5 --- /dev/null +++ b/docs/decisions/D-042.md @@ -0,0 +1,64 @@ +# D-42 タグ一括編集の固定値と境界 + +**決定**: 仕様([SPEC §9](../spec/09-http-api.md) / §12.3)が定めていない値と境界を次のとおり固定する。設定には出さない。 + +- **操作の JSON 形**は [SPEC §9](../spec/09-http-api.md) のとおり(`set` / `ref` / `replace` / `number` / `delete`)。キーは + 大文字化・値は NFC に正規化してから評価する。`PICTURE` は対象外(アートワークは P1-3)。 + 操作は 1〜64 件。`set` の空(空文字・空配列)は削除と同義。`ref` の `%key%` は同じトラックの + **先頭値**で展開し、無ければ空(結果が空なら削除)。`replace` は各値に全一致置換で `$1` が使え、 + 空になった値は落ちる。正規表現は `fancy-regex`(先読み・後方参照可)でバックトラック上限 + 100,000。`number` は `start + 位置`、`pad` 桁で 0 埋め(0〜6。範囲外は 400)。 + **`set_rows`**(2026-09-21、P4-14)は `rows: { "<track_id>": 値 }` で行ごとに違う固定値を書く。 + 値の規則は `set` と同じ、`rows` に無い行は触らない(preview で「変更なし」)、1〜10,000 行。 + 一括編集の UI には出さず、スクリプトからの補填(`scripts/backfill_source_url.py` の `SOURCE_URL`)に + 使う。行ごとに別バッチにすると履歴が汚れ巻き戻しも 1 件ずつになるので、1 op で済ませる +- **評価は preview と apply で同じ純粋関数**(`domain::tagops::apply_ops`)を、DB の `track_tags` + に対して行う(ファイルは読まない。DB はファイルのキャッシュで、同じ `tag_version` なら同じ + タグ集合)。apply は snapshot の各行に対して**再評価**し、現在値との差分だけを `edits` にする。 + preview 後に `tag_version` が進んだ行は評価せず `skipped_conflict` の op として記録だけする + (`affected` に含む。DB も版も触らない)。**op の事前条件は preview 時の snapshot の値** + (dev / inode / size / mtime_ns / ctime_ns / tag_hash / rel_path。[D-33](D-033.md))で、apply 時の DB 値では + ない。preview の後にタグはそのままで音声だけ差し替えられた(`tag_version` は据え置き)ファイルは + tagwrite の事前条件確認が conflict にし、外部 rename の追随は [D-41](D-041.md) の規則(rel_path 差 + ctime + のみ)に乗る +- **preview は selection の解決・pending・各行のタグを 1 つの読み取りトランザクション**で読む + (WAL のスナップショット)。途中でスキャンが commit しても、token の `tag_version` と表示した + 差分の世代が行ごとに混ざらない +- **連番の位置は「解決した selection を要求の `sort` で並べたときの位置」**で、反映待ちの行も + 位置を消費する(preview と apply で番号がずれない)。`sort` は一覧と同じ文字列で、省略時は + 既定ソート。preview の `items` は値が変わる行だけ返す(6 万件全部の差分を運ばない。変更なしは + 件数だけ)。反映待ちの行は評価せず `pending_excluded` に数える +- **apply は token を処理の間だけ占有する**(`SelectionStore::claim`)。同じ token の並行 apply は + 占有中なので `preview_stale`。409(`pending` / `preview_stale`(ops 不一致)/ `no_changes`)では + `release` して同じ token でやり直せるようにし、201 で `finish`(消費)する。pending の事前確認と + Editor の再確認(同じトランザクション)の間に別バッチが入っても、Editor の `Pending` を同じ + 409 に変換して token を残す。ops の照合は正規化後の canonical JSON の完全一致。token 不明・ + 期限切れは `preview_stale` +- **`no_changes`**: snapshot 全行が変更なし(または反映待ちで除外)のときは 409。バッチは作らない +- **Derived の追随**は applied になった op のトランザクションで、`derived_files` の + `src_tag_version` が現在の `tag_version` と違うときだけ `transcode` ジョブ + (payload `{ track_id, audio_version, tag_version, kind: "retag" }`、dedup key + `transcode:<track_id>:<audio_version>`)を投入する。P0 では `derived_files` が空なので何も + 起きない。ハンドラと `kind` の解釈は P1-10 が決める(`retag` = タグ上書きだけ、既定 = 再エンコード) +- **UI**: 操作リストは `useBatchEdit` が持ち、選択を変えても残る。プレビュー結果は + 「選択・操作・ソート」の組(`previewKey`)に紐づき、どれかが変わると古くなる(表の差分は消え、 + [適用] はプレビューし直すまで押せない)。差分は表の列 → タグキーの写像(Title / Artist / + Album / AlbumArtist / Date / #)があるセルにだけ 旧→新 で重ね、選択集合にあって変更の無い行は + 薄く描く。`pending` の 409 は「M 件を除外して適用 / 待つ」で、この確認もプレビューの key に + 紐づく(選択・操作・ソートを変えたら消える)。`preview_stale` はプレビューのやり直しを促す。**インライン編集**はダブルクリックしたセルの列を `set` 1 件の操作にして + 同じ preview → apply を続けて呼ぶ(ユーザにはプレビューを見せない)。`#` 列は `2-03` 形式で + DISCNUMBER + TRACKNUMBER、それ以外は TRACKNUMBER だけ。反映待ちの行はダブルクリックしても + 開かない。Artist 列の多値は `, ` で連結表示されているので、インライン編集では 1 値になる + (多値は一括編集の `set` に配列で渡す。UI の入力は 1 値) +- **`tracks.album`(albums の複製)は編集で変わらない**([D-41](D-041.md))。ALBUM を編集した行の Album 列は + 次回スキャン後に追随する + +**理由**: 評価をサーバの純粋関数に閉じることで、preview に見せた値と apply で書く値が一致し、 +クライアントは表示だけを持てばよい。再評価にしたのは、preview 結果(数万行 × 変更)を token に +抱えて apply まで持ち歩くより、`tag_version` が同じなら同じ結果になる性質を使う方が単純で、 +版が進んだ行の検出(conflict)も同じ比較で済むため。連番が反映待ちの行を飛ばさないのは、 +preview で見た番号と apply の番号を一致させるため(飛ばすと「除外して適用」で全部ずれる)。 + +**却下**: クライアントで操作を評価して新値を送る案(正規表現の方言が JS と Rust で違い、 +preview と apply の一致を保証できない)。preview の全行差分を返す案(6 万行で数十 MB)。 +409 で token を消費する案(「除外して適用」のたびに preview からやり直しになる)。 diff --git a/docs/decisions/D-043.md b/docs/decisions/D-043.md new file mode 100644 index 00000000..02c5a3a7 --- /dev/null +++ b/docs/decisions/D-043.md @@ -0,0 +1,124 @@ +# D-43 パス生成と一括リネームの固定値と境界 + +**決定**: 仕様([SPEC §5](../spec/05-library-layout.md) / §7.5 / §8 / §9)が定めていない値と境界を次のとおり固定する。設定には +出さない(テンプレートは `[layout]` の 3 本だけ。リクエストでは変えられない)。 + +- **テンプレート**(`domain::pathgen::Template`): プレースホルダは `category` / `albumartist` / + `artist` / `album` / `title` / `disc` / `track` / `year` / `edition`。`{track:02}` のように `:0N` + (N ≥ 1)で 0 埋め幅を指定できるのは整数フィールド(`disc` / `track`)だけ。`{track:2}` や + `{title:02}` は書式エラー。未知の名前・閉じていない `{` とともに設定の読み込み時に落とす(fail-fast)。拡張子はテンプレートに含めない(形式はファイルが決める)。 + ytmusic の `<Cat>/<Artist>/<Album>/<track>. <title>` は `{category}/{albumartist}/{album}/{track}. {title}` + で再現できる +- **テンプレートの選択**: album に category が無ければ `unsorted`、album が複数ディスク + (`albums.disc_count > 1`、または active な構成トラックの `disc_no` の最大が 2 以上)なら + `multi_disc`、それ以外は `single_disc`。`{category}` は `albums.category_id` の名前 +- **値のフォールバック**: `albumartist` → `artist_display` → `Unknown Artist`、`album` → + `Unknown Album`、`title` → 現在のファイル名(拡張子なし)、`track` → 0、`disc` → 1、 + `year` は album の `date`(無ければトラックの `date`)の先頭 4 桁が数字のときだけ +- **置換テーブル**は ytmusic の `FILENAME_REPLACE_TITLE` + `EXTRA` を全要素に適用する(ytmusic は + albumartist に `:` と `*` だけ当てていたが、表を 1 本にする。[SPEC §5](../spec/05-library-layout.md) の表のとおり)。表に無い + SMB / exFAT 禁止文字は同じ流儀で全角にする(`/`→`/` `\`→`\` `|`→`|` `"`→`"`)。 + 制御文字は落とす。末尾のドット・スペースは削る。Windows の予約名は `_` を後置する + (`CON` → `CON_`、`con.txt` → `con_.txt`)。空になった要素は `_`。タグ値そのものは変えない +- **切り詰め**: 各要素 255 バイト、パス全体 240 UTF-16 単位。要素はその要素の末尾を、全体は + ファイル名の stem を `…`(U+2026)付きで切る。拡張子は保つ。省略記号の前の末尾スペース・ + ドットは削る。切り詰めた結果に元の文字が 1 つも残らない(ディレクトリだけで上限を超える、拡張子が + 長すぎる、先頭が多バイト / サロゲートで予算に入らない)ときは生成エラー(計画では conflict)に + する。上限を満たさないパスは返さない +- **衝突降格の単位はリリース**: `mb:<MUSICBRAINZ_ALBUMID>` → `album:<album_id>`(`disc:<DISCID>` は [D-67](D-067.md) 追記 3 で外した)の + 順で決めるキーが同じなら同一リリース。同じ宛先ディレクトリに 2 つ以上のリリースが来たら、 + そのディレクトリに**既にいる**リリース(選択外の占有者、または選択内で現在そこにいるもの)が + 1 つだけでそれと同じなら合流(降格なし)、それ以外は `{album} ({year})` → `{album} ({edition})` に + 降格する。年 / edition が無い、または降格しても衝突するものは `conflict`(マージにはしない。[D-7](D-007.md)) +- **ファイル名の衝突**は `rel_path_key` で判定する(選択内の重複、選択外の active 行の占有)。 + 選択内の現在 key は占有とみなさない(swap / 循環が通る)。missing 行が宛先 key を持っていれば + prepare で `\0vacated:<id>` へ明け渡させ(スキャナと同じ規則)、ファイルが残っていれば + `RENAME_NOREPLACE` が最終判定になる。自分自身の大小文字だけの変更は許可する +- **バッチ 1 つに `rename` ジョブ 1 つ**(payload `{ batch_id }`、dedup key + `rename:batch:<batch_id>`、並列 1)。[SPEC §8](../spec/08-jobs.md) の「track 単位」を rename では採らない: 2 phase は + バッチ全体の順序を要し、track ジョブに分けると phase の境界を DB で管理する必要が出る。 + ジョブは開始時にバッチの全トラックをロックする(取れなければ再キュー) +- **DB の overlay は prepare の時点**で `rel_path` を新値にする(2 段階更新。[D-24](D-024.md) と同じ + 「DB 先行更新 + pending」)。`expected_rel_path` が記録時点の物理パス +- **一時名は `spindle-rename-<op_id>.<ext>`**(source と同じディレクトリ、隠しファイルにしない)。 + `.spindle-tmp-*` はスキャナが 1 時間で回収するので使えず、隠すとスキャナが対象外にして + トラックを missing にする。スキャナは pending の rename op があるトラックの所在が + source / 一時名 / 最終名(DB の `rel_path`)のいずれかなら衝突にしない(`edit::in_progress_keys`)。 + どれでもなければ op を `skipped_conflict` にし、**同じトランザクションで `rel_path` を実在パスへ + 追随させる**(rename ジョブは pending の op しか見ないので、overlay の最終名を残すと次回スキャンまで + DB が実在と食い違う) +- **phase 1 の事前条件は stat だけ**(dev / inode / size / mtime_ns / ctime_ns)。タグは読まない + (内容は変えないので `tag_hash` の再計算は不要。in-place のタグ書き換えは ctime で見える)。 + preview 時の `tag_hash` が apply 時の DB 値と違う行は prepare で `skipped_conflict` にする + (計画の前提が崩れている。[D-33](D-033.md)) +- **op の終端は commit で一括**(1 トランザクション)。各 op の所在(最終名 / 一時名 / source / + 不明)をファイルから決め、`rel_path` を所在へ揃え(2 段階更新)、物理属性を追随する。所在が + 不明(ファイルが無い。一時名が外部に消された等)なら記録時点の `expected_rel_path` と物理属性へ + 戻す(次回スキャンが inode で追随するか missing にする)。ただしその key を別の行(swap の相手の + 実在パスを含む)が持っていれば戻せないので `\0vacated:<id>` のままにする(所在が分かっている行が + 正。UNIQUE を踏んで commit が永久に失敗する経路を作らない)。順序は **所在不明の行を先に予約 key へ + 退避 → 所在の分かった行を実在パスへ(バッチ外の占有者は明け渡し)→ 所在不明の行を空いていれば + 記録時点のパスへ**(所在不明の行の overlay が、相手の戻り先と重なることがある)。phase 2 で宛先が取られていた op は + source へ戻して `skipped_conflict`、戻せなければ一時名のまま `skipped_conflict`(DB の `rel_path` + は一時名。警告を出す)。rename の直後は毎回親ディレクトリを fsync する(phase 1 / 巻き戻し / + phase 2 のどこで電源断しても rename が永続化されている) +- **cancel は phase 2 に入るまで**: phase 1 の各 op の前と phase 1 完了直後に確認し、退避済みを + source へ戻して全 op を `failed('cancelled')` に閉じる。phase 2 に入ったら最後まで進める + (一部だけ戻すと swap / 循環が解けない)。ジョブが走っていないバッチの cancel / リカバリ / + 最終失敗は `close_rename_ops` がバッチの pending を一度に閉じる(rename op はパスが互いに + 絡むので op 単位に閉じない) +- **クラッシュ復旧は所在の再判定**: 再投入されたジョブが op ごとに最終名 → 一時名 → source の + 順に inode で所在を判定し、続きを行う。起動時の特別処理は無い([D-41](D-041.md) と同じ) +- **album は coordinator が追随する**(「ディレクトリ = album」を DB でも保つ): overlay と + overlay 解消の両方で、宛先ディレクトリに album 行があれば合流(missing なら復活)、無ければ + ある album の active な構成トラック全部が同じ宛先へ動くとき(album 全体の移動)は `rel_dir` を + 書き換えて id を維持([D-32](D-032.md))、それ以外は新規 album(メタは構成トラックのキャッシュ列の最頻値、 + category は先頭ディレクトリ名が語彙に一致すればそれ、無ければ旧 album から引き継ぐ)。宛先の + album 行が missing でこちらが album 全体の移動なら、その行を `\0displaced:<id>` へ退かせて + id を維持する。構成 0 になった旧 album は `missing_since`。スキャナは変更のないディレクトリの + 照合を省く([D-38](D-038.md))ので、ここで揃えないと album が古いまま残る +- **同梱ファイル(cover.jpg / disc.cue / rip.log 等)は動かさない。** rename op はトラック 1 本の + パスだけを所有し、巻き戻しの対象もそれだけ。album 全体を動かした後の旧ディレクトリに残る + 同梱ファイルの扱い(追随・GC)は未決(残課題)。埋め込み統一([D-49](D-049.md) / [D-60](D-060.md))で Library の同梱 + ファイルは実データで png 1 本しか無く、Library に `rip.log` / `disc.cue` を置き始める P2-8 で決める + → [D-67](D-067.md) で決めた: rename ジョブが commit 後に、album 全体の移動で active な行が無くなった旧ディレクトリの + 既知の名前のファイルを宛先へ移し、空なら rmdir する(op としては記録しない) +- **API**([SPEC §9](../spec/09-http-api.md)): preview は選択を固定して token を返し、行ごとに `old` / `new`(衝突なら + null と `reason`)。apply は token の集合で計画を取り直し、`affected` / `conflict` を返す。 + 409 の規則は tags と同じ(`pending` / `preview_stale` / `no_changes`) + +**理由**: 2 phase をバッチ単位のジョブにすると、phase の境界がプロセス内の制御フローだけで済み、 +クラッシュ復旧は「所在をファイルから判定する」1 本になる。一時名を可視にするのは、スキャンが +リネームと並走する前提(不変条件 5)で pending 中のトラックを missing にしないため。album を +coordinator で追随させるのは、リネーム直後の一覧(category / albumartist / album のツリー)が +次回 deep scan まで古いままになるのを避けるため。 + +**却下**: track 単位の rename ジョブ(phase 境界の管理が DB に要る)。`.spindle-tmp-*` を一時名に +使う案(スキャナが回収する)。phase 2 の途中で cancel を効かせる案(swap の片側だけ戻せない)。 +同名の album をマージする案([D-7](D-007.md))。 + +**追記(2026-09-25。ファイル名の書式)**: `[layout]` の既定(`config.example.toml` / SPEC)を `{track:02}. {title}` +(番号の後にピリオド)にする。multi_disc は `{disc}-{track:02}. {title}`。理由: 旧パイプラインから移した Library の +約 7,500 曲が `01. タイトル` で、購読の番号揃えや Inbox の配置が付ける `165 タイトル`(ピリオド無し)と同じ +ディレクトリで混ざった(リハーサルの明透・理芽)。既存の多数派に合わせ、混ざった分はリネームで揃える。購読の +番号揃えの改名判定(ファイル名の先頭の番号)は数字の続く部分だけを読むので両方の形で同じ番号になる +(`leading_number` の単体試験)。テストが自前で与える layout はそのまま(書式に依存しない)。 + +**追記(2026-09-25。edition の供給源と、片方だけが edition を持つ衝突)**: `{album} ({edition})` の降格は +`albums.edition` を使うが、それまでスキャナがこの列に値を入れる処理が無く、全 album で NULL(edition の段は一度も +効いていなかった)。実機の IM@S CM Solo の 2 枚(通常版 4 曲と、`Hi-Res/` サブフォルダの 1 曲。ALBUM / ALBUMARTIST / +DATE が同じ)がリネームで「降格に必要な値が無い: edition」になって見つかった。 +- **edition = 構成トラックの `EDITION` タグの最頻値**(大文字正規化済みのキー。他の album 値と同じ決め方)。 + スキャナの album の再計算、Inbox の配置(下書きの `tags` の変更を当てた後の値)で入れる。リネームで album 行を + 作り直すときは旧 album の値を引き継ぐ。変化の無い既存の album は、edition が NULL で構成トラックに `EDITION` が + あれば次のスキャンで埋める(NULL のときだけ。`idx_track_tags_key` で EDITION を持つトラックだけを読む。 + **missing の確定の後**に行う: この run で消えた曲もその前は active なので、消えた曲の値を残さない)。Inbox の配置が + 既存の同一リリースの album を採用した(追記・合流・missing の復活)ときも、edition が NULL なら今回の曲の値で埋める。 + 同数のときは文字列の小さい方(スキャナ・配置・埋め戻しで同じ。下書きの順には依らない) +- **最後の段で edition を持たないリリースは元の `{album}` のまま**(ユーザの判断)。通常版 + Hi-Res 版は通常版が + `IM@S CM Solo - 034 速水奏`、Hi-Res が `IM@S CM Solo - 034 速水奏 (Hi-Res)` になる。edition の無いリリースが + 2 つ以上なら元の名前どうしで再び衝突し、edition 同士が同じでも衝突するので、どちらも conflict(マージにはしない)。 + 年の段は従来どおり(年が違えばそこで分かれる)。既にいるリリースが 1 つだけでそれと同じなら合流する規則も + 従来どおりで、宛先に通常版が既にいる場合は新しく来た方が年の段で分かれる(edition の段まで行かない) +- **却下**: 両方に EDITION を付けて規則を変えない案(通常版に「CD」等の値を付けるだけの手間が増え、既存の + フォルダ名も変わる) diff --git a/docs/decisions/D-044.md b/docs/decisions/D-044.md new file mode 100644 index 00000000..6f2b547d --- /dev/null +++ b/docs/decisions/D-044.md @@ -0,0 +1,36 @@ +# D-44 巻き戻しの固定値と境界 + +**決定**: 仕様([SPEC §7.5](../spec/07-pipeline/05-tagwrite.md)「巻き戻し」/ §9 / §12.4)が定めていない値と境界を次のとおり固定する。 + +- **逆バッチは通常のバッチ**で、`reverts_batch_id` だけが違う。tags は `prepare_tags_tx`、rename は + `prepare_rename_tx` に乗る(DB 先行更新 + tagwrite / rename ジョブ、事前条件は記録時点の DB 値、 + pending の 409、キャンセル、リカバリはすべて同じ経路)。`delete` は `missing_since` を戻すだけの + DB 操作なので、同じトランザクションで op を applied にして即終端にする(構成トラックが戻った album の + `missing_since` も外す)。`archive`(ロスレス正規化)は `prepare_normalize_tx` に乗る([D-46](D-046.md)) +- **対象集合はトラック単位**で引く: 元バッチの `applied` op − 逆バッチ群(`reverts_batch_id` = 元)で + `applied` になった op の `track_id`。1 バッチ 1 トラック 1 op なので op と track は 1:1 +- **現在値の比較は DB の値**(`track_tags` / `rel_path` / `missing_since`)で行う。DB はファイルの + キャッシュで、スキャン済みの外部変更はここで conflict になり、未スキャンの外部変更は tagwrite / + rename の事前条件確認(tag_hash / stat)が conflict にする。どちらも op 単位の `skipped_conflict` +- **計画時点の conflict にも edits を残す**(戻そうとした変更 = 現在値 → 元の値)。履歴画面が + 「何を戻そうとして、現在値が何だったか」を出せる。preview 後に版が進んだ行(tags の + `expected_tag_version` 不一致)は評価しないので edits は空 +- **`reverted_at` は `aggregate_batch` が立てる**: 逆バッチが終端になった時点で、元バッチの applied op が + 逆バッチ群で全件 applied になっていれば元バッチに `reverted_at`。partial なら立てず、再 revert は + 残りだけが対象。やり直し(redo)= 逆バッチの revert で、同じ規則で逆バッチに `reverted_at` が立つ。 + 元バッチの `reverted_at` は redo でも消さない(「#39 で戻し済み」の事実は変わらない。さらに戻すなら + redo バッチを revert する) +- **一覧の `reverted_by`** は `reverted_at` が立っているときだけ、applied op を持つ逆バッチの最大 id。 + 一覧は新しい順で 1000 件まで(履歴はユーザ操作 1 回 = 1 行) +- **`description` は省略可**で、無ければ UI が「(#N の巻き戻し)」と表示する(DB には入れない) +- **UI**(`HistoryView`): 一覧は画面を開いたときに取り、開いている間は SSE `batch` で取り直す + (250ms で間引く)。開いている行の詳細も一緒に取り直す。[巻き戻す] は終端かつ applied の op があり + `reverted_at` が無いときだけ。逆バッチの行は「巻き戻す (=やり直し)」。[キャンセル] は + prepared / applying のみ。conflict の op は「ファイルを再読込した現在値」として `current` を出す + +**理由**: 巻き戻しを既存のバッチ機構にそのまま乗せることで、pending / conflict / cancel / リカバリの +規則が 1 本になる。対象集合をトラック単位で引くのは、部分的に戻った後の再 revert(残りだけ)と +redo を同じ式で扱うため。 + +**却下**: 逆バッチを専用の状態機械にする案(同じ規則を二重に持つ)。元バッチの `reverted_at` を redo で +消す案(履歴の事実が書き換わり「#N で戻し済み」の表示が揺れる)。 diff --git a/docs/decisions/D-045.md b/docs/decisions/D-045.md new file mode 100644 index 00000000..77980f27 --- /dev/null +++ b/docs/decisions/D-045.md @@ -0,0 +1,58 @@ +# D-45 移行の振り分けと Library のロスレス形式(実機合わせ) + +**決定**: + +- **実機**: プールは `tank` ではなく `ssd`(NVMe、928G)と `hdd`(43.6T)。旧ライブラリは + `ssd/musics`(342G、既に `insensitive` + `formD` + `utf8only=on`)で、最上位は `Opus/` + `Original/` `AAC/`。`Playlists/` は `Opus/Playlists` と `Original/Playlists` の中にある +- **`Original/` は「生データ」ではなく大半がロスレス master**。内訳は ALAC 7,572(うち 24/96 が 4)、 + webm 1,512(YouTube 生データ)、mp3 14、AIFF 2(同名の ALAC あり)。`Opus/` はその 1:1 の + 非可逆版。`AAC/` は iTunes 用に FDK-AAC 256k + ReplayGain 適用済みで音声を書き換えた配布物 +- **振り分け**(`scripts/migrate_plan.py` が一覧を生成し、[移行手順](../admin-guide/migration.md) の rsync が使う): + + | Original の原本 | Library(master) | Derived | Archive | 捨てる | + |---|---|---|---|---| + | ALAC | `.m4a` そのまま | 再生成(旧 `.opus` は移さない) | — | — | + | webm | 対応する `Opus/*.opus`(remux) | — | `.webm` | — | + | mp3 | `.mp3`(非可逆原本) | — | — | 対応する `.opus`(非可逆→非可逆) | + | AIFF + 同名 ALAC | ALAC(整数 PCM) | — | `.aiff` | — | + | `AAC/`、`.exclude` / `.noimage`、`.fpl` / `gen.sh` | — | — | — | 移さない | + + Library の音声は 9,098 本。Hi-Res の 24/96(`Album/Hi-Res/01.m4a`)とアルバム直下の 16/44 は + `audio_md5` が違う別トラックとして両方 Library に入れる(整理は表 UI で後から)。 + プレイリストは `Opus/Playlists/*.m3u8`(28 本)だけを `Playlists/m3u8/` へ +- **Library のロスレスは FLAC に統一する。** 移行時は ALAC のまま取り込み、変換は spindle の + ジョブで行う: P1-4 の WAV → FLAC 正規化を「ロスレス → FLAC 正規化」(WAV / ALAC / AIFF)に + 広げ、PCM MD5 照合 → 一致時のみ元ファイルを `Archive/` へ退避 → 台帳 → 30 日後 GC の同じ機構に + 乗せる。`audio_md5` は同じ PCM なら形式に依らず一致する([D-37](D-037.md))ので同一性は保たれ、 + `audio_version` は上げない(Derived の再生成は起きない) +- **Apple 向け(ALAC / AAC)は Derived に置かず、将来のエクスポート・同期プロファイルで + オンザフライ変換する。** [D-9](D-009.md)(Derived は Opus のみ)は変えない +- **データセットの置き場**: `ssd/media/{Library,Derived,Inbox}` と `ssd/apps/spindle` は SSD、 + **`Archive` は `hdd/media/Archive`**(`/mnt/hdd/media/Archive`) +- **DSD**(現状 0 本): 買ったら原本 `.dsf` / `.dff` は Archive、Library には PCM 変換した FLAC + (24/176.4 または 24/88.2)を master として置く。DSD → PCM は不可逆なので Archive の原本は + 消さない。FLAC は PCM 専用で DSD を格納できない + +**理由**: + +- SPEC の master の定義は「手元にある最高品質 = Library の実体」。`Original/` を丸ごと Archive に + 送る旧手順では 7,572 曲のロスレスが再生も編集もできない場所に埋もれ、非可逆の Opus が master に + なる。[D-1](D-001.md) の役割別構成をそのまま適用すれば、ロスレスが Library、生データ(webm)が Archive、 + Opus は Derived になる +- FLAC を選ぶのは、ブラウザ再生が全種で直送できる(ALAC は Safari 以外で毎回 Opus 変換、§11)、 + Vorbis comment で複数値タグが素直(旧 `AAC/Readme.md` の「`; ` を `、` に置換」は MP4 ilst の + 制約の回避策だった)、STREAMINFO の MD5 で `flac -t` の健全性検査ができる(P1-5)、CD リップ + (P2)と WAV 正規化(P1-4)も FLAC なので Library のロスレスが 1 形式になる、から +- 移行前に ffmpeg で一括変換しないのは、ilst → Vorbis comment のタグ移し替え(複数値・ディスク + 番号・コンピレーション・アートワーク)が不完全で 7,572 曲の検品ができないこと、変換後は + 旧ツリーとの rsync checksum 照合が効かなくなること。ジョブなら lofty でタグを移し、失敗すれば + 戻せる +- Archive を hdd に置くのは、FLAC 化のとき 220G の ALAC が Archive へ退避され、SSD だけだと + 一時的に 520G を超えて空き(435G)に収まらないため。Archive は追記のみで速度が要らない +- 旧 `Opus/` を Derived に流用しないのは、旧パスがテンプレート(`01 Title` 形式)と一致せず、 + `derived_files` に紐づかない孤児として GC 対象になるだけだから。再生成は CPU 時間だけで済む + +**却下**: ALAC のまま Library に残す(Safari 以外の再生が全件変換、複数値タグの制約が続く)。 +`AAC/` を Archive に置く(音声を書き換えた非可逆で原本性が無い)。`Original/Playlists` の fpl を +移す(foobar 専用。P1-6 の取り込みでパスは書き換えるので m3u8 だけで足りる)。 diff --git a/docs/decisions/D-046.md b/docs/decisions/D-046.md new file mode 100644 index 00000000..dfe5675e --- /dev/null +++ b/docs/decisions/D-046.md @@ -0,0 +1,100 @@ +# D-46 ロスレス正規化の固定値と境界 + +**決定**: 仕様([SPEC §7.4](../spec/07-pipeline/04-lossless-normalize.md) / §8 / §9、[D-10](D-010.md) / [D-45](D-045.md))が定めていない値と境界を次のとおり固定する。 + +- **起動は selection API**(`POST /api/normalize/preview` → `/apply`。rename と同型で + `selection_token` を使う)。スキャンは WAV / ALAC / AIFF を見つけても自動投入しない。移行で + 取り込んだ ALAC 7,572 本(220G)の変換は数時間の CPU と Archive への実コピーを伴うので、 + いつ・どの範囲を変換するかはユーザが決める。Inbox 取り込み(P2-10)の後続ジョブとしての + 自動投入はそのときに足す。`[normalize].wav_to_flac = false` なら API は 409 `normalize_disabled` +- **編集バッチの機構に乗せる**: `edit_batches` + `edit_ops(kind='archive')` + `edits` + (`rel_path` 旧→新、`codec` 旧→新)。宛先は拡張子を `.flac` に置き換えた同じパス。 + ジョブは track 単位の `normalize`(dedup `normalize:<track_id>:<op_id>`、並列 2。op ごとに一意に + しないと、巻き戻し直後の新しい op がまだ `running` の前のジョブに相乗りして実行されない)で、 + ハンドラが自分で + `track_locks` を取る。pending の op があるトラックは再編集できない(409)ので、変換中にタグ編集や + リネームが割り込むことはない +- **DB は先行更新しない**(tags / rename の overlay と違う)。変換が終わるまで表は元ファイルの実体 + (ALAC / WAV)を示す。エンコードが失敗すれば何も変わらない。コーデックを先に FLAC と見せると + 失敗時に戻す overlay 解消が要り、表示も嘘になる +- **照合は独立した 2 つのデコーダ**: 元の PCM MD5 は symphonia(`decoded_pcm_md5`、スキャンと同じ + 計算)、エンコード側は ffmpeg でデコードした WAV を `flac -8 --verify` に通した STREAMINFO の + MD5。両者が一致するときだけ置く。どちらかが元ファイルを読み違えれば不一致になり、op は + `failed`(元ファイルは無傷、生成物は捨てる)。ALAC を ffmpeg に渡すときは `/dev/stdin` に + root から開いた FD を繋ぐ(MP4 は moov が末尾にあると pipe では読めない。パス文字列は渡さない) +- **タグは lofty で写す**: 読み側が Vorbis 名へ揃えた項目と画像の実体を、生成した FLAC の + VorbisComments と PICTURE ブロックにそのまま書く。写した結果の `tag_hash` が元と違うときだけ + `tag_version` を進める(MP4 ilst で表現していたキーが落ちた等。同値なら進めない)。 + `audio_md5` は同じ PCM なので変わらず、`audio_version` も据え置く +- **破壊フェーズは 3 段で、unlink の直前まで元ファイルを照合し続ける**(排他ロックは使わない。 + 不変条件 5)。(1) 宛先の FLAC を置き、元ファイルを同じディレクトリの一時名 + `spindle-normalize-<op_id>.<ext>` へ `RENAME_NOREPLACE` で退避する。rename した実体の inode が + 自分の FD と違えば(stat と rename の間に差し替えられた)戻して conflict。以後に元パスへ現れる + 外部のファイル(tmp + rename)は自分の inode と分離され、触らない。(2) Archive へ実コピー + (別プールなので rename できない)。コピーしながら取った SHA-256 が最初の値と違えば(同じ inode + への in-place 更新)conflict、書いた tmp を読み戻して一致しなければ I/O 失敗として再試行。 + rename の前に **元ファイルのコンテナ全体の SHA-256 を `edits(key='source_sha256')` に記録**する。 + (3) unlink の直前に同じ FD の stat とバイト列、一時名にあるのがその FD の実体(dev / inode) + であること、宛先にあるのが期待した音声(MD5)であることをもう一度照合し、外れていれば + conflict。**unlink が不可逆な確定点**で、その後の失敗(dir の fsync 等)では宛先も Archive も + 消さず再試行に回す(反映済み経路で確定する)。 + 確定点では同じ blocking 関数の中で一時名の実体(dev / inode)を再確認してから unlink する。 + conflict では元を元パスへ戻し、自分が置いた宛先と Archive の未確定コピーを消す(ユーザデータは + 元の inode に無傷で残っている)。消すのは**今そのパスにあるのが自分の置いた実体で、置いてから + 変わっていない(置いた直後の stat と dev / inode / size / mtime / ctime が一致し、内容の SHA-256 も + 書いたときと一致する。カーネルの時刻は粗い粒度なので stat だけでは同じ tick の変更を見落とす) + ときだけ**で、 + 外部が差し替え・上書きしていれば触らない(パスは識別子ではない)。消すときも unlink をパスに + 対して直接は行わず、同じディレクトリの隔離名(`.spindle-undo-…`。隠しファイルだが + `.spindle-tmp-` ではないので、スキャナの取り残し回収の対象に**ならない**)へ原子的に rename して + パスから切り離し、外した実体(dev / inode・size / mtime・内容)を照合する。一致した自分の生成物 + だけを `.spindle-tmp-` の名前へ移してから unlink する(消す前に落ちても 1 時間後に回収される)。 + 照合に外れれば元の場所へ戻す(塞がっていれば回収パス。どちらも失敗すれば隔離名のまま残り、 + 自動では消えない)。一時名の実体が差し替えられて + いれば、開いている FD の内容を元パスへ、元パスも塞がっていれば同じディレクトリの回収パス + `spindle-recovery-<op_id>-<random>.<ext>` へ書き出す(所在は op の error に残る)。元をどこにも + 残せなかったときは、同じ音声を持つ自分の生成物(宛先の FLAC・Archive のコピー)を 1 つも消さず、 + 実在する複製だけを op の error に記す。I/O 失敗(再試行)では生成物 + だけ消し、元は一時名に残す(戻す rename は ctime を進めて次の試行の事前条件が外れるため。 + 一時名にある元は「rename による ctime の差」として許容する)。op を閉じるとき(最終失敗・ + キャンセル)に、一時名にあるのが記録した inode なら元パスへ戻す。 + 台帳 `archived_files` は `rel_path`(Archive 相対 = 元の Library 相対)で UNIQUE、 + `eligible_after = now + [gc].retention_days` +- **巻き戻しは同じ op 種別で向きを逆にする**(`rel_path` 新→旧、`codec` 新→旧。[D-44](D-044.md) の + 「未実装」を解消)。元ファイルは Archive から Library へ**コピー**で戻し、Archive の実体は消さない + (Archive は追記のみ。台帳は `restored`)。Library にあった FLAC は Archive へ move し、台帳に + `reason='restore'` の行を足す(GC の対象。マイグレーション 0003 で CHECK を広げた)。 + やり直し(逆バッチの revert)は WAV を再び Archive へ move するが、同じパスの行が既にあるので + 作り直さず `held` に戻して期限を更新する。復元元が GC 済み(`deleted`)や台帳に無い op は + `skipped_conflict` +- **冪等性の判定はファイルの実体**: 再投入されたジョブは Library/<旧>(無ければ一時名)の有無、 + Library/<新> の音声 MD5(期待値と一致するときだけ自分の成果物)、Archive/<旧> のバイト列(元と + 一致するときだけ退避済み)から続きを行う。<旧> も一時名も無く、<新> の音声 MD5 が DB と一致し、 + Archive/<旧> の SHA-256 が記録した `source_sha256` と一致するときだけ「反映済み」として DB を + 確定する。記録が無い・一致しないときは何も消さず `skipped_conflict`(宛先も Archive の実体も + 残す)。宛先に別のファイルがあれば `skipped_conflict`、Archive の同じパスに別のファイルがあれば + **Library を触る前に** `failed`。置いた宛先の読み直しに失敗したときは terminal にせず再試行 + (反映済み経路で確定する) +- **スキャナは作業中のパスを避ける**: pending の archive op の元(`expected_rel_path`)・一時名・ + 宛先(`edits.rel_path` の新値)の key にあるエントリは新規登録も移動も missing 判定もせず、 + seen だけにする。DB を先行更新しないので、ジョブが宛先を置いてから DB を確定するまでの窓で + スキャンが宛先を「新規トラック」に登録すると、確定時に `rel_path` の UNIQUE を踏む。 + inventory を取った後・commit の前にジョブが DB を確定した(op はもう pending でない)場合は、 + `Identity::New` の挿入直前に `rel_path_key` の占有を読み直し、占有されていれば挿入せず + seen だけにする(走査全体を UNIQUE 違反で失敗させない) +- **cancel は Library を触る前まで**(変換中の ffmpeg / flac は子プロセスごと止め、tmp を消す)。 + 配置を始めたら最後まで進める。外部の変更は事前条件(stat + tag_hash)で `skipped_conflict`、 + 変換の間に元ファイルが更新されていれば配置の直前に同じ FD の fstat で検出する +- **UI は未着手**(rename と同じ。curl で起動できる)。設定画面の「退避ファイルの一覧と復元」 + ([SPEC §12.6](../spec/12-ui/06-other-screens.md))は台帳 API と合わせて後で載せる + +**理由**: 破壊的操作(Library からファイルを外す)は必ずバッチとして巻き戻せる必要があり +(不変条件 4)、既存の編集バッチの機構に乗せれば pending / conflict / cancel / リカバリ / 履歴 / +巻き戻しの規則が 1 本になる。DB を先行更新しないのは、変換の成否がファイルを読むまで分からず、 +失敗が普通に起きる(MD5 不一致、ビット深度非対応)ため。2 つのデコーダで照合するのは、 +「可逆変換だから情報は失われない」を実装が検査できる形にするため。 + +**却下**: スキャンで自動投入(220G の変換が起動直後に勝手に走る)。ffmpeg だけで FLAC まで作る +(`flac -8` の参照エンコーダを外すうえ、照合が単一デコーダの自己一致になる)。元ファイルを +Archive から move で戻す(Archive の追記のみの原則に例外が増える)。DB を先行更新して codec=flac +を先に見せる(失敗時の overlay 解消が要る)。 diff --git a/docs/decisions/D-047.md b/docs/decisions/D-047.md new file mode 100644 index 00000000..aa48fb0c --- /dev/null +++ b/docs/decisions/D-047.md @@ -0,0 +1,63 @@ +# D-47 ReplayGain 解析の起動と固定値(実装合わせ) + +**決定**(P1-1): + +- **起動は `POST /api/rg { selection }`**(selection は rename / normalize と同じ `ids` / `filter` + の 2 形)。含まれる active なトラックの `album_id` ごとに `rg` ジョブ(dedup `rg:album:<id>`)を + 投入し、`album_id` を持たないトラックは track 単位(`rg:track:<id>`)。既に queued / running の + album は `duplicates` に数えて投入しない。preview 段階は無い(DB にしか書かない)。全曲は + `filter = {"flags":["no_rg"]}` で指定する。UI の起動ボタンは未着手(curl で起動できる) +- **album の集計はデコード結果が 2ch のトラックだけ**([SPEC §6](../spec/06-data-model.md) / [D-22](D-022.md) のとおり mono も除外)。 + 判定は DB の `channels` 列ではなく実際にデコードしたチャンネル数(ファイルが正)。集計に + 入らないトラックは track の値だけ持ち、`rg_album_gain` / `rg_album_peak` は NULL。track 単位の + ジョブも album の値を持たない +- **開いた FD を DB の行と照合してから解析する**: root から開いた FD の fstat(dev / inode / + size / mtime / ctime)が行と一致しなければ失敗(パスは識別子ではない。同名で差し替えられて + いれば別トラックの音声をこの行の値として保存してしまう)。デコード後にも同じ FD を fstat し直し、 + 解析中の in-place 更新を検出する。外部のタグ書き換えでも size / mtime は動くので、その場合は + 再スキャンで行が更新された後の再試行で通る +- **album は all-or-nothing**: 構成トラックが 1 本でもデコードできなければジョブを失敗にし、 + 何も書かない。一部だけ書くと album gain が揃わないまま次の再解析まで残る。書き込みの + トランザクション内で構成(active な構成トラックの id と stat・rel_path)を読み直し、解析した + 行の集合と違えば(scanner が missing にした・別 album へ移した・新しいトラックが加わった・ + 同じ行を別実体へ追随させた)何も書かずに失敗する。 + 失敗したジョブは `last_error` に該当パスを持ち、`/api/jobs/:id/retry` で再投入できる +- **無音(絶対ゲート -70 LUFS 以下)は gain 0 dB**(補正なし)。積分ラウドネスが `-inf` になる + ので `reference - lufs` が定義できない。peak は測定値のまま +- **デコードは symphonia を優先し、持たない形式だけ ffmpeg**(Opus は demux まで symphonia、 + PCM は ffmpeg の `f32le` を stdout のチャンクで受ける。WavPack / APE は lofty の属性 + ffmpeg)。 + ffmpeg の stdout はメモリに溜めない(`ExternalCommand::stdout_channel`。受け手が消えたら子を + グループごと止める。SIGPIPE を無視する子でもタイムアウトまで待たない) +- **`rg_scanned_at` だけ更新**し、`audio_version` / `tag_version` は動かさない(`rg_written_at` + は値が変われば NULL。[D-48](D-048.md))。走査中に missing になった行は書かない + +**理由**: rg ジョブは [SPEC §8](../spec/08-jobs.md) で album_id 単位・CPU コア数並列と決まっているが、起動の入口が +§9 に無かった。selection で受ければ UI の選択・フィルタとそのまま繋がり、移行直後の「全曲」も +`no_rg` フラグで表現できる。2ch 判定をデコード結果で行うのは、`channels` 列が lofty 由来の +キャッシュであり、不変条件 1 に従うと解析時に読んだ実体を採用すべきため。 + +**却下**: scan 完了時の自動投入(移行直後に 9,098 曲の解析が勝手に始まる。normalize と同じ理由)。 +失敗トラックを飛ばして album を書く(嘘の album gain が残る)。ffmpeg で全部デコードする +(外部プロセス起動と f32 のパイプが 9,000 回。symphonia で済む形式は済ませる)。 + +**追記(P1-13)**: 外部で音声が差し替わって `audio_version` が進んだ行は、スキャナ(Phase 4 の +`apply_content`)と tagwrite の overlay 解消(`sync_track_to_file`)の両方で解析値を捨てる +(`db::replaygain::reset_analysis`: `rg_*` と `rg_scanned_at` / `rg_written_at` を NULL)。古い値を +Derived や再生に使わないため。album の他のトラックの `rg_album_*` は次の album 解析で揃う。 + +**追記 2(2026-09-25、P4-22。ユーザの決定)**: 値を捨てた行は**自動で解析し直す** +(`db::replaygain::reset_and_reanalyze`。上の 2 経路が同じトランザクションで rg ジョブを積む)。 +投入単位は `POST /api/rg` と同じ規則(`scopes_of`: album gain が on の album は album 単位、それ以外は track +単位。[D-74](D-074.md))で、dedup も同じなので Inbox の配置や手動の投入と二重にならない(同じ album の複数トラックが +同時に差し替わっても album のジョブは 1 本)。解析は DB に値を入れ、タグへの書き込みは従来どおり +`[replaygain].write_tags` に従う。aac の Derived は RG が揃うまで作らない([D-75](D-075.md))ので、値を捨てるだけだと +その行の aac が止まったままになっていた(リハーサルで [D-89](D-089.md) により ALAC 7 本の `audio_md5` が変わったとき、 +手で `POST /api/rg` した)。ジョブの組み立て(`new_album_job` / `new_track_job`)はスキャナ・編集(DB 層) +からも使うので `db::replaygain` に移し、`jobs::handlers::rg` から再公開する。 + +投入量: 対象は「音声が変わった」と判定された行だけ(外部の差し替え・deep scan での `audio_md5` の変化)で、 +新規トラックや RG が未解析の行には積まない(冒頭の却下の理由どおり、移行直後の全曲解析は人が起動する)。 +deep scan で数千行の音声が変わったとき(デコーダの修正等)は track 単位で数千本積むが、scan 完了時の +transcode の自動投入(`enqueue_all_stale`)と同じ扱いで、rg の並列度は CPU コア数・終端のジョブ行は +`[gc].jobs_done_days` で消える。**却下**: scan 完了時に `rg_scanned_at IS NULL` の行をまとめて積む +(未解析の新規トラック・移行直後の全曲まで拾ってしまい、上の「自動投入しない」と矛盾する)。 diff --git a/docs/decisions/D-048.md b/docs/decisions/D-048.md new file mode 100644 index 00000000..2220d878 --- /dev/null +++ b/docs/decisions/D-048.md @@ -0,0 +1,49 @@ +# D-48 ReplayGain のタグ書き込みは通常の編集バッチに乗せる(実装合わせ) + +**決定**(P1-2): + +- **`POST /api/rg/write { selection, description?, skip_pending? }`** で、selection の解析済み + トラックについて DB の `rg_*` を形式ごとのタグに変換した **tags op の編集バッチ**を記録する + (`Editor::prepare_rg_write`)。旧値の記録・DB 先行更新(overlay)・track 単位の tagwrite・ + 事前条件・巻き戻し・キャンセル・リカバリはタグ編集と共通で、専用のジョブ種別も op 種別も持たない。 + preview 段階は無い(値は DB から一意に決まり、ユーザが選ぶものが無い)。反映待ちがあれば 409 + `pending`(`skip_pending` で除外)、書く行も一致済みの行も無ければ 409 `no_changes`、 + `[replaygain].write_tags = false` なら 409 `rg_write_disabled` +- **書くキーは形式ごとに固定し、値の無いキーは消す。** Opus は `R128_TRACK_GAIN` / + `R128_ALBUM_GAIN`(Q7.8、-23 LUFS 基準。[SPEC §6](../spec/06-data-model.md) の式)だけを書き、`REPLAYGAIN_*` 4 キーは + 消す(RFC 7845 §5.2.1 は Opus に `REPLAYGAIN_*` を使わないとしている。両方あると基準の違う + 値をプレイヤーが拾う)。他形式は `REPLAYGAIN_TRACK_GAIN`(`+0.00 dB`、小数 2 桁、-18 LUFS + 基準)/ `REPLAYGAIN_TRACK_PEAK`(線形の true peak、小数 6 桁。1.0 を超えうる)/ + `REPLAYGAIN_ALBUM_GAIN` / `REPLAYGAIN_ALBUM_PEAK` を書き、`R128_*` は触らない(Opus 専用で + generic Tag に写像できない)。album の値を持たないトラック(album 無し・2ch 以外)は album + のキーを消す。MP4 / MP3 / APE 等へは lofty の写像(`----:com.apple.iTunes:replaygain_*`、 + `TXXX:REPLAYGAIN_*`)で書く。`OpusHead` の output gain は触らない([SPEC §6](../spec/06-data-model.md)) +- **`rg_written_at` はフラグではなく「ファイルの RG タグが解析値と一致していると確認した時刻」。** + 書き込み op の applied に限らず、DB をファイルの現在値へ揃える経路(`sync_track_to_file`: + applied の追随、overlay の解消、外部変更の採用)のすべてで `file_matches` を判定し直し、 + 一致すれば `now`、一致しなければ NULL にする。DB のタグが既に変換結果と一致している行 + (再解析で同じ値になった、前回の書き込みが済んでいる)は op にせず `rg_written_at` だけ立てる + (DB のタグはファイルのキャッシュなので、pending が無ければファイルも一致している)。 + 巻き戻しやユーザの手編集で RG のキーが解析値と違う値になれば自動的に NULL に戻る。 + **再解析(`db::replaygain::store`)で値が 1 つでも変わった行も NULL にする**(時刻が秒単位 + なので、確認と再解析が同じ秒に起きると `rg_written_at < rg_scanned_at` では検出できない)。 + 値が全て同じで確認が有効なら確認時刻を `now` へ進める(同じ値の再解析で未書き込みに落とさない) +- 一覧のフィルタに **`rg_unwritten`**(`rg_scanned_at IS NOT NULL AND (rg_written_at IS NULL OR + rg_written_at < rg_scanned_at)`)を足す。移行直後の「解析済み全曲を書く」は + `filter = {"flags":["rg_unwritten"]}` で表現する。UI の起動ボタンは未着手 + +**理由**: 不変条件 4(タグ書き込みは旧値を記録してから)と 3(タグだけの変更で `audio_version` を +上げない)は編集バッチがそのまま満たす。専用ジョブにすると事前条件・overlay・巻き戻しを二重に +持つことになる。`rg_written_at` を op の属性(書き込みバッチかどうかのフラグ)にすると、巻き戻しや +手編集でファイルの RG タグが変わっても「書き込み済み」のまま残り、UI の半透明表示が嘘になる。 +ファイルの内容から判定すれば不変条件 1(ファイルが正)と整合する。 + +**却下**: 値だけ書いて他のキーを残す(Opus に `REPLAYGAIN_*` と `R128_*` が併存する、mono に +なった曲の古い album gain が残る)。書き込み専用の op 種別 / ジョブ種別。preview → apply の +2 段階(選ぶものが無い)。 + +**追記(P1-13)**: スキャナは外部のタグ変更を取り込んだ行(`tag_hash` が変わった。pending の tags op +がある行は overlay を守るので除く)で `sync_written_at` を呼び、外部ツールが RG タグを消せば +`rg_written_at` を NULL に、解析値と一致する値を書けば `now` にする(`Scanner::with_replaygain_reference` +で基準を受ける)。`tag_version` が進むので Derived の追随(P1-10)が RG タグを `R128_*` へ変換して +埋めるのは P1-10 側の責務。 diff --git a/docs/decisions/D-049.md b/docs/decisions/D-049.md new file mode 100644 index 00000000..eb411583 --- /dev/null +++ b/docs/decisions/D-049.md @@ -0,0 +1,63 @@ +# D-49 アートワークの解決とキャッシュ(読み側。実装合わせ) + +**決定**(P1-3 の読み側): + +- **album のアートワークは同梱カバー画像 → 最初のトラックの埋め込み画像の順。** 同梱画像は + `cover` / `folder` / `front` × `jpg` / `jpeg` / `png` / `webp` を名前 → 拡張子の優先順で 1 つ + (大文字小文字は区別しない。ZFS insensitive と同じ)。埋め込みは構成トラックを + `disc_no` / `track_no` / `rel_path` 順に見て最初に見つかる 1 枚(front cover を優先、無ければ + ファイル内の最初)。形式と寸法はバイト列のヘッダ(`imagesize`)で判別する +- **原画像は元の形式のまま、サムネイルだけ WebP。** `artwork` 表は画像の SHA-256 で一意化し、 + 原画像を `<data>/thumbs/<hex>/orig.<ext>` へコピーする(同じ画像は 1 回だけ。Library には + 何も書かない)。`thumbnail` ジョブ(dedup `thumbnail:<artwork_id>`、並列 4)が ffmpeg で + `256.webp` / `768.webp`(長辺、拡大なし、quality 82)を tmp + rename で作る +- **解決はスキャンの Phase 5(commit の後)で、予約された album だけ。** Phase 4 は行が変わった + トラックの**現在の album と直前まで属していた album**(分割・移動で構成を失った側)の + `artwork_resolved_at` を同じトランザクションで NULL にして再解決を予約する。予約は DB に残るので、 + Phase 5 が cancel・失敗・プロセス停止で終わっても次のスキャンで続きを行う(ジョブの冪等性)。 + Phase 5 の対象はこの予約に加えて、同梱画像の有無・stat(`albums.cover_inode / cover_size / + cover_mtime_ns / cover_ctime_ns`。トラックではないので最速パスでは拾えない。マイグレーション + 0004)が前回と違う album と、参照中の原画像がキャッシュに無い・`artwork.bytes` と長さが違う album + (thumbnail ジョブも原画像の欠損で参照 album を予約する)。同じ長さの破損は incremental では + 見えないが、`put_original` が既存ファイルの SHA-256 を照合して不一致なら置き直すので deep で直る。 + deep は Phase 4 の同じトランザクションで active な album 全件を予約する(commit 直後・Phase 5 の + 前に止まっても残る)。Phase 5 も候補を全件、解決を始める前に予約し(`artwork_resolved_at = NULL`)、 + 成功した album だけが予約を消す。決められなかった album(deep だけを理由に対象になったものを含む)と、cancel・ + DB エラー・panic・プロセス停止で処理できなかった album は予約のまま残り、次の incremental で続きになる。マイグレーション直後の既存 album と、アートワーク無しで + 走ったスキャナの結果も NULL なので次のスキャンで解決する。画像の読み取りは並列、DB 更新は + 1 トランザクション +- **決められない album は状態を動かさない。** キャッシュへ置けない、同梱画像の I/O 失敗、読んで + いる間に同梱画像が変わった(読み前後の stat 不一致)、構成トラックを 1 本でも開けない・タグを + 読めない(先頭が読めなければ「画像なし」も「後続が最初」も確定できない)ときは、旧画像も + `artwork_resolved_at` も据え置いて次のスキャンでやり直す。同梱画像が画像として認識できない + (magic だけ一致して寸法が読めない・0・16,384 px 超)ときだけは埋め込みへ倒し、stat は記録する + (変わるまで読み直さない)。同梱画像は 32 MiB を上限とし(それ以上は画像とみなさない)、読み取りも + 上限 + 1 で打ち切る +- **Phase 4 の commit 後の Phase 5 は run の状態を戻さない。** missing の確定と `scan_runs = completed` + は commit 済みなので、Phase 5 の cancel は「続きは次回」、失敗は `ScanReport.artwork_error` + (警告ログ)にとどめ、scan ジョブは done にする。予約が残っているので取りこぼさない +- **`GET /api/artwork/:hash?size=`** は `size` があれば WebP、無ければ原画像を元の MIME で返す。 + サムネイルが未生成なら原画像へ倒す。倒した応答は `no-cache`(ETag で再検証。後で WebP に + 置き換わる)、それ以外は `immutable`。`GET /api/albums` に `artwork_hash`(hex)を足す +- UI のアルバムグリッドは missing の album を出さず、クリックで一覧を `album_id` に絞る + +**理由**: foobar2000 や SMB クライアントは同梱の cover.jpg を見るので、それを優先すると spindle と +他のプレイヤーで同じ絵になる。ハッシュアドレスにすると同じ画像を持つ数千 album でも原画像と +サムネイルは 1 組で済み、URL が不変なのでブラウザキャッシュを最大にできる。原画像を Library から +都度読む案は、埋め込み画像の取り出しに毎回 lofty のパースが要り、cover.jpg の差し替え後に旧画像を +出せない(将来の巻き戻しに使えない)。ffmpeg は既に必須の外部バイナリで libwebp を内蔵する。 + +**却下**: 埋め込み優先(ディレクトリ単位で他のクライアントが見る絵と食い違う)。`image` クレート +(WebP は可逆のみでサムネイルが大きい)。`webp` クレート(Docker ビルドに C 依存が増える)。 +スキャンのたびに全 album の同梱画像をハッシュする(9,000 album × 数百 KB を毎回読む)。 +サムネイル未生成時の 404(グリッドに壊れた画像が出る)。 + +**却下(追加)**: Phase 5 の対象を「この run で行が変わったトラックの現在の album」から都度計算する +(構成を失った旧 album が対象から漏れ、Phase 5 が途中で止まると次回 incremental では track が +unchanged なので二度と再試行されない)。Phase 5 の cancel / 失敗で run を cancelled / failed に +上書きする(missing_since が立っているのに run が failed という矛盾。[SPEC §7.1](../spec/07-pipeline/01-scan.md))。 + +**未決**: 書き側は [D-60](D-060.md)(埋め込み統一。cover ファイルは書かず、抽出も作らない)。 +参照が無くなった `artwork` 行と `thumbs/` の回収は GC(P1-11)。スキャナが外部の埋め込み画像の +変更を検出するのは tag_hash 経由(`PICTURE` 疑似キー)なので、画像だけ差し替えられた場合も +Phase 5 に入る。 diff --git a/docs/decisions/D-050.md b/docs/decisions/D-050.md new file mode 100644 index 00000000..9605a6cc --- /dev/null +++ b/docs/decisions/D-050.md @@ -0,0 +1,36 @@ +# D-50 同一性解決の audio_md5 は要求される分だけ並列で計算する(実装合わせ) + +**決定**(P1-0): + +- `identity::resolve` が md5 を要求する条件を絞る。段 1(inode 一致で `size` も `mtime` も違う)は + **行が `audio_md5` を持つときだけ**要求する(持たなければ照合できない)。段 2 は**移動候補** + (`audio_md5` を持ち、段 1 で claim されず、旧 `rel_path_key` が inventory に無い行)が 1 つも無ければ + md5 を要求しない(計算しても照合相手が無い)。初回スキャン(既存行なし)と移動の無い増分スキャンでは + 要求が 0 件になる +- 要求されうるエントリの集合を `identity::md5_requests`(md5 が無い前提で `resolve` を走らせて要求を + 記録する。実際の `resolve` の要求を含む)で先に求め、スキャナが Phase 3 と同じ並列度で計算してから + キャッシュを引くコールバックで `resolve` を呼ぶ。結果は Phase 3 のフィンガープリント計算にも渡す + (同じファイルを二度デコードしない) +- 進捗コールバックに相を持たせる(`Progress = Fn(ScanPhase, done, total)`。`Md5` は要求があるときだけ、 + `Read` は Phase 3)。ジョブの進捗(`done / total`)は相を区別せず最新値を出す +- ログの既定フィルタ(`RUST_LOG` 未設定時)で `lofty` と `symphonia*` を `error` に落とす。ファイルごとの + WARN(非対応のメタデータ・軽微な破損)は初回 deep scan で数千行になり、読めなかった結果は spindle 側の + ログと `ScanReport.errors` で分かる + +**理由**: P0-14 のリハーサル(ALAC 7,572 本)で初回 deep scan が 75 分かかった主因は、Phase 2 が全エントリの +ALAC / WAV / AIFF を**直列に**フルデコードしていたこと。しかも初回は既存行が無いので、その md5 は何とも +照合されずに捨てられていた(Phase 3 が並列に計算し直す)。要求を絞ると初回・通常の増分で Phase 2 の +デコードが消え、移動があるときだけ未決エントリ分を並列に計算する。判定の結果は変えない +(`md5_requests` は `resolve` の要求の上位集合なので、`resolve` が引くキャッシュに欠けは無い)。 + +**却下**: Phase 2 の md5 を丸ごと Phase 3 へ回す(段 2 の移動判定に md5 が要るので、判定を Phase 3 の +後に遅らせる大改造になる)。段 2 の要求を移動候補の md5 と一致しうるエントリに更に絞る(md5 を計算する +前に一致は分からない。候補の有無で 0 件にできれば十分)。 + +**計測**(2026-09-17、リハーサル環境 9,098 トラック / ALAC 7,570 本、12 コア): 空 DB からの初回 +deep scan 3,583 秒 → **552 秒**。既存 DB への deep scan 554 秒(Phase 2 の要求 0 件、Phase 3 が +並列にフルデコード)。変更なしの増分は 1 秒未満。 + +**未決**: deep scan は Phase 3 で全エントリのフィンガープリントを計算し直すので、可逆のフルデコードは +残る(deep の定義そのもの。並列度分だけ短縮)。P1-4 の正規化で ALAC が FLAC になれば、deep でも +STREAMINFO の MD5 を読むだけになる。 diff --git a/docs/decisions/D-051.md b/docs/decisions/D-051.md new file mode 100644 index 00000000..129cf9e9 --- /dev/null +++ b/docs/decisions/D-051.md @@ -0,0 +1,110 @@ +# D-51 Derived の生成と追随はトラック単位の `transcode` ジョブが現在値に揃える + +**決定**(P1-10): + +- **`transcode` ジョブ(payload `{track_id, audio_version, tag_version}`、dedup + `transcode:<track_id>:<audio_version>`、並列 cpus-1、基盤が `audio_version` で stale 判定して track + lock を取る。[SPEC §8](../spec/08-jobs.md))は「そのトラックの Derived を現在の状態に揃える」ハンドラ。** [D-42](D-042.md) の + payload `kind` は解釈しない(廃止)。ハンドラは毎回現在値から必要な処理を判定する + (`domain::derived::plan`): 非可逆 / missing / 1ch・2ch 以外(チャンネル数不明を含む。属性を読めなかった + ファイルはマルチチャンネルかもしれない)は no-op(Derived を作らない。[D-8](D-008.md) / [D-22](D-022.md))。 + `derived_files` が無い・`src_audio_version` が違えば再エンコード。音声版が一致していて `rel_path` + が期待値(Library の `rel_path` の拡張子を `.opus` に替えたもの)と違えば Derived を rename + (元が無ければ再エンコード)。`src_tag_version` / `src_artwork_id` / `src_rg_scanned_at` のどれかが + 違えばタグ・画像だけ上書き(Derived が無ければ再エンコード)。全部一致なら no-op。 + Library の FD は開いた直後とエンコード後に fstat して DB の行と照合し、違えば失敗(再スキャン + 後の再試行で通る。パスは識別子ではない) +- **投入契機は 4 つ**で、すべて `db::derived::enqueue_if_stale`(上の判定に当たるときだけ投入)に + 集約する。(1) **scan ジョブの完了時**に対象を 1 クエリで取り出して一括投入 + (`enqueue_all_stale`。初回は可逆全曲、以後は差分。外部の移動・音声差し替え・カバー差し替え・ + 実行中に dedup で落ちた分もここで拾う)。(2) tagwrite が applied になったトランザクション + ([D-42](D-042.md) の `enqueue_derived_retag` を置き換え)。(3) rename が applied になったトランザクション。 + (4) RG 解析値の保存と同じトランザクション。**タグ版に乗らない 2 つの世代を `derived_files` に + 持つ**(マイグレーション 0005): `src_artwork_id`(埋めた画像。NULL = 画像なし)と + `src_rg_scanned_at`(埋めた `R128_*` の元になった `tracks.rg_scanned_at`。NULL = 未解析で書いた)。 + これが無いと RG の解析やカバーの差し替えが Derived に届くのは次のタグ編集まで。`src_artwork_id` に + 記録するのは album の `artwork_id` ではなく**実際に埋めた**画像の id(原画像がキャッシュに無くて + 画像なしで書いたら NULL。album の id を書くと、スキャンが原画像を復旧しても同じ SHA-256 → 同じ id + なので不一致が出ず、画像なしのまま固定される)。`rg_scanned_at` は値が変わる再解析では前回より + 必ず大きくする(同じ秒でも世代が進む。`db::replaygain::store`) +- **エンコードは `media::encode::OpusEncoder`(`FlacEncoder` と同型)。** Library の FD を + `/dev/stdin` で ffmpeg に渡して `<data>/tmp` の WAV へ(ビット深度に合う `pcm_s*le`、無ければ + `pcm_s24le`)→ `opusenc --bitrate <derived_bitrate> --vbr --music --discard-comments + --discard-pictures` → lofty で Vorbis Comment と画像を書く → Derived の宛先ディレクトリの tmp + (`create_tmp`)へコピー → `replace_file`。`derived_files` の upsert はファイル配置後 +- **タグは Library ファイルの `TransferTags` をそのまま写し、RG だけ DB の解析値から `R128_*` へ変換して + 差し替える**(`REPLAYGAIN_*` は消す。[SPEC §7.6](../spec/07-pipeline/06-derived.md)「再解析しない」。解析前なら RG タグは書かない)。 + **画像はトラック自身の `artwork_id`、無ければ album の `artwork_id` の `768.webp`**([D-61](D-061.md) で改訂。 + P1-3 のキャッシュ。無ければ thumbnail ジョブと同じ変換で作る。原画像も無ければ画像なしで作り、 + 次のスキャンが原画像を復旧したときに `src_artwork_id` の不一致で書き直される)を `image/webp` の + front cover 1 枚として埋める +- **Derived の削除は行わない。** missing は可逆(SMB 切断)なので Derived を残し、 + `retention_days` 超の回収と孤児ファイルの回収は GC(P1-11)。期待パスを**別トラックの** + `derived_files` 行が占有しているとき(A を消して B を A のパスへ移した: B は inode で追随し、A は + key を明け渡して missing になるが、A の Derived の行は残る)は、占有側が missing ならその行を消して + 上書きする(Derived は再生成物でユーザデータではない)。占有側が生きていれば failed(バックオフで + 再試行。A が生きたまま別のパスへ移り、その跡地に B が来た場合で、A の transcode が先に Derived を + 動かせば次の試行で通る。占有側のファイルは占有側のロックを持たないので触らない)。**占有の確定は + 物理的な書き込みの前に 1 トランザクションで行う**(`claim_path`: 読んで missing なら行を消す、まで + 同じトランザクション)。scanner は track lock を取らないので、確認と書き込みの間に A が別のパスで + 復活しうる。先に行を消しておけば、復活した A は「Derived 無し」として自分の期待パスに作り直すだけで、 + B が置いたファイルを A のパスへ動かすことはない。**期待パスの canonical key は `derived_path_locks` + (マイグレーション 0005)で排他予約してから触る**(encode / move / retag の全経路。別のジョブが + 持っていれば試行回数を数えずに再キュー、終了時に解放。持ち主が `running` でなくなれば無効、 + 起動時リカバリで全件消す)。`track_locks` は track 単位なので、`x.flac` と `x.wav` のように別の + Library パスが同じ期待パスに写る 2 本の並走や、先に走っている retag が後から同じパスを置き換える + ことを防げない。予約があれば、並走は直列化されて負けた方が「生きているトラックが持っている」で + 失敗し(勝者のファイルは上書きされない)、retag 中の宛先を別のトラックが claim することもない +- **Library の swap / 循環 rename は自己退避で解く。** 期待パスを**生きている**トラックが持っていて、 + それがその相手の期待パスでもない(相手も追随待ち)ときは、自分の Derived を同じディレクトリの + 一時名 `<名前>.moving-<track_id>` へ退避して行のパスもそこへ向け(音声版が古くても退避する。次の + 試行の Encode が置き換えて退避ファイルを消す。実体が無ければ行を消して key を明け渡し、次の試行は + Encode)、相手の transcode が queued / running なら再キュー(試行回数を数えない)、いなければ失敗 + (バックオフ。次の scan で両方投入される)。 + 相手はそれで自分の元パスへ移れ、自分は次の試行で相手が空けたパスへ移る(rename バッチの 2 段階と + 同じ考え方。相手のファイルは相手のロックを持たないので触らない)。相手の期待パスでもあるとき + (`x.flac` / `x.wav`)だけが本当の衝突で、失敗にする。退避名は起動時回収の対象外(行が指している)で、 + クラッシュで行が追随しなかった退避ファイルは GC が孤児として回収する +- **SIGKILL / 電源断で残った作業ファイルは起動時に回収する**(`transcode::sweep_tmp`。ワーカー起動前)。 + `<data>/tmp` の `spindle-transcode-*` / `spindle-normalize-*` と Derived の `.spindle-tmp-*`。単一 + インスタンスで起動前は何も走っていないので年齢を見ずに消す。Derived は scan の対象外なので + scanner の猶予付き回収は効かない +- `delivery` ビューは変えない([D-25](D-025.md)) + +**理由**: ハンドラが「現在値に揃える」1 種類だと、retag / move / 再エンコードの区別を投入側が +正しく判定する必要がなく、投入が重複しても(dedup は `audio_version` 単位)最初の 1 本で全部 +片づく。冪等性(電源断・再投入)も同じ判定で済む。scan 完了時の一括投入にしたのは、旧 `Opus/` を +捨てた移行直後は Derived が空で([D-45](D-045.md))、手動の起動を待つ理由がないから。画像を album の WebP +サムネイルにするのは、Android 側に cover.jpg のミラーが無く埋め込みが要る一方、原画像(数百 KB〜 +数 MB)を 7,500 本に埋めると Derived が数 GB 太るため。長辺 768 の WebP は数十 KB で足りる。 + +**却下**: `kind = "retag"` / `"move"` / `"encode"` を投入側で決める(判定が 2 か所になり、投入後に +状態が変わると誤る)。Derived を 1 本の同期ジョブで全曲処理する(並列度と stale 判定を自前で持つ +ことになり、[SPEC §8](../spec/08-jobs.md) の `transcode` と二重になる)。トラック自身の埋め込み画像を写す(同梱 +cover.jpg 優先の [D-49](D-049.md) と食い違い、原寸のまま太る)。missing になった時点で Derived を消す +(SMB 切断のたびに再エンコードが走る)。手動 API のみ(移行直後の 7,500 本を誰も投入しない)。 + +**計測**(2026-09-17、リハーサル環境 ALAC 7,570 本、12 コア、`transcode` 並列 11): 起動時スキャンの +完了で 7,570 件を一括投入し、39 分で全件完了(failed 0)。Derived 30 GB。投入自体は 1 トランザクションで +0.14 秒。エンコード中のロードアベレージは 13 前後で、他のコンテナと同居する NAS では並列度を設定で +落とせるようにする余地がある([SPEC §8](../spec/08-jobs.md) の cpus-1 固定のまま)。 + +**追記(2026-09-20。P4-7 / P4-8)**: [D-75](D-075.md) が次を上書きする。`transcode` の処理単位は +`(track_id, variant)` で dedup キーは `transcode:<track_id>:<variant>:<audio_version>`([SPEC §8](../spec/08-jobs.md))、 +`derived_files` は系統ごとに 1 行、`aac` 系統は非可逆も対象([D-8](D-008.md) 追記)で +RG の解析世代の差分は Retag でなく Encode、`delivery` は `opus` 系統に固定、`has_derived` は `opus` の +行の有無。判定の入力に `audio_profile` / `tag_profile`(設定の世代)が加わる。それ以外(判定を +ハンドラが現在値から下す、退避経路、投入経路)はこの決定のまま。 + +**閉じた未決**(2026-09-18、リハーサル環境 9,098 トラックの実データで需要を確認。出てきたら再開): +- マルチチャンネルのダウンミックス([SPEC §7.6](../spec/07-pipeline/06-derived.md)): 全トラックが 2ch。DSD / SACD も非対応方針で、 + マルチチャンネルが入る経路が無い。作るなら `tracks` に列を足し、対象判定と `-ac 2`、RG 集計の扱いを決める +- 非可逆の `force_transcode`([SPEC §7.6](../spec/07-pipeline/06-derived.md)、[D-15](D-015.md)): 非可逆は Opus 1,512 / MP3 14 / AAC 2。Opus は変換の + 意味が無く、残りは 16 本で容量逼迫の解決にならない +- Derived 側の `cover.jpg` ミラー: 全件に WebP を埋め込み済み(トラック単位の画像は [D-61](D-061.md))。Library は + [D-49](D-049.md) で同梱ファイルを書かない方針なので、Derived にだけ書くと方針が割れる。埋め込みを読まない + Android プレイヤーが出てきたら再検討 + +**未決**: `transcode` の並列度の設定化。進捗表示(ジョブ画面の件数で代替)。transcode が**実行中**に +scan が同じトラックの版を進めた場合、その scan の一括投入は dedup で落ちる(ハンドラが記録するのは +読み始めた時点の版なので不一致は残り、次の scan で投入される)。 diff --git a/docs/decisions/D-052.md b/docs/decisions/D-052.md new file mode 100644 index 00000000..2871205b --- /dev/null +++ b/docs/decisions/D-052.md @@ -0,0 +1,56 @@ +# D-52 再生は原本か Derived を Range で直送し、オンザフライ変換は Derived が無いときだけ + +**決定**(P1-9): + +- **`GET /api/stream/:id`** は Library の原本を Range 対応で返す(単一範囲、206 / 416、 + `Accept-Ranges: bytes`、HEAD、`ETag` は `(inode, size, mtime_ns)`、`Content-Type` は codec から)。 + ファイルは `RootDir::open_file`(dirfd 基準)で開き、**開いた FD の fstat が DB の行と一致する** + ことを確かめてから返す(パスは識別子ではない。同名で差し替えられていれば 409 `stale`。次の + スキャンで直る)。missing は 404 +- **`?transcode=opus`** は `delivery` ビューが Derived を指す(音声版が一致)なら **Derived の Opus を + 同じ Range 対応で直送**する。[SPEC §11](../spec/11-playback.md) の「変換結果はキャッシュしない(Derived と役割が重複)」は、 + Derived がそのキャッシュだという意味。Derived が無い・音声版が古いときだけ ffmpeg を stdout + パイプで起動して Ogg/Opus(libopus 128k)を chunked で返す。このときは Range に応えず、 + `?start=<秒>` を `-ss` に渡してその位置から返す。子プロセスはクライアントの切断(本文の drop)で + group ごと kill する。上限はトラック長 + 猶予(60 秒)で、本文のストリームが `Sleep` も poll する + ので stdout が止まったままでも期限で起きて打ち切る(本文はエラーで終わり、子は kill)。stdout の + EOF 後は終了コードを検査し、非ゼロなら本文をエラーで終える(200 の途中で切れる形になるが成功と + 区別できる)。malformed な Range(数値でない・桁あふれ・逆順・複数範囲)は無視して 200、構文は + 正しいが範囲外のときだけ 416。`If-None-Match` は `*` と弱比較(`W/`)と並記を扱い、304 にも + `ETag` / `Cache-Control` を付ける +- **クライアント能力は再生時に URL で選ぶ。** 起動時に `canPlayType()` で codec ごとの可否を + 持ち、再生できない codec なら `?transcode=opus`。サーバへ能力を通知して保存する形([SPEC §11](../spec/11-playback.md))は + 取らない(状態を持たないほうが単純で、同じ結果になる)。**可逆はブラウザが再生できても既定で + Derived の Opus を使う**(帯域。設定「可逆は原本を再生」を localStorage に持ち、原本を選ぶと + Range 直送) +- **RG はクライアントで掛ける。** `GET /api/tracks` の行に `rg { track_gain, track_peak, album_gain, + album_peak }`(内部表現 -18 LUFS 基準の dB、未解析なら null)を足し、UI は Web Audio の + `GainNode` で `10^(gain/20)` を掛ける。peak でクリップしないよう `min(10^(gain/20), 1/peak)`。 + モードは off / track / album の 3 択(foobar2000 と同じ。localStorage `player.rgMode`)。album は + `album_gain` / `album_peak` を使い、`album_gain` が無い(単独曲・未集計)行は track に倒す + (foobar の "album, fallback to track")。peak も同様に `album_peak` → `track_peak` +- **UI**: 表の行先頭に ▶(ホバーで表示)を置き、その行から**表示中の順序で連続再生**する。 + 下部バー左に ▶ / ‖、シーク、経過 / 総時間、音量、RG モード(off / track / album)、曲名。`transcode=opus` を要求しても + Derived の直送か ffmpeg の chunked かはクライアントには応答からしか分からないので、`loadedmetadata` + で duration が有限なら `<audio>` のネイティブシーク(Range)、無限 / NaN なら chunked として + `start=` で読み直して表示時刻をオフセットする。metadata 前のシークは保留して確定後に適用する。 + 次曲は表の現在の順序で決め、末尾で未読なら読んでから続け、フィルタ・ソートの変更で現在曲が表から + 消えたらその曲で止まる。ユーザの play / stop は自動継続の待ちを必ず打ち切る + +**理由**: P1-10 で可逆全曲の Derived が揃うので、再生の大半は静的ファイルの Range 配信で済み、 +ffmpeg の起動はライブラリに加わった直後の短い期間に限られる。ffmpeg の chunked 出力はシークが +できない(`-ss` の再起動で代替)ので、Range が効く Derived を優先する。RG をクライアントで掛けるのは、 +サーバ側でゲインを掛けると原本の直送ができなくなり、Derived にも `R128_*` が入っていてブラウザは +それを読まないため。 + +**却下**: 変換結果のサーバ側キャッシュ(Derived と二重)。サーバへの能力通知(セッションに状態を +持つ理由が無い)。`<audio>` の `volume` で RG(0..1 なので正のゲインを掛けられない)。 + +**閉じた未決**(2026-09-18): Safari 向けの AAC 変換(`transcode=aac`)。Safari 18.4(2025-03。 +iOS / iPadOS / macOS)以降は Ogg Opus をネイティブ再生でき、FLAC / ALAC はもともと再生できる。 +実機の Safari で Derived の Opus と Opus 原本の再生を確認したので作らない。Opus 不可のブラウザが +必要になったら ffmpeg の aac エンコーダ → ADTS で足せる。 +ハイレゾのサンプルレート変換。可逆は既定で Derived の Opus(48 kHz)を +再生するので、96 kHz(実データで 64 本)がそのまま流れるのは「原本」を選んだときだけで、ブラウザは +そのまま再生できる。原本を選んだ人にリサンプルを掛けるのは逆なので作らない。[SPEC §11](../spec/11-playback.md) の +「クライアント設定で変換可」は既定の Derived 再生がその役割。 diff --git a/docs/decisions/D-053.md b/docs/decisions/D-053.md new file mode 100644 index 00000000..d52a3cee --- /dev/null +++ b/docs/decisions/D-053.md @@ -0,0 +1,59 @@ +# D-53 手動プレイリストと m3u8 の書き出し・取り込みの固定値と境界 + +**決定**(P1-6。仕様 [SPEC §9](../spec/09-http-api.md) / §10 / §12 が定めていない値と境界): + +- **1 プレイリストに同じトラックは 1 回だけ。** 追加・取り込みで既にある・入力内で重複する + トラックは黙って飛ばし、件数(`skipped` / `duplicates`)だけ返す。`playlist_items` の主キーは + `(playlist_id, position)` で重複を許す形だが、表の行 = トラックの同一性(選択・一括編集・ + `sort=position` の 1:1 JOIN)を崩さないために使わない。`position` は 0 から連続で、追加・除外・ + 移動のたびに全行を振り直す(高々数千件) +- **並びは表と共通。** 項目一覧の API は作らず、`GET /api/tracks?filter={"playlist_id":N}&sort=position` + で取る。`position` は `filter.playlist_id` と組でだけ有効(単独は 400)。`playlist_items` を JOIN して + `(playlist_id, position)` の主キー順で読むので temp B-tree は出ない。UI はプレイリストに入ったら + `position` 昇順、出たら既定ソートへ戻す。並べ替え(行のドラッグ)は `position` 昇順のときだけ +- **移動は「集合を `before` の直前(null なら末尾)へ」。** 移動する側は現在の相対順を保つ。 + `before` が集合の中・プレイリストに無いときは 400 +- **書き出し先は `Playlists/<profile>/<name>.m3u8`。** foobar / android / internal の 3 プロファイルが + 同名で衝突しないよう、プロファイル名のディレクトリに分ける。`Playlists/<profile>/` は `Library/` と + 同じ深さなので相対パスは [SPEC §10](../spec/10-playlists-export.md) どおり `../../Library/…` / `../../Derived/…`。旧ライブラリから + 移した `Playlists/m3u8/` は取り込み元として残す(書き出しはそこへ戻さない)。書き出しは tmp + rename、 + `playlist_exports (playlist_id, profile_id)` に `out_path` と時刻を記録する(改名後の書き出しは + 新しい名前のファイルで、古いファイルは消さない。GC の対象にもしない) +- **プレイリスト名はそのままファイル名になる**ので、`RelPath` の 1 要素と同じ規則(`/` `\` NUL 不可、 + SMB / exFAT の禁止文字・予約名・末尾ドット)で名前単体と `<name>.m3u8` の両方を検証し(長さの + 255 バイトは拡張子込み)、前後の空白は落とす。**一意性は `name_key`(`canonical_key` = casefold + + NFD。マイグレーション 0006)で判定する。** ZFS の insensitive + formD では `Foo.m3u8` と `foo.m3u8`、 + NFC と NFD の同名が同じ実体なので、`name` の BINARY UNIQUE だけでは別プレイリストの書き出しが + 上書きし合う。違反は 409 `duplicate`。既存行の backfill はマイグレーション実行時に登録する SQL 関数 + `spindle_canonical_key()`(Rust の `canonical_key` そのもの。`lower()` は ASCII しか畳まない)で行い、 + 同じ key の行が既にあれば id 最小の 1 本を残して後続を空いている `<name> (n)`(n = 2, 3, …。他の + 全行の key と二次衝突しないもの、`.m3u8` 込みで 255 バイトに収まるよう名前側を削る)に改名する + (消さない。SQL では書けないので `db::migrations::post_sql(6)` が同じトランザクションで行い、 + UNIQUE INDEX もそこで作る) +- **m3u8 は `#EXTM3U` + `#EXTINF:<秒>,<Artist - Title>` 付き、UTF-8 BOM なし、LF。** missing の + トラックは書かず件数(`skipped_missing`)で返す。`delivery` プロファイルは `delivery` ビューの + `path`(音声版が一致する Derived、無ければ原本) +- **`GET /api/playlists/:id/export?profile=` は本文を返し、`POST` が書く。** GET は trusted CIDR の + allowlist([D-27](D-027.md))に入っているので、他プレイヤーが curl で取れる。POST と import は Playlists root が + 無ければ 503 +- **取り込みは Playlists root 下のファイルから**(`GET /api/playlists/import` が `.m3u8` / `.m3u` を + 深さ 4 まで列挙、`POST { path, name? }` で 1 本を手動プレイリストにする)。行の解決は + `\` → `/` にしてから、絶対パス(`/…`、UNC `//host/share/…`、ドライブレター `C:/…`)なら + `/Library/` `/Derived/` のうち文字列中で最も早く現れるものの直後から取り、**無ければ root の外として + 解決しない**(偶然同じ + rel_path があっても誤一致させない)。相対なら先頭の `./` `../` を剥がし先頭の root 名を落とす。 + root 相対にしてから `rel_path_key` の完全一致 → 拡張子を除いた stem の一致(旧 `Opus/` の `.opus` 行が + 今の `.m4a` / `.flac` に当たる)。stem の候補が複数なら active を優先し、**その中でまだ複数なら曖昧と + して解決しない**(同じ stem の `.flac` と `.m4a` が両方 active のような状態でどちらかを黙って選ばない)。 + 解決できない行は応答の `unresolved` に返すだけで DB には残さない。名前の既定はファイル名の stem +- **自動再書き出し(ライブラリ変更をトリガにデバウンス)は P1-7 で**(スマートプレイリストが + 前提。手動は明示の書き出しだけ)。`auto_export` 列は PATCH で切り替えられるが今は何も駆動しない + +**理由**: 旧ライブラリの `Playlists/m3u8/` 28 本は foobar 由来で、`../Anime/…/1.01. Crow Song.opus` の +ように旧 `Opus/` 相対かつ拡張子が今の Library と違う。パスの完全一致だけでは 1 本も当たらないので +stem で当てる。プロファイル別のディレクトリにするのは、SPEC の `Playlists/m3u8/` 1 段だと 3 つの +出力先が同名で上書きし合うため。 + +**却下**: 同じトラックの複数回収録(表の行の同一性が崩れる)。項目一覧の専用 API(表の集合は +サイドバーの scope で差し替えるだけという [SPEC §12.1](../spec/12-ui/01-skeleton.md) の原則に反する)。取り込みを画面からの +ファイルアップロードにする(ファイルは既に Playlists root にある。SMB を開かずに済む)。 diff --git a/docs/decisions/D-054.md b/docs/decisions/D-054.md new file mode 100644 index 00000000..f564b23b --- /dev/null +++ b/docs/decisions/D-054.md @@ -0,0 +1,49 @@ +# D-54 スマートプレイリストは評価結果を materialize し、常駐タスクが再評価・再書き出しする + +**決定**(P1-7。仕様 [SPEC §9](../spec/09-http-api.md) / §10、[DSL](../user-guide/dsl.md)、[D-16](D-016.md) / [D-39](D-039.md) が定めていない値と境界): + +- **評価結果は `playlist_items` に書く(materialize)。** ルール(`rule_ast`)が正で、項目はその + キャッシュ。ORDER BY / LIMIT を適用した並びをそのまま `position` にするので、表の + `playlist_id` scope・`sort=position`・m3u8 の書き出しは手動プレイリストと同じ経路で動き、 + LIMIT / ORDER BY と表のキーセットページングが衝突しない。smart への項目の追加・除外・移動は + 409 `smart`、manual へのルール差し替え・再評価は 409 `manual` +- **`added`(初回スキャン日時)は `tracks.added_at`**(マイグレーション 0007。既存行は `seen_at` で + backfill。スキャナが INSERT 時に設定し、復活でも変えない) +- **プレビューは id 無しの `POST /api/playlists/preview { rule }`**(構文・型の検証と評価件数、AST)。 + [SPEC §9](../spec/09-http-api.md) の `POST /api/playlists/:id/preview` は「保存前のルールを試す」用途に合わないので + 置き換える。表でのプレビューは [D-39](D-039.md) どおり `filter.dsl`(WHERE だけ。ORDER BY / LIMIT は無視し、 + `Filter::parse` で構文・型を検証して 400)。`POST /api/playlists/:id/refresh` で手動再評価 +- **自動再評価・再書き出しはプロセス内の常駐タスク**(`playlist::autoexport`)。`jobs` 表の + `type` CHECK が固定でジョブ種別を足せず、ルールが正なので永続化する理由も無い。`library` + イベント・終端の `batch` イベント・完了した job を合図に `[export].autoexport_debounce_sec` + だけ待ち(その間の合図はまとめる)、全 smart を再評価して並びが変わったものだけ書き直し、 + `auto_export = 1` で書き出し記録のあるプレイリスト(手動も)を記録済みプロファイルへ書き直す。 + 起動時にも 1 回走る。`auto_export = 0` は再書き出しの対象外(再評価はする)。項目が書き換わった + プレイリストは SSE `playlist { playlist_ids }` で知らせ、UI は一覧の件数と(表示中なら)表を取り直す + (UI は元の `library` イベントを即時に処理済みで、デバウンス後の書き換えを知らないため) +- **プレイリストへの書き込み API は 1 トランザクション。** 複合 PATCH(name / auto_export / rule)は + どれかが通らなければ何も変えず、ルールの差し替え・作成は評価と materialize まで含めて原子的 + (評価の実行時失敗のうち `regexp()` 由来 — バックトラック上限など。SQLite は UDF のエラーを文字列で + しか返さないので接頭辞 `regexp: ` で見分ける — は 400 `rule_failed` で巻き戻す。それ以外の SQLite + 障害は 500 のまま)。 + `rewrite_items` は SAVEPOINT で囲み、外側のトランザクションの有無によらず原子的 +- **キーワードには語境界がある**(`ISLAND` を `IS` + `LAND` と読まない。`)AND` や `IS"x"` の記号の + 隣接は通る)。`LIMIT` は 1..=i64::MAX、`added` の日付は暦日として妥当な 1..=9999 年だけ +- **SQL 生成の固定値**: `IS` は `COLLATE NOCASE`、`HAS` は `LIKE`(メタ文字はエスケープ。どちらも + ASCII の大小だけ畳む。日本語の casefold まではしない)、`MATCHES` は全コネクションに登録した + `regexp(pattern, text)`(fancy-regex。スレッドごとにコンパイル済みをキャッシュ)、`GREATER` / + `LESS` は数値フィールドと `date`(辞書順)/ `added`(`YYYY-MM-DD` は UTC 0 時か epoch 秒)だけ、 + `duration` は秒(`IS` は丸めた秒)。真偽(`lossless` `has_derived` `missing`)は `IS true / false` + だけ。任意タグは `EXISTS (track_tags …)` で多値のどれかが一致すれば成立。`NOT` は `coalesce(…, 0)` + で NULL を偽に固定。ORDER BY は NULL を末尾、文字列は NOCASE、`t.id` でタイブレーク +- キャッシュ列以外のフィールド名は `track_tags` のキーとして大文字化して引く。存在しないタグ名は + 単に一致しない(エラーにしない。foobar と同じ) + +**理由**: スマートを仮想(毎回 SQL に展開)にすると、表のキーセットページングと LIMIT / ORDER BY +が両立せず、書き出しも別経路になる。materialize なら P1-6 の機構をそのまま使え、評価コストも +ライブラリ変更のたびに 1 回で済む(9,098 曲で数十 ms)。 + +**却下**: 仮想スマート(上記)。ジョブとしての再評価(CHECK 制約の変更に表の作り直しが要る)。 +`IS` を canonical key で比較(タグの値に key 列が無く、全行の関数評価になる)。 + +`HAS` の語境界は foobar 実機と突き合わせ済み(部分一致で一致。[D-55](D-055.md))。foobar クエリへの変換は [D-55](D-055.md)。 diff --git a/docs/decisions/D-055.md b/docs/decisions/D-055.md new file mode 100644 index 00000000..64466781 --- /dev/null +++ b/docs/decisions/D-055.md @@ -0,0 +1,47 @@ +# D-55 foobar クエリ変換は変換できない項を落として notes に出し、プロファイルは固定 + +**決定**(P1-8。仕様 [SPEC §10](../spec/10-playlists-export.md)「エクスポート形式」「foobar クエリへの変換」、[DSL](../user-guide/dsl.md) が定めて +いない値と境界): + +- **変換は AST → `{ query, sort, notes }` の純粋関数**(`playlist::fb2k`)。`GET /api/playlists/:id/fb2k_query` + が保存済みの `rule_ast` を変換して返す(smart のみ。manual は 409 `manual`)。SPEC の `.txt` 出力は + 作らず、UI のダイアログからクリップボードへコピーする +- **写像表**: `albumartist` → `%album artist%`、技術情報は生の値を返す `%__…%`(`%__codec%` + `%__samplerate%` `%__bitrate%` `%__channels%` `%__bitspersample%`。`%channels%` は mono / stereo の + 表示文字列になるので比較・ソートに使えない)、`duration` → 特殊フィールド `%length_seconds%` + (技術情報でないので `PRESENT` / `MISSING` は変換不能)、その他の標準タグ・任意タグは同名。 + spindle 固有(`verification` `category` `source_type` `lossless` `added` `has_derived` `missing`)と + `MATCHES` は変換不能 +- **変換不能な項は落とし、`notes` に原文(spindle の記法)と理由を残す。** 落ちて空になった `AND` / + `OR` と、子が落ちた `NOT` も落とす。全部落ちたら `query` は空で、その旨も `notes` に出す。 + `OR` の 1 項が落ちると結果は狭まるが、黙って別の条件に置き換えるよりは `notes` で見せて手で + 直してもらう方が安全 +- **演算子**: `PRESENT` / `MISSING` は foobar の後置形、`date` の `GREATER` / `LESS` は `AFTER` / + `BEFORE`。値は空白・括弧・`"` を含むか予約語と同じなら二重引用符で囲む。`"` を含む値は foobar 側で + エスケープできないので `notes` に警告。複合式の子は常に括弧で囲み、foobar 側の優先順位に頼らない +- **`ORDER BY` は `sort`(ソートパターン)へ分離。** ソートパターンは title-format の出力を文字列で + 比べるので、spindle が数値順にするフィールド(`tracknumber` `discnumber` `samplerate` `bitrate` + `channels` `bitdepth` `duration`)は `$num(…,10)` でゼロ埋めする(比較には付けない)。降順・ + `random`・`LIMIT` は表せないので `notes` +- **`delivery` の書き出しは `stale_tags`(タグ追随待ちの Derived)を件数で返す**(`POST …/export` の + 応答。自動再書き出しはログ)。追随ジョブの完了を待つ方式は取らない(書き出しがジョブ待ちで止まる + 方が運用上困る。追随が終われば次の合図で書き直される) +- **エクスポートプロファイルは 3 つ固定で CRUD の API は持たない。** 実運用で変えたいのは foobar の + UNC prefix だけなので `[export].fb2k_prefix` を正とし、起動時に `export_profiles` の `foobar` 行の + `path_prefix` を揃える(`db::playlists::sync_foobar_prefix`)。`.pls` も作らない(`format` の CHECK に + 列挙だけ残す) + +**理由**: Autoplaylist はファイルで渡せないので、ユーザが貼れる 2 本の文字列と「何が落ちたか」が +あれば足りる。技術フィールドまで写すのは、`%codec% IS flac` のような条件がライブラリの整理で +普通に出てくるため。 + +**却下**: 変換不能な項を `%path% HAS` などへ近似する(`category` はディレクトリ名と一致するが +部分一致で誤ヒットする)。プロファイル CRUD(3 つ以外の出力先が無い)。`.txt` の書き出し +(Playlists root に置いても foobar はそこから読まない)。 + +**実機との突き合わせ**(2026-09-19、Windows の foobar2000 で確認。変換器の変更は不要): +- `%title% HAS ab` は **部分一致**(`xaby` に当たる)。spindle の `LIKE %v%` と同じ +- 値の二重引用符は挙動を変えない(`HAS "ab"` は `HAS ab` と同じ)。**単一引用符は文字通りの値になり + 一致しなくなる**ので、変換器は値を囲むのに `"` だけを使う(`'` で囲まない) +- `%__bitspersample% MISSING` / `PRESENT` は技術情報フィールドにも効く(非可逆 / 可逆の件数と一致) +- `%codec% IS flac` も期待どおり diff --git a/docs/decisions/D-056.md b/docs/decisions/D-056.md new file mode 100644 index 00000000..21fdafbc --- /dev/null +++ b/docs/decisions/D-056.md @@ -0,0 +1,66 @@ +# D-56 GC は 5 区分を 1 本のジョブで回収し、dry-run は同じ計画関数を API が同期で返す + +**決定**(P1-11。仕様 [SPEC §6](../spec/06-data-model.md)「論理削除」/ §8 / §13 `[gc]`、[D-45](D-045.md) / [D-46](D-046.md) / [D-49](D-049.md) / [D-51](D-051.md) / [D-53](D-053.md) が +GC に委ねた事項の固定値と境界): + +- **物理削除を行う唯一の経路は `gc` ジョブ**(並列 1、dedup key `gc`、payload `{}`)。対象は 5 区分: + | 区分 | 対象 | 判定 | 物理削除 | + |---|---|---|---| + | A | missing トラック | `missing_since <= now - retention` **かつ Library に実体が無い**(`stat` で再確認。あれば次のスキャンが復活させるので飛ばす。symlink 等 NotFound 以外のエラーも飛ばす) | 行のみ。CASCADE で `playlist_items` / `track_tags` / `derived_files` / RG / verifications | + | B | missing アルバム | `missing_since <= now - retention` かつ構成トラック 0(A の削除後に判定) | 行のみ | + | C | Archive の退避ファイル | `archived_files.state='held'` かつ `eligible_after <= now` | `Archive/<rel_path>` を unlink → `state='deleted'`。実体が既に無ければ warn して `deleted`。unlink がそれ以外で失敗したら `held` のまま次回 | + | D | Derived の孤児 | `Derived/` 配下の実体で `derived_files` に `rel_path_key` が無いもの(A で行が消えた Derived もここで拾う)。`.spindle-tmp-*` と **mtime が 24 時間以内**の実体は除外 | unlink。空になったディレクトリも消す(root は残す) | + | E | アートワーク孤児 | `artwork` 行: `albums.artwork_id` から参照されず、`edits` の `PICTURE` 値(旧 / 新)にも現れない([D-60](D-060.md)。巻き戻しに要る)。`thumbs/<hex>/` の mtime が 24 時間以内なら行も残す(アップロード直後・参照前を守る)。`thumbs/<hex>/`: 行の無い hex(**mtime が 24 時間以内**の dir は除外。スキャン Phase 5 が原画像を置いてから行を入れるまでの間を守る) | 行を DELETE、dir を再帰削除 | + 触らないもの: Archive 内の台帳に無いファイル、`Playlists/` の古い書き出し([D-53](D-053.md))、Library の + 同梱ファイル([D-43](D-043.md) の残課題)、`data/tmp`(起動時回収の領分) +- **判定(`gc::plan`)と実行(`gc::execute`)を分け、dry-run は `GET /api/gc/preview` が `plan` を同期で + 呼んで返す**(区分ごとの件数・バイト数と先頭 50 件のパス)。ジョブの結果を返す列が `jobs` に + 無く、「ジョブとしての dry-run」は結果をログでしか見られない。`POST /api/gc` はジョブの投入 + (未完了があれば 409 `duplicate`) +- **scan と GC は同じ名前付き排他 `library`(`job_mutexes`。マイグレーション 0009)を取り、取れた + 側だけが走る。** 取れなければ `Outcome::Requeue`(1 秒後に再試行)。check-then-requeue を両側に + 置くだけでは同時に claim されたとき両方が譲り合い続けるので、勝者を DB で一意に決める。排他は + `track_locks` と同じくジョブの終端で自動的に解放され、持ち主が running でなくなれば奪える。 + 起動時リカバリで全件消す。A の行削除がスキャン Phase 4 の commit と、E(dir) の削除が Phase 5 の + 原画像の配置と競合しないため +- **実行順と原子性**: A → B → E(行) を 1 トランザクション(削除の SQL でも `missing_since` / + 参照の無さを再確認する)→ C → D → E(dir)。ファイル削除は 1 件ずつ、失敗はログして続行し、 + 区分ごとの件数・バイト数・失敗数を `info!` で出す(**削除件数のログは必須**)。24 時間の猶予は + in-flight の書き込みと手作業で置いた実体の保護。Derived のサブディレクトリが読めなければその下 + だけ飛ばして続ける(root が読めなければ計画できず失敗) +- **計画と実行の間に状態が変わりうるので、物理削除の直前に 1 件ずつ再確認する**(計画は数秒〜 + 数分前の読み取り。その間に巻き戻し・redo・スキャンの復活・transcode の claim・Phase 5 が動く): + - C: 行がまだ `held` で `eligible_after <= now`、`rel_path` が計画時と同じことを確認し、その台帳の + トラック行が残っていれば**トラックのロック**(`track_locks`)を GC のジョブで取ってから unlink + する(巻き戻しの normalize ジョブと同時に走らない。取れなければ今回は飛ばす)。`deleted` への + 遷移は `UPDATE … WHERE state = 'held'` の CAS。クラッシュで実体だけ消えて `held` が残った行は + 次回 NotFound として `deleted` に進む + - D: その key を指す `derived_files` 行が無いことを確認して **transcode と同じ排他予約** + (`derived_path_locks`。`track_id` は NULL。マイグレーション 0008 で NULL 可にした)を GC の + ジョブで取ってから `stat` し直し、一覧時と inode / mtime が同じときだけ unlink、件ごとに解放する。 + transcode は予約無しに宛先へ書かない([D-51](D-051.md))ので、GC が持っている間は置き換えられない。running + なジョブの予約があれば飛ばし、終わったジョブの残骸は奪う(`lock_path` の規則) + - E(dir): その hex の `artwork` 行が無く(E(行) が参照の出現で skip されたとき・スキャンが行を + 入れたとき)、dir の mtime がまだ猶予を過ぎていることを確認してから消す +- **冪等**: 途中で落ちても次回が続きを拾う(行が消えて実体が残った Derived は D、`held` のまま実体が + 無い Archive は C で `deleted` に進む)。キャンセルは区分の境界と件ごとの進捗で見る +- **起動契機は backup と同じスケジューラ**(起動時と 10 分ごとに「最後の終端 `gc` から 24 時間」で + due 判定)+ 手動 `POST /api/gc`。間隔は定数(`[gc]` に設定を足さない。`retention_days` が 30 日 + なので 1 日 1 回で足りる) +- **UI は作らない**([SPEC §12.6](../spec/12-ui/06-other-screens.md) の設定画面は骨格のみ。API とジョブで受け入れを満たす) + +**理由**: 編集履歴(`edit_ops` / `archived_files` の `track_id`)は意図的に FK にしていないので、 +行を消しても巻き戻しの根拠は残る。Derived は transcode が物理書き込みの前に `derived_files` の +行で期待パスを占有する([D-51](D-051.md))ので「行の無い実体」= 孤児と判定できる。 + +**却下**: ジョブとしての dry-run(結果の置き場が無い)。Archive の台帳に無いファイルの回収 +(Archive は追記のみでスキャン対象外。何が置かれたか分からないものは消さない)。区分ごとに別 +ジョブ(順序に依存がある: A → D、E 行 → E dir)。 + +**未決**: 設定画面からの起動([SPEC §12.6](../spec/12-ui/06-other-screens.md))。Library の同梱ファイルの回収([D-43](D-043.md))。 + +**追記(2026-09-25。保持期間の既定)**: `[gc].retention_days` の既定(`config.example.toml` / SPEC)を 30 → **7 日** +にする(ユーザの判断で保持を短くする)。対象は retention に従う GC の区分すべて(`missing_since` の行、Archive へ +退避した原本、Derived の孤児、[D-90](D-090.md) の破棄待ちの Inbox の取り込み)。影響: 正規化で退避した原本を「退避ファイル」から +戻せる期間と、missing の行・破棄待ちを取り消せる猶予も 7 日になる。コードに既定値は無く(設定の必須キー)、 +テストが自前で与える値はそのまま。1 日 1 回の定期実行はそのままで足りる diff --git a/docs/decisions/D-057.md b/docs/decisions/D-057.md new file mode 100644 index 00000000..c173540c --- /dev/null +++ b/docs/decisions/D-057.md @@ -0,0 +1,38 @@ +# D-57 FLAC 健全性チェックは検査結果を版付きで記録し、MD5 の補填は別タスクにする + +**決定**(P1-5。仕様 [SPEC §7.9](../spec/07-pipeline/09-flac-check.md) / §8 / §13 `[normalize]` が定めていない値と境界): + +- **`flaccheck` ジョブは読むだけ。** payload `{ track_id, audio_version }`(版付き。dedup key + `flaccheck:<id>:<ver>`。ワーカーの stale ゲートで古い版は no-op、`track_locks` で同じトラックの + 書き手と直列化)、並列 CPU コア数。対象は `codec = 'flac'` で active。それ以外は no-op で `done` +- **手順**: root で開いた FD を `fstat` して DB の `(dev, inode)` と照合(不一致は書かずに `done`。 + 次のスキャンで版が進めば再投入される)→ STREAMINFO の MD5 を読む(`media::fingerprint`。全ゼロは + 未設定)→ `flac -t -`(FD を stdin で渡す。タイムアウトは長さに比例、終了コード検査、stderr は + ログ)→ 1 UPDATE で記録(`WHERE audio_version = ?` で版を再確認) +- **結果は `tracks` の列**(マイグレーション 0010): `flac_check`(`ok` / `md5_missing` / + `decode_error`)、`flac_checked_at`、`flac_check_version`(検査時の `audio_version`。現在値と違えば + 結果は古い。スキャナは触らない)、`flac_check_error`(stderr の末尾)。判定は `flac -t` の終了 + コード ≠ 0 → `decode_error`、0 で MD5 全ゼロ → `md5_missing`、0 で MD5 あり → `ok` +- **起動契機**: `POST /api/flaccheck { selection }`(RG と同型。selection の active な FLAC を + track 単位で投入)と、`[normalize].flac_verify_on_import = true` ならスキャン完了時に + 「結果が無い・版が古い」FLAC を全件投入(Derived の追随と同じ場所) +- **一覧**: 行に `flac_check { status, checked_at, stale }`。固定フィルタ `flac_unchecked`(FLAC で + 結果が無いか古い)と `flac_error`(`decode_error`)。UI はバッジとサイドバーのフィルタだけで、 + 起動ボタンは RG と同じく未着手 +- **MD5 の補填(`flac_fix_missing_md5`)は P1-5b に切り出す。** 方式は再エンコードではなく + **STREAMINFO の MD5 16 バイトだけを tmp + rename で書き換える**(デコードして PCM MD5 を計算し + 埋める。圧縮も音声も変わらず `audio_version` 据え置き、inode / mtime は追随)。編集バッチに + 乗せ(`edit_ops.kind` に `md5` を足すマイグレーションが要る)、旧値 = 全ゼロを `edits` に残して + 巻き戻せるようにする。今は実データに FLAC が無く、今後の FLAC は自前の `flac -8 --verify` と + CD リップで MD5 が付くので急がない + +**理由**: SPEC の「再エンコードして補填」は MD5 を付けることが目的で、`flac -8` の再圧縮は禁止事項 +(圧縮レベルを揃える一括再エンコード)と紙一重。16 バイトの書き換えで目的を満たせる。検査は +読むだけなので編集履歴に乗せず、版で古さを表す。 + +**却下**: 検査結果を履歴表にする(一覧のフィルタとバッジには列が素直)。`flac -t` の stderr から +MD5 未設定を読む(版差で文言が変わる。STREAMINFO を直接読めばよい)。normalize の archive op を +FLAC → FLAC に広げて補填する(2,000 行の壊れやすい経路を同じパスへの置き換えに拡張するより、 +専用の小さな op の方が安全)。 + +**未決**: P1-5b(補填)。UI の起動導線(RG と一緒に一括編集パネルへ)。 diff --git a/docs/decisions/D-058.md b/docs/decisions/D-058.md new file mode 100644 index 00000000..652ae019 --- /dev/null +++ b/docs/decisions/D-058.md @@ -0,0 +1,109 @@ +# D-58 UI は foobar2000 の配置に寄せ、ツリーの表示形式はパターンで定義し、絞り込みは album_ids で行う + +**決定**(P1-12。[SPEC §12](../spec/12-ui/README.md) が定めていない配置・形式と、起動導線の置き場): + +- **配置は foobar2000(macOS、DUI)のユーザのレイアウトに寄せる。** ヘッダ(画面切替・検索・ジョブ要約) + → プレイヤーバー(曲名 / ◀◀ ▶ ▶▶ / シーク / 音量 / RG)→ 本体は左右分割。左はツリー + プレイリスト + + 固定フィルタ、その下にアルバムアート(選択行のアルバム、無ければ再生中)。右は上にプロパティ領域 + (タブ: プロパティ / 一括編集 / 操作)、下に表。左右・上下の境界はドラッグで可変、localStorage に + 永続化。配色は現状のライトのまま(ダークは別タスク。CSS 変数は `:root` に集約済み)。 + 表の既定列は `Playing | Artist/album | # | Title / artist | Duration` +- **ツリーの表示形式は foobar の Album List と同じくパターン文字列で定義する。** `|` が階層、 + `%field%` が差し込み、`[ … ]` は中のフィールドが全部空なら省く(角括弧は条件記号で表示しない。 + foobar の title formatting と同じ)。フィールドはアルバム単位の値(`category` `albumartist` `album` + `date` `year` `original_date` `edition` `disc_count` `rel_dir` `folder`)に限る(`GET /api/albums` の + 行から組む。トラックのタグは無い)。`%folder%` だけは特別で、パターン全体がそれだけのときに + `rel_dir` の階層をそのまま展開する(深さはアルバムごとに違う。他のフィールドや文字と混ぜると + 構文エラー)。Library 直下(`rel_dir` が空)のアルバムは「(なし)」の 1 ノードに載せる(消すと + All Music の件数と合わない)。組み込み 5 形式はプリセット: by folder structure `%folder%`(既定。 + リハーサル DB はカテゴリ未推定で by category が 1 本になるため)/ by category + `%category%|%albumartist%|%album%` / by artist `%albumartist%|%album%` / by album + `%album% — %albumartist%` / by year `%year%|%album%`。ユーザ定義は名前 + パターンで localStorage に + 保存。空になった階層は「(なし)」で末尾、並びは日本語 collator、件数はトラック数の合計 +- **ノードの絞り込みは配下の album id の集合。** `GET /api/tracks` の filter に `album_ids`(配列)を + 足し、SQL は `t.album_id IN (SELECT value FROM json_each(?))` で 1 パラメータにバインドする(件数の + 上限なし。ホワイトリスト経由)。組み込み形式もこの経路に統一し、ルートは filter 無し。selection の + filter 形にもそのまま乗る +- **プロパティタブは Selection Properties の再現。** Metadata(標準タグ + 任意タグ全部)と Location / + General(ファイル・技術情報・RG・検証・FLAC 検査・Derived)。複数選択は共通値、異なれば + `<複数の値>`(先頭 50 件で判定)。Metadata の値のダブルクリック編集は選択全体への 1 op の一括編集 + (インライン編集と同じ preview → apply)。`GET /api/tracks/:id` のセッション向け応答に `detail` + (tags / size / mtime / sample_rate / bit_depth / channels / bitrate / audio_md5 / original_codec / + added_at)を足す。CIDR 経由の限定フィールドは変えない +- **起動導線は操作タブ。** リネーム / 正規化(preview の old → new と衝突理由を一覧 → 適用。pending の + 409 は既存の 2 択)、RG 解析 / RG 書き込み / FLAC 検査(投入件数・重複・skip を通知)、 + プレイリストへ追加。既存 API のみ。ジョブの完了は行の値(RG / FLAC 検査 / Derived)を変えるが + `library` イベントは scan だけが流す([SPEC §9](../spec/09-http-api.md))ので、クライアントは `job` の done / failed で + 表示ページとプロパティの詳細を取り直す(一括 transcode で数千回にならないよう 3 秒に 1 回に + 間引く) +- **ジョブ画面([SPEC §12.5](../spec/12-ui/05-jobs.md))と設定画面(§12.6)を骨格から実装する。** ジョブ画面は種別ごとの並列度 + (`GET /api/jobs` に `concurrency` を足す)と待ち行列の表、一覧(実行中・待ち / 失敗・取り消し / + すべて、種別で絞る)、`edit_batch_id` から履歴画面のそのバッチを開く。プロパティ領域は一覧のとき + だけ出す(ジョブ / 履歴 / 設定では表の代わりに画面が入るので選択の意味が無い)。設定画面の + `GET /api/config` は `Config` が保持する原文(`source`)をそのまま返し、画面からは書き換えない + (変更はファイルを編集して再起動)。`GET /api/archive` は台帳に `batch_id`(退避した op のバッチ)を + 添え、保持中の行は「バッチ #n を巻き戻す」で履歴画面のそのバッチを開く(復元の API は別に作らない。 + [SPEC §7.4](../spec/07-pipeline/04-lossless-normalize.md) のとおり巻き戻しが復元)。 設定は `GET /api/config` + (読み込んだ `config.toml` の原文。秘密は config に無い)、再スキャン / deep scan、GC の preview → + 実行、`GET /api/archive`(`archived_files` の一覧。復元は対応バッチの履歴から巻き戻し) + +**理由**: ユーザが日常的に使っている foobar の配置に寄せれば、移行後の操作の学び直しが要らない。 +ツリーの形式をパターンにすると組み込みとユーザ定義が同じコードで済み、絞り込みを album_ids に +統一すると形式ごとのフィルタの写像が要らない。 + +**却下**: ノードごとに `category` / `albumartist` の固定フィルタへ写す(合成階層に写せない)。 +ツリーのパターンにトラックのタグを許す(アルバム単位で持っていない。必要なら `GET /api/albums` の +拡張で足す)。ダークテーマ(別タスク)。 + +**未決**: `by genre`(albums にジャンルが無い)。ダークテーマ。 + +**追記**(2026-09-20、上部の圧縮): 本体の上に 4 本(上部ナビ / プレイヤー / 右パネルのタブ / 「選択 N 件」) +あって狭かったので 2 本にした。上部ナビとプレイヤーを 1 本の上部バーに統合し、ジョブ要約のテキスト +(実行中 · 反映待ち · 失敗)は「ジョブ」ボタンの件数バッジ + 失敗の赤点 + ツールチップに畳む(画面切替の +「ジョブ」と同じ内容を 2 か所に出していた)。たまにしか開かない履歴 / 設定とログアウトは ☰ メニューへ。 +検索ボックスは上部バーから左サイドバーの先頭(スクロールしない)へ(foobar2000 の「ツリーの下」は +サイドバー全体がスクロールする spindle では隠れるので先頭にした)。右パネルは「選択 N 件」をタブ行の +右端に寄せ、フィルタ形の選択の説明はツールチップに移した。 + +**追記**(2026-09-20、P4-6): アルバム画面は `GET /api/albums` の全件を出していてツリーの選択・プレイリスト・ +検索語を見ていなかった。プレイリストは album の情報だけでは絞れない(中のトラックが属する album が要る)ので、 +クライアントで絞るのではなく `GET /api/albums?filter=` にトラック一覧と同じ JSON フィルタを受け、「一致する +active なトラックを 1 本以上持つ album」を返す。アルバム画面は「いまのトラック一覧と同じ絞り込みのアルバム」に +なる(検索語でも絞られる)。WHERE の生成はトラック一覧と共有し、列名はホワイトリスト・値はバインドのまま。 +実装で決めた点: `filter` が空・`{}` は全件(active なトラックの有無で絞らない。フィルタ無しと同じ)、指定が +あれば `a.id IN (SELECT t.album_id FROM tracks t WHERE t.missing_since IS NULL AND (<filter_where>))`(`missing` +フラグと組み合わせると空になるが、「表に出るトラックの album」の定義に従う)。web はツリー用の全件と画面用の +絞り込みを別に持ち(`useAlbums(enabled, filterParam, wantFiltered)`)、絞り込みはアルバム画面を表示中で +`filterParam` が空でないときだけ取る(ツリーのクリックごとに数千件を取り直さない)。応答は最新の要求だけを +採用し、結果は「どのフィルタに対する完了か」(成功 / 失敗とも)を持つ。現在のフィルタの完了が無い間(応答待ち) +と失敗時は前のフィルタの結果を出さず全件を出し、失敗は全件の成否とは別に見せる。同じフィルタの取り直し +(library / job イベント)の間は前回の結果を出し、届いたら差し替える。 + +**追記(2026-09-21。P4-10 ダークテーマ)**: 配色をライト / ダークで切り替える。直書きの色はすべて +`index.css` の `:root` の変数に集約し(バッジは色相ごとに `--badge-<hue>-bg` / `-fg` の対、影・モーダル +背景・行の状態色・欠落・スケルトンも変数)、ダークは `:root[data-theme='dark']` の 1 ブロックで同じ変数を +全部差し替える(`color-scheme` も切り替え、フォーム部品とスクロールバーを追随させる)。セレクタ側は変数しか +見ないことを `web/src/lib/theme.test.ts` が固定する(直書きの色が 2 ブロック以外に無い、両ブロックの変数 +集合が一致)。設定は `'light' / 'dark' / 'system'`(既定 `system` = OS の `prefers-color-scheme` に追随し +変化も購読)で localStorage の `spindle:theme` に置き(`lib/theme.ts`、`hooks/useTheme.ts`)、解決結果を +`<html data-theme>` に書く。初回描画の白飛びを防ぐため `index.html` のインラインスクリプトが React より先に +同じ規則で `data-theme` を付ける。切り替えは設定画面の「表示」節(ラジオ 3 つ)。上部バーには置かない +(☰ は履歴 / 設定 / ログアウトで足りる)。`@media (prefers-color-scheme)` を CSS に書かない(保存値が OS より +優先で、判定は JS 側の 1 か所に置く)。ライトの値は従来と同じだが 2 箇所だけ揃えた: 行の再生ボタン +(`.td-sel .play-row`)は未定義変数のフォールバック `#888` だったのを `--muted`(#6b7280)に、編集セルの +不正値の枠は単発の `#d33` を `--error` に。影の 0.15 も `--shadow`(0.12)に統一。ダークの淡色(欠落行・ +未検証バッジ・欠落バッジの文字)は本文 13px / バッジ 11px で、選択行(`--accent-bg`)や反映待ち + 選択 +(`--pending-selected-bg`)の背景に載っても 4.5:1 を切らないよう #c4c9d1 / #9ca3af にし、反映待ち + 選択の +背景は `--muted` の文字が 4.5:1 を保つ #1c3358 にした(codex のレビューで計測。欠落 / 反映待ちの行は +選択できるので、状態の組み合わせで確認する)。 + +**追記**(2026-10-20、設計書の吸収。P4-20 の上部バー): ナビの並びを `ライブラリ / アルバム / CD / YouTube / Inbox` +にし、ジョブ・記録・設定は ☰ に入れた([SPEC §12.1](../spec/12-ui/01-skeleton.md))。並びはユーザの指定で、「ライブラリが先頭」と +「CD の右隣が YouTube、その右が Inbox」の両方を満たす。ものは 入力 → Inbox → ライブラリ と流れるが、ライブラリが日常の +入口なのでタブは流れの順には並べず、導線は「取り込みタブのすぐ右の Inbox に赤い数字が増える」ことで示す。それまでは +Inbox が取り込みタブの左にあり、CD を取り込んだ次にどこを見るかが画面から読めなかった。 +左カラム(ツリー)は表の scope を差し替えるだけなので、それが効く画面(ライブラリ / アルバム)だけに出し、ほかは全幅に +する。下のアルバムアートも一緒に隠れるが、曲名は上部バーに残り、CD 画面では候補のジャケットが出るので失うものは無い。 +Inbox のバッジは一覧(下書き・失敗理由まで読む)を常時取ると重いので、件数だけの `GET /api/inbox/summary` を足した。 +ジョブが ☰ に移って見えなくなる印は、☰ のボタンに失敗の赤点だけ(SSE の点と合わせて 2 つまで。3 つ並べると読めない)、 +実行中・待ちの件数はメニュー内の「ジョブ」に添える。 diff --git a/docs/decisions/D-059.md b/docs/decisions/D-059.md new file mode 100644 index 00000000..3d56ff09 --- /dev/null +++ b/docs/decisions/D-059.md @@ -0,0 +1,40 @@ +# D-59 MD5 の補填は md5 op の編集バッチにし、tagwrite ジョブで反映する + +**決定**(P1-5b。[D-57](D-057.md) が「別タスク」とした補填の具体化): + +- **op は `kind = 'md5'`、edits は `key = 'audio_md5'`**(値は 32 桁の hex。全ゼロ = 未設定)。 + `old_value` は記録時の STREAMINFO の値(補填では全ゼロ)、`new_value` は記録時 null で、反映時に + デコードして計算した値を書き戻す。巻き戻しは逆向きの md5 op(old = 計算値、new = 全ゼロ。 + `new_value` があれば計算せずそれを書く)。DB は先行更新しない(overlay 無し。archive op と同じ) +- **反映は STREAMINFO の 16 バイトだけを tmp + rename で書き換える。** 補填は 2 段階: デコードして + 計算した値をまず `edits.new_value` へ耐久化し(別トランザクション)、それから書く。rename 後・ + DB 確定前にプロセスが落ちても、再実行で「事前条件は外れているがファイルは新値」と分かり applied に + 確定できる(冪等・巻き戻し可)。事前条件は実体(dev / inode / size / mtime / ctime)と記録時の + MD5 値。どちらか外れていて新値でもなければ `skipped_conflict`(外部で補填・差し替え済み。 + ファイルが正で、DB を現在値に揃える)。FLAC として読めない・デコードできないは + `failed`(再試行しても直らない)。同じトランザクションで `audio_md5`(全ゼロなら NULL)と + `flac_check`(値あり → `ok`、全ゼロ → `md5_missing`)を揃え、`audio_version` / `tag_version` は + 据え置く(音声もタグも変わらない) +- **ジョブは tagwrite を再利用する。** 契約(そのトラックの pending op をファイルへ反映する。 + `track_locks`・stale ゲート・再試行・最終失敗で op を閉じる)が同じ。`Editor::apply_op` が + op の kind で分岐する。新しい job type を足すと `jobs` 表(4 表から参照)も作り直しになる。 + payload は `{ track_id, op_id, batch_id, unversioned: true }` で dedup key は + `tagwrite:<id>:md5:<op_id>`。`unversioned` は jobs 層の明示的な契約(`db::jobs::UNVERSIONED_KEY`)で、 + 版付き種別でも stale 判定をせず `track_locks` だけ取る(md5 op は tags overlay を持たず版も進めない + ので、補填が queued の間に外部のタグ変更で `tag_version` が進んでも捨ててはいけない)。key を op ごとに + するのは、tags と同じ key だと元ジョブが running のうちに巻き戻したとき逆 op のジョブが Duplicate に + なって走らないため +- **マイグレーション 0011 は `edit_ops` を作り直す。** SQLite は CHECK を ALTER できず、`edit_ops` は + `edits`(CASCADE)と `archived_files`(SET NULL)から参照されているので、FK を切らずに DROP すると + 履歴が消える。runner に「FK を切って適用する版」(`FOREIGN_KEYS_OFF`)を持たせ、トランザクションの + 外で `foreign_keys=OFF` → 適用 → commit 前に `foreign_key_check` → `ON` に戻す(SQLite の 12 手順) +- **API は `POST /api/md5fill { selection, description?, skip_pending? }`**(RG 書き込みと同型。preview は + 無い)。対象は selection の active な FLAC で `flac_check = 'md5_missing'`。`[normalize].flac_fix_missing_md5 = false` + なら 409 `md5_fill_disabled`。UI は操作タブの「MD5 を補填」 + +**理由**: 補填は既存の編集バッチの性質(事前条件 → tmp + rename → DB 追随、巻き戻し)をそのまま +使えるので、専用の小さな op にするのが最も安全。 + +**却下**: `kind = 'tags'` に特別なキーで載せる(履歴の種別が嘘になる)。新 job type `md5fill` +(`jobs` 表の作り直しを伴う)。反映時に `flac -t` で再検査する(全部デコードして計算した時点で +検査と同じことをしている)。 diff --git a/docs/decisions/D-060.md b/docs/decisions/D-060.md new file mode 100644 index 00000000..48c8ae18 --- /dev/null +++ b/docs/decisions/D-060.md @@ -0,0 +1,66 @@ +# D-60 アートワークの書き側は埋め込み統一。差し替えは `PICTURE` の tags op で、cover ファイルは書かない + +**決定**(P1-3 書き側。[D-49](D-049.md) の「未決」の具体化): + +- **spindle は同梱の cover ファイルを書かない。画像はトラックへの埋め込みに統一する。** ライブラリは + 既に全 album が埋め込みで(リハーサル環境 721 album 全部が `origin='embedded'`、同梱画像 0)、 + album で共通の画像と、トラックごとに画像が違う album(神椿系)が混在しているため、埋め込みに + 統一している。cover ファイルを併用すると正が 2 つになり(cover を置いた後の埋め込みが古いまま + 残る、トラックごとに画像が違う album に誰かが cover.jpg を置くと [D-49](D-049.md) の規則でそれが勝つ)、 + 読み側の規則も書き側の機構も 2 系統になる。読み側の同梱画像優先([D-49](D-049.md))は外部が置いたものを拾う + ためだけに残す。埋め込み → cover ファイルの「抽出」は作らない +- **差し替えは `kind='tags'` の op で、キー `PICTURE` を変更する。** 値は `tag_hash` と同じ + `<mime>:<sha256hex>` の配列(無しは null)。`track_tags` は既にこの形で `PICTURE` を持つ + (スキャナが `TagSet` ごと保存する)ので、旧値・overlay・`tag_version`・差分・書き戻し確認 + (`file_matches_new_values`)・巻き戻し(`revert_tags`)は既存のまま効く。新しい op 種別も + マイグレーションも要らない。ユーザの JSON タグ操作(`tagops::parse_ops`)は `PICTURE` を拒否した + ままで、画像 op は `Editor::prepare_picture(description, track_ids, sha256)` だけが作る + (`prepare_tags_with` に「`PICTURE` を `[<mime>:<hex>]` に置く」評価器を渡す) +- **差し替えは「全画像を捨てて、上げた 1 枚を front cover として入れる」。** 裏ジャケ等の別種別を + 残す案は、lofty の画像種別が形式ごとに揺れる(MP4 は種別を持たない)ので確実に動かず、 + ライブラリの実態も 1 枚運用なので採らない。既に同じ 1 枚だけを持つ行は差分なし(unchanged) +- **画像の実体は `ArtworkStore`(`thumbs/<hex>/orig.<ext>`)と `artwork` 行で持つ。** アップロード + (`POST /api/artwork/upload`。生のバイト列、`Content-Type: image/*`、上限 32 MiB = `MAX_COVER_BYTES`。 + 形式と寸法はヘッダで判別し JPEG / PNG / WebP 以外は 400)が新画像を置き、`thumbnail` ジョブを + 投入する(UI のプレビューと album の解決に使う)。**tagwrite は書く前に、捨てる旧画像を同じ + store へ退避する**(`stage_tags` が `PICTURE` の変更を含む op で `read_parts` により実体ごと読む。 + 退避できなければ `failed` で何も書かない。`artwork` 行は `Staged::Written` で持ち帰り、finish_op + と同じトランザクションで upsert)。新画像のバイト列が store に無ければ `failed`(「画像が + キャッシュに無い」)。`AlreadyMatches`(外部が先に同じ画像を書いていた)は退避対象が無いので、 + その op の巻き戻しは「画像がキャッシュに無い」で failed になり得る(正直にそう記録する) +- **`write_tag_changes` は `pictures: Option<Vec<Picture>>` を取り、`Some` なら形式ごとに全画像を + 除去して挿入する**(FLAC は PICTURE ブロック、Opus / Vorbis は VorbisComments、MP4 / MP3 等は + generic Tag)。`None` なら画像に触らない(既存のタグ編集はこれ) +- **GC 区分 E は `edits` が参照する画像を回収しない。** `key='PICTURE'` の旧 / 新値に現れる hex の + `artwork` 行と `thumbs/<hex>/` は、履歴が残る限り巻き戻しに要る(画像は数百 KB なので履歴と同じ + 寿命で良い)。加えて、アップロード直後・参照前の行が消えないよう、E(行) にも dir と同じ + **24 時間の猶予**を付ける(`thumbs/<hex>/` の mtime が猶予内なら行も消さない)。既にある画像の + 再アップロードは `put_original` が dir を touch して猶予を数え直し、E(行) は計画の後・削除の直前にも + 書き込みコネクションを持ったまま猶予を再確認する(アップロードは touch → 同じ writer で upsert の + 順なので、touch が先なら残り、削除が先なら upsert が行を作り直す) +- **applied のとき、そのトラックの album を `mark_unresolved` して増分スキャンを投入する**(dedup。 + 既に走っていれば予約が残り次回で拾う)。Phase 5 が [D-49](D-049.md) の規則で album の絵を解決し直す。Derived は + `tag_version` が進むので既存の追随で再タグが走り、album の絵が変われば `src_artwork_id` の不一致で + もう 1 回走る(二重だが正しい)。トラック自身の画像を Derived に埋める・再生画面に出すのは + P1-3c(`tracks.artwork_id`、[D-51](D-051.md) の改訂) +- **API は `POST /api/artwork/embed { selection, sha256, description?, skip_pending? }`**(RG 書き込み・ + MD5 補填と同型。preview は無い)。対象は selection の active 全行(形式は問わない)。`sha256` の + `artwork` 行か原画像が無ければ 404 `artwork_not_found`。UI は操作タブの「アートワーク」節 + (ファイル選択 → アップロード → 256px のプレビュー → 「選択 N 件の埋め込み画像を差し替え」)。 + 履歴画面の `PICTURE` 値はサムネイルで出す + +**理由**: 埋め込み統一なら書き側は既存のタグ編集バッチに 1 キー足すだけで、事前条件・overlay・ +巻き戻し・リカバリ・stale ゲートの全部が流用できる。cover ファイルの実利は「差し替えが軽い +(FLAC を書き直さない)」と「Explorer のフォルダサムネイル」だけで、差し替えは日常操作ではない +(たまに 1 album 数百 MB の書き直しなら許容。CLAUDE.md が禁じるのは全件再エンコードの規模)。 + +**却下**: cover ファイルの op(`kind='cover'`)を album の先頭トラックを「担い手」にして記録する +(ディレクトリ単位の巻き戻し、cover と埋め込みの二重管理、GC との調停が丸ごと増える)。 +`edit_ops` に `album_id` を足して `track_id` を nullable にする(`edit_ops` の作り直しに加え、 +ロック・pending 一意性・履歴画面・リカバリの全部が「トラック or album」の分岐を持つ)。 +`kind='picture'` の専用 op(`tags` op と同じ事前条件・overlay・巻き戻しを二重に持つことになる。 +md5 op が専用なのは overlay も版も持たないから)。front cover だけ置き換えて他の種別を残す(上記)。 +古い画像を退避せず巻き戻し不可にする(不変条件 4)。 + +**未決**: P1-3c(トラック単位のアートワーク)。アップロード画像の再エンコード(大きすぎる画像を +縮めて埋める)は要望があれば。 diff --git a/docs/decisions/D-061.md b/docs/decisions/D-061.md new file mode 100644 index 00000000..8338052f --- /dev/null +++ b/docs/decisions/D-061.md @@ -0,0 +1,58 @@ +# D-61 トラックは自身の埋め込み画像を持ち、Derived と再生表示はそれを優先する + +**決定**(P1-3c。[D-60](D-060.md) の未決。トラックごとに画像が違う album 向け): + +- **`tracks.artwork_id`(マイグレーション 0012。`artwork` への FK、`ON DELETE SET NULL`)はそのトラック + 自身の埋め込み画像**(`pick_embedded`: front cover → 無ければ最初の 1 枚)。無ければ NULL。埋めるのは + ファイルの画像を実体ごと読む 2 か所: スキャナ Phase 3(`read_entry` が `read_audio_file_with_pictures` + で読み、選んだ 1 枚を `ArtworkStore` へ置く。既に同じ長さの原画像があれば stat だけで済ませる)と + tagwrite(`stage_tags` の書き戻し確認と外部変更の読み取り。store は `Editor::with_artwork`)。 + `TrackContent.picture` は `Unread` / `Absent` / `Found` / `Failed` の 4 値で、`insert_track` / + `update_content` が同じトランザクションで反映する: `Unread`(読んでいない・store が無い)は何も + 触らない、`Absent` は `artwork_id = NULL`、`Found` は `artwork` 行を upsert して `artwork_id` に + する(どちらも `artwork_dirty = 0`)、`Failed`(キャッシュへ置けない)は `artwork_id` を据え置いて + `artwork_dirty = 1`(次項)。normalize の経路は `Unread`(画像は変わらない) +- **キャッシュへ置けない(store の I/O 失敗)は「画像なし」と区別し、`tracks.artwork_dirty`(0012)で + 再試行する。** `register_track_picture` は `Err` を返し、`TrackContent.picture` は `Failed`: `artwork_id` + は据え置き、`artwork_dirty = 1` を立てる。スキャナは Phase 2 で `artwork_dirty` の行を(物理属性が + 同じでも)読み直す対象に含め、`Found` / `Absent` を記録できたら 0 に戻す。物理属性に依らない印なのは、 + deep で変更なしの行を検証して失敗した場合や、tagwrite が conflict / cancel で外部の画像を読んで + 物理属性を現在値に揃えた場合は、stat では再試行の契機が残らないため。`Absent` に潰すと DB が NULL の + まま固定される +- **deep scan はトラックの画像もハッシュを照合して置き直す。** 増分は同じ長さの原画像があれば stat だけ + (同じ画像を持つ数千トラックで実体を読み直さない)だが、同じ長さの破損は album の絵に採用されない + 画像(同梱 cover のある album の 2 曲目以降)だと Phase 5 の `put_original` に届かないので、deep で + `register_track_picture(verify = true)` が直す +- **tagwrite で画像が変わったときの追随は `sync_track_to_file` に集約する。** ファイルの現在値を DB に + 揃えた直後に `artwork_id` の前後を比べ、変わっていれば album の再解決を予約して増分スキャンを投入し、 + サムネイルが無ければ thumbnail ジョブを投入する。Applied / AlreadyMatches / Conflict / Failed / cancel + (`close_op`)の全経路が通る(外部の画像変更を conflict で読んだときも物理属性を現在値に揃えるので、 + ここで予約しないと次の増分スキャンには「変更なし」と見えて album の絵が古いまま固定される)。 + Phase 4 が pending の tags op のために `update_content` を飛ばした行も、その op の反映・conflict・ + cancel のいずれかでここを通る +- **既存行は deep scan 1 回で埋まる。** 変更なしの増分は最速パスでファイルを読まないので NULL のまま。 + NULL の間は Derived も表示も album の絵へ倒れるので壊れない。専用のバックフィルジョブは作らない + (リハーサル環境はリリース時に再移行する) +- **album の絵([D-49](D-049.md))は変えない。** グリッドと album 単位の表示は同梱画像 → 最初のトラックの埋め込み + のまま。Phase 5 が `tracks.artwork_id` でファイルの再読みを省く簡略化は別の改善 +- **Derived に埋める画像は `COALESCE(tracks.artwork_id, albums.artwork_id)`**([D-51](D-051.md) の「album の + `artwork_id`」を改訂)。`load_target` / `enqueue_all_stale` のクエリだけ変わり、`plan` と + `src_artwork_id`(実際に埋めた id)の意味はそのまま。album 共通の album は同じハッシュ → 同じ id + なので再タグは走らない +- **GC 区分 E の「参照されている」に `tracks.artwork_id` を足す。** 忘れると FK SET NULL で消えた + `artwork_id` が増分では復旧しない +- **API / UI**: `TrackRow` と `GET /api/tracks/:id` に `artwork_hash`(トラック自身。無ければ null)。 + 左下のアートワークは選択行(先頭)または再生中トラックの自身の画像 → 無ければ album の画像。 + `AlbumGrid` は変えない + +**理由**: 埋め込み統一([D-60](D-060.md))のライブラリでは、トラックの絵はそのトラックの埋め込みが正。album 単位の +解決([D-49](D-049.md))だけだと、トラックごとに画像が違う album で全曲が 1 曲目の絵になる(再生表示と Derived)。 +スキャナはもともと Phase 3 で画像を含むタグを読んでいるので、選んだ 1 枚をハッシュアドレスの store へ +置くだけで済み、同じ画像を持つ数千トラックでも実体は 1 つ。 + +**却下**: Phase 5 でトラックごとに画像を解決し直す(Phase 5 は album 単位の予約で回っており、トラック +全件を再読みすることになる)。`track_tags` の `PICTURE` 値から `artwork_id` を導く(値はハッシュだけで +実体が store に無いことがあり、front cover の種別も持たない)。バックフィルジョブ(deep scan で足りる)。 +Derived で album の絵を優先する(トラックごとに違う album で目的を果たせない)。 + +**未決**: Phase 5 の簡略化(上記)。 diff --git a/docs/decisions/D-062.md b/docs/decisions/D-062.md new file mode 100644 index 00000000..1ebbcf9f --- /dev/null +++ b/docs/decisions/D-062.md @@ -0,0 +1,31 @@ +# D-62 dev 番号は識別子の一部として永続化しない(dev の付け替えは「変更なし」) + +**決定**(2026-09-19。TrueNAS 実機のホスト再起動で起動時スキャンが全件を読み直した事故から): + +- **同一性解決の inode 段に「dev の付け替え」を足す。** 同じ `(dev, inode)` の候補が無いとき、別の + dev に同じ inode を持つ行が**ちょうど 1 つ**あり、`size` / `mtime_ns` / `ctime_ns` が**すべて**一致 + すれば同じ実体とみなす。`changed = false`(タグもフィンガープリントも読み直さない)で、commit の + 最速パスが `dev` だけを現在値へ直す(`update_physical`)。属性が 1 つでも違えば採らない(inode 再利用 + と区別できないので、次の段 = md5 / path へ落ちて読み直す)。候補が複数なら曖昧として採らない +- **編集の事前条件と「行と FD の照合」から `dev` を外す。** `edit_ops.expected_dev` は記録するが、 + tags / rename / md5 op の照合、rename と normalize の所在判定(`same_inode`)、flaccheck / RG / + 再生(`/api/stream`)の `matches` は inode / size / mtime_ns / ctime_ns(op はさらに `tag_hash` / + MD5 値)で判定する。列とスナップショットの `dev` は残す(inode 段の最速パスの索引 `(dev, inode)` + はそのまま) + +**理由**: Linux の `st_dev` はマウント時に割り当てる番号で、ZFS データセットはホスト再起動で変わる +(実機で `ssd/media` が 79 → 80)。SPEC は `(dev, inode)` を安定したものとして書いていたため、 +再起動後の起動時スキャンで全行が inode 段を外れ、path 段で `physically_changed = true` となって +9,098 件全部のタグ読みと可逆デコード(`lossless_md5`)が走った。データは壊れない(`audio_md5` は +一致するので版は動かない)が、再起動のたびに CPU 全コアで約 10 分・ライブラリ全読み(約 200 GB)を +払う。編集の事前条件も同じ前提だったので、バッチ準備と反映の間に再起動が入ると全 op が +`skipped_conflict` になるはずだった。dev が守るのは「別ファイルシステムの同じ inode 番号」だけで、 +Library の root は 1 データセットであり、size / mtime / ctime(op は tag_hash も)を併せれば +取り違えの余地は実質無い。 + +**却下**: 起動時に dev の対応表を作って DB を一括更新する(root 配下に複数データセットがあると +対応が一意に決まらない。行ごとの照合で十分)。`dev` 列を落とす(inode 段の索引として有用で、hardlink +判定にも使う。走査中は安定している)。dev 不一致を「変更あり」のまま md5 だけで確認する(可逆の +デコードが要り、避けたいコストそのもの)。 + +**未決**: なし。 diff --git a/docs/decisions/D-063.md b/docs/decisions/D-063.md new file mode 100644 index 00000000..640d4875 --- /dev/null +++ b/docs/decisions/D-063.md @@ -0,0 +1,44 @@ +# D-63 遡及照合はオフセットを探し、verify.log は Library に置かない + +**決定**(2026-09-19。P2-9): + +- **オフセットは最初から探す。** AccurateRip / CTDB に登録されている CRC は他人のドライブで + 吸ったもので、読み取りオフセットの補正が違えば同じ盤でも全サンプルが数十個ずれる。CUETools と + 同じ ±(5×588−1) サンプルの範囲で探し、「一致したトラック数 → 一致した信頼度の和 → 0 に近い」の + 順に 1 つ選んで `album_verifications.detected_offset` に残す。吸い出しのオフセットはディスクで + 1 つなので、トラックごとに別のオフセットは採らない。探索のために 1 オフセットごとに + デコードし直すのではなく、1 回流す間にトラック境界 ±2939 の近傍で累積和(AccurateRip v1 は + Σ(n × 語) で位置に線形)と CRC32 の途中状態(zlib の combine で部分列の CRC が出る)を記録し、 + 任意のオフセットの CRC を O(1) で出す(`cd/crctable.rs`)。AccurateRip v2 は線形でないので + オフセット 0 だけ比べる(CUETools も同じ)。CTDB の照合はパリティではなく trackcrcs の総当たり + (CUETools のフォールバック経路と同じ。パリティは P2-7 の修復で扱う) +- **verify.log は `data/verify/<album_id>.log`。** [SPEC §5](../spec/05-library-layout.md) はアルバムディレクトリに置く前提だったが、 + Library に同梱ファイルを置き始めると一括リネーム後の旧ディレクトリへの追随([D-43](D-043.md) の残課題、 + P2-8 で決める)が先に問題になる。アプリのデータ領域に album_id で置き、 + `album_verifications.log_path` で参照する。アルバムの全ディスクぶんを毎回書き直す +- **AccurateRip の ID は CUETools / dBpoweramp 式を既定にする。** Enhanced CD(末尾がデータ + トラック)で id1 / id2 は音声トラックだけを足すがリードアウトは実際の値、FreeDB ID はデータ + トラックも数える。データトラックを落として −11400 で切る libdiscid 式の ID も DB に別キーとして + 存在する(Hybrid Theory JP 盤で両方を実サーバで確認)が、エントリ数は CUETools 式のほうが多い。 + 必要なら `Toc::audio_session()` から作れる +- 照会に失敗したらジョブを失敗させて再試行し、何も記録しない。「照会できなかった」を + `not_found` や `mismatch` に混ぜない +- **記録は 1 トランザクションで、ジョブ単位に冪等。** 照合を始めたときの `audio_version` と + 記録直前の fstat(root からパスで開き直す)の両方が一致するときだけ書き、ディスク × 手法の行と + トラックの行と `tracks.verification` を 1 トランザクションで書く。verify.log の rename も + トランザクションの中(rename に失敗すれば DB も巻き戻す)。commit の後・done の前に落ちて + 起動時リカバリで同じジョブが再実行されても、`album_verifications.job_id`(`(job_id, disc_no, method)` + で一意)で既存行を見つけて何もしない(rename は commit の前なのでログも確定済み。読み直して + 別の結果で上書きしない) + +**理由**: 遡及照合の目的は「既存 FLAC の格付け」で、オフセット 0 だけでは補正なしのリップや +プレス違いが全部 `mismatch`(要確認)になり、格付けとして役に立たない。CUETools が実用になって +いるのは探索があるからで、累積和を使えば探索のコストはデコード 1 回 + 数百万回の整数演算で済む。 + +**却下**: オフセット 0 だけで始めて実機の一致率を見てから決める(探索の実装コストが小さく、 +先送りする理由が無かった)。verify.log をアルバムディレクトリに置く(P2-8 の同梱ファイルの決定を +待つ。決まったらそちらへ寄せてもよい)。AccurateRip の crc450 でオフセットを検出する +(CUETools の手法だが、累積和があれば全オフセットの完全な CRC を直接比べられる。crc450 は +計算だけ残してある)。 + +**未決**: ~~verify.log の置き場は P2-8 で同梱ファイルの扱いが決まったら見直す~~([D-67](D-067.md) で `data/verify` のままに確定)。 diff --git a/docs/decisions/D-064.md b/docs/decisions/D-064.md new file mode 100644 index 00000000..a7e07ffe --- /dev/null +++ b/docs/decisions/D-064.md @@ -0,0 +1,95 @@ +# D-64 MusicBrainz の候補は「リリース × medium」、TOC の入力源は差し替え可能にする + +**決定**(2026-09-19。P2-3): + +- **照会は DiscID → TOC の 2 段。** `ws/2/discid/<DiscID>` を引き、404 なら同じエンドポイントに + `?toc=`(MusicBrainz 形式の TOC)を付けて fuzzy に引く。MB 側がプレス違い・登録漏れの DiscID に + 対して TOC の近いリリースを返す。fuzzy の結果は `exact = false` として区別する +- **候補の単位はリリース × medium。** リリースの media のうち、自分の DiscID を `discs` に持つ + medium は exact、そうでなければ音声トラック数が同じ medium を近似の候補にする(複数枚組で + トラック数が同じディスクが複数あれば、それぞれが候補になる)。exact を先に並べ、MB の順は保つ。 + fuzzy の応答にも自分の DiscID を持つ medium が混ざることがあり、それは exact +- **`inc=recordings artist-credits labels release-groups isrcs`。** トラックのタイトル・アーティスト + 表記(joinphrase で繋ぐ。トラック固有が無ければリリースのもの)・長さ・recording / track の id・ + ISRC、リリースの日付・国・レーベルとカタログ番号・バーコード・注記・release-group id を候補に + 持たせる。タグの写像(P2-8 の配置で書く)はこの候補から決める +- **UA 必須・1 req/s はクライアント内で守る。** 直前の要求時刻を持ち、間隔が空くまで待つ + (ロックを持ったまま待つので並行する照会も直列)。503(負荷制限)は 1 度だけ間隔ぶん待って + 再試行し、それでも 503 なら API は 503 `musicbrainz_unavailable`(一時的。届かない・壊れた応答の + 502 `lookup_failed` とは分ける)。両方の要求に `cdstubs=no` を付ける(未登録 DiscID に CD stub が + あると 200 で別の形が返り、404 → fuzzy に進めない)。`[musicbrainz].url` で照会先を差し替えられる + (テストと自前ミラー) +- **TOC の入力源は差し替え可能にし、ドライブが無い間は貼り付け。** `POST /api/cd/lookup { toc }` は + TOC 文字列(CTDB 形式か MusicBrainz 形式。`Toc::parse`)を受け、ドライブからの TOC + (P2-1 / P2-2 の `GET /api/cd/status`)も同じ文字列で渡す。CD 画面は今は貼り付け欄 + (`cdrecord -toc` の出力も LBA を拾って受け付ける)を入力源にし、P2-1 で検出に差し替える。 + 貼り付けはデバッグ用に残す + +**理由**: 候補をリリース単位にすると複数枚組でどの medium かが決まらず、トラック対応も +確認できない。DiscID だけで引くとプレス違いの盤(同じ内容で TOC が数セクタ違う)が 0 件になり、 +[D-21](D-021.md) の「手入力」に落ちる場面が増える。TOC の入力源を API の境界で文字列に揃えておけば、 +ドライブの実装(P2-1)を待たずに照会と候補選択の全経路をテストでき、届いた後は検出を差し替える +だけで済む。 + +**却下**: libdiscid の FFI(TOC からの整数演算で足りる。P2-2)。CD stub(MB の cdstubs)を +候補に含める(品質が低く、手入力経路があるので不要)。cover art の取得(P2-8 の配置で +Cover Art Archive を引くか決める)。 + +**未決**: fuzzy の候補が多いときの並べ方(今は MB の順)。手入力と候補の「補正」は [D-65](D-065.md) で決めた。 + +**追記(2026-09-22。P2-3 拡張。DiscID 以外の識別経路)**: 実盤(嵐「Five」の初回盤)が MusicBrainz に +登録されているのに DiscID もトラック長も未登録で、TOC の fuzzy では原理的に出なかった。識別を 5 経路にする: +(1) DiscID(exact)、(2) ユーザが貼ったリリース URL / MBID(`ws/2/release/<id>`。どんな盤でも当てられる保険)、 +(3) ディスクの Q サブチャネルから読んだ ISRC(`ws/2/recording?query=isrc:A OR isrc:B…` を 1 回 → 一致数の +多いリリースから上限 5 件を取得)、(4) MCN = JAN / UPC(`ws/2/release?query=barcode:` → 上限 3 件)、 +(5) TOC の fuzzy。ISRC / MCN はドライブが TOC と同じ回に SG_IO の READ SUB-CHANNEL で読み +(`GET /api/cd/status` の `isrcs` / `mcn`)、UI が照会に添える。DiscID で当たれば (3)(4) は引かない((2) だけ +足す)。同じリリース × medium は 1 件に束ね、出てきた経路を `matched_by`(discid > release > isrc > +barcode > toc)で持って、その順に並べる。fuzzy の応答に混ざった DiscID 持ち(上記)は discid 経路として扱う。 +指定リリースが読めない・トラック数の合う medium が無いときは `notes` で返し、照会全体は失敗にしない。 +候補を選んだ後(DiscID 未登録のとき)は libdiscid と同じ形の登録 URL(`cdtoc/attach?id=&tracks=&toc=`)を +UI に出し、登録は本人がブラウザで行う。上の「未決」の並べ方はこれで閉じる(経路の強さ、同じ経路の中は +MB の順)。 + +**追記 2(2026-09-22。接続の経路と診断)**: この回線の **IPv4 から MetaBrainz のインフラ全体 +(musicbrainz.org / beta.musicbrainz.org / coverartarchive.org、443 と 80)へ繋がらない**ことが分かった。 +TCP は受理されるが TLS ハンドシェイクの途中で切られる(`unexpected eof`。TLS 1.2 を強制しても、鍵交換の +曲線を絞っても同じ)。同じ回線から github.com の IPv4 は通り、MetaBrainz の **IPv6 は通る**(TrueNAS ホスト・ +開発コンテナ・spindle のコンテナすべてで確認。Docker の bridge は ULA + NAT66 で外へ出られる)。レート制限 +(503 + JSON)とは別物で、先方のエッジがこの公開 IPv4 を落としている。対処: +- `[musicbrainz].address_family = "auto" | "ipv6" | "ipv4"`(既定 auto)。`Auto` 以外なら reqwest の DNS 解決を + その族に絞る(`cd::select_addrs` / `FamilyResolver`)。reqwest に族を選ぶ設定は無いのでリゾルバを差し替える。 + 指定した族のアドレスが 1 つも無ければ、黙ってもう一方へ倒さず「IPv6 のアドレスが無い」と分かる失敗にする + (塞がっている族へ落ちると原因の分からない TLS エラーになるだけで、1 回の解決で片方しか返らない事故も + 見えなくなる。接続の失敗は下の張り直しで 1 度やり直すので、一過性の解決漏れはそこで復帰する) +- 応答が返る前に落ちた要求(TLS の失敗・idle なコネクションの再利用・一過性の切断)は **1 度だけ張り直す**。 + タイムアウトは対象外(待ち直しても同じで、時間が倍になるだけ) +- ログと API の本文は `cd::error_chain` で原因の末端まで出す。reqwest の `Display` は + 「error sending request for url (…)」で止まり、TLS の失敗・タイムアウト・接続断の区別が消えていた +- **照会結果は入力(TOC + ISRC + MCN + 指定リリース)ごとに 10 分覚える**(追記 3。`MusicBrainzClient` の中。 + 8 件まで、古いものから捨てる)。1 枚の照会で最大 13 本の要求が飛ぶので、画面を開き直すたびに引き直すと + 1 req/s の規約に対して重い。失敗は覚えない(押し直せばまた引きにいく)。画面の「MusicBrainz に照会」 + ボタンは `refresh: true` で捨てて引き直す(DiscID を登録した直後などに効く)。ディスク検出からの自動照会は + 付けない +- MusicBrainz の `ws/2` は読み取りに認証が不要で、**OAuth トークンを取ってもレート上限も IP 遮断も変わらない** + (トークンは投稿・ユーザデータ用)。MetaBrainz アカウントが効くのはローカルミラー(Live Data Feed の + レプリケーショントークン)を立てる場合で、そのときは `[musicbrainz].url` を差し替える + +**追記 4**(2026-09-23。P4-20): 経路を**段階にして打ち切る**。`discid` → `ids`(ISRC 検索 + +バーコード検索)→ `toc`(TOC 近似)の順で、上の段で候補が 1 件でも残れば下の段は引かない。 +候補の数はトラック数で絞ったあとで数える(ISRC が当たっても曲数の合う medium が無ければ 0 件で、 +次の段に進む)。**DiscID が 200 でも候補が 0 件なら次の段へ落とす**(登録済みの DiscID でも medium の +トラック数が合わなければ候補にならず、そこで止めると行き止まりになる)。ただし `exact` は真のままに +して、DiscID の登録を勧めない。ユーザが貼ったリリース指定は段に関係なく常に足し、`toc` に進むかの +判定では「残った候補」に数える(ただし `ids` は引く。複数経路を束ねる D-64 本体の動きを壊さない)。 + +**`ids` の候補は常に TOC 近似より強い、と決め打ちしている。** ISRC は録音単位なので、1 曲だけ一致する +コンピレーションでも曲数さえ合えば候補に残り、TOC 近似を止める。正解を自動で取り逃すことはあり得るが、 +逃げ道は「さらに広げて探す」(`can_widen`)で残す。一致した ISRC の数などの確度を持って比べる設計は +採らない(値を経路ごとに持ち回る仕組みが要り、割に合わない)。 + +理由: 実機(嵐「Five」)で、ISRC 経路に正解があるのに TOC 近似が全く別の盤を大量に返し、 +候補一覧が読めなくなっていた。TOC 近似は「トラック長が近い」だけの弱い一致で、DiscID が未登録で +ISRC も無い盤のための最後の手段。応答の `stage` と `can_widen` で画面に段を見せ、「さらに広げて +探す」(`widen`)で明示的に下の段まで引ける。`widen` はキャッシュの鍵に入れる(広げた結果が +普通の照会に返らないように)。 diff --git a/docs/decisions/D-065.md b/docs/decisions/D-065.md new file mode 100644 index 00000000..c2de2d3d --- /dev/null +++ b/docs/decisions/D-065.md @@ -0,0 +1,87 @@ +# D-65 手入力は「候補を土台に編集する 1 つのフォーム」、貼り付けの行解析は決め打ち + +**決定**(P2-4、[D-21](D-021.md) の具体化): + +- **候補も手入力も同じフォーム(`DiscDraft`)に収束させ、確定で `DiscMetadata` にする。** 候補を + 選ぶとフォームに写り(アルバム・アルバムアーティスト・日付・レーベル / カタログ番号 / バーコード・ + ディスク番号 / 枚数・各トラックのタイトル / アーティスト、MusicBrainz の release / recording / track + id と ISRC)、そこから直せる。候補ゼロ件なら空のフォームに直行し、候補がどれも違えば「候補を使わず + 手入力」で空にする。どちらも同じ `DiscMetadata`(`source: musicbrainz | manual`)なので、吸い出し + (P2-5)と配置(P2-8)の入力は 1 経路。候補を写し直すと編集中の内容は消える(画面で断る) +- **行は TOC の音声トラックと 1:1。** 番号と長さは TOC から取り、編集できない。候補のトラックは + 位置順に当て、足りなければ空行、余れば捨てる。`POST /api/cd/lookup` の応答に `tracks: [{ number, + length_ms }]` を足し、候補が無くても行数と長さが出る +- **確定の条件はアルバム名・アルバムアーティスト・各トラックのタイトル。** 日付は `YYYY[-MM[-DD]]` + か空。トラックのアーティストが空ならアルバムアーティストで埋める。タイトルの分からない盤のために + 「空のタイトルを `Track NN` で埋める」ボタンを置く(黙って埋めない) + → **P4-20 で覆した**(下の追記を見ること) +- **貼り付けの行解析は web(TypeScript)に置き、規則は決め打ち。** 貼るたびに往復せず即座にプレビュー + でき、間違いはフォームで直す前提なので、推測して当てに行かない。規則: 行頭の番号(`1.` `01` `1)` + `[01]` `Track 1:` `M-1` `#1`、全角。1..=99 だけで、年や `100 Years` は番号にしない)、行末の時間 + (`4:32` `(3:05)` `1:02:03`。秒は 00..59)、アーティストの区切り(`/` と `/` > `|` と `|` > + ` - ` ` – ` ` — `。既定は「タイトル / アーティスト」で、チェック 1 つで逆順。区切りは**アーティスト側の + 端**で切り、タイトル内の ` - Remix - ` を残す。端にぶら下がった区切り(`Title/`、`A - B -`)は + 貼り付けの残骸として落とし、空白無しの `-Intro-` は残す)、タブがあれば表の + 貼り付け(数字だけの列は番号、時間の列は長さ、残りがタイトル・アーティスト)、空行と `Disc 1` / + `トラックリスト` / `収録曲` の見出しは飛ばす。番号の無い行は前の行 + 1。**番号で行に対応付ける**ので、 + 貼り付けの行数が TOC と違っても写せるところは写し、行数の違い・TOC に無い番号・未設定の行を警告に + 出す。アーティストの無い行は既存の値を保つ(候補の表記を貼り付けで消さない)。番号と時間の判定は + 全角の数字・コロンも受けるが、タイトルの全角は正規化しない +- **画面の遷移は純粋な reducer(`web/src/lib/cdState.ts`)で、段が上から消える。** toc → result → + selected / draft → confirmed の順に段があり、上の段が変わると下は全部消える(TOC の編集と「結果を消す」は + 結果と貼り付けの本文ごと、照会のやり直しと候補の選び直しは選択より下。同じディスクの照会のやり直しでは + 貼り付けの本文を保つ)。確定後は候補の選び直しと手入力を受け付けず、「編集に戻る」で確定だけ外す。 + hook(`useCdLookup`)は非同期の照会と dispatch だけにして、遷移は vitest で固定する +- **確定画面はタグ名で見せ、写像を関数(`albumTags` / `trackTags`)で固定する。** P2-8 の配置はこの + 写像で書く。MusicBrainz の id は recording が `MUSICBRAINZ_TRACKID`、リリース内の track が + `MUSICBRAINZ_RELEASETRACKID`(Picard の写像。取り違えやすい)。写像は `[キー, 値の列]` で、ISRC のような + 多値は Vorbis コメントの同じキーの反復として持つ(`;` で 1 値に繋がない)。`TRACKTOTAL` と + `MUSICBRAINZ_DISCID` は TOC から吸い出し時に付ける + +**理由**: 同人・VTuber・インディーズの国内盤は MusicBrainz にあっても誤字や表記揺れがあり、 +「候補は選ぶだけ、手入力は最初から全部」だと誤字 1 つで全部書き直しになる。候補と手入力を別の形に +すると P2-8 のタグ写像が 2 経路になり、MusicBrainz の id を持ち越すかどうかも経路ごとに決めることになる。 +貼り付けの解析は「そこそこ当たって、外れたらすぐ直せる」ことが価値で、通販ページの形は無限にあるので +規則を増やして当てに行くより、プレビューとフォームで直す方が速い。 + +**却下**: 解析を Rust の API にする(貼るたびに往復。純粋関数なので vitest で固定化できる)。 +空のタイトルを確定時に黙って `Track NN` にする(黙った既定はタグに残る。ボタンで明示させる) +→ **P4-20 で覆した**。プレースホルダで埋まる値が常に見えているので「黙って」ではない。 +アーティストの区切りを「最初の出現」で固定する(`Title - Remix - Artist` が壊れる)。全角の英数字・ +記号をタイトルごと正規化する(`第2章` や `!` が変わる。番号と時間の判定だけ全角を受ける)。 +画面の遷移を hook の useState に散らす(テストできず、TOC 編集で確定が残るような取りこぼしが見えない)。 + +**未決**: 貼り付けの行数が TOC と違うときに「上から順に当てる」選択肢(今は番号で対応付けるだけ)。 +複数枚組の貼り付け(`Disc 2` 以降を別のディスクとして扱う。今は見出しを飛ばして番号の重複を警告)。 + + +**追記**(2026-09-23。P4-20。上の「確定の条件」と「遷移」を覆す): + +- **空のタイトルは確定を止めない。** 表では `Track NN` を**プレースホルダとして見せ**(値は空)、 + 取り込みの直前に空のままなら `Track NN` を採る(`defaultTitle` / `finalizeDraft`)。 + 「空のタイトルを `Track NN` で埋める」ボタンは要らなくなったので消した。 + 確定の条件はアルバム名・アルバムアーティスト・日付の形・ディスク番号だけ +- **「確定」の段そのものを廃止した。** 表を直接編集して「取り込む」1 発にしたので、`confirmed` / + `draftErrors` と `confirm` / `unconfirm` / `fill_titles` は無い。「編集に戻る」も無い +- **フォームは TOC が読めた時点で必ず存在する**(表は照会の前から出る)。消えるのは別のディスクに + 替わったときだけで、照会の開始と失敗では消さない +- **CD 画面からは直せない**(2026-09-23 追記。ユーザ要望)。候補を選ぶ / 写す範囲を変える / + 「どれも違う」の 3 つだけで、補正とトラックリスト貼り付けは **Inbox の承認画面**に移す + (取り込んだものは Inbox を通る。[D-67](D-067.md) 追記)。入力欄が 2 か所にあると、どちらで直すのか + 分からなくなる。貼り付けは P2-10 で Inbox の承認画面へ移した(下の追記 2) + +理由: 分からないトラックに既定の名前が「最初から見えている」方が、ボタンを押させるより分かりやすい +(ユーザ要望)。画面が「表を直して取り込む」1 本になり、確定を挟む理由が無くなった。黙って埋めるのを +避けたかった元の意図は、プレースホルダで**埋まる値が常に見えている**ことで満たしている。 + +**追記 2**(2026-09-23。P2-10。貼り付けを Inbox の承認画面へ移した): + +- **写し先は Inbox の下書き(`InboxDraft`)の、選んだ 1 枚のディスクの行。** Inbox の件は TOC ではなく + ファイルなので、対応付けは行の `track_no`(ファイルの並びではない)。件に複数のディスクがあるときだけ + 「写す先」を選ばせる(上の未決の「`Disc 2` 以降を別のディスクとして扱う」は決めず、1 回の貼り付けは + 1 枚ぶん)。同じ番号の行が複数ある(下書きの番号が重複している)ときは、どれに写すか決められないので + 写さずに警告する +- **アーティストを貼った行は多値の「そのまま保つ」([D-70](D-070.md))を外す。** 保ったままだと貼った値が + 書かれず、画面も結合表示のまま変わらない。貼った以上は 1 値で書く意図とみなす。アーティストの無い行は + 従来どおり既存の値(と保つ設定)を保つ +- CD 画面側の `lib/cd.ts` の `applyTracklist` は消した(Inbox 版は `lib/inbox.ts`) diff --git a/docs/decisions/D-066.md b/docs/decisions/D-066.md new file mode 100644 index 00000000..fae90d7b --- /dev/null +++ b/docs/decisions/D-066.md @@ -0,0 +1,36 @@ +# D-66 CTDB の修復は CUETools の定義どおりに、CRC が合うときだけ適用する + +**決定**(P2-7): + +- **符号は CUETools `CDRepair` そのもの。** 16 bit 語を stride = 11760 語(10 セクタ)の行に並べ、 + 列ごとに GF(2^16)(生成多項式 0x1100B)の Reed-Solomon。データ行は先頭 1 行(leadin)と末尾 + 1 行 + 端数(leadout)を除いた K = 2N/stride − 2 行で、CTDB のディスク CRC の範囲と一致する。 + シンドローム S_i(c) = Σ_r d_r·α^{i(K−r)} を直接 Horner で取る(CUETools は LFSR でパリティを作って + から変換するが、パリティファイルが持つのはシンドロームなので、変換を挟まない方が短い) +- **パリティファイルは面順のシンドローム。** `hasparity` の URL に npar 面 × 11760 語(LE)。XML の + `syndrome` 属性は列 0 で、旧い `parity` 属性(列 0 のパリティ 8 語)は `Parity2Syndrome` の式で + 同じ形にする。取得は Range で先頭 npar 面だけ(CUETools は 4 → 8 → 16 と広げる。npar 8 で 188 KB)。 + 列 0 が属性と合わなければ拒む +- **オフセットは列 0 だけで探す**(`syndrome` 属性があれば通信なしで決まる)。ずらした列は隣の列に + leadin / leadout の 1 語を足し引きして出る(`GetSyndrome` と同じ)。探索範囲は ±2939(CrcTable と + 同じ。CUETools は ±5879)。完全一致を優先し、無ければ列 0 の誤りが npar/2 未満で復号できる + うち最少のもの +- **直した後のディスク CRC がエントリの値に一致するときだけ計画を採用する。** 誤りパターンの + CRC への寄与は線形なので、計画の段階で `crc32_combine` により適用前に求まる(CUETools と同じ)。 + RS が「直せた」と言っても CRC が合わなければ誤訂正として捨てる。適用は 2 回目の走査で語を XOR + し、そこでもデータ行の CRC を取って返す +- **計画と適用を分ける。** 吸い出し(P2-5)は tmp の PCM を 2 回読む: 1 回目で CRC 表とシンドローム表、 + 2 回目で適用。ディスク全体(最大 800 MB)を載せない。修復の対象は自分の吸い出し結果だけで、 + 既存 FLAC(§7.3 の遡及照合)は直さない(音声の書き換えは版管理と巻き戻しの対象で、別の判断が要る) + +**理由**: 傷は 1 セクタ(1176 語)単位で来て、1176 の列に 1 語ずつ散るので、列あたり npar/2 個 +(4 か 8)の訂正能力で数セクタぶんが直る。これが CTDB を主にする理由([D-13](D-013.md))そのもの。CRC の +ゲートを外すと、能力を超えた誤りを RS が別の符号語に「直して」しまったものが通る。 + +**却下**: パリティを LFSR で作って変換する(CUETools 互換の内部表現だが、ファイルにあるのは +シンドローム)。オフセット探索に CRC 表を使う(誤りがあると CRC は一致しないので探せない)。 +遡及照合の FLAC を直す(版管理と巻き戻しの範囲で別途決める)。 + +**未決**: オフセット探索を ±5879 に広げる(CrcTable の範囲も一緒に広げる必要がある)。 +80 分 3〜5 秒のシンドローム計算を SIMD で速める(吸い出しは 10 分かかるので今は要らない)。 +CTDB への提出(自分のシンドロームを面順にした `DbSyndromes::from_table` は用意してある)。 diff --git a/docs/decisions/D-067.md b/docs/decisions/D-067.md new file mode 100644 index 00000000..95ef4e24 --- /dev/null +++ b/docs/decisions/D-067.md @@ -0,0 +1,209 @@ +# D-67 CD の配置は rip ジョブが直接登録し、同梱ファイルは album 全体の移動に追随する + +**決定**(2026-09-19。P2-8。[D-43](D-043.md) / [D-63](D-063.md) / [D-64](D-064.md) / [D-65](D-065.md) の残課題を閉じる): + +- **配置は `cd::place::place_disc` の 1 関数で、rip ジョブ(P2-5)の最終段として呼ぶ。** 入力は + `Toc`、`DiscMetadata`([D-65](D-065.md) の確定フォームと同じ形 + `category`)、`TrackLayout`、オフセット適用済みの + tmp の PCM(s16le / 2ch / 44.1k)、`RipReport`(ドライブ・オフセットと出所・試行回数・トラックごとの + 再読み / C2 数・AccurateRip / CTDB の `MethodResult`・修復の適用数。P2-5 が埋める)。`POST /api/cd/rip` + と吸い出しは P2-5 で、P2-8 はテストから `place_disc` を直接呼んで固定する +- **エンコードは raw PCM を `flac -8 --verify` に直接渡す**(ffmpeg を挟まない。正規化の `FlacEncoder` + はデコードが要るので経路が違う)。トラックごとに PCM の MD5 を取り、STREAMINFO の MD5 と一致する + ときだけ成果物にする。タグは [D-65](D-065.md) の写像(`album_tags` / `track_tags` を Rust に移植)+ `TRACKTOTAL` + (TOC の音声トラック数)+ `MUSICBRAINZ_DISCID` を lofty で書く。`category` はタグに書かない(パス専用。 + [SPEC §5](../spec/05-library-layout.md)) +- **パスは `pathgen::plan` を再利用する**(`[layout]`、category 無しなら `unsorted`、`disc_count > 1` なら + `multi_disc`。降格と衝突は [D-43](D-043.md) の規則のまま)。リリースキーは `mb:<release_id>` → 無ければ、宛先 + ディレクトリに albumartist と album が一致する複数枚組(`disc_count > 1` か構成トラックの `disc_no` の + 最大が 2 以上)の album があり、入力も複数枚組で、その `disc_no` のトラックがまだ無ければ**その album に + 合流**(複数枚組の手入力で 2 枚目が `({year})` に降格しない)→ それ以外は `disc:<DiscID>` の新規リリース + (Library へ直接置いていたときの規則。D-67 追記 2 で Inbox 経由になり、追記 3 で `disc:` のキーは無くなった)。 + `release_id` があるときは合流を探さない(別リリースなら降格し、降格先が album になる。合流先を持ったまま + 降格すると「ディレクトリ = album」が壊れる)。計画はエンコードの前に一度(衝突を早く知る)と、 + `library` の排他を取った後にもう一度行い、後者を確定にする(エンコードの間に scan / rename が album を + 動かし得る)。rename は `library` の排他を取らない(track_locks だけ。[D-38](D-038.md) の並走前提)ので、登録の + トランザクション(writer で直列)でも全経路を再検証する: 合流先は `rel_dir_key` が計画の宛先と一致し + active であること、宛先の既存 album はリリースキーが計画と同じであること(違えば衝突。missing の + album は同じキーなら復活、違えば `\0displaced:` へ退かせて新規)、同じパスの行は `audio_md5` が + 一致すること。検証に失敗したら、この呼び出しで**新しく置いた**ファイルだけを消し(既存の成果物と + 他人のファイルは触らない)、空になったディレクトリを消してから Conflict にする +- **配置は `job_mutexes` の `library` を取ってから**(scan / gc と同じ。取れなければ Requeue)。 + トラックと同梱ファイルを tmp → fsync → `RENAME_NOREPLACE` → dir fsync で置き、1 トランザクションで + `albums`(無ければ作成。合流なら触らない)/ `tracks`(スキャナの `track_content` / `insert_track`。 + `source_type = 'cd_rip'`、`verification`)/ `track_tags` / `album_verifications`(`source = 'rip'`、 + 手法ごと、`log_path` は Library 相対の rip.log)/ `track_verifications` を書く。`tracks.verification` + の写像は verify ジョブと同じ(CTDB 一致 → `verified_ctdb` → AR 一致 → `verified_ar` → 候補あり不一致 → + `mismatch` → 候補なし → `not_attempted`)。後続は `rg`(album)と `transcode`(`derived::enqueue_if_stale`)。 + カバー画像は無いので thumbnail は出ない(Cover Art Archive は引かない。`/api/artwork/embed` で後から + 埋め込む) +- **冪等性は MD5 で判定する。** 宛先に既にファイルがあれば STREAMINFO の MD5 が自分の PCM の MD5 と + 一致するときだけ自分の成果物とみなして配置を飛ばす。DB に同じ `rel_path` の行が既にあり `audio_md5` + も一致すれば(配置の後に落ちてスキャナが先に拾った)挿入せずその行を採用し、出自と検証だけ書く。 + どちらでもなければ conflict で失敗し、Library は触らない +- **同梱ファイルの名前は 1 枚なら `disc.cue` / `disc.toc` / `rip.log`、複数枚組は `disc<N>.cue` / + `disc<N>.toc` / `rip<N>.log`**(N = `disc_no`、0 埋めなし)。形式: + - `rip.log`: 先頭行 `spindle rip log v1`(スキャナの判定キー)。ドライブ / デバイス / 読み取り + オフセットと出所 / エンコーダ / 各種 DiscID と TOC 文字列 / アルバム情報 / トラック表(LBA・長さ・ + 再読み・C2・CRC32・ARv1・ARv2・CTDB CRC・照合結果・ファイル名)/ 手法ごとの結論と修復 / 総合結果 + - `disc.cue`: EAC 流の複数ファイル cue(`REM DISCID` / `REM DATE` / `CATALOG`(バーコードが 13 桁の + とき)/ `TITLE` / `PERFORMER`、トラックごとに `FILE "…" WAVE` / `TRACK NN AUDIO` / `TITLE` / + `PERFORMER` / `ISRC` / `INDEX 01 00:00:00`)。ギャップは前トラック末尾に付く形(INDEX 00 は TOC から + 分からない)。データトラックは `REM` で記す + - `disc.toc`: `Toc` から cdrdao 構文(`CD_DA` / `CATALOG` / `CD_TEXT` / `TRACK AUDIO` + + `FILE "…" 0 MM:SS:FF`)で生成。実ドライブの `cdrdao read-toc`(P2-2)も `Toc` にしてから同じ形で + 書く(形式を 1 つに) +- **同梱ファイルは album 全体の移動に追随する([D-43](D-043.md) の残課題)。** rename ジョブは phase 2 の後の + commit トランザクションの中(`tx.commit()` の前)で、applied の op の旧ディレクトリのうち宛先が 1 つで + active な行が残らないものについて、**既知の名前**(`cover` / `folder` / `front` × 画像拡張子、 + `disc*.cue` / `disc*.toc` / `rip*.log`)の通常ファイルを新ディレクトリへ `RENAME_NOREPLACE` で移し + (衝突は残して警告)、旧ディレクトリが空なら `rmdir`(`ENOTEMPTY` は無視)。commit の前に動かすのは、 + トラックの実体は phase 2 で既に宛先にあって commit はそれを記録するだけなので、途中で落ちても再実行が + 同じ判定に至り(旧ディレクトリに同梱ファイルはもう無い)、commit の後に落ちて追随だけが永久に残る窓を + 作らないため。終端を観測した側は追随済みの状態を見る。`edits` には記録しない。巻き戻しは逆向きの + album 全体の移動になるので同じ経路で戻る。一部だけの巻き戻しでは動かない(トラックが残る側に + 付いていく)。rename op がトラックのパスだけを所有する原則([D-43](D-043.md))は変えない +- **verify.log は `data/verify` のまま**([D-63](D-063.md) の未決を閉じる)。Library に置く同梱ファイルは rip 由来の + 3 つだけ +- **スキャナは spindle の rip.log から `source_type = 'cd_rip'` を復元する。** ディレクトリの inventory で + `rip.log` / `rip<N>.log` を見つけたら先頭行だけ読み、`spindle rip log v1` なら印を付け、**新規挿入する + 行**だけ `cd_rip` にする(既存行は触らない。`verification` は復元せず遡及照合 §7.3 で付け直す)。 + 不変条件 1(DB はキャッシュ)を出自についても保つ +- **`category` は確定フォームで選ぶ。** `GET /api/categories` / `POST /api/categories { name }`([SPEC §9](../spec/09-http-api.md) に + あって未実装だった)を足し、`DiscDraft` / `DiscMetadata` に `category`(語彙の名前。null なら + `_Unsorted`)を持たせる + +**理由**: スキャナ経由の登録(置いてから incremental scan)だと出自と検証結果を書く相手の行が +いつ現れるか分からず、競合を避けるにはどのみち `library` の排他が要る。直接登録なら配置と登録が +1 つのジョブに閉じ、冪等性の判定も MD5 の 1 規則で済む。同梱ファイルの追随を rename ジョブに置く +のは、album 全体の移動を既に coordinator が判定していて、そこでしか「ディレクトリが空になる」 +ことが分からないため。出自を rip.log から復元するのは、DB を消しても `cd_rip` の絞り込みが戻る +ように(検証は照会し直せば戻るが、出自は照会では戻らない)。 + +**却下**: 置いてからスキャンで拾わせる(上記)。同梱ファイルの移動を op として記録する +(rename の 2 phase に絡み、巻き戻しの対象が増える割に、ユーザが編集した値ではない)。同梱 +ファイルを残して GC に回収させる(旧ディレクトリに cover.jpg だけが 30 日残る)。verify.log を +アルバムディレクトリへ移す(既存ログの移行が要り、Library に置く理由が無い)。Cover Art Archive の +取得(同人 / 国内盤は無いことが多く、埋め込みは後からできる)。`SOURCEMEDIA=CD` のようなタグで出自を +持つ(外部ツールが付けた同名タグと区別できない。rip.log は spindle の署名付き)。 + +**未決**: 同梱ファイルの追随が衝突で残ったときの回収(今は警告だけ)。cdrdao の `read-toc` が持つ +ISRC / pre-emphasis / CD-TEXT を `Toc` に持たせて disc.toc に書く(P2-2 のドライブ実装で決める)。 + +**追記**(2026-09-23。ユーザ要望。**実装は P2-5**): **吸い出したものは Library へ直接置かず、Inbox を +通す。** CD 画面からは編集を外し、値を直す場所を Inbox の承認画面に一本化する。 + +- 吸い出した FLAC は `[paths].inbox` の下に 1 ディレクトリ = 1 枚で置く(複数枚組はディスクごと) +- **MusicBrainz から写した内容と検証結果はサイドカー `spindle-inbox.json` に書く**([D-70](D-070.md) が + ytmusic 用に作った仕組みをそのまま使う)。これで承認画面に候補の値が最初から入り、打ち直しが要らない。 + タグにも書くが、`category` はタグに書かない(パス専用。[SPEC §5](../spec/05-library-layout.md))ので、サイドカーが唯一の伝え方になる +- `rip.log` / `disc.cue` / `disc.toc` も件のディレクトリに置き、承認して配置するときに Library へ運ぶ + (既存の同梱ファイルの追随と同じ扱い) +- **AccurateRip / CTDB の照合結果は配置時に登録する。** `album_verifications` / `track_verifications` / + `tracks.verification` は `tracks` の行が要るので、Inbox に居る間は登録できない。サイドカーに + `RipReport` を入れておき、Inbox の配置がそれを読んで `source = 'rip'` で入れる +- `source_type = 'cd_rip'` も配置時に付ける(スキャナの rip.log 判定は残す。外から置かれた rip にも効く) + +**理由**: ユーザの導線は「入力(CD / YouTube)→ Inbox → ライブラリ」で、CD だけ Library へ直行するのは +一貫しない。また CD 画面と承認画面の両方に入力欄があると、どちらで直すのか分からなくなる。承認を +挟むことで、取り込んだ直後に名前を直してから Library に入る。 + +**この追記で `place_disc` は作り直しになる**(宛先が Library から Inbox へ、登録が `tracks` から +`inbox_items` + サイドカーへ変わる)。既存の `tests/cd_place.rs` も書き換わる。P2-5 でまとめて行う。 + +**リップ開始の入力は空の名前を許す**(codex 指摘)。CD 画面から編集を外したので「どの候補も違う」 +盤は名前の無いまま取り込むことになるが、いまの `DiscMetadata::validate`(`src/cd/metadata.rs`)は +`EmptyAlbum` / `EmptyAlbumArtist` を返し、web の `validateDraft` もアルバム名とアルバムアーティストを +必須にしている。このまま P2-5 で既存型を配線すると、その経路は必ず開始を拒まれる。 + +- リップの開始に渡すのは「**名前は空でもよい**」別の契約にする(`DiscMetadata` の検証は Library へ + 置くときのもの。Inbox の承認で `InboxDraft` の検証が必須を担保するので、二重に持たない) +- 名前が空なら Inbox の件のディレクトリ名は DiscID など盤の識別子から作る(`_Unsorted` へ落とすのは + 承認時の判断) +- web の `validateDraft`(`lib/cd.ts`)は CD 画面から使われなくなった。P2-5 で開始の前提を決めるときに + 消すか、空名を許す形に直す +- 受け入れ条件: **候補ゼロ件・アルバム名もアーティストも空のまま Inbox まで完走する** + +**サイドカーは `RipReport` の各トラックをファイルに明示的に結びつける**(codex 指摘)。既存の +`cd::place::disc_record` は `RipReport` の配列順と `track_ids` の配列順を zip しているが、Inbox では +ユーザが `disc_no` / `track_no` / タイトルを直せるので、走査順や下書き順を暗黙に信じてはいけない。 + +- サイドカーは**ファイルの basename(不変の鍵)でトラックへ対応させる**。配置時に、件の全ファイルと + 1 対 1 で対応すること・CRC の件数・`disc_no` が揃うことを確かめてから登録する +- `source_type = 'cd_rip'` / `album_verifications`(`source = 'rip'`)/ `track_verifications` / + `tracks.verification` は、Inbox の登録(`register_item`)と**同じトランザクション**で書く +- `album_verifications.log_path` は移動後の `rel_path`(Library 相対)にする +- CD は `album_gain = true` で提案する(SPEC 7.2 / [D-74](D-074.md)。いまは配置時に立てていた) +- サイドカー v1 は `category` / `files` しか持たず、`RipReport` も serde 型ではない。**形を足すのは + P2-5 の明示タスク**(テストも) + +**追記 2**(2026-09-23。P2-5 の Inbox 側の実装で決めたこと): + +- サイドカーは v1 のまま `rip`(`RipEntry`)を足す。無い件ではキーを出さず、旧い読み手は未知のキーを無視する + ので版を上げない。`RipReport` / `MethodResult` はそのまま serde 型にした(列挙は snake_case)。Inbox に + 置かれたまま版をまたぎ得るので、キーの綴りはテストで固定する。モジュールは `import/ytmusic/sidecar.rs` + から `import/sidecar.rs` へ移した(CD と共有するため) +- **検証記録の `disc_no` は下書きの値**(承認でディスク番号を直せば、記録もそのディスク番号に付く)。1 件 = 1 枚 + なので、下書きのディスク番号が揃っていなければ配置しない +- **Inbox 経由の記録は `job_id` を NULL にする**(`record_album` の `job_id` を `Option` に)。1 回の inbox ジョブが + 複数の件を置くので、ジョブ id は `(job_id, disc_no, method)` の一意性の鍵にならない(2 枚の CD を同じ回に + 置くと 2 件目が「記録済み」で落ちる)。代わりに**全トラックに `source = 'rip'` の記録が既にあれば書かない** + (`all_have_rip_records`)。登録の commit の後・Inbox を消す前に落ちると、残った音声で走査が件を `pending` + に戻し、再承認で同じファイルと行を採用して登録へ戻ってくるため(codex 指摘)。同じ音声の盤を吸い出し直して + 同じ行に置いた場合も書かない(Library は変わらず、最初の記録が残る。照合し直すなら遡及照合) +- **サイドカーは読んだ FD の同一性(inode / size / mtime / ctime)を登録の直前と Inbox を消す前に照合する** + (codex 指摘)。登録前に変わっていれば置いたファイルを片付けて `Changed`(件は `pending`)、登録後に変わって + いれば消さずに残す。外から差し替えられた記録を古いまま登録しない・新しいものを消さない(ファイルが正) +- rip.log が宛先の別内容と衝突して移せなかったときは `log_path` を NULL にする(存在しないパスを指さない) +- **Inbox への置き方**(`place_disc`): Inbox 直下の隠しディレクトリ `.spindle-rip-<DiscID>` で組み立て、 + ディレクトリごと `CD/<albumartist - album> [<DiscID>]` へ `RENAME_NOREPLACE` する。走査は `.` で始まる + ディレクトリを見ないので、ファイルやサイドカーが揃う前の盤が件になって承認されることがない(1 本ずつ + 置くと、途中の件を承認すると検証記録の無い `download` として Library に入る)。名前が空なら + `CD/[<DiscID>]`: **MusicBrainz の DiscID は `.` で始まり得る**(base64 の変形で `.` `_` `-` を含む)ので + 括弧で包む(素のままだと隠しディレクトリになり、走査に見えない。テストの TOC で実際に起きた)。 + トラックのファイル名は `NN.flac`(TOC の番号)。サイドカーとの対応付けの鍵なので、承認で変わる値 + (タイトル)を入れない。Inbox の行は DB に直接書かず([D-68](D-068.md))、公開後に `inbox` ジョブを投入して走査に + 出させる +- **Inbox 経由で置いた CD の album に `albums.discid` は入れない**(ユーザ判断)。入れると追記先の判定 + (`destination` は DiscID を持つ album を除く)から外れ、MusicBrainz に無い複数枚組の 2 枚目が 1 枚目に + 合流できなくなる。既存曲が大量にあり、album の同一性の規則を今は動かさない。~~**未決**: スキャナは + `MUSICBRAINZ_DISCID` から `discid` を復元するので、DB を作り直した後だけ値が付く食い違いが残る~~ + → 下の追記 3 で `albums.discid` ごと外して解消 + +**追記 3**(2026-09-23。ユーザ判断。上の未決を閉じる): **`albums.discid` を落とし、DiscID を album の +同一性に使わない。** リリースの同一性は `mb_release_id`(`MUSICBRAINZ_ALBUMID`)→ album 行の 2 段にする。 + +- **DiscID は 1 枚ごとの値で、album の列には収まらない。** 複数枚組では album に DiscID が複数あり、スキャナは + 最頻値(どの 1 枚かは決まらない)を入れていた。しかも Inbox 経由の CD は NULL で、DB を作り直すと値が付き、 + 追記先の判定・リリースキー(`disc:`)・album の照合([D-32](D-032.md))が作り直しの前後で変わっていた。リリースの MBID は + 全ディスクで 1 つなので、MusicBrainz にある複数枚組は MBID で自然に合流する +- マイグレーション `0024` で索引 `idx_albums_discid` と列を落とす。リリースキーは `mb:` → `album:<id>` + (`placement::release_key` / `edit::rename` の `release_of`)、album の照合は MBID が 1 件一致 → 構成 + トラックの過半数。稼働 DB(721 album)は `discid` も `mb_release_id` も 0 件なので、失われる値は無い +- **DiscID の置き場所**: トラックのタグ `MUSICBRAINZ_DISCID`(1 枚の識別。吸い出しで付く)、 + `album_verifications`(ディスク単位の行)、rip.log / disc.cue。アルバム単位で DiscID を引く機能が要るように + なったら、そのときに 1 枚ごとの表(`album_discs(album_id, disc_no, discid)`)を足す +- **CD とそれ以外は混ぜない。** `discid` を落とすと、MBID の無い CD の album と YouTube などの album が + どちらも「MB キーの無い album」になり、Inbox の追記先([D-70](D-070.md))で互いに混ざる([D-7](D-007.md) の「同名 ≠ 同一 + リリース」に反する)。トラックのタグ `MUSICBRAINZ_DISCID` を **CD から来た印**にし(ファイルにあるので + 作り直しでも同じ判定)、`import::inbox::destination` は CD の件を CD の album にだけ、しかも album に + まだ無い `disc_no` のときだけ採用する(MBID の無い複数枚組の 2 枚目を 1 枚目に合流させる。同じ番号は + 同名の別の盤なので降格)。CD でない件は CD でない album にだけ採用する。MBID のある件は従来どおり + 追記先を持たず、配置の `mb:` キーで合流 / 降格が決まる +- **同じ判定を 3 か所で通す**(codex 指摘): (1) 追記先(`destination`)、(2) 再実行の「自分の成果物」 + (`self_album`。同じ音声の行が CD でない album にあっても、CD の件をそこへ合流させない)、(3) 登録の + トランザクション(計画の後にタグ編集や別の配置で album が変わり得るので引き直す。MB キーの album は対象外)。 + 「自分の成果物」は同じ音声の行がある album を**全部**候補に集め、件に MBID があればそのリリースの album、 + 無ければ MB キーの無い album で件を入れられるものが**ちょうど 1 つ**のときだけ採る([D-29](D-029.md)。音声の一致だけで + 別の album を自分とみなさない。件に MBID が無いのに `mb:` の album を採らない)。 + 判定は `fits_plain_album`: CD かどうかは album の**全行**で見る(同じ音声の行を自分の成果物として除くと、 + 他の album の行まで除いてしまう)、ディスク番号の重なりは自分の成果物の行を除いて見る(再実行) +- **購読の束ね先**(P4-16): 束ねてある album が CD の album になっていたら同期を止める(Inbox はそこへ + ダウンロードを追記しないので、揃えだけが進んで配置先と食い違う)。稼働 DB に CD の album は 0 件なので、 + 既存の束ねの移行は要らない + +**却下**: 両方の経路で `discid` を書き、合流は別の規則で救う(1 枚目だけの時点では 1 枚ものに見えるので、 +2 枚目が来たときに `discid` を NULL に戻す更新が要り、規則が 3 か所に増える)。1 枚ごとの表を今作る(読む +機能が無い。遡及照合は STREAMINFO から TOC を作り直すので DiscID を使わない)。 + +**関連**: Inbox 経由の件への MusicBrainz 照会は、YouTube の件には行わない(曲の身元は `SOURCE_URL`、 +[D-70](D-070.md))。候補ゼロ・「どれも違う」で取り込んだ CD の引き直しだけを TASKS P4-21 に積む diff --git a/docs/decisions/D-068.md b/docs/decisions/D-068.md new file mode 100644 index 00000000..15736b2e --- /dev/null +++ b/docs/decisions/D-068.md @@ -0,0 +1,64 @@ +# D-68 Inbox は 1 ディレクトリ = 1 件の承認キューにし、配置は CD と同じ経路で登録する + +**決定**(2026-09-19。P2-10。[D-20](D-020.md) の具体化): + +- **件 = 音声ファイルのあるディレクトリ。** `inbox` ジョブ(並列 1・固定キー)が Inbox を歩いて + `inbox_items`(`rel_dir_key` で一意。状態 `pending` → `approved` → `placing` → `placed` | `failed`、 + `rejected`)と `inbox_files`(stat・コーデック・表示用タグ列・全タグ JSON)に写す。**正は Inbox の + ファイルで行はキャッシュ**: stat が変わったファイルだけタグを読み直し、ディレクトリが消えれば行も消す + (`placed` は 24 時間残す)。`approved` の件でファイルが変わっていたら `pending` に戻す。検出は + `[inbox].poll_interval_secs`(既定 60、0 で自動なし)の周期投入と `POST /api/inbox/scan` +- **補正はアルバム単位とトラック単位の両方**(category / albumartist / album / date と、各トラックの + disc_no / track_no / title / artist)。下書き(`proposal`)はタグから作る(最頻値、category は GENRE → + `genre_category_map`)。承認の検証は CD の確定([D-65](D-065.md))と同じ厳しさ: album / albumartist / 各 title が + 空でない、`(disc_no, track_no)` は 1 以上で重複なし。不足のまま Library に入れない([D-20](D-020.md)) +- **補正はファイルのタグに書く。** Library へコピーする tmp に、補正で変わるキーだけ + `write_tag_changes` で書いてから置く。ファイルが正のまま再スキャンしても DB と一致する。Inbox の原本は + move で消えるので、この書き込みは編集バッチ(巻き戻し)の対象にしない(Library のデータの書き換えでは + なく、Library に入る前の整形) +- **配置は CD の配置([D-67](D-067.md))と同じ経路。** `pathgen::plan`(category 無しは `unsorted`、`disc_no` の + 最大 ≥ 2 なら `multi_disc`、リリースキーは MUSICBRAINZ_ALBUMID の最頻値があれば `mb:`、無ければ件ごとの + 新規キー)→ `library` の排他 → tmp + `RENAME_NOREPLACE` → 1 トランザクション登録(`source_type = + 'download'`、宛先 album のリリースキー再検証、同パス行の MD5 検証)→ 失敗時はこの呼び出しで置いた + ファイルだけ片付ける。共通部分(1 ファイルの配置・後始末・リリースキー・album の検索 / 作成・行の + 登録)は `src/import/placement.rs` に置き、`cd::place` と `import::inbox` が使う。コピーに使う FD を + fstat で承認時の行と照合し、コピーの後にも同じ FD を照合し、置いたファイルの音声の指紋が承認時に読んだ + ものと一致することを確かめる(外れたら `Changed` → `pending` に戻して再承認。コピー = 検証済みの複製)。 + 既知の同梱ファイル(cover 画像 / cue / toc / log)も移し、空になった Inbox のディレクトリを消す +- **状態遷移は CAS、`placing` はジョブの先頭で回復する。** `approve` / `reject` / `reopen` と worker の + `approved → placing` は `UPDATE … WHERE state IN (…)`(`db::inbox::transition`)で、読んでから書くまでに + 他が動かした件を上書きしない。配置の途中でプロセスが落ちて `placing` のまま残った件は次の `inbox` + ジョブが `approved` に戻して配置し直す(並列 1 なのでジョブ開始時の `placing` は必ず前の実行の残り。 + 配置は冪等)。件の `placed` と normalize バッチも登録トランザクションの中で確定する(commit 後に落ちても + `placing` が残らず、normalize の投入も欠けない。`edit::prepare_normalize_in`)。`placed` の件のディレクトリに走査で音声が見えたら `pending` に戻す(消せなかった原本や + 配置後に置かれたファイルを `placed` の裏に隠さない) +- **後続は rg / transcode に加えて normalize。** WAV / ALAC / AIFF は `[normalize].wav_to_flac` なら + 編集バッチを作って投入する([D-46](D-046.md) で P2-10 に先送りしていた自動投入。登録と同じトランザクション)。 + 既存ライブラリの一括変換はこれまでどおり手動 + +**理由**: 承認キューの単位をディレクトリにするのは、購入分の zip を展開した形がそのまま 1 アルバムで、 +Library と同じ「ディレクトリ = album」の規則に乗るため。トラック単位の補正を入れるのは、配信購入分の +TITLE / TRACKNUMBER の欠落や表記揺れが CD と同じくらい多く、外部ツールで直してから戻る往復が承認の +価値を削ぐため。配置を CD と共通化するのは、排他・冪等性・再検証・後始末の規則を 2 か所で別々に +守らないため。 + +**却下**: Inbox を直接スキャン対象にする([D-20](D-020.md))。補正を DB だけに持つ(再スキャンで元のタグ値に戻り、 +不変条件 1 と矛盾する)。inotify(コンテナ越しに不安定)。件ごとに別のジョブ(並列 1 の固定キーで走査と +配置を 1 本にすれば、配置中の件を走査が触る競合が無い)。 + +**追記(2026-09-22、P4-18、[D-81](D-081.md))**: inotify の却下理由を改める。実機は Inbox を bind mount していて同じ +カーネルなので inotify 自体は動く(動かないのは NFS / SMB をコンテナ内でマウントする構成。SMB からの投入は +Samba がホスト側で書くのでイベントになる)。それでも見送るのは、(1) ディレクトリごとの再帰 watch(zip 展開で +増えるサブディレクトリの登録競合)、(2) コピー中のイベント嵐に対する静穏期間、(3) queue overflow に備えた +ポーリングのフォールバック、が要り、フォールバックが残る以上「変化が無ければ投入しない」を別途入れる必要が +あって、それを入れると inotify の利点は検出の遅れ(最大 60 秒)だけになるため。毎分のジョブ行で一覧が埋まる +問題は指紋の比較で解く([D-81](D-081.md))。要るなら即時トリガとして後から足せる。 + +**未決**: `placed` の行を残す期間(今は 24 時間)。Inbox のサブディレクトリを 1 件にまとめる規則 +(ディスクごとのサブディレクトリ `Disc 1` / `Disc 2` を 1 アルバムに束ねる。今はディレクトリごとに別の件)。 + +**追記**(2026-09-20、P3-4): 配置の直後に、その album のアートワークをスキャンと同じ規則で解決し +thumbnail を投入する(`scanner::resolve_album_artwork_now`)。これまでは次のスキャン(起動時 / 手動)まで +画像が出なかった。YouTube のダウンロード([D-70](D-070.md))で新しい album ができる度に待たされるのが目立つため、 +Inbox 配置全般の改善として入れた。失敗しても予約(`artwork_resolved_at = NULL`)が残るので従来どおり +スキャンが続きをやる。 diff --git a/docs/decisions/D-069.md b/docs/decisions/D-069.md new file mode 100644 index 00000000..8fc1ca7f --- /dev/null +++ b/docs/decisions/D-069.md @@ -0,0 +1,34 @@ +# D-69 動画タイトルの解釈は外部のメタデータプラグインに任せ、spindle は汎用に保つ + +**決定**(2026-09-19。P3-1 / P3-2): + +- **spindle はタイトルの慣習を知らない。** YouTube の動画タイトルとチャンネルからトラックのメタデータ + (タイトル・アーティスト・アルバム・category)を決める処理は、外部コマンド(メタデータプラグイン)に + JSON のプロトコル([SPEC §7.7](../spec/07-pipeline/07-ytmusic.md))で問い合わせる。spindle が持つのは、プラグインの起動(引数配列・ + タイムアウト・終了コード検査・stderr のログ)、Response の検証、タグと `pathgen::TrackFields` への写像、 + category の語彙への自動追加だけ +- **チャンネル定義(チャンネル → artist / category、album 名の形)もプラグイン側。** spindle の設定は + `[ytmusic].metadata_command` と `metadata_timeout_secs`(とダウンロード元の URL。P3-3)だけ +- **プロトコルは提供元に依らない形**(`source` / `op` / `item` を持ち、未知のフィールドは無視する)。 + `ok: false` の `reason` に `skip`(意図的に取り込まない)を持ち、要対応(`unmatched` / + `unknown_channel`)と区別する。プラグインは判定できたかに関わらず終了コード 0 で返し、非ゼロ・不正な + JSON・必須の値の欠落はプラグインの故障として扱う +- **category は無ければ語彙に追加する**(同じ canonical key があればそれを使う)。プラグインの定義が + category の正で、初回起動の空 DB でも追加の操作なしに動く +- 参照実装は `AkashiSN/spindle-ytmusic-meta`(private)。宣言的ルール(TOML)のエンジンとフィクスチャ + (Python 版 ytmusic のテスト 98 件から生成)とチャンネル定義を同梱した Rust のバイナリで、新パターンは + Claude がそのリポジトリでルールとフィクスチャを 1 件ずつ足す + +**理由**: spindle は公開リポジトリであり汎用に保ちたい。タイトルのパターン、チャンネル名、アーティストの +グループ、フィクスチャの実タイトルはすべて利用者固有で、ルールを TOML に外出ししてもエンジン側に +「`(from …)` / `with …` / `【合唱】` / `Live ver.`」といった慣習が残り、テストにも実タイトルが出る。 +実行ファイルの境界で切れば spindle 側に固有名詞が一切入らず、プラグインは別言語(Python 版そのままでも) +でも書ける。 + +**却下**: spindle にルールエンジンと同梱 TOML を持つ(一度実装したが、エンジンに慣習が残るので履歴ごと +取り下げた)。ルールだけを別リポジトリに外出しして spindle が読む(同上)。GUI からのルール編集 +(ルールの正が 2 か所になり、フィクスチャで守れない)。チャンネル定義を spindle の config.toml に置く +(固有名詞の置き場を 2 つに分けない)。 + +~~**未決**: ダウンローダ(P3-3)で `ok: false` のアイテムをどう見せるか。プラグインを Docker イメージに +どう同梱するか。~~ → [D-70](D-070.md) で決めた(Inbox の `_unmatched` の件として見せる。プラグインは実行時マウント)。 diff --git a/docs/decisions/D-070.md b/docs/decisions/D-070.md new file mode 100644 index 00000000..00402c80 --- /dev/null +++ b/docs/decisions/D-070.md @@ -0,0 +1,119 @@ +# D-70 YouTube のダウンロードは Inbox に置くだけにし、判定の確認と既存アルバムへの追記は Inbox が担う + +**決定**(2026-09-20。P3-3): + +- **ダウンローダ(`ytdl` ジョブ)は Library に触らない。** yt-dlp で音声(webm の Opus)を取り、 + ffmpeg で `.opus` に remux(再エンコードなし)し、プラグインの判定をタグに書いて **Inbox に置く** + ところまで。配置・登録・後続ジョブは Inbox の承認と配置([D-68](D-068.md))がそのまま担う。プラグインが正しく + 判定したものも人が一度見てから Library に入る(自動で反映されたものを確認したい、という要求。 + [D-68](D-068.md) の「承認キューを挟む」と同じ思想) +- **判定できなかったもの(`ok: false` の `unmatched` / `unknown_channel` / 未知の reason)も Inbox に + 置く**(`Inbox/youtube/_unmatched/<channel>/`)。TITLE に動画タイトルをそのまま書き、他は空にして + Inbox の補正フォームで人が埋める。**受け皿を別に作らない。** `skip` はダウンロードせず終わる +- **タグに載らない情報はサイドカー `spindle-inbox.json` で渡す**(category、ファイルごとの判定・ + メッセージ・URL・チャンネル)。Inbox の行はキャッシュで DB に直接書かないという [D-68](D-068.md) の規則を守るため、 + ダウンローダが Inbox の DB を触らない。走査は音声でないので無視し、`GET /api/inbox` が読んで件に + 付け、配置の成功時に消す(Library へは持っていかない)。人が手で置いた件には無いので従来どおり +- **Inbox は既存の album に追記できるようにする。** MUSICBRAINZ_ALBUMID が無く、宛先ディレクトリに + active な album があり、その album にも MB キーが無ければその album を採用する(これまでは件ごとの + 新規キーで衝突 → failed)。「花譜のお歌」のように育つアルバムに曲を足すのが YouTube 取り込みの通常 + 形なので、これが無いと成立しない。MB キー同士が違えば従来どおり衝突。TRACKNUMBER の無いファイルは + 採用する album の active な `track_no` の最大 + 1 から名前順に採番して提案し、承認時に採用 album の + active なトラックと `(disc_no, track_no)` が重なれば 400 で先に直させる +- **重複取り込みは `SOURCE_URL` タグで防ぐ。** yt-dlp の `webpage_url`(正規形)をファイルに書き、 + Library(`track_tags`)か Inbox(`inbox_files.tags`)に同じ値があれば取り込まない。DB を消しても + ファイルに残る(ファイルが正)。プロトコルの予約キーに加える +- **webm は `Archive/youtube/<id>.webm`。** Library のパスを写さない(リネームで追随できない)。 + DB に行は作らない(`archived_files` は GC の退避台帳で `eligible_after` が付く。追記のみの生データを + GC の対象にしない) +- **プラグインは実行時マウント。** 参照実装は private で固有名詞を含むので、公開イメージに焼かず + 公開 CI からも pull しない。static バイナリをホストの `/mnt/ssd/apps/spindle/bin/` に置き、compose で + `/usr/local/bin/spindle-ytmusic-meta:ro` にマウントする。起動時診断で実行できなければ警告(yt-dlp と同じ。 + 起動は通す) +- **技術的な失敗だけがジョブの失敗。** 取り込み済み・webm の音声が無い・プラグインの故障は再試行しても + 変わらないので `JobError::Fatal`(バックオフせず `failed`)を足す。ネットワークの失敗は従来どおり再試行 + +**理由**: ダウンローダに配置・採番・排他を持たせる案([SPEC §7.7](../spec/07-pipeline/07-ytmusic.md) の当初の記述)は、Inbox と同じことを +2 か所に持つうえ、判定を人が確認する場所が無い。Inbox に寄せれば downloader は「ファイルを置く」だけの +小さなジョブになり、確認・補正・受け皿・後続ジョブはすべて既存の経路で済む。 + +**却下**: `ytmusic_downloads` テーブルと専用画面(Inbox で代替できる量の UI とマイグレーションが増える)。 +`ok: false` をジョブの失敗で見せる(直す場所が Jobs タブになり、補正もできない)。プラグインを +別イメージから `COPY --from`(private イメージを公開ビルドが pull できない)。webm を Library のパスに +写す(リネームで追随できない)。 + +**追記**(2026-09-20、実機の通し確認): 実 URL で ダウンロード → Inbox(既存 album「花譜のお歌」への +追記、採番 246)→ 承認 → Library まで通り、ytmusic CLI の経路は置き換えられた。見つかった差: 承認画面の +下書きは `artist` を 1 値で見せるが、ファイルの `ARTIST` はプラグインの多値(「花譜」「理芽」)で、 +未編集なら多値のまま Library に入る(上の規則どおり)。承認画面が「実際に書かれるもの」と違って +見えるのは良くないので、**承認画面は ARTIST の全値(`;` 区切り)と埋め込みのカバー画像を見せる** +(Inbox のファイルの `PICTURE` を返す `GET /api/inbox/:id/artwork/:hash` を足す。設計時に `:file` から +hash アドレスへ変更。下の追記)。P4-4。 + +**追記(2026-09-20、P4-4 の設計。codex の設計レビューで 2 往復)**: + +- 下書きのトラックに **`keep_artists`**(ファイルの多値 ARTIST をそのまま保つ)を持たせ、意図を明示する。 + 提案は多値なら true で、`artist` は全値を **`"; "`** で結合した表示文字列(foobar2000 等の多値の慣習。 + Library の一覧が `artist_display` に使う `", "` とは区別)。配置は true なら**現在の個数に関係なく** ARTIST に + 触れず(承認後にファイルが 1 値に変わって走査 → pending → 保存値の再表示、という経路でも安全側)、 + false なら `artist` の 1 値で置き換える(`;` で分割しない。分割規則を持ち込むと `;` を含む 1 値を + 表せない)。画面は元の値を境界の分かるチップで見せ、チェックを外すと 1 値の入力になる。チェックを + 出すのは多値のときだけで、保存値が true でもファイルが多値でなくなっていれば false に戻す +- **却下**: 結合文字列との一致を「未編集」の識別子に兼用する案(codex の指摘: `["A; B", "C"]` と + `["A", "B; C"]` が同じに見える、1 値 `A; B; C` に畳む意図を表せない、同じ文字列を打ち直しても 1 値に + ならない)。先頭値との一致(旧規則。画面と書かれるものが食い違う) +- 旧下書き(`keep_artists` 無し。failed / rejected にも残り、reopen 後は保存値が優先される)は旧規則で + 解釈する(`artist` が先頭値のままなら保つ)。互換を落とすと再承認で多値が 1 値に潰れる +- 画像の口は **`GET /api/inbox/:id/artwork/:hash`**(TASKS の `:file` から変更)。ハッシュアドレスなら + Library の `/api/artwork/:hash` と同じく内容が不変で `immutable` + ETag が成り立つ。ファイル指定だと + ファイルが書き換わったときに同じ URL で別の画像が返り、キャッシュを信用できない。`:id` で件に絞るのは + 他の件のファイルを開かせないため。**304 は実体の照合の後**(DB の `PICTURE` だけで 304 を返すと、実体が + 消えた / 差し替わった後も一致する ETag に 304 を返し「消失 / 不一致は 404」に反する)。MIME はタグの値を + 信用せず内容の sniff([D-49](D-049.md) と同じ規則) +- 代表画像: `inbox_files.tags` の `PICTURE` は画像種別を持たないので、走査が Library の `pick_embedded` + (front cover 優先、無ければ先頭)で選ぶ画像を先頭に置き、画面は先頭を代表にする。件の代表は各ファイル + 代表の最頻(同数なら先に現れたもの)で、件の中の多数派を目安に見せる要約。配置後の album の代表 + (同梱カバー → disc / track 順で最初のトラックの埋め込み、追記先なら既存の代表)とは異なり得る。 + 忠実なのはトラックごとのチップ / サムネイルで、見出しの画像は要約、と切り分ける +- 状態非依存で返す(rejected / failed でも reopen 前に確認したい。placed は実体が消えているので 404)。 + サムネイルは作らない(承認画面だけで使い、件は配置で消える。原寸を `<img>` で縮小して十分) +- 承認画面が見せるのは埋め込み画像だけ。同梱の `cover.jpg` は配置時に埋め込みより優先されるが、その + 表示は今回の範囲外(件のディレクトリを API のたびに歩くことになる。必要になったら走査で + `inbox_items` に同梱カバーの有無を持たせる) +- 受け入れ: `tests/inbox_api.rs`(提案の結合と `keep_artists`、artwork の 200 / 304 / 404(不明 hash・他の件・ + 実体消失で If-None-Match が一致しても 404・差し替え)/ 401、front cover が先頭)、`tests/inbox_job.rs` + (`keep_artists` で多値を保つ・false で 1 値・旧下書きの互換)、`web/src/lib/inbox.test.ts`(`pictureOf` / + `itemCover` / `draftFrom` の旧下書き) + +**追記(2026-09-21。P4-13)**: 導線を操作タブの節から独立した YouTube 画面に移す([SPEC §12.6](../spec/12-ui/06-other-screens.md))。操作タブは +選択したトラックへの操作の場所で、選択と無関係なダウンロードが混ざって見つけにくく、投入後の行方も +ジョブ画面に埋もれていた。画面には ytdl ジョブの一覧(結果と「Inbox で確認」)を置き、`/youtube?url=` で +URL を受ける(ブックマークレット。同一 origin の GET なので CORS / CSRF の話が無い)。再生リストの展開時に +Library / Inbox に `SOURCE_URL` のある動画は投入しない(動画ごとのジョブが「取り込み済み」で失敗する +のは同じ結果だが、一覧が失敗 200 行で埋まる。P4-14 で既存曲に `SOURCE_URL` が付いたので、再生リストを +貼れば新しいものだけが落ちる)。`[ytmusic].ytdlp_args` で yt-dlp に毎回付ける引数を配列で渡せる +(`--extractor-args` / `--cookies`。P4-16 の同期が常用になると要る)。**UA / Referer は付けない**: yt-dlp の +YouTube 抽出は player client(web / ios / android …)の偽装で innertube API を叩くので、ブラウザ風の UA を +上書きすると client の偽装と食い違って弾かれる(yt-dlp の公式見解)。Referer も innertube には効かない。 +ブロックへの対処は yt-dlp の更新(P4-12 (7))と `ytdlp_args` の 2 つ。 + +**追記(2026-09-22、P4-19)**: 二重取り込みの検出は `SOURCE_URL` の一致だけでは足りないので、Inbox の承認 +画面に**同名の警告**を出す。追記先の album に同じタイトル鍵(NFKD + casefold + 空白の畳み込み。`(Cover)` / +`【… Live ver.】` の注記は落とさない)の active な行があれば、そのトラックに `same_title`(track_id / +rel_path / duration_ms)を返し、画面が「⚠ Library に同名: <ファイル名>(長さ)」と出す。**承認は止めない** +(判断は人が行う、が D-70 の原則。同じ曲名の別テイクは正当)。理由: `SOURCE_URL` は補填(P4-14)で +`title-mismatch` / `no-track` になった行や、CD / 購入で入れた曲には付かず、再アップロードや切り抜きは URL が +違うので、同期は「持っていない」と判断して落としてくる。実データで測ると、同一 album 内の同名は 9,102 行中 +27 グループ(119 行。多くはキャラ別 Solo ver. などの正当なもの)で、YouTube 由来の `〜のお歌` では 6 グループ。 +そのうち VALIS「彷徨フォーエバー 【Live ver.】」は**長さが完全一致で URL だけ違う**(P4-16 のリハーサルで +気づかずに承認した二重取り込み)。判定を「注記まで含めて一致」に限るのは、緩めると毎回警告が出て無視される +ようになるため。長さを添えるのは、別テイクと本当の重複を人が見分ける唯一の手がかりだから。 + +**追記(2026-09-25。P4-22。追記のディスク番号)**: 追記先の album の active な曲がどれも `disc_no` を持たず、下書きの +`disc_no` の最大が 1(人が 2 以上にした値は捨てずに書く)で CD の取り込みでないときは、配置で `DISCNUMBER` を**書かず、件のファイルにあれば消す** +(`import::inbox::omits_disc`。`destination.uses_disc` を応答にも載せ、画面は同じ規則で disc 列を空で見せる)。 +理由: YouTube の件は提案が disc 1 固定で、旧パイプライン由来の disc なしの album(Library の 6,659 曲)に追記すると、 +disc → track の並べ替えで追記した曲だけが末尾に回った(リハーサルの明透 165・理芽 233。バッチ 22 で手当て)。 +ファイルが持っていた `DISCNUMBER` も消すのは、ファイルが正(DB は `disc_no` をタグから登録する)で、1 つでも残ると +同じ並びの問題が戻るため。下書きの `disc_no` は u32 のまま 1 で持つ(番号の衝突判定は既に NULL を 1 として扱う)。 +宛先がディスク番号を使っている album・CD の件(複数枚の medium 位置)・新規の album は従来どおり書く。 diff --git a/docs/decisions/D-071.md b/docs/decisions/D-071.md new file mode 100644 index 00000000..8d760f22 --- /dev/null +++ b/docs/decisions/D-071.md @@ -0,0 +1,74 @@ +# D-71 偽ハイレゾ検出は表示と絞り込みだけに使い、判定はカットオフの「崖」と実効ビットで下す + +**決定**: 可逆かつ `sample_rate > 48000` または `bit_depth > 16` のトラックを `hirescheck` ジョブ +(読むだけ。track_id + audio_version の版付き、flaccheck と同型)で解析し、結果を `tracks.hires_check` +に記録して一覧のバッジ・固定フィルタ・DSL に出す。**判定を消費する自動処理は置かない**(P3-5、[SPEC §7.10](../spec/07-pipeline/10-fake-hires.md))。 + +- **用途は「知るため」に限る。** 具体的には、移行データに別トラックとして両方入っている `Hi-Res/` + の 24/96 とアルバム直下の 16/44 の、どちらを残すかの判断材料。および配信ハイレゾの格付け + (CD 由来の AccurateRip / CTDB に相当する裏付けが無い)。Derived(可逆はどのみち Opus 48 kHz)・ + 配布ビュー・RG・リネームは判定に依らない +- **アップサンプリングはカットオフ周波数だけで判定しない。** `cutoff_hz ≤ 25 kHz` に加えて、 + カットオフ前後 1 kHz の落差 `cliff_db ≥ 30 dB` を条件にする。SRC のローパスは急峻な崖を作るが、 + アナログテープ起こしや静かなアコースティックは本物でも 22 kHz 前後から自然に減衰するため、 + カットオフだけでは誤検出する。崖の無いものは `inconclusive` にして人が見る +- **カットオフの探索は 1/3 オクターブで平滑化した系列で候補を決め、エッジは平滑化前の系列で + 詰める。崖も平滑化前で、同じエッジを中心に測る。** 24 kHz での 1/3 オクターブ幅は約 5.5 kHz + あり、平滑化した系列の「最高ビン」をそのまま cutoff にすると窓の下端ぶん(最大 1/6 オクターブ、 + 22.05 kHz の brickwall なら 24.75 kHz)上へずれて、主対象の 44.1 → 96 kHz を取り逃がす。 + また平滑化した系列で前後 1 kHz を比べると崖を自分でぼかして 30 dB に届かない。境界(完全ゼロの + 帯域、上端まで信号がある本物、Nyquist 直下のカットオフ、全無音)は [SPEC §7.10](../spec/07-pipeline/10-fake-hires.md) で有限値か NULL に + 定めてある。既定の `cutoff_hz` は 25 kHz: 48 kHz マスターの 24 kHz brickwall と Hann 窓の漏れ込み + (数百 Hz)を拾える余裕を取る。本物の 96 kHz 録音は 30 kHz 以上まで伸びるのが普通で、崖の条件も + あるので 25 kHz でも誤検出は増えない。 + 実装時の補正(2026-09-20): エッジは「床 + 10 dB を上回る最高ビン」ではなく **200 Hz 幅の dB 平均の + 段差が最大のビン**、探索範囲は候補の 1/3 オクターブ下まで。Hann 窓のサイドローブ(18 dB/oct)が + しきい値を上回る範囲までエッジを押し上げるのを避ける(内容がフルスケール近くまで詰まった信号で + 数 kHz ずれ、24 kHz の brickwall を取り逃がしていた)。床は −150 dB を下限にする(完全ゼロの + 帯域で −200 dB に張り付くと遠い漏れまで拾う) +- **計測できたものが無ければ suspect より先に `inconclusive`。** 全無音は OR = 0 で実効ビットが + 求まらず(末尾ゼロが 32)、そのまま式に入れると `padded` になる。cutoff と実効ビットが + ともに NULL なら疑いの判定に進まない +- **ビット深度は全サンプルの OR の末尾ゼロで見る。** `round(sample × 2^(bit_depth−1))` を i32 に + 戻して OR し、実効ビット = `bit_depth − 末尾ゼロ`。`≤ 16` なら `padded`。ディザやゲインを + かけた水増しは検出できないが、それは「16 bit 相当の情報量」の証明が原理的にできない領域で、 + 素の 8 bit ゼロ詰め(最も多い形)だけを確実に拾う。32 bit は f32 で正確でないので判定しない +- **計測値(`hires_cutoff_hz` / `hires_cliff_db` / `hires_effective_bits`)も保存する。** `upsampled` と + `inconclusive` を分けるのは崖なので、崖の値が無いと 29.9 dB と 3 dB が同じに見える。 しきい値は `[hires]` に + 既定値付きで置くが、判定は検査時に確定し、しきい値を変えても既存の結果は書き換えない + (再判定は手動投入)。計測値が残っていれば目視の判断と DSL(`%cutoff% LESS 23000`)は + いつでもできる +- **対象は 44.1/48 kHz かつ 16 bit の可逆を含まない。** 非可逆由来の可逆(44.1 kHz の FLAC が + 16 kHz で切れている)の検出は、対象が Library の可逆全曲に広がるうえ、しきい値の誤検出リスクが + 高い(MP3 128k の 16 kHz と本物の暗い録音の区別がつかない)。要望が出るまで作らない + +- **並列度は `max(1, CPU コア数 / 2)`。** ジョブの Semaphore は種別ごとに独立で、rg(コア数)・ + transcode(コア数 − 1)・flaccheck(コア数)と同時に走ると合計がコアを超える。デコード + FFT は + `flac -t` より重く、スキャン完了時に rg / transcode と一緒に自動投入されるので、hirescheck 側を + 半分に抑える。CPU 系ジョブ共通の予算は残課題([SPEC §17](../spec/17-open-issues.md)) +- **`JobType::version_field` に `Hirescheck` を加える。** これが無いとワーカーの stale ゲートと + `track_locks` が効かない(flaccheck と同じ落とし穴) + +**理由**: 仕様の「任意機能」であり、自動処理に結びつけると誤検出の被害が Derived や削除に +及ぶ。表示に留めれば誤検出のコストは「人が見て無視する」だけで済む。 + +**却下**: カットオフだけで判定(自然なロールオフの本物を偽と言う)。計測値を残さず判定だけ保存 +(しきい値を変えるたびに 24/96 の全曲を再デコードする)。判定を読み取り時にしきい値から導出 +(DSL と固定フィルタが設定値に依存し、SQL に閾値を毎回バインドする割に得るものが小さい)。 +重複グループ(同じ `audio_md5`)への統合表示(`Hi-Res/` と 16/44 は PCM が違うので `audio_md5` は +一致せず、別マスターを結びつける仕組みは無い。P3-5 の範囲を超える)。非可逆由来の可逆の検出 +(上記)。 + +**追記**(2026-09-20、実機で較正): TrueNAS の対象 124 本(ALAC 24/96 と 24/48)で観察した結果、 +本物の 96 kHz(106 本)は cutoff 25.4〜43 kHz / 崖 0.8〜8 dB、疑わしいもの(複数プロデューサーの +寄せ集めコンピ 16 本と 48 kHz マスターの 2 本)は cutoff 20.2〜24.8 kHz / 崖 4〜21 dB だった。 +スペクトルを見ると、SRC の段差は「エッジ直下の音楽の残り(−67 dB 程度)」と「上げた後の床 +(−83 dB 程度)」の差で決まり、実 SRC では 12〜21 dB にしかならない。30 dB は帯域内フルスケールの +合成信号でしか出ない値で、17 本が全部 `inconclusive` になった。また 15 kHz からなだらかに落ちて +22 kHz 手前で床に沈む型(段差ゼロ)があり、これは崖をどう測っても拾えない。本物はすべて +25.4 kHz 以上まで伸びていたので、cutoff の絶対値で拾う。決定: +- `[hires].cliff_db` の既定を 30 → **10 dB**(本物の膝 8.1 dB は届かない) +- `[hires].hard_cutoff_hz = 22500` を追加。cutoff がこれ以下なら崖に関わらず `upsampled` + (44.1 kHz の Nyquist + 余裕。`cutoff_hz` 以下であること)。22.5〜25 kHz(48 kHz マスター)は + 従来どおり崖で判定する +- 既存の結果は書き換えない(規則どおり)。実機は手動で再投入した diff --git a/docs/decisions/D-072.md b/docs/decisions/D-072.md new file mode 100644 index 00000000..1a73d6f5 --- /dev/null +++ b/docs/decisions/D-072.md @@ -0,0 +1,42 @@ +# D-72 外部メタデータは MusicBrainz を「盤の識別」にだけ使い、値は公式表記を手で入れる。Discogs / VGMdb / .fpl は作らない + +**決定**(2026-09-20。[SPEC §17](../spec/17-open-issues.md) の残課題を閉じる): + +- **外部ソースは MusicBrainz だけ。** Discogs も VGMdb も作らない。`.fpl` 書き出しも作らない([SPEC §10](../spec/10-playlists-export.md) の + 「非対応」で確定) +- **候補から写すのは既定で「盤を見分けるのに要る最小限」**: `ALBUM` / `ALBUMARTIST` / `DATE` / + `DISCNUMBER` / `DISCTOTAL` / `TRACKTOTAL` と MusicBrainz の id(`MUSICBRAINZ_ALBUMID` / + `MUSICBRAINZ_DISCID`。同一性と遡及照合の鍵(DiscID は [D-67](D-067.md) 追記 3 で album の同一性からは外し、CD の印と + 1 枚の識別に使う)。要らなければプロパティタブで消せる)。`LABEL` / + `CATALOGNUMBER` / `BARCODE`、トラックのタイトル / アーティスト / ISRC は既定では写さず、 + 「全部写す」を選んだときだけ写る。トラック行は番号と長さだけの空行になり、公式サイトの貼り付け解析 + ([D-65](D-065.md))で埋める。空のフィールドはタグに書かない(`tagRows` が落とす。従来どおり) +- **プロパティタブに foobar2000 の Properties と同じ「フィールドの削除」と「フィールドの追加」を置く。** + 値のダブルクリック編集(既存)に加えて、フィールド(ラベル)自体を右クリック(または行末の ×)で + 消し、新しいキーを追加できる。どちらも既存の一括編集 op(`delete` / `set`)で選択全体に効き、 + バッチとして巻き戻せる。サーバの変更は無い +- P4 のタスク: 確定フォームの「写す範囲」(既定 = 最小限、「全部写す」)、プロパティタブのフィールド + 削除 / 追加 + +**理由**: foobar2000 での運用実績。MusicBrainz も freedb も表記揺れが激しく(複数アーティストの並び、 +キャラクター名と声優名の書き方)、結局すべて手で埋めることが多かった。そのとき外部ソースの +価値は「複数枚を取り込むときにどれがどれか分からなくならない」ための識別だけで、値は公式サイトや +ジャケット裏の表記から入れていた。Discogs の表記も登録者次第で MusicBrainz と同じ問題を持ち、 +識別としては DiscID(TOC)に劣る。カタログ番号や JAN は要らず、既存ライブラリのタグもアルバム +アーティスト・アルバム名・年・トラック数程度で足りている。 + +**追記 2**(2026-09-23。P4-20。上の「既定で最小限」を CD 画面について覆す): **CD 画面の写す範囲の +既定は「全部写す」**にする。「最小限」は選べるまま残す。 + +理由: P4-20 で CD 画面はトラック表が主役になり、「候補を選んだら表の `Track 01` が実名に変わる」のが +期待される動きになった。既定が最小限だと、候補を選んでも表が空のままで、ユーザから見ると候補を +選んだ意味が分からない。D-72 の「値は公式表記を手で入れる」という考え方自体は変えておらず、 +**入り口の既定を変えただけ**(写した値は Inbox の承認画面で直せるし、「最小限」に切り替えれば +元の挙動になる)。 +Inbox の承認フォームはこの範囲を使っていないので影響しない。 + +**却下**: Discogs をカタログ番号検索の補助に残す(値を取っても直すので効かない)。VGMdb(非公式 +ミラーのスクレイピング依存)。Cover Art Archive(**→ [D-82](D-082.md) で覆した**。当時は「MB で当たる盤の +ジャケットは取れるが、要望が無い。アートワークは埋め込み / 同梱 / 手動で足りている。[D-60](D-060.md)」と判断した +が、候補が複数出たときの見分けに要るという要望が出たので引くことにした)。AcoustID(配信音源を MB に結びつける穴を +埋めるが、要望が無い)。gnudb / iTunes Search / Deezer / Spotify(文字列照合で、識別にもならない)。 diff --git a/docs/decisions/D-073.md b/docs/decisions/D-073.md new file mode 100644 index 00000000..3fd3fc89 --- /dev/null +++ b/docs/decisions/D-073.md @@ -0,0 +1,33 @@ +# D-73 CPU 系ジョブは種別ごとの上限に加えて共通の並列予算(= CPU コア数)を取る + +**決定**(2026-09-20。[D-71](D-071.md) の残課題。P4 で実装): + +- `worker.rs` に CPU 系(`rg` / `transcode` / `flaccheck` / `hirescheck`)が共有する Semaphore を + 1 本足し、許可数は CPU コア数。該当種別は種別ごとの Semaphore(今のまま)に加えてこの予算も取る。 + 種別単独のときは今と同じ速さで、複数種別が同時に走るときだけ合計がコア数に収まる +- 予算の取得順は「種別 → 共通」。共通が取れなければ種別の許可を持ったまま待つ(ジョブは + `running` にしない。キューの順序は変えない) +- `GET /api/jobs` の `concurrency` に `cpu_budget` を足して画面に出す +- 受け入れ: 種別ごとの上限を超えないこと(既存)に加え、4 種を同時に投入したとき実行中の合計が + コア数を超えないこと(`tests/jobs.rs`。ハンドラを sleep にして running の数を数える) + +**理由**: 種別ごとの Semaphore は rg = コア数、transcode = コア数 − 1、flaccheck = コア数、 +hirescheck = コア数 / 2 で、スキャン完了時に 4 種がまとめて投入されると最悪 3.5 × コア数の重い +処理が同時に走る。NAS では I/O も競合し、同じプロセスの axum の応答が鈍る。種別の上限を下げるだけ +だと単独で走るときも遅くなる。 + +**却下**: 上限を下げるだけ(単独が遅くなる)。現状維持(自動投入は 1 日 1 回程度だが、初回スキャン +直後は数千件が一度に走る)。 + +**追記(2026-09-20。P4-1 の実装)**: 共通が取れないときは「種別の許可を持ったまま待つ」ではなく、 +**種別の許可も手放して claim せず、次の周回(実行中のタスクの完了で wake が来る)で試す**。スケジューラは +1 本のループなので、そこで待つと他の種別の claim まで止まる。ジョブは queued のままで順序も変わらない +ので、決定の意図(running にしない・順序を変えない)は同じ。`cpu_budget` は `concurrency` の中ではなく +`GET /api/jobs` の兄弟フィールド(`concurrency` は種別名をキーにした表で、画面がそのまま行にするため)。 +`JobType::cpu_bound` が対象種別を決める。**予算を分け合う種別は 1 件ずつラウンドロビンで claim し、 +次の周は最後に claim した種別の次から始める**(種別名順に空きが尽きるまで取ると、先頭の flaccheck のキューが +尽きるまで rg / transcode / hirescheck が始まらない。codex のレビュー指摘。当初は「開始位置を周回ごとに +1 つ進める」だったが、何も取れない周回(tick・別の wake)でも進むため開始位置が実質ランダムになり、 +同じ種別に続けて予算が回ることがあった。2026-09-22 に CI で観測して修正)。受け入れは `tests/jobs.rs`(コア数 2 で +rg + flaccheck を 3 本ずつで実行中の最大が 2、4 種を種別名順に固めて 3 本ずつ投入しても先頭 6 本の開始に +4 種が揃う、thumbnail は予算に縛られず 4)。 diff --git a/docs/decisions/D-074.md b/docs/decisions/D-074.md new file mode 100644 index 00000000..9b14e5b2 --- /dev/null +++ b/docs/decisions/D-074.md @@ -0,0 +1,45 @@ +# D-74 album gain は album ごとの属性。既定 off、CD 取り込みは on、Inbox の承認画面で選ぶ + +**決定**(2026-09-20。P4-5 で実装): + +- `albums.album_gain`(真偽、既定 false。マイグレーション 0017)。**true の album だけ** rg を album 単位 + (`rg:album:<id>`。全曲デコード + ゲート付き集計)で投入し、false の album は track 単位 + (`rg:track:<id>`。対象の曲だけ)。`rg_album_*` は true の album にしか付かず、書き出しも同じ + (false なら album のキーは書かず、あれば消す。[D-48](D-048.md) の「値の無いキーは消す」) +- **CD 取り込み**(`cd/place.rs`)で作る album は **true**。アルバムとして通して聴く単位だから +- **Inbox の承認画面**に「album gain を計算する(アルバム通し再生用)」のチェックボックス。既定 off。 + 既存 album への追記なら、その album の現在値が初期値で、変えると album の属性も変わる +- アルバム画面([SPEC §12.6](../spec/12-ui/06-other-screens.md))で後から切り替えられる。false → true で album 単位の rg を投入、 + true → false で `rg_album_*` を NULL にして「RG 未書込」に戻す(次の書き込みで album のキーが消える) +- 既存 album はすべて false、既存の `rg_album_*` は 0017 で NULL に揃える(ファイルには書かない) + +**追記(2026-09-20。P4-5 の実装)**: 切り替えの置き場は**操作タブ**の「ReplayGain / FLAC」節 +(選択行が属する album ごとのチェックボックス、`PATCH /api/albums/:id`)。アルバム画面は無く、アルバム +一覧は表を絞るだけなので。CD の配置は**合流(複数枚組の 2 枚目)でも on** にする。0017 で album の値を +消した行は `rg_written_at` を NULL に戻し `rg_scanned_at` を 1 進める(Derived の `R128_ALBUM_GAIN` が +タグ上書きで追随する)。rg ハンドラは**書き込み時に属性を読み直し**、album 単位の job でも off なら +album の値を書かず、track 単位の job は on の album に属する track の album 値を据え置く(投入と切り替えの +競合で値が嘘にならない)。**スキャンは rg を自動投入しない**([D-47](D-047.md) のまま。新規・音声差し替えの +トラックは `POST /api/rg`(`no_rg` フィルタ)か取り込み経路で解析する)。`store` は `rg_scanned_at` を +前の値より小さくしない(off で `now + 1` へ進めた直後に同じ秒の解析が保存しても巻き戻らない)。 +transcode は Derived に記録する同じトランザクションで元の世代(音声版 / tag_version / 画像 / RG 世代 / +所在)を読み直し、読んでから書く間に動いていれば同じジョブを再キューして揃え直す(属性の切り替えは +track lock を取らず、running の間の投入は dedup で弾かれるため。音声版が動いた場合は再キューした +ジョブが版のゲートで終わり、scanner が投入した新しい版のジョブが揃える)。 +- `POST /api/rg { selection }`、承認後・配置後の自動投入は album の属性を見て投入単位を + 決める。設定ファイルに `[replaygain].album_gain` は置かない(album ごとに決まる) +- **RG の一致判定(`rg_written_at`)は完全一致のまま。** 既存ライブラリの RG タグ(foobar2000 / ytmusic + が書いた track gain。9,099 本すべて track のみ、album は 0 本)は spindle の解析値と丸め・実装差で + 0.01〜0.1 dB ずれるので、初回の「解析値をタグに書く」で全ファイルが書き換わる。**これは許容する** + (spindle の値で統一する)。以後は、書いた値が DB と完全一致して `rg_written_at` が立ち、再解析は + 音声が差し替わったときと手動だけで、同じ音声なら同じ値なので二度と書き換わらない + +**理由**: 実機の通し確認で、YouTube の 1 曲を「花譜のお歌」(245 曲の育つコレクション)に追記した +だけで album 単位の rg が走り、246 曲すべてをデコードし直した。album gain は「全曲をつなげてゲート +した値」なので各曲の値から合成できず、曲が増えるたびに全曲の再解析と全ファイルの書き換えになる。 +運用はアルバムをまたぐシャッフルが主で、既存ライブラリも track gain だけだった。album gain を +使うのはアルバムとして通して聴く CD だけで、それ以外は必要なときに人が選ぶ。 + +**却下**: 全体設定で album gain を off(CD の album gain まで消える)。育つコレクションだけ off +(判定基準が曖昧で、なぜ計算されないかが見えない)。RG の一致判定に許容差(既存の値を尊重して +書き換えを避ける案。spindle の値で統一したいので採らない)。 diff --git a/docs/decisions/D-075.md b/docs/decisions/D-075.md new file mode 100644 index 00000000..d6c21911 --- /dev/null +++ b/docs/decisions/D-075.md @@ -0,0 +1,70 @@ +# D-75 Apple 向けに `aac` 系統の Derived を足す。RG は焼き込み + iTunNORM 0 dB、多値は結合、プレイリストは出さない + +**決定**(2026-09-20。P4-7 で Derived を系統化、P4-8 で `aac` 系統を実装。仕様 [SPEC §7.6](../spec/07-pipeline/06-derived.md)): + +- Derived を**系統(variant)ごとに 1 本**にする。`opus`(Android の同期・Web 再生・配布ビュー。今まで + どおり)と `aac`(Mac のミュージック.app へ取り込む Apple 向け)の 2 つで固定。パスは + `Derived/<variant>/` 以下に Library のミラー、`derived_files` の主キーは `(track_id, variant)`、 + 設定は `[encode.derived.<variant>]`(`enabled` / `bitrate`。`aac` は `lossy_sources` と + `multi_value_separator` も) +- **エンコーダは ffmpeg 内蔵 `aac`**(`-b:a 256k`。ABR に近く真の VBR ではない)。追加依存を持たない +- **非可逆原本も AAC へ**(`lossy_sources = true`。[D-8](D-008.md) の例外)。原本が AAC でも再エンコード(2 本。 + stream copy では焼き込みとリサンプルが効かない) +- **ReplayGain は track gain を音声に焼き込み**(クリップ防止に peak で上限)、タグには `iTunNORM` を + 0 dB 相当で書く。`REPLAYGAIN_*` / `R128_*` は書かない。**RG 未解析なら作らず待つ**(rg の保存で投入)。 + RG の解析世代が変わったら再エンコード(`opus` はタグ上書きで済むが、`aac` は音声に入っている) +- **多値フィールドは `" & "` で 1 値に結合**(設定で変更可)。ミュージック.app は複数値の 1 つしか + 見せないため +- 画像は 768 の JPEG(`covr` の WebP は読まれない) +- **設定の世代を行に持つ**: `audio_profile`(codec / bitrate / サンプルレート規則 / 焼き込み方式)が違えば + Encode、`tag_profile`(区切り / iTunNORM 規則)が違えば Retag。区切りやエンコーダ引数を変えたときに + 既存の Derived が追随する(音声版だけを見る今の判定では UpToDate のまま取り残される) +- `enabled = false` は**凍結**(作らない・触らない・既存行は使い続ける)。GC は系統を区別しない +- **実装(P4-7)**: 系統の設定は起動時に `derived_variants` 表へ写し(`db::derived::sync_variants`。 + `export_profiles` の `fb2k_prefix` と同じ流儀。[D-55](D-055.md))、投入判定(`enqueue_if_stale` / `enqueue_all_stale`) + は表から系統ごとの設定を引く(呼び出し側は設定を持ち回らない)。0018 より前に queued だった旧 payload + (`variant` 無し)は opus として扱う。`transcode` の `Resolved` に設定を持ち、表に無い系統のジョブは + 何もせず Done。テストのフィクスチャは `common::enable_opus_variant` で表を作る +- **配布ビューもプレイリストも `aac` には作らない。** `delivery` は `opus` 系統に固定。ミュージック.app + へは `Derived/aac/` のファイルをそのまま取り込み、プレイリストは Apple 側で作る +- **実装(P4-8)**: `derived_variants` に `lossy_sources` / `multi_value_separator` を 0019 で足す(`eligible` + が投入判定で前者を、ハンドラがタグ結合で後者を表から引く。`audio_profile` / `tag_profile` の文字列に + 埋めて解析し直すより素直)。`aac` の対象は `!missing && channels ∈ {1,2} && (lossless || lossy_sources) + && RG 解析済み`(`rg_scanned_at` / `rg_track_gain` / `rg_track_peak` の 3 つが揃っている。時刻だけの行で 0 dB + の焼き込みを確定させない)。節を省略したときの `enabled` は aac だけ false(既存 config のまま新版を + 起動しても始まらない)。エンコードは ffmpeg 1 パス(`-af volume=<gain>dB [-ar 48000] -c:a aac + -b:a <k>k -f mp4`。opus のような中間 WAV は要らない)。焼き込み量は `min(track_gain, −20·log10(peak))` + で、peak は true peak(1.0 超なら減衰側)。これはエンコーダ入力の上限で、AAC 再符号化後のオーバー + シュートは保証しない(安全余裕は入れない。受け入れテストは ebur128 の測定に ±0.5 dB の許容)。 + gain が有限でなければ 0、peak は有限かつ > 0 のときだけ上限を掛ける。`iTunNORM` は 1〜2 値目 `000003E8` に加えて 3〜4 値目 + (同じ 0 dB の基準 1/2500 表現)を `000009C4` で埋める(0 のままだと読む側の解釈が不定になりうる。 + iTunes が読むのは 1〜2 値目)。MP4 のタグは Vorbis 名を lofty の `ItemKey` に写像して標準 atom へ、 + 写像できないキーは `----:com.apple.iTunes:<KEY>` のフリーフォームへ直接置く(lofty の generic Tag は + 未知キーを捨てる)。`iTunNORM` だけは内部キーの大文字化に関わらず atom 名を固定する。画像は thumbnail と同じ変換の JPEG 版(`thumbs/<hex>/768.jpg`)。RG の解析世代 + (`src_rg_scanned_at`)の差分は `aac` では Encode。album gain の on / off も世代を進めるので、その album + の aac は作り直される(track gain しか使わないが、値ベースの判定に列を足すより単純。まれな操作)。 + `lossy_sources` を true → false にしても既存の非可逆の行とファイルは消さない(Skip = 凍結と同じ)。 + ハンドラは `OpusEncoder` と `AacEncoder` の両方を持ち、系統でエンコーダ・タグ書き込み・画像の形式・ + 記録する bitrate を選ぶだけで、期待パスの予約・占有・配置・退避・drift の再キューは共通 +- **実機の順序(P4-8)**: 実機の 9,099 本のうち `rg_scanned_at` があるのは 270 本(残りはファイルに + foobar 時代の track gain タグがあるだけで DB に値が無い)。「RG 未解析は作らず待つ」ので、先に + `POST /api/rg` を全件流し(解析値は DB にしか書かない。Library のタグへの書き込みは別の + `POST /api/rg/write` で、それは aac の前提ではない)、完了後に `[encode.derived.aac]` を有効にして + デプロイする。ファイルの + 既存 `REPLAYGAIN_*` を暫定値に使う案は、値を spindle で統一する方針([D-74](D-074.md))と食い違うので採らない。 + RG 未解析でも焼き込み無しで作って解析後に作り直す案は、二度エンコードで CPU 時間が倍になるので採らない + +**理由**: Apple 純正の「ミュージック」は Opus を読めず、ReplayGain タグも読まない(独自の Sound Check = +`iTunNORM` をトラック単位で、端末の設定が ON のときだけ適用)。端末設定に依らず音量を揃えるには焼き込み +が確実で、焼き込み済みの上でサウンドチェックが ON でも二重にならないよう `iTunNORM` を 0 dB で置く。 +アルバムをまたぐシャッフルが主な運用なので album gain は使わない([D-74](D-074.md))。Apple 側の取り込みは +ファイルだけで足りるので、プロファイル `apple` の m3u8 は YAGNI。 + +**却下**: `fdkaac`(Debian non-free。真の VBR で品質にも定評があるが、依存が 1 つ増える。256k なら +内蔵 `aac` で実用上透過)。`iTunNORM` のみ(端末で ON にする必要があり、換算式と Apple 側の扱いが +不確か)。焼き込みのみ(サウンドチェック ON の端末で Apple が独自に計算した値が重なる)。非可逆原本を +除外する(Apple 側で YouTube 由来の 1,513 本が欠ける)。AAC 原本の stream copy(焼き込みと矛盾。 +複製して実ゲインの iTunNORM を書く案は、その 2 本だけ端末設定に依存する)。`enabled = false` で +ファイルを消す・GC から除外する(消すのは再エンコードの往復、除外は孤児の意味が変わる)。系統ごとに別の root(`paths.derived_aac`。 +データセットを分ける需要が無く、GC と孤児回収が二重になる)。プロファイル `apple` の m3u8 +(SMB の絶対パス / 相対パス。取り込みがファイルだけなので不要)。 diff --git a/docs/decisions/D-076.md b/docs/decisions/D-076.md new file mode 100644 index 00000000..c91cd9a0 --- /dev/null +++ b/docs/decisions/D-076.md @@ -0,0 +1,44 @@ +# D-76 終端を書けなかった `running` は再試行で救い、残ればワーカーが稼働中に回収する + +**決定**(2026-09-20。P4-8 の実機で観測した取り残しの対策。P4-9): + +- **終端遷移の書き込みを再試行する。** `run_one` の終端トランザクション(未 flush の進捗・ + `track_locks` / `job_mutexes` の解放・状態遷移)が `Err` を返したら、1, 2, 4, 8, 16, 32 秒の間隔で + 再試行する(6 回、約 1 分)。ハンドラの結果は `anyhow::Error` を含むので、書く前にメッセージ化した + Clone 可能な形(`Terminal`)にしてから閉包に渡す。再試行の間は種別の許可と CPU 予算を**持ったまま** + (DB が書けない間に次のジョブを始めても claim から失敗する。持ったままなら詰まりが見える)。 + 使い切ったら今までどおり `failed` としての記録を 1 度試み、それも駄目なら `running` のまま手放す。 + 停止(shutdown)が来たら再試行の途中でも abort し、DB は `running` のまま起動時リカバリに任せる + (今と同じ) +- **稼働中の回収。** ワーカーの周回は claim の前に `sweep_cancel_requested` を書き込みで呼んでいる。 + 同じトランザクションで `state = 'running'` の id を読み、プロセス内の実行中表(`Jobs.running`。 + cancel 用の token 表)に無い行を回収する: `cancel_requested_at` が立っていれば `cancelled`、 + そうでなければ `queued`(`started_at = NULL`。`attempts` / `run_after` は触らない = 起動時リカバリと + 同じ)。その `job_id` の `track_locks` / `derived_path_locks` / `job_mutexes` を消し、`last_error` に + 「終端を記録できないまま running に残っていたので再キューした」と書き、ログと SSE にも出す +- **実行中の登録を claim の直後に移す。** 今は `execute` タスクの先頭で `track_running` を呼ぶので、 + claim の直後の周回で「DB は `running` だが表に無い」瞬間があり、回収が本物を戻してしまう。token の + 生成と登録を `claim_one` の spawn 前(スケジューラと同じタスク、claim と順序が固定)に移す。 + 停止時に claim 済みを `queued` へ戻す経路では登録しない。これで `started_at` の閾値は要らない。 + 単一インスタンス前提([SPEC §8](../spec/08-jobs.md))なので、表に無い `running` は「このプロセスが終端を書けなかった行」 + 以外に無い +- 起動時リカバリ(`recover`)はそのまま。回収はその稼働中版で、対象を「表に無い行」に絞っただけ +- 受け入れ(`tests/jobs.rs`): ハンドラが `PRAGMA query_only = ON` を writer に掛けて `Done` を + 返し、別タスクが数秒後に OFF にする → `done` で `attempts = 0`(再試行で完走)。行を直接 `running` + にしてロック 3 種を入れ、ワーカーを起動 → `queued` に戻りロックが消え `last_error` に回収の旨、 + その後ハンドラが走って `done`。cancel 要求付きは `cancelled`。数秒 sleep するハンドラの本物の + `running` は戻されず `done` + +**理由**: 2026-09-20 の実機で、開発コンテナと共有する ssd プールが cargo の `target/` で満杯になり、 +transcode 88 本の終端書き込みが SQLITE_FULL で失敗、`failed` としての記録も同じ理由で失敗して +`running` のまま残った(ロックも残る)。空きができた後も次回起動まで詰まったままで、docker のログも +満杯で欠けていたので、画面の「実行中 88」以外に手掛かりが無かった。一時的な障害なら再試行で仕事の +結果を失わずに済み、長い障害でも回収で再起動なしに自力で戻る。両方あると、短い障害は透過で、長い +障害は再実行(ジョブは冪等)で済む。 + +**却下**: 無限に再試行する(許可を永久に握り、DB 固有の恒久的な失敗で種別が止まる。上限 + 回収で +同じ結果に届く)。回収を N 秒間隔の別タイマーにする(周回ごとの書き込みに `SELECT id … WHERE state = +'running'` を足すだけで済み、索引 `idx_jobs_queue` があるので安い。タイマーを足すと停止と wake の +扱いが増える)。`started_at` に経過秒の閾値を置いて競合を避ける(登録の順序を固定すれば要らず、 +閾値はテストの時間依存を増やす)。回収で `attempts` を進める(ジョブの失敗ではなく基盤の障害。 +起動時リカバリも進めない)。 diff --git a/docs/decisions/D-077.md b/docs/decisions/D-077.md new file mode 100644 index 00000000..d8032ec4 --- /dev/null +++ b/docs/decisions/D-077.md @@ -0,0 +1,34 @@ +# D-77 MP4(ALAC / AAC)の任意キーは `----:com.apple.iTunes:<KEY>` のフリーフォーム atom。読み書きの写像は 1 か所 + +**決定**(2026-09-21。P4-11。仕様 [SPEC §7.5](../spec/07-pipeline/05-tagwrite.md)「形式ごとの写像」): + +- Library の MP4 への tagwrite は lofty の generic `Tag` を経由せず、ilst を直接編集する + (`domain::tags::apply_ilst`)。Vorbis 名が lofty の `ItemKey` に写像でき MP4 の atom を持つキーは + 標準 atom(`©nam` `trkn` `cpil` `----:com.apple.iTunes:CATALOGNUMBER` 等。値の encode は lofty の + `Tag` → `Ilst` 変換に載せ、`trkn` / `disk` の対や bool の special-case を借りる)、写像表に無いキーは + `----:com.apple.iTunes:<KEY>` のフリーフォーム atom(多値は 1 atom の複数値)。`iTunNORM` は内部 + キーが大文字でも綴りを固定する +- 読み側(`collect_mp4`)も同じ写像。標準 atom は今までどおり generic `Tag` 経由で Vorbis 名に、 + generic `Tag` が捨てるフリーフォーム atom は `mean = com.apple.iTunes` に限って名前を大文字化した + キーで取り込む(`TagSet` はキーを大文字化する。標準 atom と同名になるものは標準 atom を優先)。 + 他の `mean` は読み書きとも触らない +- 置き換え・削除はフリーフォームの名前を**大小文字を無視して**消してから書く。外部ツールが + `MyKey` で書いた atom を spindle が `MYKEY` で上書きしたとき、旧綴りの atom が残ると読み戻しで + 多値に見えて op が failed に閉じる +- Derived の `aac` 系統([D-75](D-075.md)、`write_mp4_tags`)も同じ `apply_ilst` で書く(空の ilst に + `TransferTags` をキーで束ねて渡す)。別名キー(LABEL / ORGANIZATION、TRACKTOTAL / TOTALTRACKS)が + 両方あれば後勝ち、は変えない +- MP3 / WAV 等は generic `Tag` のまま。写像できないキーは書けず、読み戻し照合で failed に閉じる + +**理由**: 2026-09-21 の実機(P4-3 の確認)で、ALAC のプロパティタブに `SPINDLETEST` を足すと tagwrite が +「書き込み結果が意図と一致しない(この形式では表現できない)」で失敗した。lofty の generic `Tag` は +`ItemKey` に無いキーを書けず、読み側でも `Ilst` → `Tag` の変換がフリーフォーム atom を捨てるので、 +外部ツール(foobar2000 / Picard)が書いた独自キーが DB に載らない = ファイルが正(不変条件 1)に +反していた。P4-8 が Derived 向けに書いたフリーフォームの写像を Library の読み書きに使えば解け、 +写像を 1 か所にすると Derived と Library でキーの見え方がずれない。 + +**却下**: 既存の ALAC は FLAC 正規化でいずれ消えるので放置(読み側の取りこぼしは残るし、Inbox に +来る m4a も同じ経路)。lofty の `preserve_format_specific_items`(companion tag)に頼る(読み側の +取り込みは解けず、書きは global option の状態に依存する)。フリーフォームの名前を元の綴りのまま +読む(`TagSet` は大文字化する規約で、FLAC のキーも大文字化して DB に載っている。綴りを残すには +DB とハッシュの規約を変える必要があり、往復の二重化は削除側を大小文字無視にすれば防げる)。 diff --git a/docs/decisions/D-078.md b/docs/decisions/D-078.md new file mode 100644 index 00000000..f5de2220 --- /dev/null +++ b/docs/decisions/D-078.md @@ -0,0 +1,54 @@ +# D-78 再生リストの購読は album を `album_id` で束ね、同期は「列挙 → 揃え → 投入」を毎回 DB から計算し直す + +**決定**(2026-09-21、P4-16。[SPEC §7.7](../spec/07-pipeline/07-ytmusic.md)「再生リストの購読と同期」): +- **購読 = 再生リスト 1 本 → Library の album 1 つ**(`playlist_subscriptions`)。追記先の同一性は + `album_id`(登録時は NULL。同期か配置が Inbox の追記先と同じ規則(`destination_of`)で引けたときに + CAS で束ねる。[D-32](D-032.md) で album 全体の移動は id を維持する)。`albumartist` / `album` / `category` は束ねる前の + 初期値と表示用で、PATCH で変えると NULL に戻る。同じ追記先の購読は 1 つだけ(`target_key` UNIQUE と + `album_id` の部分 UNIQUE で DB で閉じる) +- **同期ジョブの順序は列挙 → 追記先 → 照合 → 番号揃え(tags → rename)→ ytdl 投入**。購読由来の ytdl は + `TRACKNUMBER` = 再生リストの位置を書くので、先に既存の行を揃えて隙間を空ける。揃え終わる前の失敗では + 投入しない +- **phase は永続化しない。** 各段の前に追記先の active 行に pending の op が無くなるまで待ち、差分は + 待った後の DB から計算し直す。rename の候補は「現在の `TRACKNUMBER` が目標位置と一致する対象行のうち + ファイル名の先頭の番号が合っていないもの」で、今回のバッチの applied 集合には依らない。**改名は + ファイル名だけ**で、ディレクトリは今のまま(リハーサル環境で album の category が未推定だったため、 + テンプレートの dir で動かすと album ごと `_Unsorted` へ移った。名前の書式だけ違う行も触らない) +- **ytdl の dedup `ytdl:<url>` は変えない。** Duplicate は満たしたと数えず「別の投入が走行中」として結果に + 出すだけ。承認されれば次の同期が Library で拾い、失敗すれば dedup が空いて購読の情報付きで再投入 +- **承認の後続は latch(`sync_requested_at`)+ Requeue + dispatcher。** 配置と手動要求は latch を書いてから + 投入し、走行中の同期は終わる前に latch を見て `Outcome::Requeue`。handler の最終確認から終端までの窓に + 立った latch と失敗で残った latch は常駐の dispatcher(30 秒ごと)が回収する。時刻の比較はしない +- **取れない entry(非公開・削除)は位置を占め続ける。** Library に無ければ「取れない」一覧 + (`kind: private | deleted | unknown`。現行の yt-dlp は `title: null` で来るので区別できず unknown)。 + 番号揃えもその位置を数えるので番号が飛ぶ。公開に戻れば次の同期で拾う +- **固定行が番号を塞ぐ。** `SOURCE_URL` 無し・再生リストに無い・`SOURCE_URL` 重複の行は動かさず、その + 番号への移動は「揃えられない」で報告する。対象同士の swap / cycle は可 +- プラグインが skip / 判定不能でも購読由来なら投入する(再生リストは人が選んだもの)。追記先は購読の値、 + TITLE / ARTIST はプラグインの判定(無ければ動画タイトルと albumartist) +- 上限(`max_enqueue`、既定 50)を超えた分は次回に持ち越す。定期同期は `last_attempted_at`(成否を問わない + 開始時刻)を基準にする(最終 retry が failed になってもバックオフを迂回しない) +- API は `/api/ytmusic/subscriptions`(TASKS の `/api/playlists/...` から変更。`/api/playlists` は spindle + 自身の m3u8 プレイリスト) +- 実装レビュー(codex)で足した規則: 購読の id は再利用しない(AUTOINCREMENT。0022)、同期が queued / running の間は PATCH / DELETE を + 409(`sync_running`)で拒み(各段の前の `updated_at` 検査は控え)、子バッチの終端は batch_id の + pending = 0 で待ち、同じ動画が再生リストに複数回あればその行は + 固定して投入は最初の位置だけ、子バッチが全件 applied でなければ `Failed` で投入しない(計画時の改名の + 衝突は報告だけ)、購読が消えていれば skip も通常どおり + +**理由**: P4-14 で `SOURCE_URL` が揃い「再生リストのどこまで持っているか」が分かるようになったので、URL を +1 件ずつ貼る運用(P4-13)をなくせる。番号を再生リストの順に揃えたい(P4-15 の要求)が、Library に無い +動画を正しい番号に挿入して後続をずらす操作は手作業では 3 段階の一括操作になるため、同期の一部として +`SOURCE_URL` で計算する。設計レビュー(codex)で直した点: (1) 投入が揃えより先だと承認が揃えの前に進み +番号が重なる、(2) phase を持たない再計算方式は「tags 適用後・rename 前に落ちた」境界を今回の applied 集合に +頼ると取りこぼす、(3) Duplicate を満たしたと数えると購読の情報の無いジョブに項目を永久に取り違える、 +(4) 承認の後続を「走行中なら Duplicate」で流すと lost wakeup になり、時刻比較は UNIX 秒精度で同秒の要求を +落とす、handler の最終確認から終端までの窓は generic worker を触らずに dispatcher で閉じる、(5) album を +毎回パスで解決すると album のタグ変更や layout 変更で別 album を作り得る、(6) `find_source_url` の LIMIT 1 は +重複 URL を隠す、(7) 定期投入を成功時刻だけで判定すると失敗後にバックオフを迂回する。 + +**却下**: phase / バッチ id を job payload に永続化して再開(再計算で同じ安全性が得られ、並行する手動 +バッチも同じ経路で扱える)。URL 単位の download ジョブと購読の関連表(同じ動画が複数の購読に出るのは稀で、 +Duplicate を「走行中」として次の同期に任せる規則で足りる)。generic worker の終端トランザクションに購読の +フックを足す(dispatcher で閉じる方が影響が狭い)。TRACKNUMBER を書かず Inbox の max+1 に任せて配置後に +揃える(毎回 2 バッチ増える。位置を先に書けば通常は承認の初期値がそのまま正しい)。 diff --git a/docs/decisions/D-079.md b/docs/decisions/D-079.md new file mode 100644 index 00000000..3ed49a38 --- /dev/null +++ b/docs/decisions/D-079.md @@ -0,0 +1,29 @@ +# D-79 イメージは CI が 1 回ビルドして起動確認に通ったものをそのまま GHCR へ push する。版は `/health` が答え、依存の更新は Renovate + +**決定**(2026-09-21、P4-12。[SPEC §14](../spec/14-deploy.md)「イメージの配布」): +- 置き場は GHCR `ghcr.io/akashisn/spindle`(public、`linux/amd64`)。`main` → `edge` / `sha-<7>`、 + `vX.Y.Z`(この形だけ。他の `v*` は CI が拒む)→ `X.Y.Z` / `X.Y` / `latest`(+ Release)。PR は push しない。 + 書き込み権限(GHCR / Release)は push イベントだけの `publish` ジョブに限り、PR で走る `docker` ジョブは + `contents: read`(同一リポジトリの PR や Renovate のブランチでも write token を持たない)。publish は + docker ジョブが `docker save` した成果物を skopeo で複製する +- CI の docker ジョブが `load` でビルドして起動確認し、**同じイメージ**を `docker push` する + (二度ビルドしない。確認した digest = 配布する digest) +- 版は `--build-arg SPINDLE_VERSION=$(git describe --tags --always)` → `build.rs` → `spindle --version` と + `GET /health` の `version`。加えて `/health` に `ytdlp`(起動時に `yt-dlp --version` を 5 秒で 1 回)。 + 「いまどの版が動いているか」を docker のログでなく `/health` で答える +- 本番は TrueNAS のカスタムアプリ(compose 相当)。自動配備は作らず、更新は UI で pull し直す手順を + OPERATIONS に書く。マイグレーションは前進のみ、戻すときはバックアップから +- 依存の更新は **Renovate**(GitHub App)。yt-dlp は Dockerfile の `ARG` を `# renovate:` 注釈の + custom manager(github-releases)で追う。マージは手動 +- `edge` は開発用で DB の互換を約束しない。スカッシュは最初の `vX.Y.Z` の前に 1 回だけ + +**理由**: `deploy/compose.yaml` が指す `ghcr.io/akashisn/spindle:latest` は存在せず、実機は `git archive` +→ ホストで `docker build` の手作業だった。YouTube の抽出は yt-dlp が古いと壊れるので、更新を仕組みに +する必要があった。Renovate は GitHub App なので、その PR で CI(`pull_request`)が走る(`GITHUB_TOKEN` +で作った PR は走らない)。自作の更新ワークフロー + PAT や Dependabot(`ADD https://…` の版を追えない) +より少ない部品で済む。 + +**却下**: 週 1 の自作ワークフローで yt-dlp の PR を作る(PAT が要る)。Dependabot(yt-dlp を追えない)。 +`arm64` のマルチプラットフォーム(Rust のクロスビルドが遅く需要が無い。要るときに足す)。自動配備 +(TrueNAS のカスタムアプリの更新は UI 操作。手動で十分)。CI で二度ビルドして push(`push: true` で +別に走らせると確認したものと digest が変わり得る)。 diff --git a/docs/decisions/D-080.md b/docs/decisions/D-080.md new file mode 100644 index 00000000..2ca98049 --- /dev/null +++ b/docs/decisions/D-080.md @@ -0,0 +1,21 @@ +# D-80 一覧のキーボードは「カーソル行」を選択と別に持ち、Shift + 移動は範囲そのものに置き換える + +**決定**(2026-09-22、P4-17。[SPEC §12.2](../spec/12-ui/02-track-list.md)「キーボード」): +- 表は選択(`lib/selection.ts` の immutable な集合)とは別に **カーソル行**(id)を持つ。クリックと移動キーが + 置き、見た目は枠だけ。選択の意味論(immutable、filter 形、anchor)は変えない +- 素の移動 = その行だけを選択、**Shift + 移動 = anchor からカーソルまでの範囲そのもの**(`rangeSelect`。 + 戻れば縮み、Ctrl で足した飛び地は消える)、Ctrl + 移動 = カーソルだけ、Space = トグル。クリックの Shift は + 従来どおり「足す」(`clickRow`)で、キーとクリックで挙動が違う +- filter 形(Ctrl+A)のまま Shift + 移動すると ids 形の範囲に置き換わる。anchor が無ければ移動前のカーソル行を + anchor にする +- カーソルは読み込み済みの行の中でだけ動く。End は「読み込み済みの末尾」であって total の末尾ではない + +**理由**: foobar2000 / Explorer の Shift + 矢印は「anchor からの範囲」で、戻すと縮む。キーの Shift を +クリックと同じ「足す」にすると、行き過ぎた選択を Shift + ↑ で戻せない。クリックの Shift を「範囲そのもの」に +変えると既存の「Ctrl で飛び地を足してから Shift で別の塊を足す」使い方が壊れるので、クリック側は触らない。 +未読込の行はサーバから id が届いていないので選択に入れようがなく、「読み込み済みの外へは出ない」が +仮想化(hooks/useTracks の先頭から順の積み上げ)と矛盾しない唯一の規則。 + +**却下**: カーソルを選択(`Selection`)の中に持つ(選択は表示に依存しない値で、カーソルは表示上の位置。 +SSE や再取得で選択を書き換えない規則を守るため別に持つ)。End で total の末尾まで先に読み込む(6 万件を +一気に読む導線になる。要るなら「末尾へ」ボタンで別途)。Enter で再生(頼まれていない。要望が出たら足す)。 diff --git a/docs/decisions/D-081.md b/docs/decisions/D-081.md new file mode 100644 index 00000000..f17b73b4 --- /dev/null +++ b/docs/decisions/D-081.md @@ -0,0 +1,27 @@ +# D-81 ジョブ一覧の衛生: 変化が無ければ投入しない、やることが無いのは失敗でない、確認済みは消せる、古い行は GC が消す + +**決定**(2026-09-22、P4-18。[SPEC §7.8](../spec/07-pipeline/08-inbox.md)「検出」、§8「終端の行の片付け」、§9、§12.5、§13 `[gc]`): +- **Inbox の周期の監視はジョブでなく常駐タスク**が行う。`[inbox].poll_interval_secs` ごとに Inbox の指紋 + (音声ファイルのパス・inode・size・mtime・ctime の集合のハッシュ。`import::inbox::fingerprint`)を取り、 + 前回投入時と違うとき、配置待ち(`approved`)か期限切れの `placed` があるとき、起動直後だけ `inbox` ジョブを + 投入する。投入が Duplicate(前のジョブが未完了)なら「前回投入時の指紋」を更新せず次の周回で投入し直す + (走査を終えた後に置かれた変化を取りこぼさない)。手動の `POST /api/inbox/scan` は常に投入。監視の状態 + (最後に確認した時刻)は `GET /api/inbox` の `watch` で画面に出す +- **取り込み済み**(同じ `SOURCE_URL` が Library / Inbox にある)の ytdl は `done`(note に所在)。失敗 + (`Fatal`)にしない。上部バーの赤丸(`summary.failed > 0`)は本当の失敗だけに付く +- **終端(failed / done / cancelled)のジョブ行は消せる**: `DELETE /api/jobs/:id`(queued / running は 409)、 + `DELETE /api/jobs?state=failed`(まとめて消せるのは failed だけ)。ジョブ行は作業記録でユーザデータでは + ないので物理削除(`edit_ops` / `album_verifications` の `job_id` は SET NULL、ロック表は CASCADE) +- **GC が古い終端の行を消す**: done / cancelled は `[gc].jobs_done_days`(既定 7)、failed は + `jobs_failed_days`(既定 30)。0 で消さない。`GET /api/gc/preview` に `jobs: { done, failed }` + +**理由**: 実機で inbox ジョブが毎分 1 行(1 日 1,440 行)積もり、ジョブ画面の「完了」が Inbox の確認で埋まって +他の完了が見えなかった。汚染の原因は「毎分の確認」でなく「確認のたびにジョブ行を作る」ことなので、確認を +ジョブの外に出して変化があったときだけ投入する。赤丸はリハーサルで既に Library にある URL を投入した 2 件の +「取り込み済み」で点きっぱなしになっていた。やることが無いのは異常ではなく、失敗に混ぜると本当の失敗が +埋もれる。確認済みの失敗を消す口が無いと赤丸を消せない。`jobs` は 5 万行を超えていて消す仕組みが無かった。 + +**却下**: inotify([D-68](D-068.md) 追記。即時トリガとして後から足せる形にしてある)。失敗に「確認済み」フラグを足す +(行を残す理由が無い。消せば十分)。done をまとめて消す API(数が多く一覧の上限で切れるので画面からは +1 件ずつ、古いものは GC)。失敗ジョブの削除イベントを SSE で流す(行が無くなるので画面は取り直す。他のタブは +次のジョブイベントで追随する)。監視の状態を DB に持つ(プロセス内の値で足りる。再起動直後は必ず 1 回投入する)。 diff --git a/docs/decisions/D-082.md b/docs/decisions/D-082.md new file mode 100644 index 00000000..0153ee22 --- /dev/null +++ b/docs/decisions/D-082.md @@ -0,0 +1,45 @@ +# D-82 候補のジャケットは Cover Art Archive から引き、サーバが中継する + +**決定**(2026-09-23。[D-72](D-072.md) の却下を覆す。P4-20 で実装): + +- CD 画面の候補に**ジャケットを出す**。取得元は Cover Art Archive の `release/<mbid>/front-500` +- **サーバが中継する**(`GET /api/cd/cover/{release_id}`)。ブラウザから直に引かない +- **MB に画像がある盤だけ**。無ければ 404 で、画面は空の枠のまま。手動のアップロードはしない + (既存の `POST /api/artwork/upload` が別にある) +- MB 本体の 1 req/s のスロットは使わない(相手が archive.org で別。画像の取得で照会が待たされない) +- **中継の境界を決めておく**: `cover_art_url` は起動時に `Url` として検証して `join` で組み立てる、 + リダイレクトは 5 回まででかつ HTTPS → HTTP のダウングレードは追わない、本文は 8 MiB まで + 逐次読みで打ち切る、`Content-Type` は `image/*` だけ、返すときは `nosniff`。 + **「画像が無い」(404)と「上流がおかしい」(502)は混ぜない**(混ぜると診断できない) +- 取り込み時に FLAC へ埋める配線は P2-8。`DiscMetadata` に画像を持たせる形は P2-5 で決める + +**理由**: [D-72](D-072.md) は「要望が無い」として却下したが、候補が複数出たときジャケットが一番早い見分けに +なるという要望が出た。サーバで中継するのは、(a) `[musicbrainz].address_family` / UA / `error_chain` の +診断をそのまま使えること(この回線は MetaBrainz の IPv4 が塞がれている。[D-64](D-064.md) 追記 2)、 +(b) P2-8 で FLAC に埋めるときに結局サーバが同じ画像を取ること、の 2 つによる。 + +**信頼の境界(未解決のまま残す点)**: この経路はサーバがユーザの代わりに外へ出ていく +(server-side request)。利用者が入れられるのは MBID だけで、宛先は `cover_art_url` と**上流が返す +リダイレクト先**で決まる。**リダイレクト先のホストは許可リストで絞っていない**。上限 5 回・HTTPS の +ダウングレード拒否・`image/*` だけ・8 MiB までで抑え、「既定の Cover Art Archive と、管理者が +`cover_art_url` に入れた宛先は信頼する」という前提を置く。spindle は宅内の単一利用者向けで、 +`cover_art_url` を書けるのは管理者自身だからこの前提で足りる。**厳密に閉じるなら**既定では +`coverartarchive.org` と `archive.org` 系だけを許可し、自前ミラーは設定した origin に限る形にする +(今回は採らない)。 + +**追記**(2026-09-23。実機確認で判明): **接続に使う IP の族は `[musicbrainz].address_family` を +引き継がない**(`Auto` を渡す)。当初は UA と一緒に共用する設計だったが、実機(`address_family = +"ipv6"`)でジャケットが取れなかった。 + +この経路は **coverartarchive.org(MetaBrainz)→ archive.org(Internet Archive)** の二段構えで、 +`archive.org` には AAAA が無い(`getent ahostsv6 archive.org` は IPv4 射影しか返さない)。ipv6 に +固定すると CAA の 307 は受け取れても飛び先へ届かず、リダイレクトを追えないまま終わる。実機での +確認: `curl -6 -L` は 307 で止まり、`curl -4 -L` は 200。族はホストごとに解決させるのが正しい。 + +`[musicbrainz].address_family` が要るのは musicbrainz.org のエッジがこの回線の IPv4 を落とすため +([D-64](D-064.md) 追記 2)で、それは相手ごとの事情。CAA 用の設定項目は足さない(archive.org に AAAA が無いのは +この回線の事情ではなく Internet Archive 側の性質なので、どの環境でも `Auto` が正しい)。 + +**却下**: ブラウザから直接 coverartarchive.org を引く(上の 2 つの理由で不利)。原寸の取得 +(数 MB あり、画面には要らない)。CAA 専用の `address_family` 設定(上の理由で `Auto` 以外に +意味のある値が無い)。 diff --git a/docs/decisions/D-083.md b/docs/decisions/D-083.md new file mode 100644 index 00000000..86c96929 --- /dev/null +++ b/docs/decisions/D-083.md @@ -0,0 +1,82 @@ +# D-83 読み取りオフセットは照合から見つけて学習する。同梱のオフセット表は持たない + +**決定**(2026-09-23。P2-5。ユーザ判断): + +- `[rip].drive_offset = "auto"` は、ドライブの型番(INQUIRY の vendor + product。ファームウェアの版は + 含めない)で**前に覚えた値**(`drive_offsets`、マイグレーション 0023)があればそれで、無ければ 0 で吸う。 + 整数を書けばその値で吸い、学習しない(`OffsetSource::Manual`) +- 吸った PCM の CRC 表で CTDB / AccurateRip と照合し(`cd::verify`)、**見つかったオフセット `r` が 0 で + なければ PCM を `r` だけずらしてから**(`cd::rip::shift_pcm`。端の `|r|` サンプルは無音)CRC を取り直し、 + 配置する。ずらした後の吸い出しのオフセットは「吸ったときの値 + `r`」で、rip.log と `RipReport` に + `Detected` として残す。`CrcTable` の `offset` は「DB の窓が自分のデータの何サンプル後ろから始まるか」 + なので、`r` が正なら先頭を捨てて末尾を埋める(向きは `tests/cd_rip.rs` で固定) +- **照合が通ったとき(CTDB か AccurateRip のどちらかで全トラック一致)だけ覚える。** 同じドライブは最後に + 通った盤の値で置き換える。どちらにも候補が無い盤は吸ったときの値のまま(`Learned` か `Unknown`) +- 端を無音で埋めてよいのは、探索範囲(±2939)が AccurateRip の末尾の除外(2940 サンプル)と CTDB の末尾の + 除外(10 セクタ以上)に収まり、CRC が変わらないから。音声としては最初か最後のトラックの端の `|r|` + サンプル(BDR-209M の +667 なら 15 ms)が無音になる。ドライブがリードイン / リードアウトを読めない + ときの EAC と同じ結果 + +**理由**: 同梱表の出所として有名なのは AccurateRip のドライブオフセット一覧だが、[D-13](D-013.md) のとおり AccurateRip +の DB 利用は許諾がグレーで、表を同梱して配るのはさらに踏み込む。照合側のオフセット探索は P2-9 で既にあり、 +CTDB / AccurateRip に載っている盤を 1 枚吸えば正しい値が分かる(EAC の「key disc」と同じ考え方)。手元の +ドライブは 1 台で、1 回覚えれば以後は同じ値を使う。 + +**却下**: 同梱表(上記)。INQUIRY + 手元のドライブだけの小さな表(表の保守が要り、他のドライブでは結局 0 で +始まる)。見つけたオフセットで再リップする(10 分かかる。ずらすのと結果は同じ)。`cd-paranoia -O` を +見つけた値で掛け直す(同上)。 + +**追記**(2026-09-23。rip ジョブの実装で決めたこと): + +- **`cd-paranoia -O` は使わない。** 手動・学習済みの値も含めて常に 0 で読み、`shift_pcm` で当てる。 + リードイン / リードアウトの外を読みに行かないので、ドライブの overread の可否に結果が左右されない。 + 読む範囲は TOC の長さから `first-last[mm:ss.ff]` で明示する(Enhanced CD の最後の音声トラックの終端を + cd-paranoia の解釈に任せない) +- **吸い直すのは比べる相手があるときだけ。** CTDB か AccurateRip のどちらかに候補があって一致しないとき。 + 照会できない・どちらの DB にも無い盤は、吸い直しても結果が変わらないので 1 回で確定する +- `TrackRead.rereads` は cd-paranoia の進捗で報告された scratch / repair / skip / read error の回数。 + cd-paranoia は C2 を使わないので `c2_errors` は 0(rip.log の C2 欄は測っていない) +- 進捗は SSE の `job` イベントに `detail`(`RipProgress`)を足して流す(DB には書かない) +- 実機(BDR-209M)で 9 分の 2 トラックの盤が 140〜160 秒。その盤は CTDB(信頼度 30、同じ TOC)と + どのオフセットでも一致せず、2 回の吸い出しで中身が違った(傷か読みの不安定)。学習の実機確認は + CTDB / AccurateRip に載っているきれいな盤で行う +- **「照合が通った」はオフセット 0 で全トラック一致のときだけ**(codex 指摘)。`match_*` の Verified は + ずれたままでも成立するので、ずれを PCM に当ててから照合し直したものだけを採用する。CTDB のパリティで + 直したとき、パリティが見つけたずれ(傷のため CRC では見つからなかったもの)も同じく直した PCM に当て、 + オフセット 0 で照合が通れば「吸ったときの値 + そのずれ」を学習する +- `[rip].drive_offset` の整数は起動時に ±2939(探索範囲)へ制限する。学習済みの値が範囲外なら使わない +- 公開の前に失敗・取り消しされたら、組み立て用の隠しディレクトリを消す(走査に見えないので、残すと同じ + 盤を吸い直すまで誰も片付けない)。修復の 2 回の走査は blocking に置き、1 MB ごとに取り消しを見る +- **当てるずれは全曲一致した手法を優先する**(codex 指摘)。全トラック一致(両方なら CTDB)→ 無ければ一部 + 一致(CTDB を優先)の順。部分一致を先にすると、傷のある盤で別のオフセットに引きずられる。学習に残す手法と + 信頼度は、当てた後にオフセット 0 で通った手法のもの + +**追記 2**(2026-09-23。ユーザ要望。foobar2000 と同じく、ディスクが無くてもドライブを判定する): + +- **AccurateRip のドライブ別オフセット表(`DriveOffsets.bin`)を実行時に取って使う**(EAC / foobar2000 / + dBpoweramp と同じ)。同梱はしない(D-83 の却下理由は「表を配る」ことで、実行時の取得は照会と同じ扱い。 + [D-13](D-013.md))。置き場は `[verify].accuraterip_url` の下、保存は `data/cd/DriveOffsets.bin`(30 日で取り直す。 + 取れなければ古い保存、それも無ければ表なし)。起動時に 1 回読み込み、`GET /api/cd/status` は読み込み済みの + 表だけを見る(ネットワークを待たない) +- 形式は 69 バイトのレコード(`offset i16 LE`、名前 33 バイト `"<vendor> - <product>"`、提出数 `u32 LE`、 + 予約)。照合は空白の連なり・`" - "` の区切り・大小文字を畳んだ鍵で、INQUIRY の型番(vendor と product を + 空白 1 つで結合)と突き合わせる。同じ型番が複数あれば提出数の多い方。実物で `PIONEER - BD-RW BDR-209M` + は +667(提出 255) +- **吸うときのオフセットは 設定の整数 → 学習済み → 表 → 0**(`cd::rip::choose_offset`。探索範囲の外は + 使わない)。表の値も照合で確かめ、ずれていれば当てて学習する(`OffsetSource::Table`) +- 型番はポーラがデバイスを開けている間に 1 回読んで状態に持つ(ディスク不要)。`GET /api/cd/status` の + `drive: { model, offset, offset_source }` で CD 画面がディスクを入れる前から出す +- 表の妥当性(codex 指摘): 空は拒否。実物の全レコードで成り立つ形(名前が空でなく 33 バイト内で NUL + 終端、予約が 0)から 1 件でも外れれば表ごと拒む。壊れた保存は取り直し、壊れた応答で正しい保存を + 上書きしない。メモリの表も取得時刻を持ち、`MAX_AGE` を過ぎたら次に使うときに取り直す(常駐し続けても)。 + 取り直しに失敗したら前の表で動き、1 時間は叩き直さない。範囲外の学習値は無いものとして表を引く +- 常駐中の取り直しは `spawn_refresher`(1 時間ごとに期限を見る。status は `peek` しか呼ばないので、これが + 無いと吸い出しをしない限り更新されない)。表が一度も無いままの失敗も時刻を覚え、`RETRY_AFTER` の間は + 取りに行かない(吸い出しのたびに 30 秒待たない)。古い保存は取り直しの前に公開する(stale-while-revalidate) + +**追記 3**(2026-09-23。実機の確認で見つけた欠け): + +- `album_verifications.drive_offset` は `source = 'rip'` の行だけに書く。値は **PCM に当てた読み取り + オフセット**(`RipReport.read_offset`。照合で見つけたずれを当てた後の値)で、`detected_offset` はそこからの + 残りのずれ。`source = 'retro'`(遡及照合)はどのドライブで吸われたか分からないので NULL。列は 0001 から + あったが書く処理が無く、吸い出しでも NULL だった。既存の行は埋め直さない(値は rip.log に残っている) diff --git a/docs/decisions/D-084.md b/docs/decisions/D-084.md new file mode 100644 index 00000000..f74c682e --- /dev/null +++ b/docs/decisions/D-084.md @@ -0,0 +1,27 @@ +# D-84 候補の無いまま取り込んだ CD は、承認画面のボタンで MusicBrainz を引き直す + +**決定**(2026-09-23。P4-21。ユーザ判断): + +- **起点は承認画面のボタン。** CD の件(サイドカーに `rip`)にだけ「MusicBrainz で引き直す」を出す。照会は + CD 画面と同じ `POST /api/cd/lookup`(DiscID → ISRC / バーコード → TOC 近似の段階化、10 分のキャッシュ、 + 1 req/s の間隔待ちはサーバの `MusicBrainzClient`)。**ジョブにせず、結果も保存しない**(件を替えると消える)。 + 自動で引くと「どれも違う」と判断した盤まで毎回引き直すことになり、レート制限の中で無駄が多い +- **吸い出しでドライブの ISRC / MCN をサイドカーに残す**(`RipEntry.isrcs` / `mcn`)。DiscID も TOC 近似も + 当たらず ISRC でだけ見つかる盤がある(嵐「Five」。[D-64](D-064.md) 追記)。旧サイドカーは空で読める +- **選んだときに写すのは、ID と空欄の名前。** リリース / リリースグループの MBID は常に写す(下書きの + `release_id` / `release_group_id` → 配置でタグと `mb_release_id`。[D-67](D-067.md) 追記 3 のリリースキー `mb:`)。 + アルバム名・アルバムアーティスト・日付・曲名・曲のアーティストは、下書きで空のところ(曲名は吸い出しが + 付けた `Track NN` も空とみなす)だけ埋め、手で入れた値は上書きしない。曲はトラック番号で候補の曲に + 対応させ、曲のアーティストがアルバムアーティストと同じなら空のまま(配置でアルバムアーティストになる) +- **ディスク番号は候補の medium の位置にする**(名前ではなく盤の識別。2 枚組の 2 枚目を 2 枚目として置き、 + 同じ MBID の 1 枚目に合流させる。1 のままだと番号が重なって配置で弾かれる) +- YouTube の件は対象外(曲の身元は `SOURCE_URL`。[D-70](D-070.md)) +- **MBID の大文字小文字は同一性に効かせない**(codex 指摘): 外部のツールが大文字の UUID を書くので、検証は + 大小を問わず通し、値(タグと `mb_release_id`)はそのまま保つ。リリースキー `mb:` は ASCII 小文字に揃え + (`placement::mb_key`。Inbox の配置・rename・自分の成果物の判定)、スキャナの album の照合も大小を無視する + +**理由**: [D-72](D-072.md) の「MusicBrainz は盤の識別に使い、値は公式表記を手で入れる」を保ったまま、名前の入っていない +盤では候補の表記を下敷きにできるようにした(空欄だけ埋めるので、公式表記で直した値は消えない)。 + +**却下**: 件が現れたら自動で照会してジョブの結果を保存する(上記)。候補の値で全部上書きする(手で直した値が +消える。CD 画面の「全部写す」は名前を入れる前の画面なので事情が違う)。 diff --git a/docs/decisions/D-085.md b/docs/decisions/D-085.md new file mode 100644 index 00000000..d4411eaa --- /dev/null +++ b/docs/decisions/D-085.md @@ -0,0 +1,25 @@ +# D-85 CD 画面は番号付きの段で流れを見せ、ライブラリにあるかを DiscID で示す。Inbox の表は全項目を出す + +**決定**(2026-09-24。ユーザ要望): + +- **CD 画面は ① 挿入中の CD → ② 認識したトラック → ③ 候補を選ぶ → ④ 取り込む の番号付きの枠。** + アルバム名を裸の見出しで出すと何の値か分からないので、① の値はすべてラベル付きにする。長い説明文は本文から + 外し、各枠の見出しの ⓘ(ホバー / フォーカスで出るツールチップ)に畳む。「表示のみ」も注記のバッジにする +- **「この盤はライブラリにある」を最上部の帯で示す。** 新しい `POST /api/cd/library { toc, release_id? }`。 + DiscID はサーバが TOC から出し、active なトラックの `MUSICBRAINZ_DISCID` タグで引く(DiscID は 1 枚ごとの値で + album の列には無い。[D-67](D-067.md) 追記 3)。当たらなければ、選択中の候補のリリースを `albums.mb_release_id` で引き、 + 「同じリリースの album がある(この盤は未取り込み)」と区別して出す(2 枚組の 1 枚目だけある、を見分ける)。 + DB だけを読み、MusicBrainz の照会の前でも失敗しても判定できる。マイグレーションは要らない + (`idx_track_tags_key` と `idx_albums_release` が効く) +- **Inbox の一覧の各件に代表画像のサムネイル。** 埋め込み画像だけ(既存の `/api/inbox/:id/artwork/:hash`)。 + フォルダの cover.jpg は拾わない(ユーザ判断。CD の件は画像を埋め込まないので空枠になる) +- **Inbox の承認画面のトラック表は全項目。** 直せる列・上の欄の写し・ファイル由来に加え、ファイルの全タグを + 列にする(ユーザ判断)。横に長くなるので枠の中で横スクロールし、「列」メニューで隠せる(見た目の好みなので + localStorage)。disc / # / 画像 / タイトルは左端に固定する(右のタグを読むときに行が分かるように)。タグの列は + 読み取り専用で、配置では変えない + +**理由**: CD 画面で「いま何が入っていて、どれとして取り込むか」が一目で追えず、説明文が多くて流れが読めなかった +(ユーザ指摘)。既に持っている盤を二重に吸い出す手間も、上部の帯で避けられる。 + +**却下**: 所持の判定を MusicBrainz の候補(リリース)だけで行う(候補を選ぶまで分からず、同じリリースの別の +盤と区別できない)。フォルダ画像の配信 API の追加(今回は不要)。 diff --git a/docs/decisions/D-086.md b/docs/decisions/D-086.md new file mode 100644 index 00000000..a58937b3 --- /dev/null +++ b/docs/decisions/D-086.md @@ -0,0 +1,39 @@ +# D-86 Inbox の承認画面でファイルのタグと埋め込み画像も直せるようにする + +**決定**(2026-09-24。ユーザ要望。モックで合意): + +- **下書きのトラックに `tags` と `picture` を足す。** `tags` はキー → 値の配列(`null` はそのタグを消す)で、 + 書くのは変更したキーだけ。`picture` は差し替える画像(`<mime>:<sha256hex>`)で、無ければファイルの画像のまま。 + どちらも省略可なので、旧い下書きはそのまま読める +- **上の欄が扱うキーは `tags` で受け付けない**(TITLE / ARTIST / ALBUM / ALBUMARTIST / DATE / TRACKNUMBER / + DISCNUMBER / DISCTOTAL / PICTURE)。直し方を二通りにしない +- **同一性の判定に使うキーには鍵をかける**(`SOURCE_URL` / `MUSICBRAINZ_*`)。`SOURCE_URL` は YouTube の + 二重取り込みの判定([D-70](D-070.md))、`MUSICBRAINZ_DISCID` などは CD の盤とリリースの識別([D-67](D-067.md) 追記 3)に使うので、 + 手で書き換えると判定が静かに壊れる。リリースの付け替えは MusicBrainz の引き直し([D-84](D-084.md))で行う +- **画像は既存のアップロード(`POST /api/artwork/upload`)で先に置き、下書きは sha256 だけを持つ。** CD の件は + `POST /api/artwork/from-caa` で Cover Art Archive の front 画像を同じく置ける。配置は `picture` のある曲だけ + 埋め込み画像をその 1 枚にする(ライブラリの差し替え [D-60](D-060.md) と同じく全部捨てて front cover) +- **曲ごとに画像が違う件(YouTube)は、既定で曲ごとの画像を保つ。** 画面は「画像の無い曲に入れる」と + 「全曲を 1 枚にそろえる」を明示的な操作にし、1 曲ずつの差し替えはトラック表の画像列で行う +- **下書きが参照する画像は GC しない**(区分 E の「参照」に `inbox_items.draft` の `tracks[].picture` を足す。 + 下書きには任意のタグの値も入るので全文の部分一致にはせず、JSON として `picture` の値だけを照合する。 + codex 指摘)。承認後に配置が失敗して再承認を待つ間に消えないように。それでも消えていたら、承認は 400、 + 配置は failed(選び直す) +- **宛先に自分の成果物(同じ音声)があっても置き換えない。代わりに今回の補正が入っているかを確かめ、 + 入っていなければ失敗にする。** 途中で落ちた件の再承認では、任意のタグや画像の補正はパスを変えないので + 同じ宛先に当たる。同じ音声だからと再利用すると今回の補正が黙って捨てられ、置き換えると外部(foobar2000 + 等)の変更や missing の行の実体を履歴なしに上書きしうる(codex 指摘。置き換えを孤児に限って照合する案は + 競合を原子的に閉じられなかった)。そこで再利用の前に、既存ファイルのタグと代表画像が今回の下書きの内容 + (`tag_changes` が空、`picture` の sha256 が一致)であることを確かめ、違えば Conflict(件は failed、 + Inbox と下書きは残る。Library のそのファイルを確かめて消すか、配置後にライブラリの編集で直す) +- **ALAC(MP4)を理由に止めない。** P4-11 で MP4 は写像できないキーをフリーフォームで書けるようになっており、 + 配置のテストで m4a に任意のキーを書けることを確かめた(モックで出した「ALAC には書けない」は古い制限だった) +- **配置先の見込みを出す**(`POST /api/inbox/:id/preview`)。音声を読まずに配置の計画(`plan_item`)を引くので、 + 途中で失敗した件の再承認(自分の成果物の再利用)では実際と食い違うことがある。画面では見込みとして出す + +**理由**: 承認画面をライブラリのプロパティと同じ操作で全項目を直せる場所にし、カバーの無い件もその場で +直せるようにする(ユーザ要望)。値の保存先はこれまでどおりファイルのタグ(不変条件 1)。Inbox の原本は +書き換えず、配置の途中のコピーに書く。 + +**却下**: フォルダに cover.jpg を置く(spindle は同梱の cover ファイルを書かない。[D-60](D-060.md))。同一性のキーも +自由に直させる(上記)。承認前に下書きをサーバへ保存する(今の「承認で保存」を保つ)。 diff --git a/docs/decisions/D-087.md b/docs/decisions/D-087.md new file mode 100644 index 00000000..dbdd211b --- /dev/null +++ b/docs/decisions/D-087.md @@ -0,0 +1,35 @@ +# D-87 YouTube 画面と一括編集・操作タブにも番号付きの段の導線を入れる + +**決定**(2026-09-24。ユーザ要望。モックで合意): + +- **YouTube 画面は「ダウンロード」と「購読」を切り替え、どちらも番号付きの段にする。** ダウンロードは + ① URL → ② 取り込み方 → ③ ダウンロード → ④ Inbox で承認、購読は ① 再生リスト → ② アルバムとして登録 → + ③ 登録して同期。④ は置いた件を並べて「Inbox で開く」だけにし、承認そのものは Inbox の画面で行う + (承認画面を二か所に作らない) +- **① で取り込み済みかを先に見せる**(`POST /api/ytmusic/lookup`。DB だけ)。動画は正規形 + `https://www.youtube.com/watch?v=<id>` にしてファイルの `SOURCE_URL` と照合する(ダウンローダの判定と同じ)。 + `list=` 付きの動画 URL は yt-dlp と同じく再生リストとして扱う。YouTube 以外の http / https は照合せずに + そのまま投入する(yt-dlp の対応サイトは広い)。照合は目安で、投入側の飛ばし([D-70](D-070.md))はそのまま残す +- **再生リストの中身は別の API で引く**(`POST /api/ytmusic/playlist`)。yt-dlp の列挙は数秒かかりネットワークに + 出るので、照合(毎入力)とは分けて URL ごとに 1 回だけ呼ぶ。読むだけでジョブにしない。同時に 2 本まで、 + 90 秒で打ち切り、接続が切れたら yt-dlp も止める。新規が 0 の再生リストは投入しない(取りこぼしがあれば投入する) +- **③ ④ が追う「今回の投入」はサーバが返したジョブと、その展開の子に限る。** 再生リストの展開が投入する + ytdl の payload に `parent_job_id` を入れる(ハンドラは読まない)。id の大小で「以降」を今回とみなすと、 + 購読の同期や別の画面の投入、重複で返った古い id 以降の履歴まで混ざる(codex 指摘) +- **一括編集・操作タブは段を横に 4 列並べる。** パネルは表の上で高さが 260px 前後の横長なので、縦に積むと + スクロールが要る。コンテナ幅が狭ければ 2 列 → 1 列 +- **操作タブは操作を 1 つ選ぶ形にする。** これまでは全部の操作を縦に並べていたが、③ と ④ をその操作用にし、 + 「巻き戻せる変更 / ジョブ / 読むだけ / すぐ反映」を印で示す。④ は押せない理由(プレビュー待ち・画像未選択・ + 追加先未選択)を添える +- **操作タブで操作を切り替えたら、前の操作の結果・失敗・反映待ちの確認を消す。実行中は切り替えない。** + 反映待ちの「除外して適用」は保持中の操作をやり直すので、別の操作の段に出ると別の操作を実行してしまう + (codex 指摘) +- **プレビューが古くなったことを見せる。** これまでは古いプレビューを黙って消していた。③ を「古い」にして + 枠を警告色にし、④ を押せなくする(`useBatchEdit.stale` / `useOperations.pathStale`) + +**理由**: CD / Inbox で入れた導線([D-85](D-085.md)、[D-86](D-086.md))を他の取り込み・編集の画面にもそろえ、どこまで済んだか、 +次に何をするかを同じ形で読めるようにする(ユーザ要望)。 + +**却下**: YouTube ④ で承認まで済ませる(Inbox の承認画面と重複する)。操作タブを全部並べたまま段だけ付ける +(操作ごとに ③ ④ の中身が違い、段が意味を持たない)。再生リストの列挙を照合 API に含める(入力のたびに +yt-dlp が走る)。 diff --git a/docs/decisions/D-088.md b/docs/decisions/D-088.md new file mode 100644 index 00000000..a87f0c92 --- /dev/null +++ b/docs/decisions/D-088.md @@ -0,0 +1,32 @@ +# D-88 マイグレーションを最初のリリースの前に 1 回だけ `0001_init.sql` へ畳む + +**決定**(2026-09-24。ユーザ依頼。[D-79](D-079.md) / TASKS P4-12 (8) の方針どおり): + +- `db/migrations/0001`〜`0024` を、最終形のスキーマを直接作る `0001_init.sql` 1 本に置き換える。列の並び・ + CHECK・既定値・索引・トリガ・ビュー・種データ(`export_profiles` の 3 行)は 24 本を流した結果と同じ + (空 DB へ流した `sqlite_master` をコメントと空白を除いて比べ、全表の `table_xinfo` / `index_list` / + `index_xinfo` / `foreign_key_list` と行を比べて一致を確かめた。違うのは `sqlite_sequence` に + `playlist_subscriptions` の行(seq 0)が無いことだけで、次の id が 1 なのは同じ) +- 0006 の Rust 側の手当て(既存プレイリスト名の衝突の改名と `idx_playlists_name_key` の作成。`post_sql`)は + 消し、索引は SQL に直接書く。`foreign_keys=OFF` で流す版の一覧(`FOREIGN_KEYS_OFF`)は空にして仕組みは + 残す(以後 CHECK を広げるために表を作り直す版で使う)。各版のアップグレード試験は、それが守っていた + 最終スキーマの性質を空 DB に対する試験へ移した(`tests/migrations.rs`) +- 版 24 までの既存 DB を新しいバイナリで開くと `Newer`(DB の版 24 > バイナリの最新 1)で起動しない。 + `edge` は DB の互換を約束しない([D-79](D-079.md))ので許容し、リハーサル環境はリリース時の再移行で DB ごと作り直す + (MIGRATION §5)。以後は CLAUDE.md の禁止事項どおり、既存ファイルは書き換えず連番を足す + +**理由**: 公開イメージを誰かが pull して DB を作った後は既存ファイルを書き換えられないので、畳めるのは +最初の `vX.Y.Z` より前、DB を作り直すこの時だけ。24 本のうち 8 本は表の作り直し(CHECK を ALTER できない +ため)で、新しい DB を作るたびに同じ表を何度も作っては捨てていた。スキーマの正を 1 ファイルで読めるように +する。 + +**却下**: 畳まずに残す(空 DB の構築が作り直しの連鎖のままで、スキーマの全体を読むのに 24 本を追う必要がある)。 +版 24 の既存 DB を新しい 0001 の版 1 へ読み替える手当てを入れる(互換を約束しない `edge` のためだけの +コードが残り続ける。リハーサル環境は作り直す)。 + +**追記(2026-09-26。ユーザ依頼)**: 「1 回だけ」を「最初の `vX.Y.Z` より前に限る」と読み替え、リリース直前に +もう一度畳む。D-88 の後に 0002〜0004 が増えた(Inbox の破棄・表の画像・category の索引)。理由は本文と同じで、 +畳めるのはリハーサル環境の DB を捨てて作り直す最初のリリースの時だけ。タグを切った後は既存ファイルを書き換えない +(TASKS P4-12 (10)、MIGRATION §5 の手順 0)。2026-09-26 に畳んだ。3 本とも列・索引の追加だけで、空 DB の最終スキーマは +`ALTER TABLE ADD COLUMN` が `sqlite_master` の SQL に残す空白の位置を除いて一致。既存の DB(版 4)は新しいバイナリ +(最新 1)で `Newer` になり起動しない(本文と同じく許容。リハーサル環境は §5 の手順 1 で作り直す)。 diff --git a/docs/decisions/D-089.md b/docs/decisions/D-089.md new file mode 100644 index 00000000..907e8fcb --- /dev/null +++ b/docs/decisions/D-089.md @@ -0,0 +1,73 @@ +# D-89 ALAC のプロセス内デコードの範囲を本番の ffmpeg に揃える(MP4 の edit list と末尾の長さ 0 のサンプル) + +**現行の規則は末尾の追記(2026-09-26)。** 以下の「旧決定」(宣言長で打ち切る)は追記で廃止した。 + +**旧決定(追記で廃止)**(2026-09-24。リハーサルの FLAC 正規化で見つかった不一致への対応): + +- symphonia のデコード出力を、**ALAC のトラックに限り**宣言長(`Track::num_frames`)で打ち切る + (`media::fingerprint::FrameLimit`)。対象は `decoded_pcm_md5`(`audio_md5`、正規化の照合値、MD5 補填)、 + `decode_s16`(CD の CRC)、`media::decode` のプロセス内経路(RG / 偽ハイレゾ検出)。宣言長が無いトラックと、 + delay / padding を持つトラックは打ち切らない。ffmpeg の経路は ffmpeg 自身が打ち切るので変えない +- ALAC 以外に広げない。symphonia 0.6.1 の MP4 は edit list を読まず、AAC の encoder delay も + `delay` / `padding` に載せないので、AAC の `num_frames`(`stts` の合計)は trim 前の長さでも再生長でもない + (1 秒 / 44.1k の AAC で num_frames 45,124、symphonia のデコード 46,080、ffmpeg 44,100)。打ち切ると + AAC の RG 値が中途半端な長さで変わるだけなので、従来の出力(46,080)を保つ(codex レビューの指摘)。 + FLAC / WAV / AIFF は宣言長とデコード長が一致するので掛けても値は変わらないが、必要が無いので掛けない +- ALAC を ffmpeg でデコードする経路には変えない。デコーダの構成(symphonia 優先・Opus は ffmpeg)はそのまま + +**原因**: リハーサル環境の正規化で ALAC 7 本(24 bit の 48k / 96k)が「変換前後の PCM MD5 が一致しない」で +失敗した(照合が働き、元ファイルは無傷)。これらの MP4 は `stts` が `[(N, 4096), (1, 0)]` で、最後のサンプルの +長さを 0 と宣言している(`mdhd` の duration も N×4096)。ffmpeg は宣言どおりそのサンプルを捨てるが、 +symphonia 0.6.1 の MP4 読みはパケットとして返し、デコーダは 1,043〜1,664 フレームを余分に出す。 +共通部分の PCM はビット単位で一致しており(内容の違いではなく長さの違い)、余分な部分はほぼ無音 +(1 本は全 0、1 本は最大でも −101 dBFS 程度)。宣言長で打ち切ると 2 本とも ffmpeg と同じ MD5 になる。 + +**理由**: ユーザは当初「ALAC のデコードを ffmpeg に揃える」を指示したが、正規化は元ファイルを symphonia、 +生成物を ffmpeg で読んで突き合わせる([D-46](D-046.md)「独立した 2 つのデコーダ」)ので、ALAC を ffmpeg にすると照合が +自己比較になって安全弁の意味を失う。原因が長さの宣言の解釈だけで、symphonia の PCM 自体は正しいので、 +宣言長に従わせる方が変更も小さく照合も保てる。ユーザはこの案を選んだ。 + +**既存 DB への影響**: 該当する ALAC(末尾に長さ 0 のサンプルを持つもの)は次の deep scan で `audio_md5` が +変わり、`audio_version` が上がって RG と Derived が自動でやり直される。それ以外の ALAC / FLAC / WAV / AIFF は +宣言長 = デコード長なので値が変わらない。AAC / MP3 / Opus / Vorbis は打ち切りの対象外で値が変わらない。 + +**却下**: ALAC だけ ffmpeg でデコードする(照合の独立性を失う。同期の呼び出し元に外部プロセスを通す変更も大きい)。 +正規化の照合で長さの差を許す(本物の欠損・切り詰めも通してしまう)。 + +**追記(2026-09-26。本番の再移行で見つかった打ち切りすぎ。ユーザ依頼)**: 本番の再移行の FLAC 正規化で、ALAC 46 本が +逆向き(symphonia の方が短い)の不一致で止まった。上の「ffmpeg は宣言どおり捨てる」は誤りで、ffmpeg は `stts` ではなく +edit list(`elst`)に従っていた。上の 7 本は `elst` の終端が宣言長ちょうど、46 本は `mvhd`(1/1000 秒)への切り上げで +3〜64 サンプル後ろにあり、長さ 0 のパケットの開始(= 宣言長)が終端より前なので ffmpeg はそれを丸ごとデコードしていた。 +46 本の余分は 64〜4,096 フレームで、43 本は無音か −100 dBFS 以下、3 本は −53〜−83 dBFS のフェードの末尾(本物の音声)。 +上の回帰試験のフィクスチャ(`stts` と `mdhd` だけを縮める)も終端が後ろの形で、ffmpeg は実際には捨てていなかった。 + +- **本番の ffmpeg(5.1)の規則**(実 ALAC と、実 ALAC から stream copy・`elst` の書き換えで作った形で確かめた): + 先頭は media_time サンプルちょうど削る。終端は media_time + 区間の長さ(movie timescale → media timescale を + 四捨五入。端数 0.4 は切り捨て、0.5 は切り上げ)。終端以降で**始まる**パケットは捨て、終端をまたぐパケットは + 丸ごと残す(長さ 0 のパケットも同じ規則)。edit list が無ければ何も削らない。「空の編集 + 区間」の 2 項 + (出力側 `-ss` の stream copy が書く)は edit list を無視して全パケットを出す +- **ffmpeg の版で違う**: 新しい ffmpeg(開発機の N-125084、2026-06)は終端で端数を切り(長さ 0 のパケットだけは丸ごと)、 + 「空の編集 + 区間」では末尾を空の編集の長さだけ削る。先頭の削り・終端 = 全パケットの終わりの形・長さ 0 の + パケットの扱いは 5.1 と同じ +- **決定**: `FrameLimit` を「宣言長で打ち切る」から「本番の ffmpeg 5.1 の出力範囲を再現する」に変える。ALAC で、 + `elst` が 1 区間・media_time ≥ 0・等速・長さ > 0 のとき、先頭を media_time 削り、終端以降で始まるパケットを読まない + (`media::mp4edit` で `moov` から読む。symphonia は edit list を読まないため)。`elst` が無い・単純な形でない・ + `mdhd` の timescale がサンプルレートと違うときは何も削らない(「空の編集 + 区間」は 5.1 と一致する)。 + edit list は symphonia が選んだトラック(`Track::id` = `tkhd` の track ID)のものだけを使う(symphonia は codec の + 分かる最初の音声トラックを選ぶので、先頭の音声トラックとは限らない)。壊れた・悪意のあるファイルで膨らまないよう、 + `moov` 64 MiB・トップレベルの箱 64 個・音声トラック 8 本・保持する `elst` の項 4 件を上限にし、子の箱は確保せずに辿る + (codex のレビューの指摘) +- **理由**: 正規化の照合([D-46](D-046.md))は元ファイルを symphonia、生成物を本番の ffmpeg で読むので、両者の出力範囲が同じでないと + 必ず失敗する。46 本の余分を捨てる(FLAC 側も宣言長で切る)とフェードの末尾(最大 85 ms)を失い、ALAC のまま残すと + 正規化の対象から恒久的に外れる。ユーザは ffmpeg の実際のデコード長に合わせる案を選び、実在しない形(先頭の削り・ + 終端がパケットの途中や手前)も実 ALAC で確かめながら再現するよう指示した +- **確かめたこと**: 実 ALAC 53 本(上の 7 本と 46 本)と、実 ALAC から作った 31 の形、長さ 0 のサンプルを持たない + `elst` 付きの実 ALAC 9 本で、`decoded_pcm_md5` が本番コンテナの ffmpeg 5.1 で作った FLAC の STREAMINFO MD5 と一致 +- **ffmpeg を上げるとき**(ベースイメージの Debian を上げると 7.x になる): 終端をまたぐパケットと「空の編集 + 区間」の + 扱いが変わる。実在する形(edit list が無い・終端 = 全長・長さ 0 のパケット)は版で変わらないので既存の 46 本 + 7 本は + 影響を受けないが、変わる形のファイルは照合で止まる(音声は失わない)。そのときは `FrameLimit` を新しい版の規則に + 合わせ、`tests/fingerprint.rs` の 5.1 に固定した期待値を直す +- **既存 DB への影響**: 46 本は次の deep scan で `audio_md5` が変わり、`audio_version` が上がって RG と Derived が + その 46 本だけやり直される。7 本の形と、edit list を持たない ALAC は値が変わらない +- **却下**: 正規化の照合で末尾の差を許す(本物の欠損・切り詰めも通してしまう)。ALAC を ffmpeg でデコードする + (本文と同じく照合の独立性を失う)。新しい ffmpeg の規則に合わせる(本番は 5.1 で FLAC を作る) diff --git a/docs/decisions/D-090.md b/docs/decisions/D-090.md new file mode 100644 index 00000000..29930858 --- /dev/null +++ b/docs/decisions/D-090.md @@ -0,0 +1,38 @@ +# D-90 却下した Inbox の件は「削除」で破棄待ちにし、GC が retention 日後に消す + +**決定**(2026-09-25。P4-22。ユーザの決定: 「却下した後に Inbox に残っている行を削除したら GC で削除対象にする」): + +- 二段にする。**却下**(従来どおり。ファイルは残し「下書きに戻す」で戻せる)と、却下した件の**削除**(破棄待ち)。 + 削除は `inbox_items.discard_requested_at` に時刻を入れるだけで、ファイルは消さない(旧マイグレーション 0002。[D-88](D-088.md) 追記で `0001_init.sql` へ統合。 + 状態は `rejected` のままにして CHECK の作り直しを避けた)。`POST /api/inbox/:id/discard` / `/undiscard` +- **物理削除は GC だけ**(CLAUDE.md の禁止事項)。GC の区分 F: `rejected` で `discard_requested_at <= now - + [gc].retention_days` の件。消すのは件のディレクトリの直下の**走査が写した音声**(`inbox_files` と stat が一致する + もの)・既知の同梱ファイル(`cd::riplog::is_companion_name`。配置が Library へ持っていくものと同じ)・サイドカー + (確かめたときと同じもの)だけ。ディレクトリは空になったときだけ消す。サブディレクトリ(別の件)・symlink・ + 知らないファイルには触らない。Inbox 直下の件(`rel_dir` が空)は音声だけ。猶予は Library の論理削除と同じ + `retention_days`(別の設定を増やさない) +- **破棄待ちの間にファイルが変わったら消さない**(安全側)。走査が変化(足された・差し替えられた)を見たら破棄待ちを + 解き、`rejected` のまま `error` に理由を残す(`pending` には戻さない: 却下は人の判断で、戻すと上部バーの件数に + 出て承認を促してしまう。見直して要らなければもう一度削除する)。GC は**確かめ → 削除**の 2 段: 確かめの段で + 直下の音声を `inbox_files` と照合し(写した後に足された・変わったもの)、同梱ファイルとサイドカーは + `inbox_files` に写らないので **ctime が「破棄の要求の秒 − 1」以降**なら要求の後に置かれた・変えられたものとみなす + (境目は安全側に「後」。1 秒の余裕は、要求の時刻が実時刻の秒なのに ctime はカーネルの粗い時計で数 ms 遅れて付き、 + 要求の直後に置いたファイルが要求の前の秒に見えることがあるため。試験で約 1/8 の頻度で見逃しを再現した。2026-09-25)。どれかが外れたら何も消さずに同じく解く。削除の段は 1 本ずつ、消す直前に確かめの + 段の stat(inode / size / mtime / ctime)と照合し、外れたらそこで止めて解く(それまでに消したのは確かめ済みの + ものだけ)。NotFound 以外で消せなければそこで止めて**行と破棄待ちを残す**(成功扱いにしない。次の GC で続きを + 行う。消えたファイルは NotFound として飛ばす)。どちらの場合も行は消さない(codex レビューの指摘) +- `rejected` から出る遷移(下書きに戻す)は破棄待ちを解く(`transition` / `set_state` が `discard_requested_at` を + NULL にする) +- GC の実行は件ごとに書き込みのトランザクションの中で「まだ rejected で期限が来ているか」を確かめ直し、ファイルを + 消してから行を消す。取り消しの API も同じ writer を通るので、確かめてから消すまでの間に取り消されることはない +- 画面: 一覧は従来どおり却下した件もバッジ付きで出す(SPEC の「一覧から外す」は実装と違っていたので直した)。 + 却下した件に「削除」(確認ダイアログに消す時期)、破棄待ちには「削除待ち」のバッジと期限、「削除を取り消す」。 + 期限は `GET /api/inbox` の `discard_retention_days` から + +**理由**: 却下した件のファイルは Inbox に残り続け、人が SMB で消すしかなかった(リハーサルで VALIS の二重取り込みを +却下した後に観測)。却下 = 即削除にすると、下書きに戻して承認し直す道が消え、物理削除が GC 以外の経路に出る。 +二段にして GC に任せれば、人が「要らない」と明示した件だけが猶予の後に消える。 + +**却下**: 却下と同時に削除する(戻せない)。確認ダイアログで即時に消す(物理削除が GC 以外に出る)。却下した件を +すべて GC の対象にする(「却下したが後で見直したい」件まで消える)。破棄待ちの専用の状態を足す(CHECK の作り直しが +要り、`rejected` と別に扱う箇所が増える)。 diff --git a/docs/decisions/D-091.md b/docs/decisions/D-091.md new file mode 100644 index 00000000..0a3fd1e0 --- /dev/null +++ b/docs/decisions/D-091.md @@ -0,0 +1,47 @@ +# D-91 CD の取り込みの表の画像は Cover Art Archive から一度だけ取り、承認画面の初期値に入れる + +**決定**(2026-09-25。P4-22。ユーザの決定: 「CD の取り込みでリリースが決まっていて Cover Art Archive に front 画像が +あれば、承認画面の初期値に最初からその画像を入れる」): + +- **取るのは inbox ジョブの走査と配置の後に一度だけ。** 対象は承認前(`pending` / `failed`)で画像がまだ無く、試行が上限 + (2)未満の取り込み。サイドカーの `rip.metadata.release_id`(吸い出しで選んだリリース。MBID)があるものだけ + 取りに行く。承認画面を開くたび・`GET /api/inbox` のたびには外へ出ない(一覧の表示を外部の遅さに引きずらせない)。 + 吸い出しの配置は inbox ジョブを投入するので、吸い出した直後の走査で取れる。この機能より前に Inbox に置いた取り込み + (実機の嵐「Five」)も、次の走査で同じ条件に当たって取れる +- **経路は [D-82](D-082.md) / [D-86](D-086.md) と同じ。** `CoverArtClient`(`address_family` は `Auto` 固定、リダイレクト 5 回・8 MiB・ + `image/*` の境界)で front を取り、`POST /api/artwork/upload` / `from-caa` と共通の + `import::cover::store_image` で `artwork` の置き場と行に置く(サムネイルのジョブも同じ) +- **記録は DB(`inbox_items.caa_picture` / `caa_tries`。旧マイグレーション 0003。[D-88](D-088.md) 追記で `0001_init.sql` へ統合)。** 外部から取ってきた値のキャッシュ + なので DB に持つ(DB を作り直せば次の走査で取り直す)。サイドカーは吸い出しの記録で、後から書き換えると [D-90](D-090.md) の + 破棄の確かめ(サイドカーの ctime)とも干渉する +- **配置を待たせない**: inbox ジョブ(並列 1・固定の dedup キー)の中で、**承認済みの配置を先に済ませてから**取る。 + 1 回の実行で取りに行くのは 4 件(`CAA_PER_RUN`)・90 秒(`CAA_RUN_BUDGET`)まで(走行中の 1 件は + `CoverArtClient` の全体 timeout 60 秒で終わる)。残りは周期の監視が「やることが残っている」 + (`needs_attention`。inbox ジョブに Cover Art Archive を渡したときだけ数える。渡していないのに数えると候補が + 減らず毎周回投入する)として次の周回で続ける。取り消しは通信の待ちの間も見る(`tokio::select!`)。別のジョブ + 種別に分けるのは `jobs.type` の CHECK の作り直しが要るので見送った +- **回数は外へ出る前に claim する**(ジョブの冪等): `caa_tries` を読んだ値との CAS で 1 回分進めて永続化してから + 通信する(`claim_caa`)。結果の記録は回数を戻さない(`max`)。通信の途中で落ちても・取り消されても、その回は + 数えられ、再起動をまたいで上限を超えない +- **画面**: 開いている取り込みは、人がまだ触っていなければ、走査の後に変わった提案(表の画像が入った等)を取り込む + (`refreshedDraft`。触っていれば上書きしない)。一覧のサムネイルは埋め込み画像が無ければ下書き・提案の画像 + (`/api/artwork/:hash`)を出す(`itemThumbUrl`) +- **再試行**: 画像の無い盤(404)と、リリースの無い取り込み(候補を選ばずに吸い出した・CD でない)は 1 回で上限に + する。上流の失敗(5xx・通信・画像でない)と置けなかったときは、claim で進めた回数のまま次の走査でもう 1 回だけ試す。 + どれも取り込みは失敗させず、画像なしのまま(理由はログ) +- **提案**: `caa_picture` があれば全曲の `picture` の初期値にする。保存した下書きがあればそちらが勝つ(`merge_saved`。 + 人が外した・差し替えたものを上書きしない)。配置は [D-86](D-086.md) どおり `picture` のある曲の埋め込み画像をその 1 枚にする +- **画面**: 全曲がその画像のままなら「Cover Art Archive の表の画像(吸い出したリリースから自動で取った)」と出し、 + 戻すボタンは「画像を外す」(`resetPictures`。CD のファイルには画像が無いので外すと画像なし)。手動の + 「Cover Art Archive から取る」は残す(リリースを引き直した後など) +- **GC**: `caa_picture` の画像は区分 E の参照とみなす(提案の初期値として配置まで要る)。取り込みの行が消えれば + 参照も外れる(配置した後は `tracks.artwork_id` が参照する) + +**理由**: CD 画面では候補のジャケットが見えていた([D-82](D-082.md))のに、吸い出したファイルには画像が無く、承認画面で +「Cover Art Archive から取る」([D-86](D-086.md))を押さないと入らなかった。見えていたものが Inbox で消えたように見え、押し忘れる +と画像の無いまま配置される。候補を選んだ時点で盤は決まっているので、既定で入れて外せる形にする方が分かりやすい。 + +**却下**: 吸い出しの時点でファイルに埋め込む([D-67](D-067.md) の吸い出しは FLAC を作って Inbox に置くまでで、取り込みの内容は +承認で人が決める。外す操作が配置の前に効かない)。承認画面を開いたときに取る(一覧の表示が外部に引きずられ、 +開くたびに引き直す)。サイドカーに記録する(吸い出しの記録を後から書き換えることになり、[D-90](D-090.md) の ctime の照合と +干渉する)。 diff --git a/docs/decisions/D-092.md b/docs/decisions/D-092.md new file mode 100644 index 00000000..88cca797 --- /dev/null +++ b/docs/decisions/D-092.md @@ -0,0 +1,52 @@ +# D-92 Library 直下のディレクトリ名はスキャナが自動で category の語彙にする + +**決定**(2026-09-25。2 回目のクリーンリハーサルで見つかった。ユーザの決定): + +- スキャナ(Phase 4 の album 照合の前、同じ書き込みトランザクション)が、音声を含む album ディレクトリ + (直下 + 1 段以上。`Anime/<albumartist>/<album>` の `Anime`)の親になっている **Library 直下の + ディレクトリ名**を、canonical key で語彙(`categories`)に無ければ登録する(`db::categories::insert`。 + API の `POST /api/categories` と同じ一意性。書き込みは単一コネクションなので API と並行しても重複しない)。 + 続く album 照合の `infer_category` は、登録後の語彙で直下の名前を category に付ける +- **語彙は Phase 4 のトランザクションの中で読み直してから**登録・照合する。Phase 2 で読んだ語彙のまま進むと、 + Phase 3 の間に API が同じ名前を足したとき登録が `None`(重複)になり、古い語彙のまま GENRE の写像が付いて + NULL の埋め戻しでも直らない(codex レビューの指摘。試験は Phase 4 の直前のフック `before_commit` で割り込む) +- 対象外: `_Unsorted`(既定)と `[layout].unsorted` の先頭の固定部分(`Scanner::with_unsorted_layout`)、 + 直下のディレクトリそのものに置かれた曲(`Loose/01.flac`。category/albumartist/album の形でない)、root + 直下の曲 +- **category が NULL のまま残っている album を埋める**: 増分スキャンの最速パスでは変化の無い album を + 照合し直さないので、album 照合の後に `category_id IS NULL AND missing_since IS NULL` の album だけを読み、 + 直下の名前に当たる語彙があれば `WHERE category_id IS NULL` の条件付きで付ける(`scans::albums_without_category` + / `set_album_category_if_null`)。一度埋まれば残るのは `_Unsorted` 等の数件なので、毎回の全件走査に + ならない(旧マイグレーション 0004。[D-88](D-088.md) 追記で `0001_init.sql` へ統合。その部分索引 `idx_albums_category_null ON albums(rel_dir) WHERE category_id IS NULL + AND missing_since IS NULL` で NULL の行だけを読む)。人が付けた値(NULL でない)は変えない +- **画面へ知らせる**: 埋めた album のトラックを `changed_ids` に足して `library` イベントを出す(ファイルが + 変わらない増分スキャンでも表の category が変わるため)。語彙だけが増えて行が何も変わらない run(直下の + フォルダの album すべてに人が別の category を付けていて、その名前の語彙がまだ無い既存の DB)も、 + `categories_registered > 0` なら `bulk` の `library` イベントを流す(codex 再レビューの指摘。まれなので表の + 取り直しの重さは問題にしない。新しい SSE の種別は足さない)。category の一覧(`useCategories`)は + `library` イベントのたびと、画面から語彙を足した / 消したときに取り直す(複数の選択欄を揃える) +- **自動登録は追加だけ。不要になった語彙は人が消す**: `DELETE /api/categories/:id`(設定画面の「category」)。 + active な album・GENRE の写像(CASCADE で黙って消えるので明示的に外してもらう)・購読(名前で持つ)・ + 承認前 / 配置中の Inbox の保存済みの下書き(名前で持つ)のどれかが使っていれば 409 `in_use`。確かめと削除は + 同じ書き込みトランザクション。missing の album は ON DELETE SET NULL で外れる。下書きを保存していない + 取り込み(提案は CD のサイドカーの category)は見ない。消した語彙の取り込みは配置で `_Unsorted` になる +- 優先順位はディレクトリ名 → GENRE → `genre_category_map`([D-38](D-038.md) のまま)。直下の名前は常に語彙になるので、 + GENRE の写像が効くのは `_Unsorted` の下など直下の名前を使わない album だけになる +- ScanReport に `categories_registered` / `albums_categorized` を足し、登録した語彙はログに出す + +**理由**: SPEC はカテゴリを「パスの最上位階層。統制語彙」と定義し、`[layout]` も `{category}/…` だが、 +`infer_category` は直下の名前が語彙に**あれば**付けるだけで、語彙を作る経路が API と購読・プラグインしか +無かった。作り直した DB では語彙が空のため、リハーサルの実機で 721 album 中 707 が category なしになり、 +(1) Inbox・購読・CD の category の選択肢に Anime / J-POP 等が出ない、(2) 一括リネームが 7,505 曲を +`_Unsorted/` へ移す提案をした。移行(MIGRATION)で毎回手で語彙を作る手順は忘れやすく、Library の実際の +構成が語彙の正なので、スキャナが揃える。 + +**既知の副作用**: album 照合(変化のあった album の `update_album_meta`)は従来どおり category を直下の +名前で上書きする([D-38](D-038.md))。直下の名前が語彙に入るようになったので、人が API で直下の名前と違う category を +付けた album は、その album のファイルが変わると直下の名前に戻る。category は配置先(パス)を決める値 +なので、違う category にしたいときはリネームで直下のフォルダを動かす + +**却下**: 移行手順で 1 回だけ登録する(手順漏れで同じ状態に戻る。SMB で直下に足したフォルダも拾えない)。 +直下のディレクトリを全部(音声の無いもの・ディレクトリの無い曲の置き場も)登録する(`Playlists` 等の +関係ないものが語彙になる)。NULL の埋め戻しを deep scan だけで行う(最初の deep scan までの最大 30 日、 +category の無い album が残る) diff --git a/docs/decisions/D-093.md b/docs/decisions/D-093.md new file mode 100644 index 00000000..9373eee2 --- /dev/null +++ b/docs/decisions/D-093.md @@ -0,0 +1,38 @@ +# D-93 表の画像だけを同じリリースグループの別の版から取れるようにする + +**決定**(2026-09-25。P4-23。ユーザの要望: 「MusicBrainz の候補に出ている別のリリース(BD 版や初回限定版)には +カバー画像があるが、取り込みたい通常盤には登録されていないとき、メタデータは通常盤のまま、カバー画像だけ別の +リリースにしたい」。自動の補完と手動の選択の両方を入れる案をユーザが選んだ): + +- **自動([D-91](D-091.md) の拡張)**: 承認前の CD の取り込みで、吸い出したリリースの front が 404 なら、サイドカーの + `rip.metadata.release_group_id` があるとき**同じ回の中で**リリースグループの front + (`release-group/{id}/front-500`。CAA がグループの代表に選んだ版の表の画像)を 1 度だけ試す + (`import::cover::fetch_front`、`CoverArtClient::group_front`)。claim した 1 回の中の試行なので回数は増やさない。 + グループの 404 はリリースの 404 と同じく打ち止め、グループの上流の失敗はリリースの失敗と同じく次の走査で + もう 1 回だけ。リリースに画像があればグループは引かない。どちらから取ったかはログ(`source`)にだけ残し、 + DB には持たない(`caa_picture` は置いた画像の値で、画面の「画像を外す」の判定にしか使わない。 + 出どころを持つにはマイグレーションが要り、見返りが無い)。画面の文言は「吸い出したリリースかそのリリース + グループから自動で取った」にする +- **手動**: Inbox の承認画面の画像の欄に「別のリリースから取る…」(`CaaReleasePicker`。曲ごとに画像が違う + 取り込みには出さない)。メタデータは取り込むリリースのまま、画像だけを置いて全曲の `picture` に入れる + (従来の `applyPicture(…, 'all')`。配置で埋め込む) + - 下書きの `release_group_id` があれば、同じグループの版を `GET /api/cd/release-group/{id}/releases` + (MusicBrainz の browse `ws/2/release?release-group=…&inc=media+labels&limit=100`。既存の + `MusicBrainzClient` の 1 req/s に乗る)で並べる。MusicBrainz のリリースは `cover-art-archive.front` を + 持つので、表の画像のある版だけをジャケット(`/api/cd/cover/{id}` の中継)付きで出し、無い版は件数だけ。 + 並びは front のある版が先、同じなら日付の古い順。見分けに日付・国・形式(`CD + Blu-ray` 等)・レーベル・ + カタログ番号を出し、取り込むリリースに印を付ける。グループが 100 件を超えるときは先頭のぶんだけ + - 「リリースグループの代表画像を取る」(`from-caa { release_group_id }`) + - 別のグループに登録された版(BD 版が別のリリースグループになっている等)のために、MusicBrainz のリリースの + URL か MBID の貼り付け。`from-caa { release_id }` はサーバで `parse_release_ref` を通すので URL も受ける +- `POST /api/artwork/from-caa` は `{ release_id }` か `{ release_group_id }` のどちらか片方(両方・無しは 400) + +**理由**: 実機の嵐「Five」のリリースグループは CD + Blu-ray(画像あり)・CD + DVD-Video(画像なし)・配信 +(画像なし)の 3 版で、DVD 付きを吸い出すと [D-91](D-091.md) は画像なしで終わる。表の画像は版が違ってもほぼ同じで、 +CAA もグループの代表画像を持っている。自動の補完で多くは埋まり、違う版の画像にしたいときだけ一覧から選ぶ。 +これまでの回避策(別の版の画像をブラウザで保存してドロップする)は MusicBrainz を別に開く必要があった。 + +**却下**: 取り込むリリースをグループ内の画像のある版へ変える(メタデータ = カタログ番号・媒体構成・ +`MUSICBRAINZ_ALBUMID` が実物の盤と食い違う)。手動を URL / MBID の貼り付けだけにする(変更は小さいが、別の版の +MBID を MusicBrainz で探して貼る手間が残る。ユーザの選択で一覧を付けた)。グループの版の一覧をキャッシュする +(開くのは件ごとに 1 回程度で、MusicBrainz の間隔制御に乗っていれば足りる)。 diff --git a/docs/decisions/D-094.md b/docs/decisions/D-094.md new file mode 100644 index 00000000..5deb2bbf --- /dev/null +++ b/docs/decisions/D-094.md @@ -0,0 +1,53 @@ +# D-94 CD の取り込みで品番(と JAN)を手で入れ、版の識別と照会に使う + +**決定**(2026-09-26。P4-25。ユーザの要望: 「挿入した CD は通常盤なのに、MusicBrainz で一致したのは初回限定盤で、 +そのまま取り込むとどの盤かという情報が落ちる。見た CD にはすべて品番(UPCJ-9085 のような)が付いていたので、 +取り込み時に手で入れて保持すれば、後で版を直せる」。下の形をユーザが選んだ): + +- **CD 画面の ① に「品番」と「JAN」の入力欄を置く(どちらも任意)。** 値の補正ではなく照会と識別の入力で、 + [D-67](D-067.md) 追記の「CD 画面からは直せない」の例外にする(他の値は従来どおり Inbox の承認画面で直す)。 + 入力は盤が替わったら消す +- **照会**: `POST /api/cd/lookup` に `catno?` を足す。リリース URL / MBID と同じく**段に関係なく常に引く** + (DiscID で当たっても引く。通常盤と初回盤は収録が同じなら TOC も同じで、DiscID では版を区別できないため)。 + `ws/2/release?query=catno:"…"`(値は英数字・ハイフン・空白だけを通し、Lucene の記号を通さない。取得の上限は + バーコードと同じ 3 件)。検索は大文字小文字・ハイフンの揺れを吸収するが部分一致もありうるので、リリースの + label-info の品番のどれかが入力と**正規化して一致する**(大文字小文字・ハイフン・空白を無視)ものだけに + 経路 `catno` を付ける。JAN は既存の `barcode:` の検索に、盤から読めた MCN が無いときの入力として渡す +- **候補の並び**: 品番の入力があるときは、経路 `catno` を持つ候補を先頭にする(既定で選ばれる)。 + `matched_by` の強い順は `catno | discid | release | isrc | barcode | toc`。品番は実物から読んだ版の識別子で、 + DiscID が別の版にしか登録されていない盤では、品番で当たった版が手元の盤になる。収録の違う版 + (ボーナストラック付き等)は DiscID 自体が変わり、トラック数の合わない候補は従来どおり落ちる +- **下書きへの写し方**(写す範囲が「最小限」でも、入力した品番と JAN は書く。ユーザが実物から読んだ値のため): + - 選んだ候補の品番が入力と一致する → `CATALOGNUMBER` は候補の表記、`BARCODE` は候補の値 + (JAN の入力があり食い違えば入力値) + - 食い違う(MusicBrainz に手元の版が無く、別の版で取り込む)→ 画面に注意を出し(例:「この候補の品番は + UPCJ-9001(初回限定盤A)で、入力した品番と違います」)、版に固有の値を引き継がない: + + | タグ | 扱い | + |---|---| + | `CATALOGNUMBER` | 入力値 | + | `BARCODE` | 引き継がない(JAN の入力があればそれ) | + | アルバム名・アーティスト・トラック名・ISRC・`MUSICBRAINZ_TRACKID` | 引き継ぐ(DiscID / ISRC で中身が同じと確かめている) | + | `LABEL`・`DATE` | 引き継ぐ(通常盤と初回盤で違うことはまず無く、日付も年はほぼ合う) | + | `MUSICBRAINZ_ALBUMID`・`MUSICBRAINZ_RELEASETRACKID`・`MUSICBRAINZ_RELEASEGROUPID` | 引き継ぐ(近い版を指す値として) | +- **版がずれている印は DB に持たない。** `CATALOGNUMBER` が `MUSICBRAINZ_ALBUMID` のリリースの品番に無いことが + 印になり、タグと MusicBrainz から後で拾える(ファイルが正)。拾って版を直す画面は今回は作らない + +**理由**: 実機で 7 枚取り込んだところ、DiscID で当たった候補が手元の盤と別の版のことがあった。DiscID は TOC から +出るので、収録が同じ版どうしは区別できない。品番は盤に必ず印刷されていて手入力の手間も小さく、MusicBrainz の +`catno:` 検索で版まで決まる(2026-09-26 に `catno:UPCJ-9001` → 「シンデレラガール」初回限定盤A の 1 件、 +`catno:upcj9001` でも同じ 1 件を確認)。MusicBrainz に手元の版が無くても、品番がタグに残れば後で直せる。 + +[D-72](D-072.md) の「カタログ番号や JAN は要らない」はこの範囲で覆す(値としてではなく、版の識別子として要る)。 +[D-72](D-072.md) の Discogs の却下はそのまま残す。品番の入力を入れたあと、MusicBrainz で当たらない盤がどれくらいあるかを +見て再検討する(TASKS の残課題)。 + +**却下**: +- 品番を「補助」に留め、DiscID / ISRC で当たった候補を常に優先する(収録が同じ版を区別できないので、 + 品番で手元の版が見つかっても初回盤で取り込まれる) +- 食い違うとき `MUSICBRAINZ_ALBUMID` も外す(版に固有なので筋は通るが、ライブラリにあるかの判定([D-85](D-085.md))と + ジャケットの自動取得([D-91](D-091.md) / [D-93](D-093.md))が効かなくなる。品番との食い違いが後で版を直す手がかりとして残る) +- 食い違うとき候補の JAN を残す(品番が違えば JAN も別物で、実物と違う値がタグに残る) +- 版がずれている印を DB やサイドカーに持つ(タグと MusicBrainz から導ける。マイグレーションに見合わない) +- レーベルの公式サイトやタワーレコード / HMV を品番で引く(公開 API が無くスクレイピングになり、サイトごとに + 解析が要って改修で壊れる。規約上も自動取得は想定外) diff --git a/docs/decisions/D-095.md b/docs/decisions/D-095.md new file mode 100644 index 00000000..920bf39b --- /dev/null +++ b/docs/decisions/D-095.md @@ -0,0 +1,117 @@ +# D-95 端末への配信を管轄に入れる。Android は ADB 直結、iPhone は Mac のミュージック.app を中継し、端末ごとのマニフェストを毎回計算する + +**決定**(2026-09-29。ユーザ依頼。仕様は [§7.11](../spec/07-pipeline/11-device-delivery.md)): + +- [SPEC §1](../spec/01-scope.md) の非目標「Android へは Derived + m3u8 のファイル同期を継続する」と §7.7 の「`sync.py`(ADB)は + 移植しない」を改め、端末への配信を管轄に入れる +- Android(Poweramp。SD カード or 内部共有ストレージ)は TrueNAS に USB で直結し、特権を持つ adb サイドカー + 越しに差分を送る。**差分を見てからボタンで**送る(自動では送らない) +- iPhone は純正ミュージックを使うため Mac のミュージック.app を中継する。常駐しない CLI `spindle-agent` が + Mac 側のコマンドで反映し、iPhone へは Finder 同期 +- 端末ごとに「あるべき状態」(マニフェスト)を保存せず毎回計算する([D-78](D-078.md) と同じ)。端末側(manifest / + エージェントの報告)が正で、`device_items` はキャッシュ +- 曲は desired / hold / remove に分け、hold(待ち・エラー)の既存の写しには触らない。パス衝突は曲単位で保留 +- トークンは意味トークン(版)と配信トークン(版 + sha256)の 2 層。送る元のハッシュは `source_hashes` に + 事前計算し、配信時に開いた FD の identity で照合する +- **不変条件の例外**: 端末上の写しと Mac の `~/Music/spindle` は Library から再生成できる複製なので、物理削除 + (`missing_since` の猶予なし)と `edit_batches` に載せない書き込みを許す。「ファイルが正」は端末側にも + 当てはめる。生 SQL の禁止は変わらない + +**理由**: 端末に何が入っていて何が古いかが spindle から見えず、一括編集のたびに人が送り直しを判断していた。 +android プロファイルの m3u8 は Derived と Library の 2 root にまたがり、端末へそのまま運べなかった。 +Linux から純正ミュージックへ直接書き込む手段は現行 iOS に無い(メディア DB が非公開、libgpod は非対応)。 + +**却下**: 配信ツリーを NAS 上に実体化(Derived と同量をもう一度持つ)。spindle の外のスクリプト(状態が +見えず、2 root 問題をスクリプトで吸収することになる)。Linux から純正ミュージックへの直接書き込み(不可能)。 +Apple Music のライブラリ同期(契約が要り、照合で音源が差し替わる)。常駐エージェント(ユーザが Mac 側での +起動を選んだ)。接続即同期(ユーザが差分の確認を選んだ)。パス衝突で端末全体を止める(1 件で全体が止まる)。 + +**実装(P5-1)**: +- `source_hash` ジョブは CPU の共通並列予算([D-73](D-073.md))の対象(並列度 2)。全曲分の SHA-256 は CPU も使う +- 原本のハッシュは、`source_hashes` の identity(inode・size・mtime_ns・ctime_ns。dev は見ない。[D-62](D-062.md))が `tracks` 行と一致するときだけ有効。違えば「ハッシュ計算中」に戻して取り直す(外部ツールが同じタグで再保存した原本を「変化なし」と誤判定しないため) +- `source_hash` ジョブは原本について、開いた FD の identity が `tracks` 行と違う(または見つからない)ときは読まずに「スキャン待ち」で終える(スキャン前に読んでも差分側で使われず、差分を開くたびに全体を読み直す空回りになるため)。Derived の実ファイルが無いのは [D-51](D-051.md) の drift なので失敗にする +- 端末の差分は、行き先を「パスを変えない管理下の曲」(保留中の曲を含む)が占めていれば、その曲をパス衝突で保留にし、不動点まで繰り返す(入れ替えの片側が保留になれば、もう片側も保留)。プレイリストはこの衝突を解いた後の desired / hold から組み立てる(保留になった曲を塞がれた行き先のパスで載せない) +- 系統が設定に無い(`derived_variants` に行が無い)端末は凍結と同じ扱いで、音声版が一致する既存の Derived と opus の非可逆原本は送る +- パス長の上限に数える root の前置きは端末から求める(adb は `/storage/<volume>/<root>/`、内部ストレージは `/storage/emulated/0/<root>/`。agent は `Music/spindle/`) +- P5-2 / P5-3 / P5-4 へ持ち越し: Derived の実ファイル欠損([D-51](D-051.md) の drift)を「送る元が無い」保留理由にし、`source_hash` の再投入を抑えて transcode へ誘導する / プレイリスト操作の順序(削除を先に)と改名の 2 段 / エラーのプレイリストが古い写しと衝突する場合 / agent のプレイリストの dest_path / プレイリストのパス長とトラックの `.spindle` 予約名 / `compute` を 1 つの読み取りトランザクションにする + +**実装(P5-2)**(2026-09-29): +- Inbox の段(B)の対象は、その件で配置した曲。マイグレーション 0003 で `inbox_item_tracks(item_id, track_id)` を足し、配置のトランザクションで書く(既存アルバムへの追記でも、前からある曲を数えない)。0003 以前に配置した件は曲の記録が無いので、段が対象外に見える +- Derived の実ファイル欠損([D-51](D-051.md) の drift)は自動で作り直す。`source_hash` が Derived を開けず NotFound なら、その曲・系統の transcode が queued / running でなく、行の `rel_path` も読んだときのままであることを同じ書き込みトランザクションの中で確かめてから `derived_files` の行を消し、`enqueue_if_stale` で作り直しを投入する。差分には「Derived 未生成」と出る +- スナップショットのキャッシュ: 全端末の差分の計算結果を `Db` が持ち、書き込みの通番(`Db::write` を通るたびに ++。読み取りの前に通番を読む)が変わるまで使い回す。通番は「読んだ時点以後の書き込みを含むかもしれないが、それ以前の書き込みは必ず含む」ので、古い結果を新しい通番で返すことは無い +- キャッシュの計算は 1 本にまとめる(ロックを持ったまま計算し、同時に外れた要求は待って同じ結果を使う)。表示だけに使う呼び出し(一覧・詳細の行の端末の状態、Inbox の段、端末一覧の件数)は、計算から 2 秒以内ならその後の書き込みがあっても使い回す(ジョブが続く間は通番がほぼ毎回進むため)。差分・plan_token・変更 API の応答・未反映の集合とスマートプレイリストの評価(書き込みの材料)は通番の一致を要する。端末が 1 台も無ければ一覧・詳細はスナップショットを取らない +- `source_hash` の投入: 差分の計算(`GET /api/devices` と差分)のたびに、未完了の dedup キーと抑制中のキーを 1 本の問い合わせで除き、残りを 1 つの SAVEPOINT で投入する。読みプールで絞って何も無ければ書き手に触らない(通番を進めない)。Web はジョブの完了による端末の取り直しを末尾の間引き(3 秒、最長 10 秒)にする +- 原本の物理同一性が `tracks` と食い違う(SMB 越しの外部の書き換えで、まだ走査していない)と、`source_hash` は走査待ちで終わる。このとき増分スキャンを投入し、note に走査待ちの印を残す。原本の payload には投入時の `tracks` の物理同一性(inode・size・mtime_ns・ctime_ns)を入れ、同じ dedup キーの最新のジョブが走査待ちで終わっていて payload の物理同一性が `tracks` の現在の値と同じ間は再投入しない(走査で `tracks` が変われば再び投入される)。抑制がないと、完了 → SSE → 一覧の取り直し → 再投入が秒に数回回り続けた +- 評価待ちの印(`ReevalFlag`)は `source_hash` と `thumbnail` の完了では立てない(端末の選曲に載るプレイリストは端末のフィールドを使えないので、端末の状態だけの変化では結果が変わらない)。再評価そのものの合図はこれまでどおり +- `device_pending` の集合は呼び出し側が渡す: DSL の `compile` は DB を知らない純粋関数のままにし、`PendingSets`(端末の `name_key` / id → 未反映の track_id 列)を引数で受け取る。SQL には `t.id IN (SELECT value FROM json_each(?))` として JSON 配列をバインドする。保存前の検証(`check`)は空の集合で行う。`Selection::Filter` の filter は `Box<Filter>` にした(PendingSets を足して enum が大きくなったため) +- `on_device` / `device_pending` の値は端末名。`canonical_key(値)` と `devices.name_key` を比べる。知らない端末名は空集合(端末の改名・削除でルールが壊れて保存できなくなるのを避ける)。演算子は `IS` だけ(それ以外は 400)。これらを使うスマートプレイリストは端末の選曲に載せられない(循環の禁止) +- 曲ごとの状態は `synced` / `pending`(追加・更新・移動・更新 + 移動)/ `waiting` / `error` / `removing`(対象外で、次の同期で端末から消える)。対象外で端末にも無い曲は状態を持たない。同期・報告で出た `device_errors` の track 行は、その曲が未反映なら理由として添え、反映済みなら `error` にする +- 容量の見積もり(差分の `estimate`): `transfer_bytes` = 追加・更新・更新 + 移動の size の合計、`peak_bytes` = [§7.11](../spec/07-pipeline/11-device-delivery.md)「同期」の実行順(削除 → パス変更のバッチ → 更新 → 追加)に沿って「今より増える量」の最大値(負なら 0)。`free` は P5-3 まで `null`。余裕(64 MiB など)は同期の実行時(P5-3)に足す +- 選曲の見積もり: `GET /api/devices/:id/estimate?selection=…&playlist_ids=…` が、仮の選曲の曲数(重複除く、missing とマルチチャンネルを除く)と、送る元のハッシュが取れている分の size の合計・取れていない曲数を返す +- PATCH で変えられるものは `name` / `selection` / `variant`。`name` 以外は generation を ++ し、open な計画・同期中は 409 `open_plan`。adb の保存先の変更は P5-3 +- ナビのバッジは `GET /api/devices` の件数(追加 + 更新 + 移動 + 削除 + エラー)の全端末の合計。60 秒のポーリングとジョブの完了で取り直す(新しい SSE イベントは P5-3 で同期の進捗と一緒に足す) +- 端末へ送るの切り替えは端末ごとに PUT を直列にし、PUT の本文は実行時に「望む選曲」から作る(続けて押したときに先の変更を消さない) +- 差分表は先頭 1000 行だけ描き「ほか N 件」と出す(初回同期は 9,000 行規模) +- `JobType` の serde 名を snake_case にして `as_str` と揃えた(`playlist_sync` / `source_hash` が JSON で `playlistsync` / `sourcehash` になっていた) +- P5-1 からの持ち越しのうち、Derived の欠損と `compute` の 1 トランザクション化は解消した +- P5-3 / P5-4 へ持ち越し: プレイリスト操作の順序(削除を先に)と改名の 2 段 / エラーのプレイリストが古い写しと衝突する場合 / agent のプレイリストの dest_path / プレイリストのパス長とトラックの `.spindle` 予約名。加えて `has_open_work` が `device_sync` の payload の `$.device_id` を前提にしていること(P5-3 で合わせる) + +## P5-3a で決めたこと(2026-09-30) + +- 実機の spike(P5-0)は後回しにし、端末の操作を `DeviceFs` trait に閉じ込めて偽の端末 FS と偽の adb で + 先に作った。実機に依存する挙動(shell v2 の stdin、toybox の `find` / `stat` / `sha256sum`、Poweramp の + レシーバと extras)は `#[ignore]` の実機テストと定数に閉じ込め、P5-3b で Xperia を繋いで固定する +- adb のサブコマンドは `shell` だけを使う。shell v2 は pty を割り当てない限り stdin / stdout がバイナリ + 安全で終了コードも返る。`exec-in` / `exec-out` は終了コードを返さず `No space` を取りこぼす +- バッチ以外の意図は `put` と `rm` だけ。パス変更は必ずバッチに入れる。バッチのメンバーは曲だけで、 + プレイリストの改名は旧パスの削除を全部先に行ってから新パスへ書く(中身は毎回生成し直すので失うものが無い) +- 失敗した操作には `done` を書かない(`done` の付いた `put` は回復で sha256 を見ずに反映済みとみなす)。 + `done` は次の意図と同じ追記に載せ、manifest の書き直し(圧縮)は完了 50 件ごとと同期の最後に行う +- 回復は `prepared` 以前のバッチを縮めずに破棄する(`batch_abort` を耐久化してから `new` を消す)。 + 同期の実行中に準備で失敗したメンバーが出たときだけ、その場で成分ごとに縮める +- 実ファイルが無い・サイズが違う曲は、回復の結果でトークンを空にして差分に「更新」として出す + (manifest から外すと管理外になって削除できなくなる) +- パス変更のバッチの一時ファイル(`staging` / `new`)の名前に `batch_id` を含める + (`.spindle/moving/<batch_id>-<op_id>`、`.new`)。縮めた後の新バッチが中止済みの旧バッチと同じ名前を + 使うと、回復が旧バッチの掃除として新バッチの `new` を消してしまうため +- 回復はバッチのメンバーの位置を、仕様の表どおりファイルが在るかどうかで判定する(`staging` に在れば + 空け済み、`src` に在れば置く、無くて `to` に在れば置き済み)。sha256 の一致で判定すると、同じサイズで + 外部から書き換えられた曲で入れ替えが壊れる。内容の食い違いはサイズの照合と「内容を検証」に任せる +- 置き先を manifest に無いファイル(手で置いたもの)が占めていれば上書きしない。同期の開始時に一覧を + 1 回取り、その曲・プレイリストは「管理外のファイルと衝突」のエラーで保留する。パス変更では、その曲を + 含む成分を全員外す +- 置き先を別の管理下の曲・プレイリスト(manifest にある別の id)が占めていれば、意図を書かずに + 「パス衝突(移動しなかった曲が置き先にいる)」のエラーで保留する。差分は移動中の曲の今のパスを「空く」と + 扱うので、その移動が実行されない(行き先が管理外・準備の失敗で縮んだ・再開時に外れた)と、追加が移動しな + かった曲を上書きし、manifest のパスが重複して以後の回復が必ず失敗するため。manifest の書き出しも読み込みと + 同じ検査(件数・id とパスの一意性・パスの範囲)を通し、壊れた正本を書かない +- 回復は、ジャーナルのバイト列が空でなければ(読めるレコードが 0 件の切れた断片だけでも)圧縮する。 + また、自分が追記する前に末尾の切れた断片を落としたジャーナルに差し替える(tmp + `mv -f`)。断片を残すと + 次の追記が断片と 1 行に連結され、その意図が消えるか、さらに次の追記で全体が読めなくなるため +- 回復が実ファイルの無い・サイズの違う項目に付ける `STALE_TOKEN` は、結果の中だけでなく manifest にも + 書く(トークンが変わった項目が 1 件でもあれば、ジャーナルが空でも圧縮する)。同期は回復した manifest から + 始めるので、今回の計画に入らなかった項目のトークンが同期の結果で元に戻ってしまうため +- 「内容を検証」は回復済み(ジャーナルが空)の正本に対してだけ行う。ジャーナルが空でなければ失敗させる + (検証の書き直しの圧縮で未完了の意図を捨てないため) +- 「内容を検証」で食い違った・無くなった曲は、manifest から外さずトークンを空(`STALE_TOKEN`)にする。 + 外すと実ファイルが管理外になり、上の規則で上書きできなくなるため。次の差分で「更新」に戻る +- adb の未接続は、adb クライアント自身の文言(行頭の `adb: device '…' not found`・ + `error: device offline` など)だけで判定する。端末側のスクリプトの `…: not found` を未接続と + 取り違えないため +- 端末の一覧(`list_files`)は、1 件でもサイズを取れなければ全体を失敗させ、同期・回復も書き込みの前に + 失敗させる。握り潰すと手置きのファイルが一覧から消えて管理外と分からず、上書きしうるため。列挙の後に + 消えたファイルでも失敗するが、同期が失敗するだけで次回やり直せる(上書きの危険より安全側)。失敗は終了コードに + 頼らず出力でも伝える: 先に `find | wc -l` で数えた件数を `N <件数>` で書き、`find -exec stat -c '%s %n' {} +` が + 0 で終わったときだけ(root が無い経路でも)終端の印 `END` を書く。終端が無い・終端の後に続きがある・形の + 崩れた行がある・件数と行数が合わない出力は失敗とする(find が stat の失敗を終了コードに伝えなくても、欠けた + 一覧を完全な一覧と読まないため)。ファイルごとに stat を起こす形は実機で遅すぎたので改めた(「実機での確認」) +- プレイリストの書き込み(追加・更新)が成り立つかは、旧パスを消す前に全操作について判定する。中身が無い・ + 行き先が管理外のファイル・行き先を今回空かない別の管理下の項目が占める操作は、旧パスも消さずに丸ごと保留 + する(保留になった改名の旧パスは空かないので、そこへ来る改名も保留になる。不動点まで繰り返し、入れ替え・ + 循環は通す)。旧パスを先に消すと、保留のときに残すべき既存の写しを失うため +- 確定済みの計画の再開で、プレイリストの改名(更新・from ≠ to)が今の状態に行が無く(旧パスの削除が済んだ)、 + 今の差分に同じ id・同じ行き先・同じトークンの追加があれば、「旧パス削除済みの正当な中間状態」として、計画の + `op_id` のまま追加として実行する。外すと同じ計画の再開が新パスを書かずに成功扱いになり、次の計画を確定する + までプレイリストが欠落するため。曲のパス変更はバッチで原子的に完遂されるので同様の中間状態は無い +- 却下: 追記ごとに `sync` を 2 回(意図と完了)打つ。1 曲あたりの adb 呼び出しが増え、初回の全曲転送で + 数時間の差になる。完了は次の意図と一緒に耐久化しても、回復が sha256 で判定できる diff --git a/docs/decisions/D-096.md b/docs/decisions/D-096.md new file mode 100644 index 00000000..3e1c9b27 --- /dev/null +++ b/docs/decisions/D-096.md @@ -0,0 +1,25 @@ +# D-96 検証バッジは一致したかだけを出し、手法ごとの結果はプロパティと照合ジョブの結果に出す + +**決定**(2026-09-29。ユーザ依頼): + +- バッジの ✔ は `verified_ctdb` / `verified_ar` を区別せず「検証済み」の 1 つにする。`tracks.verification` の + 値と写像(CTDB 優先。§7.3)は変えない(スマートプレイリストの `%verification%` もそのまま) +- どちらで一致したかは `GET /api/tracks/:id` の `detail.verifications`(手法ごとの最新の + `album_verifications` × `track_verifications`)から、プロパティの General に「CTDB」「AccurateRip」の行で出す +- `verify` ジョブは結果 1 行を `jobs.note` に残す(ディスク × 手法の一致数と信頼度 / 登録なし / 検証不能・ + 照合しなかった理由)。結果 1 行は記録と同じトランザクションで書き、commit の後に落ちた再実行はそれを返す。操作タブは投入した + ジョブを終わるまで 2 秒ごとに `GET /api/jobs?type=verify` で追い、終わったアルバムから 1 行ずつ出す。 + 一覧は状態ごとに件数の上限があるので、見えた状態をジョブ id ごとに貯め、終端が見えていないジョブは verify の + 待ち・実行中(`by_type`)が残る間は待ちとして数え、残っていなければ「結果を取れない」にする(完了の上限の + 外へ押し出されて終端を観測できなかったジョブで、待ち続けないため) +- 結果は選択を変えても消さない(閉じる・別の操作・次の照合で消す)。照合したアルバムの曲を選び直して + プロパティで手法ごとの結果を見る使い方を妨げないため。行ごとに対象のアルバムを出すので取り違えない + +**理由**: 両方で一致したトラックも CTDB 優先で「検証済み(CTDB)」になるため、実機の 6 ディスク(AccurateRip +でも 4 ディスク一致)がすべて CTDB だけで一致したように見えていた。AccurateRip の結果は verify.log にしか +無く、画面から見えなかった。投入時の「遡及照合を投入した: アルバム 1」は結果の出る場所も分からなかった。 + +**却下**: バッジを手法の組み合わせ(両方 / CTDB だけ / AccurateRip だけ)で塗り分ける(ユーザが「一致したか +だけでよい」を選んだ)。`tracks.verification` に両方一致の値を足す(マイグレーションが要り、DSL と +スマートプレイリストの値も変わる)。SSE の job イベントで追う(イベントに種別も結果も無い)。 +`GET /api/jobs` に id の指定を足す(全ライブラリの投入では数千件の id が URL に載る)。 diff --git a/docs/decisions/D-097.md b/docs/decisions/D-097.md new file mode 100644 index 00000000..b0bbb464 --- /dev/null +++ b/docs/decisions/D-097.md @@ -0,0 +1,67 @@ +# D-97 ReplayGain は解析の後に自動でタグへ書き、Derived はその書き込みの後に作る + +**決定**(2026-09-29。ユーザ依頼): + +- **`[replaygain].write_tags = true` なら、解析の後に自動でタグへ書く。** 書くべき行は `tracks.rg_write_due` + (0004 で追加)の印で持つ。印は rg の保存で確認が成り立たなくなった行と、album gain を off にして album の値を + 消した行に立ち、書き込みの編集バッチを記録したとき(一致済みで `rg_written_at` だけ立てた行も)に下りる。 + 巻き戻し・外部の変更では立たない(解析し直すまで書き直さない。書き直すと巻き戻しが効かない。ファイルが正)。 + 印が立った後に**外部ツールが RG のキーを書き換えた**ことを走査が見つけたら(取り込み前の DB の RG のキーの値と + 比べる)、印を下ろす(外部の値を自動書き込みで上書きしない)。RG 以外のタグだけが変わった行は印を残す +- 印を書くのは新しいジョブ `rgwrite`(0004 で `jobs.type` に足す)。**Library 全体で 1 本**(dedup `rgwrite`、 + payload は空)で、実行時に印の付いた active な行を album ごとにまとめ、手動の `POST /api/rg/write` と同じ + `Editor::prepare_rg_write` で **album ごとに 1 つの編集バッチ**を作る(旧値の記録・overlay・巻き戻しはタグ編集と + 共通。[D-48](D-048.md))。album の無い行は 1 行ずつ +- 同じ album を解析する rg が queued / running の album と、反映待ちの編集がある album は後回しにし、書ける + album を書いた後で**キー付きの後継を 2 秒後に 1 本積んで終わる**(album gain が off の album は曲ごとに解析される + ので出揃ってから 1 バッチにする。飛ばすと書き漏れ、rg 自体を待たせると重い再解析がやり直しになる)。キーを + 外した自分を `Requeue` すると、rg の完了ごとに積まれるキー付きのジョブと別に待機ジョブが増え続ける(codex + レビュー)。後継は dedup で常に 1 本 +- 待つ判定とバッチの記録は別のトランザクションなので、その間に始まった rg が値を変えれば 1 回余分に書くことが + ある。値が変わった行は保存で印が立ち直すので次の rgwrite で揃う(最終的な値は収束する。厳密な「1 album + 1 バッチ」の保証より、Editor の編集バッチの経路を共通に保つ方を取る) +- **`rgwrite` は開始時に自分の dedup キーを外す**(`db::jobs::release_dedup_key`)。対象を読んだ後に立った印の + ための投入は、実行中のジョブへ合流せず新しいジョブになる(合流すると読み終えた対象に吸われて消える) +- 積むのは印を立てた書き手(rg の保存・album gain の API・Inbox の配置)。取りこぼしの回収として scan の完了 + (missing から戻った行)と起動時(`write_tags` を有効にした直後)に、印が残っていれば積む +- `write_tags = false` は「タグに一切書かない」(自動も手動も。手動は従来どおり 409 `rg_write_disabled`)。印は + 残り、有効にした後の起動時に書く +- **Derived はどの系統も RG が揃ってから作る。** 解析済みで、`write_tags` なら Library のタグへの書き込みも + 確認済み(`rg_written_at >= rg_scanned_at`)。`derived::eligible` の条件で、揃わない間は凍結と同じく既存の行と + ファイルに触らない。設定は起動時に `derived_variants.rg_write_required` へ写す([D-75](D-075.md) の系統設定と同じ流儀)。 + 順序は rg → rgwrite → tagwrite の applied(`enqueue_if_stale`)→ transcode +- **確認時刻 `rg_written_at` は `rg_scanned_at` より小さくしない**(`set_written` / `sync_written_at` / 同じ値の + 再解析)。`rg_scanned_at` は album gain の切り替えや同じ秒の再解析で「前の値 + 1」へ進み壁時計より先にありうる + ので、同じ秒の確認が「未書き込み」に見えて Derived が止まる +- **スキャンで新規に登録したトラックにも rg を積む**(album の照合の後、`enqueue_analysis` の規則で)。既存の + 未解析の行は拾わない。Inbox を通さずに SMB で置いた曲も漏れなく乗せるため +- 操作タブの手動ボタンは両方残す。「ReplayGain を解析」は既存の未解析の曲・測り直し用、「解析値をタグに書き直す」 + は自動で書かれなかった行(デプロイ直後の既存の未書き込み・外部の書き換え・巻き戻し後)用 + +**理由**: 解析と書き込みが別の手動操作だったため、書き込みを忘れた曲が Library に残り、Derived(opus の `R128_*`、 +aac の焼き込み)は DB の値で作られるので Library のタグとずれていても気づけなかった。Derived を書き込みの後に +回すと、再解析で値が変わったときの aac の作り直しも 1 回で済む(従来は解析時の Encode と書き込み時の Retag の 2 回)。 +書き込みを編集バッチに載せたままなので不変条件 3・4 はそのまま満たす。対象を行の印で持つのは、ジョブの payload +(album の範囲や解析時刻の下限)で持つと、album の移動・missing・実行中のジョブへの合流・同じ秒の境界で +書き漏れや書き過ぎが起きるため(codex レビュー)。 + +**却下**: +- rg ジョブの中で直接バッチを作る(反映待ちの行を飛ばすと書き漏れ、待つと解析のやり直しになる) +- rg ジョブ 1 本 = 1 バッチ(album gain が off の album は曲ごとの解析なので、全曲の解析で曲数ぶんのバッチが並ぶ) +- `POST /api/rg` の 1 回 = 1 バッチ(全ジョブの完了を集約する仕組みが要り、途中の失敗の扱いが複雑) +- album 単位の rgwrite に解析時刻の下限(`since`)を持たせる(上の書き漏れ・書き過ぎ。最初の実装で codex が指摘) +- 巻き戻し・外部の変更で未書き込みになった行を自動で書き直す(巻き戻しが効かない。外部の値を上書きする) +- Derived を解析だけで作る(Library のタグに書く前の値の Derived が配られ、書き込み後にもう一度作り直す) + +**影響**: +- [D-47](D-047.md) の「scan 完了時の自動投入」の却下は、新規に登録した行に限って改める(移行直後の全曲解析の懸念は + 「既存の未解析を拾わない」で避ける。ただし DB を作り直した直後の走査では全曲が新規なので全曲の解析が積まれる。 + ファイルが正・DB はキャッシュの前提から、解析値を失った DB ではこれが正しい) +- opus も RG が揃うまで作らない(従来は RG 不要)。音声が差し替わった曲は再解析 → 書き込みの後に作り直す +- album gain を off にした album は、ファイルの album のキーを消す書き込みの後に Derived が追随する +- デプロイ直後、既存の「解析済みで未書き込み」の曲は印が無いので自動では書かれず、Derived の追随が止まる(既存の + Derived は凍結されて配られ続ける)。一覧の `rg_unwritten` で絞り込んで「解析値をタグに書き直す」を一度実行する +- Inbox の件の段([D-95](D-095.md) の可視化 B): 「RG」は書き込みが要る設定なら書き込みまで済んで完了、系統の段の母数は + RG が揃えば作る曲(`derived::covered`。RG 待ちも「これから作る」に数える) +- 持ち越し: 端末配信の待ちの理由(`Wait::RgPending`「RG 未解析」)は aac の解析だけを見ている。hold になる点は + 同じだが、表示を「未解析・未書き込み」に揃えるのは P5-2 の `devices.rs` の改修と合わせる diff --git a/docs/decisions/D-098.md b/docs/decisions/D-098.md new file mode 100644 index 00000000..626af5d1 --- /dev/null +++ b/docs/decisions/D-098.md @@ -0,0 +1,49 @@ +# D-98 Android の同期をジョブと常駐監視で組み込み、adb サーバは host ネットワークのサイドカーが Unix ソケットで待ち受ける + +**背景**(2026-09-30。ユーザ依頼): P5-3a のエンジン([D-95](D-095.md))を spindle に組み込む(ジョブ・`adb track-devices` の常駐監視・API・端末タブ・adb サイドカー)。設計は実機 spike(2026-09-30。XQ-CC44 / Android 14 / Poweramp build-1025 / platform-tools r37.0.1)の結果で [D-95](D-095.md) と設計文書から直した点がある。以下がその決定。 + +**決定**: + +- **サイドカー**: adb サーバは別コンテナで `network_mode: host` + `adb -L localfilesystem:/run/adb/adb.sock nodaemon server` として動かす。ソケットは名前付きボリュームで spindle と共有し、TCP は開かない。**spindle 側はそのボリュームを読み取り専用(`:ro`)でマウントする**。`ADB_SERVER_SOCKET` を渡しても自前のサーバの起動を止められるのは `tcp:` の指定だけで、`localfilesystem:` はローカルの指定とみなされ、サーバに繋がらないとクライアントが同じパスで `fork-server` を起こす(setsid するので ChildGroup の kill も届かない)。サイドカーが落ちている間にこれが起きると、戻ったサイドカーとサーバが 2 つになり、監視の `track-devices` が USB を持たない方に繋がったままになる。読み取り専用なら connect() は通り、待ち受けの作成だけが `Read-only file system` で失敗して `error: cannot connect to daemon`(未接続)になる(r37.0.1 で確認)。独自のネットワーク名前空間では端末の挿し直しを拾わない(`/dev/bus/usb` の bind・`/dev` の bind・`ADB_LIBUSB=0` のどれでも不可。netlink の uevent が届かない)ため +- **HOME**: adb の子プロセスに `HOME=<data>/adb` を渡す。`ANDROID_USER_HOME` は効かない +- **切断の分類**: 端末を抜くと stderr は空で rc=255 になる。adb の文言に当たらない失敗は `get-state` で確かめ、`device` 以外は未接続とみなす +- **Poweramp**: 再スキャンの extras は付けない。パスを指定する extra は無く、`eraseTags` は全タグ消去と CUE の副作用がある。スキャン範囲は Poweramp のフォルダ設定なので、保存先はその下に置く +- **大小文字だけの改名**: 実機の `mv` は成功扱いで何もしない。エンジンは一時名を経由するので正しく改名される。試験用の FakeFs を実機の挙動に合わせた +- **排他**: 端末ごとに `job_mutexes` の `device:<id>` とプロセス内のロックを取る。API の破棄はロックが取れなければ 409 `busy`。**`device_sync` はロックを取った後で計画がまだ open かを確かめ直し、同期の途中で閉じられていたら巻き戻す**(`close_plan` が false なら Fatal)。ロックの前の確認だけでは、待っている間に破棄された計画を進めてしまう +- **未接続の待ち**: `Outcome::RequeueAfter(300)`(attempts を数えない)。`track-devices` が `device` を見たら `device_scan` を投入して `run_after` を前倒しする。`track-devices` の stderr は常時読み捨てずに流し続ける(詰まると子が止まる)。`parse_frame` はタブを含む短い形式の行をタブで分割する(`serial\tstate`。実機が使う `-l` 形式は影響なし) +- **adb を使わない構成**: `[devices].adb_server` が空なら、Android の登録・未登録の一覧・同期・再開・検証・通常の破棄は 503 `adb_disabled` を返す。強制破棄と端末の削除は adb を使わないので動く。設定は前後に空白のある `adb_server` を拒否する(起動時の検証) +- **登録の失敗**: 行を作ってから manifest を作り、失敗したら `.spindle` を片付けて行を消す。`.spindle` だけの残り(manifest が読めない・uuid が DB に無い)は空とみなして作り直す +- **保存先の変更**: PATCH では変えない。削除して登録し直す(既存の写しの扱いを推測しない) +- **破棄**: 接続中に回復してから閉じる。回復は `vacating` 以降を完遂し `prepared` 以前を破棄するので、閉じた後に進みかけのバッチは残らない。計画の JSON が壊れていても行の id で破棄できる +- **端末が戻らないときの逃げ道**(2026-10-01。最終レビューの指摘に対しユーザが両方を選んだ): 同期の途中で端末が壊れた・手放したなどで戻らないと、計画が open のまま残り設定を変えられなくなる。そこで 2 つを用意する。(a) `DELETE /api/devices/:id` は途中の計画があっても通す。断るのは端末のジョブ(`device_sync` / `device_scan` / `device_verify`)のどれかが running のときと、端末のロックを別の処理(ジョブ・破棄)が持っているとき(409 `busy`。2026-10-01 のレビューで同期だけから広げた。走査・検証も回復で端末上のジャーナルを完遂・取り消すので、消した後も端末を書き換え続け、最後の書き戻しが外部キーで落ちる)。削除はロックを取ってから行を消し、待ちのジョブを取り消すまで持つ。ロックを待っていたジョブは取った後に行を読み直し、消えていれば端末に触らずに終わる。待ちの `device_sync` / `device_verify` / `device_scan` は断らずに取り消し(未接続の端末を待つジョブが残っていても消せる)、計画は `ON DELETE CASCADE` で行と一緒に消える。端末上のファイルには触らない。(b) `POST /api/devices/:id/plans/open/abandon` に `{ "force": true }` を渡すと、端末につながず回復もせずに計画を行の id で `abandoned` に閉じ、待ちの同期を取り消す。断るのは `device_sync` が running か端末のロック中のとき(409 `busy`)。ADB 同期が無効でも使える。安全なのは、次につないだときの回復が計画の状態と無関係に、端末のジャーナルから封印済みバッチを完遂または取り消すため。端末タブは、未接続か破棄が `not_connected` で断られたときに「強制破棄」を確認つきで出す。PATCH の規則は変えない + +**理由**: ネットワーク名前空間の分離と USB の hot-plug が両立しないのは実機で確かめた事実で、host ネットワークのサイドカーを Unix ソケット限定で公開すれば LAN に 5037 を開かずに済む。それ以外は、同期を再開できるジョブにして(不変条件 6)、端末側の破棄・回復を編集バッチと同じく巻き戻せる形に保つため。 + +**却下**: +- internal ネットワーク + 再起動で拾い直す(転送中の再起動になる。仕掛けが増える) +- host ネットワーク + `-a`(LAN に 5037 が開く) +- Poweramp の `eraseTags` 付きの再スキャン(全タグ消去と CUE の副作用) + +**持ち越し**(P5-3a の記録から、今回やらないもの): 回復の在否判定の stat 化(進みかけのバッチの分だけなので件数は小さい)、成分ごと外したときの巻き添えの理由、`Book::occupied_by_other` の索引。`list_files` を `transfer_timeout` で待つようにしたが、大きなライブラリでの実際の所要時間は Task 11 で測る。 + +**配備**(本番に Android の同期を入れるとき): 本番の config.toml には `[devices]` が無く、Android の同期は無効のまま。有効にするには `[devices] adb_server = "localfilesystem:/run/adb/adb.sock"` を足し、`/mnt/ssd/apps/spindle/adb-keys` を作り、TrueNAS のアプリの YAML に adb サービスを足す(spindle 側のソケットのボリュームは `:ro`)。 + +**実測**: shell v2 の stdin で約 38MB/s(`adb push` は約 41MB/s)。3GB で約 73 秒。 + +## 実機での確認 + +2026-10-01、TrueNAS の試験インスタンス(spindle:p5-3b + adb サイドカー)と Xperia(XQ-CC44、SD のボリューム `XXXX-XXXX`)で確かめた。 + +- 未登録の一覧に Xperia(model XQ_CC44)が出て、内部と SD の候補・空き・状態(missing)が正しい。SD に登録して manifest の device_uuid が DB と一致 +- 全曲の同期(4 曲 → 226 曲)が完了し、Poweramp のスキャンが始まる(Scan.DirWalker: Starting scan=XXXX-XXXX/Music/) +- 大小文字だけの改名(feat. → Feat.)が「移動」になり、端末上の名前も変わる(一時名経由) +- 転送中に USB を抜く → ジョブは attempts 0 のまま queued(300 秒後)→ 挿し直しで前倒しされ、送り終えた分を飛ばして残り 214 件で完了 +- 端末上の 1 曲を書き換える → 検証で「不一致 1」→ 差分に更新 → 同期で直る +- サイドカーを止めても spindle のコンテナに adb サーバは立たない(:ro のソケットで Address already in use / Read-only で失敗)。サイドカーを戻すと約 26 秒で再接続 +- 未接続で計画が開いたまま → 通常の破棄は 409 not_connected、強制破棄は 200 で閉じて待ちの同期を取り消す +- 実測: 転送は 1 曲ずつの意図・sync・sha256 の往復込みで約 6.5MB/s(1.1GB / 170 秒)。全曲 9000 曲の初回はおよそ 2 時間の見込み。高速化(意図の追記や sync のまとめ)は持ち越し +- 実機で直したこと: 一覧を 1 回の stat にまとめた(9030 ファイルで 5 分超 → 5 秒)、評価待ちはスマートプレイリストを選曲に含む端末だけに効かせる + +一覧をまとめても欠けは塞いだまま: 先に `find | wc -l` で数えた件数(`N <件数>`)と `stat -c '%s %n'` の行数を突き合わせ、最後に終端の印 `END` を置く。stat の失敗は `find -exec … +` の非 0 で終端の印が無くなり(toybox でも伝わることを確かめた)、find が失敗を隠しても件数が合わなくなる。改行を含む名前は Android の FUSE では作れず(spike で確認)、spindle 自身のパスも `sh_quote` が改行を拒むので、この形式は行の区切りに改行が使えることに頼る。壊れた行や件数の不一致は今も失敗にするが、改行入りの名前を作り込まれた場合の検出までは保証しない。 + +**追記**(2026-10-20、設計書の吸収): 実機 spike(2026-09-30)で確かめ、上に書いていなかった端末の性質。(1) 正規化: 内部共有ストレージは名前の正規化を区別せず(NFD の名前で NFC のファイルに当たる)、SD(exFAT)は区別する。spindle のキーは casefold + NFD なので安全側に倒れる(別名が衝突として保留になり得るだけ)。(2) ディレクトリ名の大小だけの変更は `mkdir -p` が既存のディレクトリを使うので、端末上の表示は旧い大小のまま残る(差分はキーで比べるので出ない。見た目だけ)。(3) 内部共有ストレージを保存先にした場合、Poweramp のフォルダ設定に入っていなければスキャンされない(登録画面に注意を出す)。(4) SD の `Music/` に以前の方法で入れた曲が残っていると、Poweramp は `Music/` 全体をスキャンするので、保存先を `Music/spindle` にすると移行の間は同じ曲が二重に見える(移行が済んだら古い曲を消す)。仕様は[§7.11](../spec/07-pipeline/11-device-delivery.md)「端末のファイルシステムの性質」。 diff --git a/docs/decisions/D-099.md b/docs/decisions/D-099.md new file mode 100644 index 00000000..f8715ace --- /dev/null +++ b/docs/decisions/D-099.md @@ -0,0 +1,34 @@ +# D-99 エージェント API はセッションと別の Bearer 経路にし、報告は全項目を検証してから 1 トランザクションで反映する + +**背景**(2026-10-01。仕様は [§7.11](../spec/07-pipeline/11-device-delivery.md) の「iPhone(Mac の spindle-agent)」と [D-95](D-095.md)): P5-4 で iPhone 向けに Mac の `spindle-agent` が使うサーバ側の API を作る。P5-4 を 4a(サーバ側の API と UI)/ 4b(`agent/` クレートのエンジン)/ 4c(実機 Mac での JXA の固定と macOS バイナリ)に分け、本件は 4a。以下がその決定と、仕様の空白を埋めた判断。 + +**決定**: + +- **P5-4 の分割**: 4a = サーバ側の API と UI、4b = `agent/` クレートのエンジン(ミュージック.app の操作を trait で差し替えて試験)、4c = 実機 Mac で JXA を固定し、CI で macOS バイナリを作る(ユーザ承認済み。ssh で届く Mac がある) +- **ワイヤ型の共有**: 「manifest の型は本体と共有」を、serde だけに依存する新クレート `agent-proto`(ワークスペースメンバー)で実現する。エージェントが spindle 本体(rusqlite bundled・lofty など)に依存しないため +- **トークンのシークレットは SHA-256**: 仕様は「シークレットのハッシュ」とだけ書く。トークンのシークレットは 256 bit の乱数なので総当たりに耐え、ファイル取得のたびに argon2id(数十 ms)を払う理由が無い。セッションと同じく SHA-256 を保存し定数時間で比較する。ワンタイムコードは仕様どおり argon2id +- **試行の予約**: pair の試行は検証(argon2id)の前に書き込みトランザクションで `pair_code_attempts` を 1 増やして予約する(ログインのレート制限と同じ流儀)。並行送信で 5 回を超えさせない。5 回目の失敗でコードを失効(セレクタとハッシュを NULL)させる +- **agent のプレイリストの dest_path はプレイリスト名そのもの**(P5-1 からの持ち越し): ミュージック.app の「spindle」フォルダ直下に同名で作るので、`device_playlist_state.dest_path` に名前を入れ、差分の「改名」判定もそれで行う。adb は従来どおり `Playlists/<name>.m3u8` +- **manifest の応答に差分を載せる**: 仕様の manifest は `items` / `playlists` だけだが、確定する計画はサーバの差分(`device_items` 基準)なので、エージェントが表示する差分がそれと食い違わないよう、サーバの差分(操作・保留・プレイリストの操作とエラー)と `plan_token` を同じ応答に載せる +- **プレイリストの中身**: manifest の各プレイリストに `tracks: [track_id]`(順序どおり)を載せる。中身の同一性はトークンが表す +- **ファイル取得の「現在のマニフェスト」は表示用スナップショット(2 秒)で引く**: 項目の有無の判定だけに使い、内容の照合は `source_hashes` の行を毎回読み直して行う。Derived の変換が続く間、要求ごとに全曲の差分を計算し直さないため。FD の中身とトークンの組がずれない性質は `source_hashes` の identity 照合が保つ +- **ファイル取得の失敗**: `If-Match` が無い → 428 `if_match_required`。弱い ETag・`*`・並記は一致とみなさない(412)。送る元の identity が行と違う・無い・symlink や通常ファイルでないものに差し替わった → 412 `source_changed` にして `source_hashes` の行を消す(次の差分の計算で `source_hash` が再投入される) +- **破棄の本文**: `POST /api/agent/plans/:id/abandon` の本文は報告と同じ `{generation, plan_id, state, errors}` に `pending_ops` / `pending_batches`(件数)を足したもの。両方 0 で state の検証が通れば、報告と同じくキャッシュを全置換して計画を `abandoned` にする +- **UI からの強制破棄を agent にも許す**: Mac が失われたときの逃げ道。`POST /api/devices/:id/plans/open/abandon {force:true}` を agent でも受け付ける(端末にもキャッシュにも触らずに閉じる)。`force` 無しの破棄は agent では 400(Mac から行う) +- **報告の本文の上限**: 10 万件の曲を載せられるよう、report と abandon だけ本文の上限を 64 MiB にする +- **実装(P5-4a)で決めたこと**: + - ファイル取得は表示用スナップショットで先に引き、項目が無いときは厳密なスナップショットで引き直す。404 / 412 は新しいデータで決める(直前に外された曲が最大 2 秒配られることはあるが、害のない写しにとどまる) + - 送る元が変わった・無いときの `source_hashes` の削除は条件なし(CAS しない)。最悪でも 1 回の再計算で済む + - ロックモードでは pair も 503 にする(`auth::guard` を通る経路と揃える) + - pair の 5 回目の失敗は、並行して届いた正しい試行より先にコードを失効させうる。安全側に倒す + - 報告の再送は、計画が `completed` で `report_digest` が一致するときだけ 200 を返す。それ以外の終端は 409 `plan_closed`。計画の JSON を読めなければ 409 `plan_unreadable` を返し、何も変えない + - エージェントの経路は `auth::guard` を通らない(`ClientInfo` が無い) + - Bearer のスキームは大小文字を区別する(使うのは自前のクライアントだけ) + +**理由**: エージェントはブラウザではなく、Cookie も CSRF も trusted_cidrs も持たない。セッションの経路に分岐を足すと、どちらかの検査が緩む事故が起きやすいので、`/api/agent/*` は Bearer だけで通る別の経路にし、セッション側は Bearer で通らない。報告は端末側の状態の主張なので、件数の上限と全項目の検証を済ませてから 1 トランザクションで反映し、途中までの反映を残さない(不変条件 6)。確定した計画の再送は `report_digest` で冪等にする。 + +**却下**: +- トークンにも argon2id を使う(256 bit の乱数には不要で、ファイル取得のたびに数十 ms を払う) +- manifest に差分を載せず、エージェントが自前で差分を出す(サーバの `device_items` 基準の差分と食い違い、表示と確定がずれる) +- ファイル取得のたびに厳密なスナップショットを取る(Derived の変換中に要求ごとの全曲の差分計算になる) +- `/api/agent` を `auth::guard` の中に置いて分岐する(セッションと Bearer の検査が混ざり、どちらかが緩む) diff --git a/docs/decisions/D-100.md b/docs/decisions/D-100.md new file mode 100644 index 00000000..ab22d588 --- /dev/null +++ b/docs/decisions/D-100.md @@ -0,0 +1,47 @@ +# D-100 Mac エージェントのエンジンは同期処理で書き、ミュージック.app・サーバ・トークンの保管を trait で差し替えて試す + +**背景**(2026-10-01。仕様は [§7.11](../spec/07-pipeline/11-device-delivery.md) の「iPhone(Mac の spindle-agent)」と「計画」(開始済みの計画の再開・終端・始まったパス変更のバッチ)、[D-95](D-095.md) の P5-3a 追記と [D-99](D-099.md)): P5-4b で Mac の `spindle-agent`(`agent/` クレート)のエンジンを作る。pair の冪等な初期化・state・回復・再発見・計画の確定と再開・追加 / 更新 / 削除・パス変更のバッチ・プレイリストの入れ替え・resolve・報告まで。ミュージック.app の本物の操作(JXA)は 4c で実機の Mac で固定するため、4b は外界を trait に閉じ込め、偽のミュージック.app と本物の spindle サーバを相手に試す。以下がその決定と、仕様の空白を埋めた判断。 + +**決定**: + +- **外界は 3 つの trait**: `Music`(ミュージック.app。4b は偽実装 `FakeMusic` と「未実装」を返す `UnsupportedMusic`、JXA 実装は 4c)、`Server`(`/api/agent/*`。本物は reqwest の blocking クライアント `HttpServer`)、`Secrets`(トークンの保管)。ローカルの root(`~/Music/spindle`)と `state.json` は本物のファイルシステムを tempdir で試す。クラッシュの試験は要所の中断点(`Failpoints::hit`)と偽実装の「実行してから失敗」で起こし、同じ state で `sync` をやり直して確かめる +- **エンジンは同期処理**: エージェントは 1 回ずつのコマンドで並行性が要らない。tokio を入れず、HTTP は reqwest の blocking、ミュージック.app は `osascript` の子プロセス(4c) +- **ローカルのファイルの同一性はキャッシュで照合する**: 仕様は再発見で「size・sha256 が state と一致することも確かめる」とする。state の各曲に `inode`・`mtime_ns` を持ち、`(size, inode, mtime_ns)` が一致すれば sha256 を読み直さない。違えば読み直して比べる +- **外で書き換えられた曲は「古い写し」にする**: size・sha256 が state と違う曲は track を消さず(再生回数を保つ)、state のトークンを空文字(ADB の `STALE_TOKEN` と同じ)にして報告する。サーバは報告のトークンが空の曲を、`(track_id, dest_path)` が希望・現状・計画の行き先のどれかと一致すれば受け入れ、`device_items` にトークン空で入れる(差分は「更新」になる)。サーバ側の変更(`src/device/report.rs`)を含む +- **ミュージック.app から track が消えた曲**: state から外し、自分の置いたローカルのファイルも消す(state にあるパスなので管理下。残すと次の追加が「管理外と衝突」になる)。報告から外れるので、サーバの差分は「追加」になる +- **報告だけの回**: 回復か再発見で state が変わり(`needs_report` を state に耐久化)、open な計画が無いときは、manifest の `plan_token` で計画を確定し、何も実行せずに報告して閉じる。その後で manifest を取り直して差分を表示する。表示する差分(サーバの `device_items` 基準)をエージェントの実状態に合わせるため。空の差分でも確定できる +- **ダウンロードの途中再開は 1 回の実行の中だけ**: 接続が切れたら同じ `If-Match` + `Range` で最大 3 回まで続きを取る。実行をまたいだ再開はしない(回復が一時ファイルを消す)。412 はその操作を実行せずに次の差分に回して続け、実行の最後の報告の後に manifest を取り直して差分を出す +- **コピー設定 ON の検出と停止**: `Music::copy_to_library()` が `Some(true)` なら pair と sync を始めない。`None`(読めない)なら仕様どおり `add` の後の `location` で検出し、通常経路でその track を消してから残りの操作を止めて報告し、設定を切るよう案内して非 0 で終わる +- **resolve の「表示したときと同じ」**: 候補(persistent ID と sha256 の組の集合)を表示した時点で `pending_ops` のその add に `candidates` として耐久化し、`resolve` は取り直した候補の集合と比べる。違えば記録を新しい候補で書き直し、表示し直して拒否する +- **トークンの保管は 4b ではファイル**(state のディレクトリの `token`、0600)。Keychain は 4c で `Secrets` の別実装として入れる。各パスは環境変数 `SPINDLE_AGENT_STATE_DIR` / `SPINDLE_AGENT_ROOT` で上書きできる(試験用。既定は `~/Library/Application Support/spindle-agent` と `~/Music/spindle`) +- **CLI に `abandon` を足す**: 仕様の CLI は pair / sync / status / resolve だが、`POST /api/agent/plans/:id/abandon` を叩く手段として `spindle-agent abandon` を置く(`pending_ops` と `pending_batches` が空のときだけ送る) +- **プレイリストの入れ替えの一時名は `.tmp-<op_id>`**(spindle フォルダの直下)。改名の後で本名が別の管理下のプレイリストと一時的に重なる(入れ替え・循環の途中)のは許す。ミュージック.app は同名を許し、照合は persistent ID で行うため。管理外(state にも `pending_ops` にも無い persistent ID)の同名があれば、その操作を「エラー: 管理外と衝突」で保留する +- **パス変更のバッチの一時ファイル名**: `~/Music/spindle/.moving/<batch_id>-<op_id>`(移動の旧ファイル・更新 + 移動の旧版)と `….new`(更新 + 移動の新しい内容)。[D-95](D-095.md) の P5-3a 追記と同じ理由で batch_id を含める。更新 + 移動の旧版は仕様どおり `.moving/` へ動かしてから(`location` を付け替えて track を残す)、`done` の前に消す +- **計画の再開の照合(`runnable`)は本体の `device::plan::runnable` をエージェント側に移植する**(`agent-proto` の型の上で)。`canonical_key`(casefold + NFD)も移植し、結合試験(`tests/agent_e2e.rs`)で本体と同じ結果になることを確かめる +- **SIGINT は扱わない**: Ctrl-C で止まっても、次の `sync` の回復が続きを行う(`vacating` 以降は前進して完遂) +- **persistent ID を記録する前に落ちた一時プレイリスト**: 仕様は「`pending_ops` に記録した persistent ID のものだけを消す(名前の接頭辞だけで消さない)」とする。作った直後・ID を記録する前に落ちると片付けられず残るので、フォルダの直下で名前がちょうど `.tmp-<op_id>`(op_id は 128 bit 乱数でその操作だけのもの)のものが 1 つなら消す。pair の `spindle-setup-<nonce>` と同じ考え方で、接頭辞での一括削除はしない +- **実装(P5-4b)で決めたこと**(レビューで変わった点を含む): + - `HttpServer` のタイムアウト: blocking の reqwest には無通信の `read_timeout` が無い。API 呼び出しは全体で 60 秒、曲の取得だけ要求ごとに 2 時間の上限を付け、TCP keepalive(30 秒)で死んだ接続を切る。接続は 10 秒。止まった NAS で永久に固まらないため + - サーバの 409 は、確定では `plan_changed` / `open_plan_exists` / `pending_reevaluation`、報告と破棄では `plan_closed` / `generation_mismatch` だけを区別し、それ以外のコード(`plan_unreadable` など)はエラーとして止まる。計画を読めないことを「閉じた」と誤読しないため + - 報告は計画の `generation` で送る(確定の後で端末の設定が変われば、サーバが拒否する) + - open な計画は、`state.plan_id` と一致するとき(自分が y/N を経て確定した計画)だけ再開して実行する。違えば実行せずに報告だけして閉じる。報告だけの回の失敗や確定直後の中断で、利用者が確認していない計画(削除を含む)を実行しないため + - パス変更のバッチの途中で track が手で消された(ミュージック.app に無い)ときは、ファイルの移動は続けて `location` の付け替え・refresh を飛ばし、`done` で行を書く。後の再発見が「track が消えた曲」として外す。回復が永久に止まらないため + - バッチの行き先を、動かない管理下の曲が占めていれば、その成分ごと `PATH_OCCUPIED` で保留する(ADB 版には無い追加の安全策)。rename の上書きと、2 曲が 1 ファイルを共有する事故を防ぐ + - pair の初期化が途中(`setup.phase == Started`)の間は、sync の marker 照合を飛ばして初期化の続きに任せる。報告だけの回で再評価待ちかつ差分が無いときは「再評価待ち」を返す + - サーバの報告の検証(`src/device/report.rs`)は、プレイリストの名前の重複を拒否しない(id の重複は拒否する)。プレイリストの入れ替えの途中(片方だけ改名した後)で止まると、管理下の 2 つが一時的に同名のまま報告される。拒否すると報告が永久に 400 になり、回復しても抜けられない。名前の重複を検証するのはエージェントの報告だけで、ミュージック.app は同名を許し、同一性は persistent ID(サーバでは playlist_id)で見る。`device_playlist_state` にも名前の一意制約は無い + - 置き先の管理外のファイルは、実行の始めに取った鍵だけでなく直前にも確かめる。追加は取得の後・配置の前に置き先を見て、ファイルがあれば一時ファイルを消して「管理外と衝突」にする。パス変更のバッチは空ける前(まだ破棄できる)に全メンバーの行き先を見直し、他のメンバーの移動元でないファイルがあればその成分を外して縮める。空けた後に現れたものは上書きせず、バッチを残して止める(巻き戻せないため。利用者が退けた後の回復が続きを置く)。回復・resolve が add の置き先を消すのは、中身の sha256 が pending の記録と一致するときだけ + - 更新の実行と回復は、track が手で消されていれば refresh を飛ばして行を書く(次の再発見が外す)。refresh の失敗で回復が永久に止まらないため + - 回復はバッチを片付けた後、残ったどのバッチも指さない `.moving/` のファイル(破棄の途中で落ちて残った new など)を消す。実行が最後まで済んだら空いたディレクトリを消す(root は残す) + - 実行の報告の後に manifest を取り直し、差分が残っていれば件数を知らせる(判断 6。取り直しの失敗は結果を変えない) + - `exec::run` が返す `Outcome.dropped` は `runnable` の見送り(`Runnable.dropped`)を含む。`apply` はそれだけを報告に使い、足し直さない + - `HttpServer` はリダイレクトを追わない(トークンを別の宛先へ送らず、HTTP へも落ちない)。基底 URL のパスは `/` で終わらせ、`https://nas/spindle` のようなサブパスを `Url::join` で失わない + +**理由**: エージェントは利用者が叩く 1 回ずつのコマンドで、並行性は要らない。外界(ミュージック.app・サーバ・Keychain)を trait にすれば、実機の Mac が無い Linux の CI でも、回復・再発見・バッチ・入れ替えの中断と再開を偽実装で列挙して試せる。管理対象は state と `pending_*` にある persistent ID とパスだけにし、名前やパスの一致だけで管理外のものに触らない。ファイルとミュージック.app を変える前に state へ意図を耐久化し、次の `sync` の回復が前進か巻き戻しで完遂する(不変条件 6)。 + +**却下**: +- エンジンを tokio で書く(並行性が要らず、`osascript` の子プロセスと blocking HTTP で足りる) +- 再発見のたびに全曲の sha256 を読む(数十 GB を毎回読む) +- 外で書き換えられた曲を state から外す(track を消して足し直すと再生回数を失う。外すだけだと次の追加が管理外と衝突する) +- 報告だけの回をせず、次の sync で直す(手で消した曲が戻るまで 2 回の sync が要り、表示する差分が実状態とずれる) +- 一時プレイリストを名前の接頭辞で片付ける(ユーザの「.tmp-」を消しうる) +- 本体の `device::plan` を `agent-proto` の型へ書き換えて共有する(4b の範囲を超えて本体の ADB 同期を触る) diff --git a/docs/decisions/D-101.md b/docs/decisions/D-101.md new file mode 100644 index 00000000..7a0081b7 --- /dev/null +++ b/docs/decisions/D-101.md @@ -0,0 +1,61 @@ +# D-101 Mac エージェントのミュージック.app は JXA で操作し、実機の spike で固定する。トークンは Keychain、バイナリは署名せず Release に置く + +**背景**(2026-10-01。P5-4c。仕様は [§7.11](../spec/07-pipeline/11-device-delivery.md) の「iPhone(Mac の spindle-agent)」): 4b([D-100](D-100.md))は外界を trait に閉じ込め、ミュージック.app は偽実装で試した。4c で実機の Mac(検証用の Mac、macOS 27.0、ミュージック 1.7)に対して JXA を固定し、`Secrets` の Keychain 実装と、macOS 用バイナリの配布を入れる。[D-100](D-100.md) が 4c に残した項目は、JXA 実装・Keychain・コピー設定の読み取り・検査と rename の間の TOCTOU。 + +**spike で分かったこと**(実機で確認): + +1. `Application('Music').add(パス文字列)` で file track ができ、`persistentID()`(16 桁の 16 進)・`databaseID()`・`location()`・`dateAdded()`・`size()` が取れる。同じファイルをもう一度 `add` すると既存の track が返る(重複しない) +2. **location の付け替えは文字列の代入で行う**(`t.location = "/abs/path"`)。`Path(...)` の代入は -1728 で失敗する。拡張子の無いファイル(`.moving/<batch>-<op>`)へも付け替えられる。存在しないパスへの代入は「タイプを変換できません」で失敗する +3. ミュージック.app は **mv されたファイルを自動で追う**(location が新しいパスになる)。タグの無いファイルは名前もファイル名に変わる +4. 同じパスのファイルを tmp → rename で差し替えると、size・duration は差し替え後のものになる。`refresh` は成功し、再生回数は保たれる。location が無い(ファイルが無い)track の `refresh` は 9044 で失敗する +5. **曲の削除**: メディアフォルダの外の曲は track だけが消えてファイルは残る。メディアフォルダの中の曲はファイルもゴミ箱へ移る +6. **コピー設定**(「ライブラリへの追加時にファイルを[ミュージック]フォルダにコピー」)が ON だと、`add` はメディアフォルダ(`<media>/Music/<Artist>/<Album>/`)へ複製し、location は複製先になる。ただしファイルが既にメディアフォルダの中にあれば複製しない。設定はライブラリごとで、ライブラリ内の暗号化された DB にあり、外から読めない(`~/Library/Preferences/com.apple.Music.plist` には無い) +7. メディアフォルダには `Automatically Add to Music.localized` と `.Media Preferences.plist` ができる +8. フォルダは `make({new: 'folderPlaylist', withProperties: {name}})`、その中のプレイリストは `make({new: 'userPlaylist', at: フォルダ, withProperties: {name}})`、改名は `p.name = 新名`、中身の追加は `duplicate(track, {to: p})`、空にするのは `delete(p.tracks)`、削除は `delete(p)`。同じフォルダに同名のプレイリストを作れる。親は `p.parent().persistentID()`。`app.folderPlaylists.whose` は使えない(`app.playlists.whose({persistentID})` を使う) +9. 一覧は `libraryPlaylists[0].fileTracks.persistentID()` のような一括取得で 1 回の Apple Event になる。一括取得は、ファイルが無い track には null を返す(実機で確認。Rust 側で欠けた曲として扱う) +10. ssh 越しの `osascript` でも Automation の許可ダイアログは出なかった(許可済み、または ssh の親が許可を持つ)。初回の操作で出る場合に備え、pair の最初に読み取りだけの操作を行う(4b の `probe`) + +**決定**(spec からの変更・空白を埋める判断): + +1. **コピー設定は読まない**: 外から読めないので、`copy_to_library()` は常に `None` を返し、`add` の後の location で検出する(4b で実装済みの通常経路の削除と停止)。仕様の「読めれば前提検査にする」は不可として閉じる +2. **root がメディアフォルダなら止める**: root(`~/Music/spindle`)の直下に `Automatically Add to Music.localized` か `.Media Preferences.plist` があれば、pair と sync を `Error::Stop` で止める。メディアフォルダの中の曲は削除でファイルまで消え、コピー設定 ON でも複製されないため、前提が崩れる。検査はコピー設定の確認の直後、副作用の前に行う +3. **JXA の呼び出し単位**: trait のメソッド 1 回 = `osascript` 1 回。一覧(`tracks_under` / `tracks_added_after` / `max_database_id`)は 1 回の一括取得で全 file track を返し、絞り込みは Rust 側で行う。名前の比較(`folders_named`)とフォルダ直下の絞り込み(`playlists_in`)も Rust 側(`canonical_key`) +4. **タイムアウト**: 1 回の `osascript` は 120 秒(ミュージック.app の起動を含むため長め)。超えたら kill して `Error::Music`。終了コードが 0 以外なら stderr を `Error::Music` に含める +5. **Keychain**: サービス名 `spindle-agent`、アカウント `token` の汎用パスワード(`security-framework`)。macOS 以外はファイルのまま。4b のファイルのトークンは移行しない(まだ配布していない) +6. **上書きしない rename**: 追加の配置とバッチの置き先への rename は、行き先が無いことを OS に保証させる(macOS は `renameatx_np(RENAME_EXCL)`、Linux は `renameat2(RENAME_NOREPLACE)`。rustix の `renameat_with(.., RenameFlags::NOREPLACE)`)。[D-100](D-100.md) で残した検査と rename の間の TOCTOU を閉じる。行き先があれば `EEXIST` を今の「管理外と衝突」/ `FOREIGN_AT_DESTINATION` と同じに扱う +7. **配布**: バイナリは署名・公証しない。tar.gz を Release に置き、README に `xattr -d com.apple.quarantine` と Automation の許可を書く +8. **実機の通し試験**: 本体側に `#[ignore]` の試験の台(seed した spindle を指定アドレスで待ち受け、ペアリングコードを表示)を置き、検証用の Mac の本物のバイナリで pair → sync → 選曲を変えて sync を手で確かめる。試験用ライブラリで行う + +**実装中に決めたこと**: + +- **osascript の起動**: trait のメソッド 1 回につき `osascript -l JavaScript -` を 1 回起動する。スクリプトは標準入力、リクエストの JSON は argv で渡す(スクリプトの文字列にパスや名前を埋め込まない。注入を防ぐ)。120 秒で kill してエラーにし、そのとき stdout / stderr の読み取りスレッドは join せず切り離す(孫プロセスがパイプを握り続けても固まらないため)。stderr は共有のバッファへ読み続け、タイムアウトと終了待ちの失敗でもそこまでに読めた分(末尾 4 KiB まで)をエラーに載せる。終了コード 0 でも stderr が空でなければエージェントの stderr へ「osascript の stderr: …」として出す(外部レビューで直した) +- **上書きしない rename の実装**: `place_new` / `rename_new` が rustix の `renameat_with(NOREPLACE)` を呼ぶ。EINVAL / ENOSYS / ENOTSUP / EOPNOTSUPP(その FS や OS が対応しない)のときは `Error::Stop` で止め、root をローカルのボリューム(APFS)へ移すよう案内する(「行き先を stat してから rename」に落とすと、その間に現れたものを上書きしうる。外部レビューで直した)。既存の事前検査は残し、`AlreadyExists` は事前検査と同じに扱う(追加は「管理外と衝突」でその曲だけ保留、バッチは `FOREIGN_AT_DESTINATION` で止める)。試験用のフェイルポイント `exec.add.checked` / `batch.place.checked` で検査と rename の間に行き先を作って確かめる +- **Keychain のエラー**: システムのメッセージだけを載せ、トークンは載せない。`SPINDLE_AGENT_SECRETS=file` で macOS でもファイルの保管に固定できる。**ssh 越しではログイン Keychain が使えない**(`User interaction is not allowed`)ため、Mac の Terminal.app で実行するか、`SPINDLE_AGENT_SECRETS=file` を付ける。README の「困ったとき」に書く +- **CI**: `agent-macos` ジョブ(macos-latest)で clippy・test(`fake` 機能)・release ビルドを行い、tar.gz と `.sha256` を成果物にする。`publish` ジョブはこれに依存し、`v*` タグでは tar.gz を Release に添付する。**edge のイメージの publish も macOS ジョブの成功に依存するようになった** +- **サーバ側**: `/api/agent/*` は Bearer トークンだけで認可する([D-99](D-099.md)。サーバに LAN / HTTPS の検査は無い)。エージェントの側が、`--insecure-http` なしでは平文の http を拒否する +- **JXA で実機に合わせた点**: 一括取得はファイルの無い track に null を返す(spike 9) + +**最終レビューで直したこと**(検証用の実機の Mac で確認した事実に合わせる): + +1. **root をシンボリックリンクを解いたパスにする**: ミュージック.app は track の場所をシンボリックリンクを解いたパスで持つ(実機で `/tmp/x` → `/private/tmp/x`、`~/Music` 下のリンクしたディレクトリはリンク先)。root が解かれていないと `tracks_under` や root 相対への変換の前方一致が外れ、反映済みの曲を見失う。起動時(`Paths`)に、在る最も近い祖先を `canonicalize` して、まだ無い残りの要素を足す。相対パスの root は止める。root の**中**のリンクは従来どおり辿らない +2. **メディアフォルダの検査を祖先にも広げる**: 決定 2 は root の直下だけを見ていたが、メディアフォルダが `~/Music` などの祖先なら root はその中に入る。root とその祖先のどれかの直下に印があれば止める(祖先は root の外なので、パスで `symlink_metadata` を見る) +3. **pair はトークンを保存できるかをコードを使う前に確かめる**: ssh 越しでは Keychain の保存が `errSecInteractionNotAllowed`(-25308)で失敗し、ワンタイムコードを使った後だったので発行し直すしかなかった。`Secrets::check_writable` を足し(Keychain はトークンの項目が在れば同じ値を書き戻し、無ければ同じサービスの試しの項目 `probe` を書いて消す。ファイルは `set` と同じ tmp → fsync → rename → ディレクトリの fsync を試しの名前で行って消し、本番の `token` / `.token.tmp` が在れば通常ファイルであることも見る。外部レビューで、試しのファイルの作成だけでは本番の置き場の型や既存項目の ACL での失敗を拾えないと指摘されて広げた)、`server.pair` の前に呼ぶ。-25308 のエラーには Terminal.app か `SPINDLE_AGENT_SECRETS=file` の案内を付け、「トークンがありません」には pair のときと同じ `SPINDLE_AGENT_SECRETS` の指定で実行する案内を足す +4. **`set_playlist_tracks` の上限を曲数で延ばす**: 実機で 1 曲あたり約 35 ms(200 曲で 7 秒)かかり、決定 4 の固定 120 秒では約 3400 曲を超えるプレイリストが毎回失敗する。この操作だけ 120 秒 + 100 ms × 曲数にする +5. **`max_database_id` は専用の op で取る**: `add` のたびに呼ぶので、5 項目の一覧(`tracks`)ではなく databaseID の最大だけを返す `max_db` にする(決定 3 の例外)。file track が 0 件だと一括取得が -1728 で落ちるので、`tracks` と同じく件数を先に見て 0 を返す + +**外部レビュー(codex)で直したこと**: + +1. **上書きしない rename に対応しない FS では止める**(上の「上書きしない rename の実装」) +2. **バッチの回復は行き先の中身を確かめてから採用する**: 置いた直後に落ち、落ちている間に行き先が差し替えられると、回復は src が無く `to` が在るだけで置き済みとみなし、実ファイルの stat と計画の sha256 を組にして記録していた(以後 stat が変わらない限り再検査されない)。回復で `to` を採用する前に、大きさと sha256 が計画と一致するかを見る。違えば `FOREIGN_AT_DESTINATION` で止め、ファイルも track の場所も触らず、バッチを残す(利用者が退けた後の回復で片付く) +3. **root の dirfd は `/` から 1 要素ずつシンボリックリンクを辿らずに開く**: 最終レビューの 1 で root は起動時に解いた実パスになったが、操作のたびにそのパスをリンクを辿って開き、無ければ `create_dir_all` していたため、起動後に root か祖先をリンクへ差し替えると root の dirfd が外を指した(その下を 1 要素ずつ辿る守りが効かない)。`/` から `O_DIRECTORY | O_NOFOLLOW` で 1 要素ずつ開き、無い末尾は親の dirfd からの `mkdirat` で作る。途中にリンク(かフォルダでないもの)があれば、読みも書きも `Error::Stop` で止める。試験は root を解いた tempdir で作る(macOS の tempdir は `/var` → `/private/var` のリンクの下) +4. **トークンの保存の確認を広げる**(最終レビューの 3 を参照) +5. **osascript の stderr を捨てない**(上の「osascript の起動」を参照) + +**理由**: 実機の spike で、Apple Event の呼び方(文字列の代入、一括取得、`whose` の制限)とコピー設定の読めなさが確定した。呼び出しを 1 回ずつの `osascript` にすると、状態を持つ常駐プロセスが要らず、失敗と打ち切りの扱いが単純になる。ミュージック.app は mv を追い、メディアフォルダの外の曲は track だけが消えるため、`~/Music/spindle` を管理下の root にしてメディアフォルダと分ければ、4b の設計(track の削除は通常経路、ファイルは自分で消す)がそのまま成り立つ。上書きしない rename は OS に保証させるのが、検査と rename の間の競合に対する唯一確実な閉じ方。 + +**却下**: +- `security` コマンドでの Keychain 操作(トークンが引数に出て、`ps` で見える) +- JXA のスクリプトをファイルで配置する(改ざんと版ずれの余地。バイナリに埋め込む) +- `Path()` での location の代入(-1728 で失敗する) +- コピー設定を plist から読む(ライブラリごとの暗号化 DB にあり読めない) +- 署名・公証(Developer ID が無い。必要になったら) diff --git a/docs/decisions/D-102.md b/docs/decisions/D-102.md new file mode 100644 index 00000000..5a9f3092 --- /dev/null +++ b/docs/decisions/D-102.md @@ -0,0 +1,23 @@ +# D-102 印のついたプレイリストの削除は印の変更と同じ規則にし、終端した計画は GC が回収する。DB 全損後の引き継ぎと id の再利用は後回しにする + +**背景**(2026-10-09。P5-6): P5-5 で [SPEC §17](../spec/17-open-issues.md) に残した端末配信の 3 件(プレイリスト削除と `generation`、終端した計画の回収、DB を失ったときの保存先の引き継ぎ)を調べた。 + +1. **プレイリストの削除**: 素の `DELETE` で、印だけが CASCADE で消える。`generation` は進まず、open な計画も止めない。調べると、`runnable` と報告の検証が吸収するので間違った結果や詰まりにはならない。ただし実行中の同期が、消えたプレイリストとその曲をいったん端末に書く無駄が残り、`generation` の定義(印を変えるたびに進める)とも合わない +2. **終端した計画**: `device_sync_plans` の終端の行を読むのは `finish` の再送判定だけで、消えるのは端末の削除だけ。1 回の計画は初回の全曲で 3〜4 MB、最悪で数十 MB あり、初回の大きい計画の破棄と取り直しを繰り返すと DB とバックアップが太る +3. **DB 全損後の引き継ぎ**: 設計書は manifest の `device_uuid` を新しい行に写すだけとしていたが、DB を作り直すと `tracks.id` / `playlists.id` が振り直される。端末の正本(manifest、Mac のエージェントの state)は id をキーにするので、uuid だけ写すと manifest の `track_id` が新しい DB の別の曲を指し、差分は他人のファイルを移動して上書きする。Mac では `Update` / `UpdateMove` がミュージック.app の track を保ったまま location を付け替える([D-101](D-101.md))ので、再生回数・レート・追加日が別の曲に移る + +**決定**: + +1. **プレイリストの削除は印の変更と同じ規則**: 印のついた端末のどれかに open な作業(open な計画か待ち・実行中の `device_sync`。`has_open_work`)があれば 409 `open_plan`(端末名を message に出す)。通れば、印のある端末の `generation` と `updated_at` を進め、プレイリストを削除する。確認から削除までを 1 つの SAVEPOINT で行う。印の無いプレイリストの削除は今までどおり。プレイリストの中身・名前・ルールの変更は止めない +2. **終端した計画は GC が回収する**: `completed` / `abandoned` で `closed_at` が `[gc].retention_days` より古いものを消す。端末ごとに最後の終端 1 件は残す(長く寝ていた Mac の報告の再送を 200 で受ける保険)。open は対象外(`close_plan` は `WHERE state='open'` の CAS なので競合しない)。`GET /api/gc/preview` に `plans`(消す件数)を足す。マイグレーションと設定は足さない +3. **DB 全損後の保存先の引き継ぎ(P5-7 Android、P5-8 Mac)と id の再利用は作らず、TASKS に残す**。引き継ぎは uuid を写すだけでは誤りで、`canonical_key(dest_path)` による id の付け替え(トークンは端末の sha256 が今の送る元と一致すれば今の配信トークン、違えば STALE)が要る。`dest_path` は Library の `rel_path` の拡張子だけを替えたもので、DB を作り直しても同じになるため。Android と Mac を別の PR にする。Mac が本命(再生回数の保全)だが、Android の方が閉じていて試しやすいので先に作る +4. **SPEC の §17 は、この 2 件は [x] に変えて残し、引き継ぎ(id の付け替えが要ることを書く)と id の再利用(`AUTOINCREMENT` でないので GC が最大 id の行を消すと再利用される。Mac で再生回数が別の曲に移りうる)を残す** + +**理由**: 削除を印の変更と同じ規則にすると、`generation` を進めるのが open な作業が無いと確かめたのと同じトランザクションの中だけになり、説明がつく。終端した計画は読む箇所が少なく、消えて困るのは遅れた再送が 404 になることだけで、エージェントは次の sync で自動回復する。引き継ぎは id の付け替えを伴い、設計書より大きい(600〜1,000 行、Android と Mac で別 PR)ので、今回の小さな 2 件と混ぜない。 + +**却下**: +- 削除で `generation` だけ進める(止めない): open な agent の計画がある間に進めると、エージェントの報告も破棄も 409 `generation_mismatch` になって閉じられず(UI の強制破棄だけが逃げ道)、adb の実行中の同期は `GenerationChanged` で失敗する。PUT が「進める」と「open な間は 409」を組にしているのはこのため +- 今の挙動を仕様にする(削除は止めず `generation` も進めない): 正しさは保たれるが、実行中の同期が消えたプレイリストを一度端末に書く無駄が残る +- 閉じるときに同じ端末の古い終端を消す(保持期間なし): 履歴が残らず、設計書・SPEC の「GC が保持期間後に消す」と変わる +- uuid を新しい行に写すだけの引き継ぎ(設計書どおり): 上の背景 3 のとおり、全曲が誤った更新・移動になり、Mac では再生回数が別の曲に移る。今の「片付けて登録し直す」より悪い +- 登録で管理外の同一ファイルを内容で引き取る: 「管理外のファイルには触らない」に例外を作り、sha256 の全曲計算が要り、Mac では persistent ID を引き取れない diff --git a/docs/decisions/D-103.md b/docs/decisions/D-103.md new file mode 100644 index 00000000..dd5b4ec5 --- /dev/null +++ b/docs/decisions/D-103.md @@ -0,0 +1,25 @@ +# D-103 DB 全損後の保存先の引き継ぎは manifest の rel_path で id を付け替え、中身は manifest の sha256 を信じる + +**背景**(2026-10-13。P5-7): [D-102](D-102.md) の 3 のとおり、DB を作り直すと `tracks.id` / `playlists.id` が振り直され、uuid だけを写すと端末の正本の id が別の曲・プレイリストを指す。トークンも版(`audio_version` 等)や `playlist_id` を含むので一致しない。さらに DB を作り直した直後は `source_hashes` も消えており、端末の写しが今の送る元と同じかをその場では判定できない。[D-102](D-102.md) は `dest_path` の `canonical_key` で付け替えるとしたが、`dest_path` は Library の `rel_path` の拡張子を替えたもので、Derived では元の曲が一意に引けない(WAV → FLAC の正規化など)ため、manifest が送った元の `rel_path` を覚えているのが確実だった。 + +**決定**: + +1. **manifest の曲に `rel_path`(Library の `tracks.rel_path`)と `source`(`SourceKind::as_str()`。`master` / `opus` / `aac`)を必須で足す**。`FORMAT` は 1 のまま(リリース前で、本番の Android は未登録)。旧 manifest は読めなくなるので、保存先を消して登録し直す。ジャーナルの `Intent` にも任意で足し(曲の `put` のみ)、回復で取り込む。移動のバッチのメンバーは必須。同期の終わり(最後の `compact` の前)に、パスのキーが今の desired と一致する項目へ今の値を書き直す(補填) +2. **remap**(`src/device/adopt.rs`、純粋関数): 曲は `rel_path` の `canonical_key` で今の tracks(missing を除く)から 1 件引いて `track_id` を付け替え、トークンを `ADOPTED_TOKEN`(`"adopted"`)にする。`path` / `size` / `sha256` / `rel_path` / `source` は変えない。プレイリストは `Playlists/<名前>.m3u8` の名前の `canonical_key` で付け替え、トークンは `STALE_TOKEN`(m3u8 の書き直しは安い)。1 つのキーに複数の曲、1 曲に複数の項目、同名のプレイリストなど曖昧なものはどれも採らない。採らなかった項目は manifest から外して**管理外**にする(ファイルは消さない)。冪等で、回復が先に付けた `STALE_TOKEN` の曲はトークンを変えない +3. **同期の近道**: `Update` でパスが同じ、book のトークンが `ADOPTED_TOKEN`、`sha256` と `size` が操作と一致する曲は、転送もジャーナルも省いてトークンだけを書き換える。信頼の水準は日常の差分と同じ(サイズまで。中身は manifest の `sha256` を信じる。これは転送時に端末上で測って照合した値)。転送量の見積もりと空き容量の確認から外し、差分の op は `adopted`(UI は「引き継ぎの照合」)。壊れた写しは回復や `device_verify` が `STALE_TOKEN` にするので送り直される。送る元は通常の転送と同じく開いて identity を照合する(計画の後に送る元が差し替わったのに同期済みにしない。変わっていれば項目エラー + 取り直し) +4. **印の復元**: 端末に m3u8 があるのはそのプレイリストに印が付いていたからなので、付け替えたプレイリストに印を付け直す(行の作成と同じトランザクション)。付けないと `selection=playlists` の端末の最初の差分が全削除になる。`generation` は作成時の既定のまま進めない +5. **API**: 通常登録が片付けるのは、中身が `.spindle` の下だけで回復に要る物が無い(`.spindle/moving` の下にファイルが無く、ジャーナルが無いか空)前の登録の失敗の残りで、manifest が無い・読めない・空(uuid が DB に無い)ものだけ。それ以外は 409 `not_empty`(曲かプレイリストを指す manifest は `volume` 違いや uuid の形が不正でも片付けない。`.spindle/moving` に曲の唯一の実体があり、ジャーナルが回復に要り得る)で、曲を指す manifest(`volume` が一致し、uuid が DB に無いもの)は 409 `adoptable`。`POST /api/devices` に `adopt: true`(adb のみ)。`register_lock` の下で、`scan` ジョブが待ち・実行中なら 409 `library_busy` → manifest を読む(無い・読めない・`volume` 違い・空・uuid の形(32 桁の小文字 16 進)が不正・回復で manifest の不整合が見つかったときは 409 `not_adoptable`、uuid が DB にあれば 409 `uuid_registered`。端末との通信の失敗は既存のエラー応答の 502 `device_failed` / 409 `not_connected`)→ `scan` の有無と remap の入力を 1 つの読み取りトランザクションで読む(スキャン中なら 409 `library_busy`。最初の確認は早めに断るためで、正はこちら)→ 回復 → remap → `compact` → 1 トランザクションで行(manifest の uuid)と印を作る → `device_scan` を投入する。途中で落ちても remap が冪等なのでやり直せる。プレビュー `GET /api/devices/adb/adopt-preview` は読むだけ(ジャーナルは回復しないので件数は目安)で `library_busy` も返す。未登録の一覧の `volumes[].state` に `adoptable` を足す +6. **バックアップ復元で行の uuid が端末と食い違う場合の付け替えは作らない**。端末を削除(端末上のファイルには触らない)してから引き継げば同じ結果になるので、SPEC にその手順を書く +7. **P5-8(Mac の `pair --adopt`)は remap をサーバで計算する**(報告で返す部分は [D-104](D-104.md) で改訂。サーバの行の uuid を差し替え、adopt API がキャッシュを直接書く)。エージェントは state の `(track_id, path, rel_path, source)` とプレイリスト名を送り、付け替え表を受け取って state のキーを直し、`needs_report = true` で `report_only` する。pending が残っていれば拒否し、`--adopt` 無しの uuid 不一致は今どおり止める。state と `agent_proto` の項目にも `rel_path` / `source` を必須で足す +8. **id の再利用は GC が `tracks` の最大 id の行を消さないことで防ぐ**(SQLite は `max(rowid)+1` を振る)。プレイリストの id の再利用は受け入れる(Update = 改名として収束し、失う情報が無い)。どちらも今回は作らず、[SPEC §17](../spec/17-open-issues.md) と TASKS に残す + +**理由**: 付け替えの鍵を manifest 自身に持たせると、DB が空でも端末だけで曲を引ける。`FORMAT` を上げて旧版を読む経路を作るより、リリース前なので旧 manifest を読めなくして保存先を作り直す方が単純で、読めない経路が残らない。トークンを `ADOPTED_TOKEN` にして近道を設けるのは、全曲(約 9,000 曲、初回およそ 2 時間)の送り直しを避けるため。信頼の水準を日常の差分に揃えるので、新しい信頼の穴は作らない。remap をサーバ側の純粋関数にすると、Mac も同じ規則を使える。 + +**却下**: +- uuid を新しい行に写すだけ([D-102](D-102.md) で却下済み): 全曲が誤った更新・移動になる +- 端末で sha256 を測り直して今の送る元と照合する: 全曲の読み出しが要り、DB を作り直した直後は送る元のハッシュも無い +- 引き継ぎをジョブにして、先に送る元をハッシュしてから照合する: 登録が長いジョブになり、途中状態の扱いが増える。manifest の `sha256` は転送時に端末上で測った値で、日常の差分と同じ水準で足りる +- 全曲を送り直す: 数時間と、端末・スナップショットの書き込みが無駄になる +- `rel_path` が manifest に無い旧 manifest を、パスなどから推測して引き継ぐ経路を残す: `dest_path` から元の曲を逆引きすると誤りうる。推測の経路が残ると、誤った付け替えで他人のファイルを上書きする +- 外れた写しを差分で削除する: 「manifest に無いファイルには触らない」に例外を作る。管理外に残して手で扱う +- token・sha256・パスがすべて違う既存の写しを Delete + Add にして id の再利用を防ぐ: Library で一括にタグを直して改名するのは正当な日常操作で、そのたびに Mac の再生回数が消える diff --git a/docs/decisions/D-104.md b/docs/decisions/D-104.md new file mode 100644 index 00000000..a2aea3b0 --- /dev/null +++ b/docs/decisions/D-104.md @@ -0,0 +1,19 @@ +# D-104 Mac の `pair --adopt` はサーバの行の uuid を差し替え、adopt API が付け替えた状態をキャッシュへ直接書く(D-103 の 7 を改める) + +**背景**(2026-10-13。P5-8。仕様は [§7.11](../spec/07-pipeline/11-device-delivery.md) の「iPhone」の「引き継ぎ」): [D-103](D-103.md) の 7 は、エージェントが state の `(track_id, path, rel_path, source)` とプレイリスト名を送って付け替え表を受け取り、state のキーを直して `needs_report = true` で `report_only` する、としていた。作る段で調べると、DB を作り直した直後は `source_hashes` が無く、送る元のハッシュが揃うまで曲が desired に入らない。`device_items` も空なので、報告の各項目は「現在の desired」「既存の `device_items`」「計画で許された遷移」のどれにも一致せず、検証(`src/device/report.rs`)を通らない。また DB を失うと Mac の端末行(uuid・トークン)も消え、新しい行は新しい uuid を持つが、エージェントの state と `~/Music/spindle/.spindle-device` は旧 uuid のままなので、uuid をどちらに揃えるかも決める必要があった。 + +**決定**: + +1. **pair の `adopt_uuid` で、サーバの行の uuid を前の登録(state)の uuid へ差し替える**。`agent_proto::PairRequest` に `adopt_uuid: Option<String>`(`#[serde(default)]`)を足す。形(32 桁の小文字 16 進)はコードに触る前に確かめ、外れたら 400 `bad_request`。差し替えはコードの消費と同じトランザクションで、行が端末の状態を一度も持っていない(`last_synced_at` が NULL、計画が 1 件も無い、`device_items` と `device_playlist_state` が空)うえ `adopt_uuid` が他の行に無いときだけ許す。行の uuid が既に `adopt_uuid` なら、報告も計画も無い(`last_synced_at` が NULL、計画が 1 件も無い)ときだけ差し替え済みのやり直しとして通す。`device_items` / `device_playlist_state` は見ない(前の adopt がキャッシュを書いた後、エージェントが付け替えを保存する前に落ちたやり直しを通すため)。報告か計画があれば DB を失っていない端末で、通すと `adopt_pending` が下りないまま adopt が 409 を返し続けるので断る(その端末は `--adopt` 無しの pair で入れ直す)。`agent_selector` はコードの発行で NULL に戻るので条件にしない。それ以外は 409 `not_adoptable` で、コードは消費せず、検証の前に予約した試行の枠も返す(`refund_pair_attempt`。コード自体は正しかったので試行の上限に数えない) +2. **`POST /api/agent/adopt` が付け替えた状態をキャッシュへ直接書く**(報告を通さない)。本文は state の曲(`track_id`・パス・`rel_path`・`source`・size・sha256・`stale`)とプレイリスト(`playlist_id`・名前)。検証は報告と同じ水準(件数の上限、パスの形、既知の `source`、id とパスの鍵の重複、空の名前。加えて sha256 は小文字 16 進 64 桁で、空(中身を失った曲)は `stale` のときだけ許す、size は i64 に収まること)で 400 → スキャン中なら 409 `library_busy`(`adopt_snapshot`)→ 書き込みのトランザクションの中で open な計画があれば 409 `open_plan`、`last_synced_at` が NULL でなければ 409 `not_adoptable` の順に確かめる。付け替えは Android と同じ規則(`remap_ids` に切り出し、manifest の `remap` もそれを使う)。書くのは `device_items`(トークンは `stale` なら `STALE_TOKEN`、それ以外は `ADOPTED_TOKEN`)・`device_playlist_state`(`dest_path` は名前、トークンは `STALE_TOKEN`)の全置換と印の付け足し(既存の印は外さない)で、`device_errors` は空にする。`generation` と `last_synced_at` は進めない。冪等で、応答は付け替え表(外れたものは `to: null`) +3. **エージェントの `pair --adopt`**: コードを使う前に、通常の pair の前提、前の登録の state(初期化が済みサーバの情報がある)、root の `.spindle-device` の照合、ローカルの回復を通し、`pending_ops` / `pending_batches` が残れば止める。pair の後は新しいトークンで adopt を送る。トークンを保存する前に `adopt_pending = true` とサーバの URL・名前を保存し(逆順だと間で落ちたとき新しいトークンと付け替え前の state が組み合わさり、次の sync が付け替えずに差分を出す)、付け替え(キーの付け替え、曲のトークンを `ADOPTED_TOKEN`(`STALE_TOKEN` はそのまま)、プレイリストのトークンを `STALE_TOKEN`、`plan_id = None`、`needs_report = false`、`adopt_pending = false`)を 1 回の保存で行う。外れた曲・プレイリストは state から外すだけで、ファイルもミュージック.app の track も消さない(管理外)。間で落ちたら、次の `sync` が回復の後・manifest の取得の前に続きを行う。`status` は途中の引き継ぎを出す +4. **state と `agent_proto::ManifestItem` に `rel_path` / `source` を必須で足す**([D-103](D-103.md) の 7 のとおり)。`STATE_VERSION` と `PROTO_VERSION` は 1 のまま(本番の Mac は pair 前)。追加・更新は manifest の同じ `track_id` の項目から値を取り、無ければその操作を項目のエラーにする。`PendingOp` は任意、`BatchMember` は必須で持ち、回復で写す +5. **同期の近道**: `Update`(`from` 無し)で、state の曲のトークンが `ADOPTED_TOKEN`、パスの鍵・size・sha256 が計画の項目と一致すれば、受け取りもミュージック.app の操作もせずにトークン(と `rel_path` / `source`)だけ書き換える。報告はそのトークンで行い、計画に含まれるので検証を通る。受け取らないので、実行前の空き容量の確認からも外す(近道と同じ判定を使う) +6. **`--adopt` 無しの uuid 不一致は今どおり止め、文言で `pair --adopt` を案内する** + +**理由**: 報告の検証はハッシュ待ちの間 desired が空で通らず、DB を作り直した直後の状態を検証の例外で通すより、引き継ぎ専用の API が前提(報告を一度も受けていない、open な計画が無い)を確かめてキャッシュを書く方が、通る経路が狭く説明がつく。結果は Android の引き継ぎ(書き直した manifest を走査で取り込む)と同じになる。uuid をサーバ側で差し替えると、`.spindle-device` と state の uuid を変えないので、エージェントの途中で落ちたときの再開に印と state の食い違いの手当てが要らない(`adopt_pending` の続きだけでよい)。プレイリストのトークンを `STALE_TOKEN` にするのはミュージック.app のプレイリストの中身の書き直しが曲の転送に比べて安いからで、サーバとエージェントで揃えるので最初の同期で同じ名前のまま中身を入れ直す。断った pair でコードを消費しないのは、端末タブでコードを発行し直す手間を増やさないため。 + +**却下**: +- エージェントが新しい行の uuid を受け入れて state と `.spindle-device` を書き換える: 印・state・サーバの 3 か所を揃える途中で落ちる場合の手当てが要り、印の照合(別の端末の root を守る)を緩めることになる +- [D-103](D-103.md) の 7 のとおり付け替えた state を `report_only` で報告し、検証に引き継ぎの例外を設ける: 例外が報告の経路全体に効き、通る範囲が広がる。desired が空の間は例外なしでは通らない +- `--adopt` 無しの uuid 不一致をコードの消費前に検出する: 同じ端末の pair し直し(コードを発行し直したとき)と区別できない。文言で案内するだけにする diff --git a/docs/decisions/D-105.md b/docs/decisions/D-105.md new file mode 100644 index 00000000..d3038efa --- /dev/null +++ b/docs/decisions/D-105.md @@ -0,0 +1,19 @@ +# D-105 id の再利用は、GC が `tracks` の最大 id の行を消さないことで防ぐ + +**背景**(2026-10-14): `tracks.id` / `playlists.id` は `INTEGER PRIMARY KEY` で `AUTOINCREMENT` ではない。SQLite は次の行に `max(rowid)+1` を振るので、GC が最大 id の行を消すと次に入る曲が同じ id を得る。端末の写し(`device_items`、Android の manifest、Mac のエージェントの state)は `track_id` で曲を指すので、7 日以上同期していない端末では差分が `UpdateMove` になる。Android は正しく収束するが、Mac ではミュージック.app の track を保ったまま location を付け替える([D-101](D-101.md))ので、再生回数・レート・追加日が別の曲に移る([D-102](D-102.md) の 4、[D-103](D-103.md))。 + +**決定**: + +1. **GC は `tracks` の最大 id の行を消さない**。`db::gc::missing_tracks`(一覧。`/api/gc/preview` の件数もここから)と `delete_tracks`(削除の SQL の再確認)の両方に `id < (SELECT max(id) FROM tracks)` を足す。計画の後に曲が増えても減っても、削除の時点の最大で判断する +2. 残した行は missing のまま残る。後で新しい曲が入れば最大でなくなり、次の GC で消える。その id は最大より小さいので二度と振られない。残した行のアルバム・Derived・アートワークは既存の「参照があれば消さない」で残る(最大 1 曲分) +3. **プレイリストの id の再利用は受け入れる**([D-103](D-103.md) のとおり。差分は改名の `Update` として収束し、失う情報が無い) +4. **古いバックアップへの復元は対象外とし、手順で扱う**。復元した DB は採番も巻き戻るので(`AUTOINCREMENT` でも `sqlite_sequence` ごと巻き戻る)、バックアップより後に足した曲に起動時スキャンが振る id は別の曲に当たり得る。端末の行と認証はバックアップの時点のまま戻るので、引き継ぎは自動では促されない。復元の手順([運用手順](../admin-guide/operations.md)「端末は引き継ぎし直す」、[SPEC §14](../spec/14-deploy.md)「バックアップ」)に、同期する前に端末を削除して引き継ぎし直す(Android は「前の登録の保存先」、Mac は `pair --adopt`)と書く。消す前に名前・形式・送る範囲を控えて同じ設定で作り直す(追加の既定は「印のプレイリスト」で、元が「全曲」なら引き継いだ全曲が削除になり、Mac ではミュージック.app の track ごと消える)。範囲が分からなければ「全曲」(Library にある曲を端末から消さない)。引き継いだ後の最初の差分に予期しない削除があれば確定しない。codex のレビューの指摘 + +**理由**: DB の中で再利用が起きるのは最大 id の行を消したときだけで、その 1 行を残せば十分。スキーマも、差分も、端末の手順も変えずに済む。復元は稀な手作業の操作で、引き継ぎ([D-103](D-103.md) / [D-104](D-104.md))が既にあるので、手順に 1 段足せば済む。 + +**却下**: +- `tracks` を `AUTOINCREMENT` に移すマイグレーション: SQLite では表の作り直しになり、FTS5 の external content・トリガー・外部キーの参照元を巻き込む。得るものが上の 1 行と同じ +- token・sha256・パスがすべて違う既存の写しを `Update` ではなく Delete + Add にする: Library で一括にタグを直して改名するのは正当な日常操作で、そのたびに Mac の再生回数が消える +- 最大 id を別表に持って挿入時に振る: 別表も DB の中なので復元で巻き戻り、DB の中の再利用は最大 id の行を残すだけで防げる +- 最大 id を DB の外のファイルに記録し、スキャンがそれより上から振る(復元でも巻き戻らない): DB の外に正本を 1 つ増やす。データセットごと ZFS のスナップショットへ戻すと両方巻き戻るので、手順の案内はどのみち要る。復元は稀なので手順で扱う +- 復元を検出して端末に引き継ぎを強制する: 検出に DB の外の記録が要り、上と同じ diff --git a/docs/decisions/D-106.md b/docs/decisions/D-106.md new file mode 100644 index 00000000..d3f1da3f --- /dev/null +++ b/docs/decisions/D-106.md @@ -0,0 +1,30 @@ +# D-106 ログはファイルのまま残し、画面は API で読む。GC・スキャン・バックアップの結果はジョブの note に残す + +**背景**(2026-10-15): 操作タブが照合の詳細を `data/verify/` の実ファイルで案内していた。コンテナの中のパスはユーザが開けない。 +設定画面の退避ファイルの一覧は本番で 7,570 件あり、すべて GC で実体を消した記録だった。GC・スキャン・バックアップは +結果を tracing にしか出さず、画面では「完了」しか分からなかった。 + +**決定**: + +1. ログはファイルのまま(照合は `data/verify/<album_id>.log` に album ごとに最新 1 回分、吸い出しは album と一緒に動く + `rip.log` / `rip<N>.log`)。画面は `GET /api/logs` で実行ごとの一覧を、`GET /api/logs/{key}/text` で本文を読む。 + 本文は root の dirfd から `openat2` で開き、上限 1 MiB。照合ログの過去分は残さない(古い実行は「上書きされた」と出す) +2. 吸い出しログの場所は DB の `log_path` を使わず、その disc の active な曲がいまあるディレクトリから引く + (`log_path` は配置時の Library 相対でリネームに追随しない。名前だけ使い、`rip*.log` 以外は読まない) +3. GC・スキャン・バックアップの結果は `Outcome::DoneWith` でジョブの `note` に残す(1 行目を一覧に、全文を展開に)。 + ジョブ行の保持期間(done 7 日)で一緒に消える。別の表は作らない +4. 編集履歴と退避ファイルとログを ☰ の「記録」画面のタブにまとめる。退避ファイルは保持中を既定にして 100 件ずつ + +**理由**: ログを DB に入れると照合のたびに数十 KB の行が積もり、ファイルが正という原則(不変条件 1)とも二重になる。 +読む経路だけ足せば、既存の書き手(照合・吸い出し・配置)は変えずに済む。結果の要約は数行なので、ジョブ行に +載せればジョブ画面からそのまま見えて、保持の規則も既存のものに乗る。 + +**追記**(2026-10-20、設計書の吸収): 作らなかったもの — サーバのアプリログ(tracing)を画面に出すこと、バックアップの +世代一覧、`scan_runs` の一覧画面、照合ログの過去分の保存。`GET /api/archive` は呼ぶのが記録画面だけなので、互換を +保たずに形を変えた(`items` / `next_cursor` / `counts`)。ログ一覧の `key` と `cursor` は不透明な文字列にし、画面は中身を +読まない(本文の取得と続きの要求に渡すだけ)。照合ログの本文は DB の `log_path`(絶対パス)を使わず、`data/verify` の +dirfd から `<album_id>.log` の名前を組み立てて開く(DB の値をパスとして信じない)。rip のジョブ行に「ログを見る」を +出さないのは、rip ジョブは Inbox に置くまでで album を知らないため(承認前の吸い出しログは Inbox の件から +`GET /api/inbox/:id/files/:name` で読む)。ジョブ画面のエラーは `title` のツールチップをやめて行の展開に出す +(ツールチップは選択・コピーができず、タッチ端末では出ない)。設定画面の category の使用数は削除の可否の判定と +同じ関数で数え、押してから 409 で断られる前に理由を見せる。 diff --git a/docs/decisions/D-107.md b/docs/decisions/D-107.md new file mode 100644 index 00000000..85da07dc --- /dev/null +++ b/docs/decisions/D-107.md @@ -0,0 +1,17 @@ +# D-107 選曲の見積もりは、送る元のハッシュが無い曲を長さ × ビットレート(原本を送るなら原本の size)で推定する + +**背景**(2026-10-15): 端末の選曲タブで「全曲」を選ぶと「合計 9,162 曲・約 605.3 MB(容量未確定 9,070 曲は含まない)」と出た。 +見積もりは送る元のハッシュ(`source_hashes`)の size だけを足していたが、ハッシュは同期の計画に入った曲にしか取らないので、 +選曲を広げる前の見積もりでは大半の曲が数えられず、容量が桁違いに小さく見えた。 + +**決定**: + +1. 送る元のハッシュが有効な曲は、これまでどおりその size を数える(確定) +2. ハッシュが無い・古い曲は推定して bytes に足し、その曲数を `estimated` で返す。原本を送る曲は `tracks.size`、Derived を送る曲 + (Derived がまだ無い・古い曲も、系統が有効なら作られる)は `duration_ms` × `derived_variants.bitrate` / 8 +3. 長さが分からない曲と、系統が凍結で Derived の無い曲(送られない)は推定せず `unhashed` に数え、bytes に含めない +4. 画面は「合計 N 曲・約 X(うち M 曲は長さとビットレートからの推定)」と出す + +**理由**: 実機の AAC 256 kbps では、ハッシュ済みの 881 曲で実際の合計 5,026 MB に対し推定 4,873 MB(差 3%。埋め込みの +アートワーク分だけ小さく出る)で、選曲を決める目安には十分。Derived を作る・ハッシュを取るのは重く、見積もりのためだけに +走らせる理由はない。差分画面の送る量(計画に入った曲)は推定を混ぜず、これまでどおり確定値だけを使う。 diff --git a/docs/decisions/D-108.md b/docs/decisions/D-108.md new file mode 100644 index 00000000..c00f4a0d --- /dev/null +++ b/docs/decisions/D-108.md @@ -0,0 +1,45 @@ +# D-108 sync の進捗は Mac の端末とサーバの端末タブに出す。サーバはメモリだけに持ち、送信の失敗では同期を止めない + +**背景**(2026-10-15): `spindle-agent sync` は確認(y/N)の後、何も出さずに数分〜数十分動いた。どこまで進んだか、速度、 +残り時間が分からず、端末タブも「同期中」とも出なかった(Android はジョブの進捗がある)。 + +**決定**: + +1. 対象は計画の実行中だけ。件数(`done` / `total`)、バイト数(受け取りが要る項目の `size` の和に対する受信量)、 + 直近 5 秒の速度、残り時間、今の項目を数える。Mac では TTY なら 1 行を上書き(0.2 秒に 1 回)、TTY でなければ + 10 秒か 10 件ごとに 1 行。終わったら要約を 1 行出す +2. エージェントは `POST /api/agent/plans/{id}/progress` へ 2 秒に 1 回まで送る(タイムアウト 5 秒。間隔は前回の送信が + 終わってから数える)。**送信の失敗(通信エラー・タイムアウト・5xx)では同期を止めず**、その回を捨てて間隔を 2 秒・ + 4 秒・8 秒…(上限 60 秒)と延ばし、3 回続けて失敗したらその計画では以後送らない。404 と 409 もその計画では以後 + 送らない(古いサーバの 404 と区別しない)。次の計画(`start`)でまた送る。途中で止めた(`Stop`)ときは要約を + 出さず、進捗の行を消すだけにする。各項目の始めにも送る機会を作り(間隔の内なら送らない)、CLI では別スレッドの + **心拍**が、前回の送信から間隔が空けば最新の値を送り直す。項目が長く掛かっても(最初の削除、ミュージック.app への + 追加で osascript を最大 120 秒待つ、大きなプレイリストの置き換え)2 秒ごとに届き、画面が「応答なし」にならない。 + 心拍とエンジンの送信は間隔・失敗の数・打ち切りを 1 つの門で共有し、重ねて送らない(送信中は他方が見送る)。 + ただし報告の前の最後の 1 回は、心拍の送信中ならその終わりを待って(上限 6 秒)から送る。門は計画ごとの番号を + 持ち、前の計画の送信の結果(404 など)は次の計画に記さない。速度は送る時点で計り直す(受信の止まった段では + 直近 5 秒の平均として 0 に落ちる)。計画を終えたら(要約・行の消去)心拍は送らない +3. サーバは**メモリだけ**に持つ(端末ごとに 1 件、DB に書かない)。応答は 204、閉じた・別の端末の計画は 404 `no_plan`、 + 世代違いは 409、値の検査は 400、本文は 4 KiB まで。受理後に計画をもう一度確かめ、閉じていたら消して `progress: null` + を流す。計画が閉じる経路(報告・破棄・強制破棄)と端末の削除でも消して流す +4. 端末の応答の `sync_progress` は、進捗の `plan_id` が今の open な計画と一致するときだけ出す。経過は `now` ではなく + サーバが計算した `age`(秒)で渡し、画面は端末の時計に依らず古さを出す。SSE は `device_progress` +5. 画面(端末タブ)は進捗バーと速度・残り時間を出し、最後の更新から **30 秒**を超えたら「応答なし」にする。SSE で届いた + 新しい進捗を、古い一覧の応答で上書きしない。表示は `sync_progress` だけで決め、`plan_open` は見ない(計画の確定は + イベントを流さず、`plan_open` は一覧の取り直し(60 秒)まで偽のままなので、短い同期では進捗が出なくなる)。非 null の + 進捗を受けたら `plan_open` も真にする。null はサーバが進捗を実際に消したとき(計画が閉じた)だけ流すので、 + 受けたら一覧を取り直して `plan_open` を合わせる(「Mac で同期が途中です」と強制破棄を 60 秒残さない)。 + 一覧の応答と SSE の前後は時刻ではなくイベントの到着順の番号で比べる(同じ 1 ms のイベントを取り違えない) +6. 描画は `Ui` ではなく `ProgressSink`(描く先の抽象)に任せる。進捗の計算(`Progress`)は時計を注入でき、描画と + 送信を間引く。`Ui` の台本は変えない + +**理由**: 進捗は表示のための一時的な値で、計画が閉じれば要らない。DB に書くとスキャンやバックアップの対象になり、 +2 秒ごとの書き込みが SQLite の単一書き込みコネクションを占める(不変条件 1 とも無関係)。送信は表示の補助なので、 +サーバや回線の不調で数十 GB の受け取りを止めてはならない(ジョブは冪等で、止めるべきなのは報告の失敗だけ)。 +404 / 409 を繰り返し送ると、閉じた計画や古いサーバにログを積むだけになる。送信は受信の書き込みの中で同期的に行うので、 +詰まったサーバに書き込みのたびに送るとタイムアウト(5 秒)ごとに 1 チャンクしか進まない。間隔を送信の終わりから数え、 +失敗で延ばし、続けば打ち切るのはこのため。エンジンは単一スレッドで、1 項目の処理(osascript など)の間は +送る機会が無い。心拍を別スレッドにしたのは、受信の書き込みの合間だけでは「生存」を知らせきれないため。心拍は +エンジンが書いた最新の値を送るだけで、数え方には触れない(エンジンの `Progress` は単一スレッドのまま)。`age` にしたのは、Mac・サーバ・ブラウザの +時計がずれていても「応答なし」を正しく出すため。描画を `ProgressSink` に分けたのは、`info` など既存の出力との +交錯(進捗の行を消してから出す)を 1 か所に閉じ込め、試験で台本 Ui を壊さずに済ませるため。 diff --git a/docs/decisions/D-109.md b/docs/decisions/D-109.md new file mode 100644 index 00000000..2ebca69a --- /dev/null +++ b/docs/decisions/D-109.md @@ -0,0 +1,37 @@ +# D-109 Android の同期の進捗は job イベントの detail で流し、Mac と同じ表示を使う + +**背景**(2026-10-16): [D-108](D-108.md) で Mac の同期には進捗が付いたが、Android(adb)の `device_sync` は件数の進捗を SSE で流していたが、 +端末タブは使わず「同期中…」としか出さなかった。数百 MB 以上を送る間、どこまで進んだか、速度、残り時間が分からなかった。 + +**決定**: + +1. 進捗は `device_sync` ジョブの `job` イベント(SSE)の detail に載せる。形は + `{"kind":"device_sync","phase","current","bytes_done","bytes_total","rate"}`。件数は `JobEvent` の `done` / `total` + をそのまま使う。[D-108](D-108.md) の `device_progress`(Mac のエージェントが送る進捗)は使わない。detail は SSE にだけ流し、 + DB の進捗の書き込みは既存の `JobContext` の間引き(250 ms)に任せる +2. 段(`phase`)は `delete` / `move`(パス変更のバッチ)/ `send`(更新・追加)/ `playlist`。Mac の `fetch`(受信中)は + 使わず、画面の見出しは `send` を「送信中」とする。`current` は送り先(削除は `from`)のファイル名、プレイリストは + そのファイル名 +3. `bytes_total` は端末へ中身を送る曲(`Add` / `Update` / `UpdateMove`)の `size` の和。引き継ぎの照合で済む曲 + (`adopted_unchanged`)は送らないので含めず、純粋な `Move` も含めない。`bytes_done` は**曲を 1 曲処理し終えるごと**に + 増やす(曲の途中では増えない)。保留(開けない・衝突・照合失敗など)になった曲の `size` も処理し終えた量として足し、 + バーを最後まで進める。パス変更のバッチは失敗した曲を外してやり直すことがあるので、バッチの中では送り終えた量の + 最大値を見せて減らさず、バッチを終えたら(成功・中断のどちらでも)バッチの全体を確定させる +4. エンジンは各項目の始め(`current` を入れて)と終わりの両方で `progress` を呼ぶ。パス変更のバッチは、仕上げ + (空ける・置く)でもメンバーごとに `current`(送り先のファイル名)を出し、置くたびに件数を 1 進める(純粋な `Move` は + 準備の段で何もしないので、ここで出さないと今の曲が空のまま、または更新 + 移動の曲名が残る)。バッチを終えたら件数を + 「バッチの開始時の件数 + バッチの項目数」に確定させる(置いた分を二重に足さない。外した曲もここで数える)。通知は + 失敗せず、耐久化と `mv` の順序は変えない。速度(`rate`)は `JobControl` が + `(時刻, bytes_done)` の標本を直近 5 秒だけ持ち、[D-108](D-108.md) と同じく 5 秒前以前で最新の標本を基準(無ければ 0)に + バイト差 ÷ 経過秒で出す。標本が 1 つ・経過 0 なら 0。純粋関数にして単体テストする +5. 画面(端末タブ)は、`sync_job` の job の進捗を受けているとき(一覧の state が queued のままでも。running の間だけ表に項目がある)に [D-108](D-108.md) と同じ進捗の行 + (`SyncProgressLine`)を出す。無ければ従来どおり「同期中…」。**「応答なし」(30 秒)は Android では出さない** + (ジョブの生死はワーカーが管理し、パス変更のバッチのように間が空く段がある)。ページを開き直したときは、 + 次の `job` イベントが届くまで「同期中…」のまま(DB の件数を一覧に足さない) + +**理由**: ジョブにはもう進捗の仕組み(`JobEvent` と SSE)があり、新しい API や保持場所を作らずに済む。進捗は表示の +ための一時的な値で、DB に書く必要が無い([D-108](D-108.md) と同じ)。バイトを曲の完了ごとに数えるのは、1 曲の転送の途中を +ストリームから測るとエンジンの構造(照合と `mv` を含む曲の単位)に手を入れることになり、曲が数 MB〜数十 MB の +この用途では粗さが気にならないため。保留の曲の `size` を足すのは、足さないとバーが合計に届かずに終わるため。 +「応答なし」を出さないのは、生死の判定をジョブの回収([D-76](D-076.md))が持っており、画面が二重に判定すると、間の空く段で +誤って警告するため。 diff --git a/docs/decisions/D-110.md b/docs/decisions/D-110.md new file mode 100644 index 00000000..c8e05547 --- /dev/null +++ b/docs/decisions/D-110.md @@ -0,0 +1,33 @@ +# D-110 MP3 の写像できないキーは ID3v2 の TXXX に書き、ブロックは split / merge で書き換える + +**背景**(2026-10-17): 00_ プレイリストから外す曲に `PLAYLIST_EXCLUDE=1` を一括編集で書いたところ、Charlotte ラジオの +mp3 13 曲が「書き込み結果が意図と一致しない(この形式では表現できない)」で failed になった。MP3 は lofty の generic +`Tag` 経由で読み書きしており、generic `Tag` は写像表に無いキー(TXXX の任意の description)を読みでも書きでも捨てる。 +しかも書きは ID3v2 全体を generic `Tag` から作り直すので、spindle が知らないフレーム(外部ツールの独自 TXXX など)は、 +別のキーを編集しただけで消えていた。 + +**決定**: + +1. MP3 の書き込みは `MpegFile` を読み、ブロック(ID3v2 / ID3v1 / APE)ごとに `split_tag` → generic `Tag` に変更と画像を + 当てる → `merge_tag` で戻す。写像できないフレーム・項目は残る +2. ID3v2 では、Vorbis 名を `ItemKey` に写像でき、その `ItemKey` が ID3v2 に写像を持ち、**かつ `ItemKey` の正規の Vorbis 名が + キーそのもの**であるキーだけを generic `Tag` で書く。別名(`TOTALTRACKS` → `TRACKTOTAL`、`ORGANIZATION` → `LABEL` 等)を + 標準フレームに書くと正規の名前で読み戻され、読み戻し照合が必ず外れるので、別名は TXXX に書く。それ以外も + description = キー(大文字)の TXXX に書く。多値は NUL 区切りの 1 フレーム(ID3v2.4 の多値) +3. どのキーでも、書く前に description を読み側と同じ規則(Unicode の大文字化)で大文字にしてキーと一致する既存の TXXX を + 消す(外部が `replaygain_track_gain` や `MyKey`、`äkey` で書いたものを置き換えても二重化しない) +4. 読みは従来の generic の集約(primary 優先、副ブロックは primary に無いキーだけ)の間に、ID3v2 の TXXX のうち generic に + 載らないもの(description を大文字化したキー、NUL で多値に分ける)を primary の一部として差し込む。description の + 無い TXXX は読まない +5. 書いた mp3 の ID3v2 は v2.4 になる(lofty の既定。v2.3 の入力も読み書きでき、書くと v2.4 に上がる)。NUL 区切りの多値は + v2.4 の形で、foobar2000 / Picard / ffmpeg は読める +6. WAV / AIFF / WavPack / APE の形式は従来どおり(generic `Tag`。対象の曲がほぼ無く、WAV は FLAC に正規化される) + +**影響**: 既存の mp3 で、これまで読めていなかった TXXX がある曲は、次のスキャンでタグの集合が増えて `tag_version` が進む +(本番の mp3 は 30 曲程度)。 + +**理由**: MP4 のフリーフォーム([D-77](D-077.md))と同じく、任意キーを書ける形式では書けるようにして、形式による一括編集の +失敗を無くす。TXXX は foobar2000 / Picard / ffmpeg が任意フィールドに使う標準の置き場。 + +**却下**: generic `Tag` の写像だけで済ませる(任意キーが書けないまま)。ID3v2 全体を自前で組み立てる(lofty の標準 +フレームの写像・`TRCK` の対・画像の扱いを二重に持つことになる)。 diff --git a/docs/decisions/D-111.md b/docs/decisions/D-111.md new file mode 100644 index 00000000..03f61e07 --- /dev/null +++ b/docs/decisions/D-111.md @@ -0,0 +1,22 @@ +# D-111 Derived のジョブは、行った処理(エンコード / タグ更新 / 移動)を結果列に出す + +**背景**(2026-10-17): タグの一括編集の後、ジョブ画面に「Derived 生成 … 実行中」が並び、再エンコードが走っているのか +タグの書き換えだけなのかが画面から分からなかった。`transcode` は 1 種類のジョブで、ハンドラが実行時に現在値から処理 +(`Plan`)を決めるので、種別の名前では見分けられない。 + +**決定**: + +1. `Plan` ごとの名前を持つ(エンコード / タグ更新 / 移動 / 移動 + タグ更新 / 変更なし / 対象外) +2. 処理を決めて Derived のパスを予約した後、実行の前にその名前を `jobs.note` に書く(`running` の行だけ)。ジョブ画面は + 既存の結果列(`note` の 1 行目)で、実行中の行にも名前を出す。書けなくても処理は止めない +3. 完了時は実際に行った処理を `DoneWith` で残す。移動・タグ更新が Derived の消失でエンコードに倒れたときは、エンコードが + 自分で「エンコード」を書いて返す。何もしなかったジョブは「変更なし」「対象外」 +4. 再キュー(パスの予約が取れない・世代が動いた)では `note` を消さない。次の実行が書き直す +5. ハンドラに入る前にワーカーが stale(版が進んだ・トラックが無い)として no-op で終えるときは、`JobType::noop_note` + (transcode だけ「変更なし」)で `note` を**置き換える**(`COALESCE` で前の試行の処理名を残さない)。他の種別は従来どおり + `note` に触らない + +**理由**: 新しい列や SSE の詳細を足さずに、既存の `note` と結果列([D-106](D-106.md))で足りる。一覧は job イベントのたびに DB から +取り直すので、長いエンコードは進捗のイベントで実行中の名前が見える。タグ更新は数秒で終わるので、完了時の `note` で見える。 + +**却下**: 投入時にジョブの種別を分ける(処理は実行時の現在値で決まり、投入時の判断は古くなる。dedup の鍵も変わる)。 diff --git a/docs/decisions/D-112.md b/docs/decisions/D-112.md new file mode 100644 index 00000000..d0abd400 --- /dev/null +++ b/docs/decisions/D-112.md @@ -0,0 +1,21 @@ +# D-112 Derived のバッジは系統ごとに O(opus)と A(aac)を並べ、凡例の高さは窓の下端までにする + +**背景**(2026-10-17): Library の表の D バッジは opus の系統だけを見ていた(aac は [D-75](D-075.md) で後から足し、プロパティでだけ見せて +いた)。iPhone への配信(Mac のエージェント)で aac も日常的に使うようになり、aac の Derived があるか・タグが追いついているかを +表で見たい。また、凡例のポップオーバーは高さを固定の `70vh` で切っていたため、上のプロパティ欄が高く表のツールバーが下に +あると、凡例の下端が窓の外に出て最後まで読めなかった(ページ自体はスクロールしない)。 + +**決定**: + +1. Derived のバッジは系統ごとに 1 つ。`opus` は **O**、`aac` は **A**(この順)。タグ版が古い(`stale_tags`)ときは点付き + (O• / A•)。どちらも同じ色(`b-derived`)。バッジの key は `derived-opus` / `derived-aac` +2. `has_derived`(DSL / フラグ)は従来どおり `opus` 系統の行の有無(配布ビューの意味を変えない) +3. 凡例は開いたとき・窓の大きさが変わったとき・位置決めの親(ツールバー)とその親(表)の大きさが変わったとき + (`ResizeObserver`。開いたままプロパティ欄の高さを変える・ツールバーが折り返す)に、自分の上端から窓の下端(余白 8px) + までを `max-height` にする。残りを超える下限は置かない。中は凡例自身がスクロールする +4. 凡例は O / O• / A / A• をそれぞれ 1 行に載せる + +**理由**: 系統の区別は 1 文字で足りる(D のままでは aac が分からない)。凡例は配置(ツールバーの位置)が利用者の欄の高さで +変わるので、固定の比率では収まらない。 + +**却下**: 凡例を `position: fixed` の中央のダイアログにする(「凡例」ボタンの直下に出る今の操作感が変わる)。 diff --git a/docs/decisions/D-113.md b/docs/decisions/D-113.md new file mode 100644 index 00000000..801b8de1 --- /dev/null +++ b/docs/decisions/D-113.md @@ -0,0 +1,33 @@ +# D-113 Inbox の承認で種類(TRACK_TYPE)と除外(PLAYLIST_EXCLUDE)を選び、タグのキーの入力欄に前方一致の候補を出す + +**背景**(2026-10-17): プレイリストの運用を「曲の性質はファイルのタグ(`TRACK_TYPE` = karaoke / bgm / drama / talk、 +曲だが外したいものは `PLAYLIST_EXCLUDE=1`)で持ち、00_<category> とシリーズのプレイリストはそれを除くスマートプレイリスト」に +した。取り込みはどの経路(CD / YouTube / 手置き)も Inbox の承認を通るので、そこで付ければプレイリストと端末の配信が +自動でついてくる。ただし任意のキーを「タグを追加」で手入力するのは手間で、付け忘れると曲でないものが紛れ込む。 +タグのキーの入力欄(Inbox のタグの追加・プロパティのフィールドの追加・一括編集)は、ライブラリでどんなキーが +使われているかが分からず、綴りを覚えておく必要があった。 + +**決定**: + +1. Inbox の承認の表に**種類**(選択欄: 曲(空)/ カラオケ / BGM / ドラマ / トーク。値は karaoke / bgm / drama / talk)と + **除外**(チェック。`PLAYLIST_EXCLUDE=1`)の列を置く。中身は既存の下書きのタグ([D-86](D-086.md))なので、サーバの変更は無い。 + 汎用のタグの列には出さず、「タグを追加」でこの 2 つのキーを足そうとしたら列を案内する。知らない値(手で書いたもの)は + 選択欄にそのまま出す +2. 種類が空の行に候補を出す(`suggestTrackType`)。タイトルの instrumental / inst. / off vocal / karaoke / カラオケ / 歌無し / + without vocal / backing track → カラオケ、ドラマ / drama / ボーナス・トラック → ドラマ、MC(番号だけ)/ ラジオ / トーク / + コメント / DJトラック → トーク、アルバム名がサウンドトラック / サントラ / soundtrack / OST / Music Collection / 劇伴で、 + トラックのアーティストがアルバムで一番多いアーティスト(作曲者とみなす)なら BGM。**候補は自動では入れない**。 + 行の「候補: …」か表の上の「種類の候補をすべて適用」で人が入れる +3. `GET /api/tags/keys` でライブラリにあるキーと曲数(missing と `PICTURE` を除く、曲数の多い順)を返す。入力欄は + フォーカスしたときに取り(30 秒は使い回す)、よく使うキー(`COMMON_KEYS`)のうちライブラリに無いものを後ろに足し、 + 入力のたびに**前方一致**(大小文字を区別しない)で絞って出す。既にあるキー(Inbox の列・プロパティの行)は除く。 + ↑↓ で選び Enter / Tab かクリックで入れ、Esc で候補を閉じる。候補を選んでいないときの Enter / Esc は従来どおり + (追加の確定・取り消し)。共通の部品 `TagKeyInput` を 3 か所で使う + +**理由**: 取り込み時に付けるのが一番漏れにくく、承認の画面ならどの経路も通る。候補を自動で入れないのは、誤判定(曲名に +「ラジオ」を含む歌など)がそのままファイルに書かれるのを防ぐため。キーは数十種類しか無い(本番で 34)ので、打つたびに +サーバへ問い合わせず、一覧を取って手元で絞るほうが速い。 + +**却下**: 候補を下書きの初期値として入れる(確認せずに承認すると誤判定が書かれる)。CD の取り込みの画面にも同じ欄を置く +(CD も Inbox の承認を通るので二重になる)。部分一致(キーの途中)で絞る(`TRACK` で `TOTALTRACKS` まで出て、狙いの候補が +埋もれる)。 diff --git a/docs/decisions/D-114.md b/docs/decisions/D-114.md new file mode 100644 index 00000000..4038396c --- /dev/null +++ b/docs/decisions/D-114.md @@ -0,0 +1,67 @@ +# D-114 Inbox の YouTube 取り込みを再判定し、判定したプラグインの版を記録する + +**背景**(2026-10-18): YouTube の曲名・アーティストはメタデータプラグイン(`spindle-ytmusic-meta`)がタイトルから決める。 +ルールに穴があると、どれにも合わず `Inbox/youtube/_unmatched/<channel>/` に動画タイトルのまま置かれるか、判定はされても +値が違う(全角 `#` で 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:<item_id>`、`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](D-069.md))。ファイル名も初回と同じ規則(`<YYYYMMDD> <title> [<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](D-090.md))。購読由来を移さないのは、その曲の置き場所が購読の設定で決まりタイトル解析では +変わらないため。新しい名前に同じ `SOURCE_URL` のファイルが残るのは前回の中断の産物なので置き換えて収束させる(ダウンローダの +手順 8 は「違えば Fatal」を保つが、そちらは別の動画との衝突を見分けるため)。再試行しないのは、失敗の多くがプラグインや +yt-dlp の状態に依り、人がルールを直してもう一度押すのが正しい復帰だから。 + +**却下**: 承認済み・配置済みの件の再判定(Library に入った曲は通常の一括編集で直す)。ok の曲を別のアーティスト・アルバムの件へ +移すこと(ok の albumartist / album はチャンネル定義か購読で決まる)。再判定前後の差分表示と確認ダイアログ(確認は失うものが +ある下書きのときだけ)。Inbox 全体の一括再判定(件ごとのボタンだけ)。動画タイトルの取り直しの常時実行(YouTube 側の変更は +追わない)。 diff --git a/docs/decisions/D-115.md b/docs/decisions/D-115.md new file mode 100644 index 00000000..8141f474 --- /dev/null +++ b/docs/decisions/D-115.md @@ -0,0 +1,48 @@ +# D-115 スマホ版は別の入口(`/m/`)・別のビルドで作り、PC 版に手を入れない + +**背景**(2026-10-19): いまの UI はデスクトップ専用([SPEC §12](../spec/12-ui/README.md))で、外出先のスマホからライブラリを眺める・探す・試し聴きする・ +誤字を直す・プレイリストを手直しする・YouTube 購読の同期を押す、ができなかった。ユーザの条件は「PC 用のページを触らずに、 +まったく影響を与えずに作る」こと。外出先は LTE + Cloudflare Zero Trust 経由で、ハイレゾ FLAC(1 曲 100 MB 近い)をそのまま +流せない。 + +**決定**: + +1. **別入口・別ビルド**: コードは `web/src/mobile/`、ビルドは PC と別の vite 設定で `web/dist/m/` に出す。PC の + `components/` `hooks/` `App.tsx` `main.tsx` `index.css` `vite.config.ts` は import も編集もしない。`api/` と `lib/` は + 編集せず import だけ。受け入れは `dist/m/` 以外の出力のファイル単位の同一(`scripts/pc-dist-hash.sh`)。配色は PC と同じ値を + スマホ側の CSS に持ち、テーマの保存値だけ共有する +2. **機能範囲**: 載せるのは ライブラリ / フォルダ / プレイリスト(普通とスマートの作成・編集・削除)/ 購読(手動同期と進捗)、 + 曲のシート、1 曲のタグの値の修正、試し聴き。**載せない**: 一括編集、リネーム、巻き戻し、CD、Inbox の承認、端末同期、 + ジョブ、記録、設定、購読の追加・変更・削除、フィールドの追加・削除、バックグラウンド再生・ロック画面・キュー、 + PWA のオフライン動作 +3. **UA で `/` を振り分ける**: `Mobi` / `Android` / `iPhone` を含む UA の `/` を 302 で `/m/` へ(iPad は Mac の UA なので PC)。 + 「PC 版を開く」は Cookie `spindle_view=pc` を立てて `/` を開く。`/m/` を開くと Cookie を消す(PC から戻る導線は PC を + 触らないと作れないため、`/m/` を直接開く) +4. **試し聴きは軽い形式**: 原本は流さず `?transcode=opus`。Opus が鳴らない端末向けに `GET /api/stream/:id?transcode=aac` + を足した(現在値の Derived の m4a は Range で直送、無ければ ffmpeg の ADTS を chunked で `audio/aac`、Range なし・ + `?start=`)。非可逆の原本は両方とも変換せず返す。iOS の `canPlayType` は当てにならないので、Opus が再生前にエラーに + なったら 1 回だけ AAC で引き直す。RG は掛けない +5. **1 曲のタグの修正も履歴に載る**: PC と同じ `batch/preview` → `apply`(`ids: [id]`)を通すので `edit_batches` に載り、 + PC の「履歴」から巻き戻せる(不変条件 4)。空にする・フィールドの追加は無い(誤字直しが目的)。標準のタグの空の行を + 埋めるのは「値の修正」として許す +6. **購読は手動同期のまま、「完了」はダウンロードの終了**: `playlist_sync` の投入完了では早すぎ、ユーザが知りたいのは成功したか + なので、投入した ytdl がすべて終わった時点を完了とする。進み具合は読み取り専用の + `GET /api/ytmusic/subscriptions/:id/progress`(直近の `playlist_sync` と、その購読の ytdl)。投入が走行中の古い ytdl に + dedup されて行が増えない同期があるため、この購読の queued / running の ytdl は作成時刻によらず含める +7. **DSL の引用符のエスケープ**: スマホのルール組み立ては値を `"` で囲み、値の `"` と `\` をエスケープして書く。サーバの文法 + (`dsl.pest` の `quoted`、`unescape`)は既にこれを受けるので、「値に `"` を含められない」として弾く必要が無い。[DSL](../user-guide/dsl.md) を直した + +**理由**: PC のビルドに入口を足すと Rollup が共有モジュールを共通チャンクへ切り出し、PC の JS の中身が変わる。別ビルドなら +PC の成果物は変わらないことをハッシュで示せる。API を足すだけなら既存の応答は変わらず、PC のページに影響しない。UA の振り分けは +サーバで 302 にでき、PC 側に何も足さずに済む。試し聴きを Opus/AAC にするのは、外出先の帯域で原本を流さないため([D-52](D-052.md))。 +手動同期にするのは、同期の後に Inbox の確認がいり、時間のあるときに人が押すのが前提だから。 + +**却下**: PC 版のレスポンシブ化(PC 版に手が入る)。購読の自動同期(確認が追いつかない)。スマホでの Inbox の承認(細かい確認は +PC で)。原本のまま試し聴き(帯域)。PC の vite 設定への入口の追加(共通チャンクで PC の成果物が変わる)。 + +**追記**(2026-10-20、設計書の吸収): `/` → `/m/` の 302 には `Cache-Control: no-store` と `Vary: User-Agent, Cookie` を +付ける(応答が UA と Cookie で変わるので、Cloudflare 等の間のキャッシュが PC のブラウザへ 302 を返さないように)。 +PC 向けの 200 には付けない(PC の応答を変えない)。PC 版の指定の Cookie はスマホ版のクライアントが立て・消す(サーバは +読むだけ)。プレイリストの削除に確認を挟むのは、スマホは誤タップしやすいため。タグの修正でフィールドの追加・削除を +しないのは、目的が誤字直しだから。ReplayGain を掛けないのは試し聴きだから。購読の「同期」で 409 `duplicate` が +返っても失敗にせず同期中として見せる(画面を閉じて戻った後の二度押しは普通に起きる)。ホーム画面に追加して開くだけの manifest は置いてよいとしたが、まだ置いていない。 diff --git a/docs/decisions/README.md b/docs/decisions/README.md new file mode 100644 index 00000000..8a347289 --- /dev/null +++ b/docs/decisions/README.md @@ -0,0 +1,136 @@ +# 設計判断の記録 + +実装中に「なぜこうなっているのか」を調べ直さなくて済むように、 +判断とその理由、および却下した案を残す。 +**ここに記録された判断を実装時に覆さないこと。** 覆すべき理由が見つかった場合は +実装を止めて確認する。 + +1 判断 1 ファイル(`D-NNN.md`、番号はゼロ詰め 3 桁)。コードやほかの文書の「D-62」は +[D-062.md](D-062.md) を指す。仕様の本文は [仕様](../spec/README.md)。 + +## 足し方 + +- 番号は最後の番号の次を使う。欠番を埋めない。既存の判断を変えるときは、その判断のファイルに + 「追記」(日付と理由)として書き足し、元の本文は消さない +- 見出しは `# D-<番号> <判断を一文で>`。本文は **決定** / **理由** / **却下** を基本にする +- 下の一覧表に 1 行足す + +## 一覧 + +| 番号 | 判断 | +|---|---| +| [D-1](D-001.md) | ライブラリ構造は役割別(フォーマット別ではない) | +| [D-2](D-002.md) | ファイルが正、DB はキャッシュ | +| [D-3](D-003.md) | 同一性は inode と audio_md5 で解決する | +| [D-4](D-004.md) | 音声とタグを別に版管理する | +| [D-5](D-005.md) | タグ書き込みは tmp + rename | +| [D-6](D-006.md) | Category と GENRE タグは別フィールド | +| [D-7](D-007.md) | アルバムフォルダ名に年を入れない | +| [D-8](D-008.md) | Derived は可逆のみ、配布ビューで解決 | +| [D-9](D-009.md) | Opus 128kbps VBR | +| [D-10](D-010.md) | WAV は FLAC へ正規化する | +| [D-11](D-011.md) | FLAC の圧縮レベル統一はしない | +| [D-12](D-012.md) | CD は全ディスクを 1 本の PCM として吸う | +| [D-13](D-013.md) | CTDB を主、AccurateRip を補助 | +| [D-14](D-014.md) | 検証状態と可逆性は別軸 | +| [D-15](D-015.md) | Subsonic API 互換は実装しない | +| [D-16](D-016.md) | スマートプレイリストは DSL → AST → SQL | +| [D-17](D-017.md) | 認証は LAN 限定でも入れる | +| [D-18](D-018.md) | SMB は insensitive + formD | +| [D-19](D-019.md) | 移行は新規データセット作成 + rsync | +| [D-20](D-020.md) | インポートは Inbox + 承認キュー | +| [D-21](D-021.md) | メタデータは MusicBrainz + 手入力の一級市民化 | +| [D-22](D-022.md) | マルチチャンネルは切り捨て | +| [D-23](D-023.md) | ジョブの dedup は未完了の間だけ効かせる | +| [D-24](D-024.md) | 一括編集は DB 先行更新 + pending 記録、スキャナは pending を巻き戻さない | +| [D-25](D-025.md) | 配布ビューは版が一致する Derived だけを返す | +| [D-26](D-026.md) | hardlink は移行ブロッカー、Library 内では作らない | +| [D-27](D-027.md) | trusted_cidrs は route allowlist だけ認証をスキップする | +| [D-28](D-028.md) | 初期パスワードは環境変数で与える | +| [D-29](D-029.md) | 同一音声の重複は自動マージしない | +| [D-30](D-030.md) | 同一性の各段で候補を検証する | +| [D-31](D-031.md) | パスは canonical key で比較し、dirfd 基準で開く | +| [D-32](D-032.md) | album の同一性は構成トラックと MBID / DiscID で引く | +| [D-33](D-033.md) | 一括編集の対象はプレビュー時のスナップショットに固定する | +| [D-34](D-034.md) | 起動時に設定を厳格に検証し、不正なら起動しない | +| [D-35](D-035.md) | 認証まわりの固定値と境界 | +| [D-36](D-036.md) | ジョブ基盤の固定値と境界 | +| [D-37](D-037.md) | パス安全層・同一性解決・フィンガープリントの固定値と境界 | +| [D-38](D-038.md) | スキャナの固定値と境界 | +| [D-39](D-039.md) | トラック一覧 API の固定値と境界 | +| [D-40](D-040.md) | 表 UI の固定値と境界 | +| [D-41](D-041.md) | 編集履歴の記録機構の固定値と境界 | +| [D-42](D-042.md) | タグ一括編集の固定値と境界 | +| [D-43](D-043.md) | パス生成と一括リネームの固定値と境界 | +| [D-44](D-044.md) | 巻き戻しの固定値と境界 | +| [D-45](D-045.md) | 移行の振り分けと Library のロスレス形式(実機合わせ) | +| [D-46](D-046.md) | ロスレス正規化の固定値と境界 | +| [D-47](D-047.md) | ReplayGain 解析の起動と固定値(実装合わせ) | +| [D-48](D-048.md) | ReplayGain のタグ書き込みは通常の編集バッチに乗せる(実装合わせ) | +| [D-49](D-049.md) | アートワークの解決とキャッシュ(読み側。実装合わせ) | +| [D-50](D-050.md) | 同一性解決の audio_md5 は要求される分だけ並列で計算する(実装合わせ) | +| [D-51](D-051.md) | Derived の生成と追随はトラック単位の `transcode` ジョブが現在値に揃える | +| [D-52](D-052.md) | 再生は原本か Derived を Range で直送し、オンザフライ変換は Derived が無いときだけ | +| [D-53](D-053.md) | 手動プレイリストと m3u8 の書き出し・取り込みの固定値と境界 | +| [D-54](D-054.md) | スマートプレイリストは評価結果を materialize し、常駐タスクが再評価・再書き出しする | +| [D-55](D-055.md) | foobar クエリ変換は変換できない項を落として notes に出し、プロファイルは固定 | +| [D-56](D-056.md) | GC は 5 区分を 1 本のジョブで回収し、dry-run は同じ計画関数を API が同期で返す | +| [D-57](D-057.md) | FLAC 健全性チェックは検査結果を版付きで記録し、MD5 の補填は別タスクにする | +| [D-58](D-058.md) | UI は foobar2000 の配置に寄せ、ツリーの表示形式はパターンで定義し、絞り込みは album_ids で行う | +| [D-59](D-059.md) | MD5 の補填は md5 op の編集バッチにし、tagwrite ジョブで反映する | +| [D-60](D-060.md) | アートワークの書き側は埋め込み統一。差し替えは `PICTURE` の tags op で、cover ファイルは書かない | +| [D-61](D-061.md) | トラックは自身の埋め込み画像を持ち、Derived と再生表示はそれを優先する | +| [D-62](D-062.md) | dev 番号は識別子の一部として永続化しない(dev の付け替えは「変更なし」) | +| [D-63](D-063.md) | 遡及照合はオフセットを探し、verify.log は Library に置かない | +| [D-64](D-064.md) | MusicBrainz の候補は「リリース × medium」、TOC の入力源は差し替え可能にする | +| [D-65](D-065.md) | 手入力は「候補を土台に編集する 1 つのフォーム」、貼り付けの行解析は決め打ち | +| [D-66](D-066.md) | CTDB の修復は CUETools の定義どおりに、CRC が合うときだけ適用する | +| [D-67](D-067.md) | CD の配置は rip ジョブが直接登録し、同梱ファイルは album 全体の移動に追随する | +| [D-68](D-068.md) | Inbox は 1 ディレクトリ = 1 件の承認キューにし、配置は CD と同じ経路で登録する | +| [D-69](D-069.md) | 動画タイトルの解釈は外部のメタデータプラグインに任せ、spindle は汎用に保つ | +| [D-70](D-070.md) | YouTube のダウンロードは Inbox に置くだけにし、判定の確認と既存アルバムへの追記は Inbox が担う | +| [D-71](D-071.md) | 偽ハイレゾ検出は表示と絞り込みだけに使い、判定はカットオフの「崖」と実効ビットで下す | +| [D-72](D-072.md) | 外部メタデータは MusicBrainz を「盤の識別」にだけ使い、値は公式表記を手で入れる。Discogs / VGMdb / .fpl は作らない | +| [D-73](D-073.md) | CPU 系ジョブは種別ごとの上限に加えて共通の並列予算(= CPU コア数)を取る | +| [D-74](D-074.md) | album gain は album ごとの属性。既定 off、CD 取り込みは on、Inbox の承認画面で選ぶ | +| [D-75](D-075.md) | Apple 向けに `aac` 系統の Derived を足す。RG は焼き込み + iTunNORM 0 dB、多値は結合、プレイリストは出さない | +| [D-76](D-076.md) | 終端を書けなかった `running` は再試行で救い、残ればワーカーが稼働中に回収する | +| [D-77](D-077.md) | MP4(ALAC / AAC)の任意キーは `----:com.apple.iTunes:<KEY>` のフリーフォーム atom。読み書きの写像は 1 か所 | +| [D-78](D-078.md) | 再生リストの購読は album を `album_id` で束ね、同期は「列挙 → 揃え → 投入」を毎回 DB から計算し直す | +| [D-79](D-079.md) | イメージは CI が 1 回ビルドして起動確認に通ったものをそのまま GHCR へ push する。版は `/health` が答え、依存の更新は Renovate | +| [D-80](D-080.md) | 一覧のキーボードは「カーソル行」を選択と別に持ち、Shift + 移動は範囲そのものに置き換える | +| [D-81](D-081.md) | ジョブ一覧の衛生: 変化が無ければ投入しない、やることが無いのは失敗でない、確認済みは消せる、古い行は GC が消す | +| [D-82](D-082.md) | 候補のジャケットは Cover Art Archive から引き、サーバが中継する | +| [D-83](D-083.md) | 読み取りオフセットは照合から見つけて学習する。同梱のオフセット表は持たない | +| [D-84](D-084.md) | 候補の無いまま取り込んだ CD は、承認画面のボタンで MusicBrainz を引き直す | +| [D-85](D-085.md) | CD 画面は番号付きの段で流れを見せ、ライブラリにあるかを DiscID で示す。Inbox の表は全項目を出す | +| [D-86](D-086.md) | Inbox の承認画面でファイルのタグと埋め込み画像も直せるようにする | +| [D-87](D-087.md) | YouTube 画面と一括編集・操作タブにも番号付きの段の導線を入れる | +| [D-88](D-088.md) | マイグレーションを最初のリリースの前に 1 回だけ `0001_init.sql` へ畳む | +| [D-89](D-089.md) | ALAC のプロセス内デコードの範囲を本番の ffmpeg に揃える(MP4 の edit list と末尾の長さ 0 のサンプル) | +| [D-90](D-090.md) | 却下した Inbox の件は「削除」で破棄待ちにし、GC が retention 日後に消す | +| [D-91](D-091.md) | CD の取り込みの表の画像は Cover Art Archive から一度だけ取り、承認画面の初期値に入れる | +| [D-92](D-092.md) | Library 直下のディレクトリ名はスキャナが自動で category の語彙にする | +| [D-93](D-093.md) | 表の画像だけを同じリリースグループの別の版から取れるようにする | +| [D-94](D-094.md) | CD の取り込みで品番(と JAN)を手で入れ、版の識別と照会に使う | +| [D-95](D-095.md) | 端末への配信を管轄に入れる。Android は ADB 直結、iPhone は Mac のミュージック.app を中継し、端末ごとのマニフェストを毎回計算する | +| [D-96](D-096.md) | 検証バッジは一致したかだけを出し、手法ごとの結果はプロパティと照合ジョブの結果に出す | +| [D-97](D-097.md) | ReplayGain は解析の後に自動でタグへ書き、Derived はその書き込みの後に作る | +| [D-98](D-098.md) | Android の同期をジョブと常駐監視で組み込み、adb サーバは host ネットワークのサイドカーが Unix ソケットで待ち受ける | +| [D-99](D-099.md) | エージェント API はセッションと別の Bearer 経路にし、報告は全項目を検証してから 1 トランザクションで反映する | +| [D-100](D-100.md) | Mac エージェントのエンジンは同期処理で書き、ミュージック.app・サーバ・トークンの保管を trait で差し替えて試す | +| [D-101](D-101.md) | Mac エージェントのミュージック.app は JXA で操作し、実機の spike で固定する。トークンは Keychain、バイナリは署名せず Release に置く | +| [D-102](D-102.md) | 印のついたプレイリストの削除は印の変更と同じ規則にし、終端した計画は GC が回収する。DB 全損後の引き継ぎと id の再利用は後回しにする | +| [D-103](D-103.md) | DB 全損後の保存先の引き継ぎは manifest の rel_path で id を付け替え、中身は manifest の sha256 を信じる | +| [D-104](D-104.md) | Mac の `pair --adopt` はサーバの行の uuid を差し替え、adopt API が付け替えた状態をキャッシュへ直接書く([D-103](D-103.md) の 7 を改める) | +| [D-105](D-105.md) | id の再利用は、GC が `tracks` の最大 id の行を消さないことで防ぐ | +| [D-106](D-106.md) | ログはファイルのまま残し、画面は API で読む。GC・スキャン・バックアップの結果はジョブの note に残す | +| [D-107](D-107.md) | 選曲の見積もりは、送る元のハッシュが無い曲を長さ × ビットレート(原本を送るなら原本の size)で推定する | +| [D-108](D-108.md) | sync の進捗は Mac の端末とサーバの端末タブに出す。サーバはメモリだけに持ち、送信の失敗では同期を止めない | +| [D-109](D-109.md) | Android の同期の進捗は job イベントの detail で流し、Mac と同じ表示を使う | +| [D-110](D-110.md) | MP3 の写像できないキーは ID3v2 の TXXX に書き、ブロックは split / merge で書き換える | +| [D-111](D-111.md) | Derived のジョブは、行った処理(エンコード / タグ更新 / 移動)を結果列に出す | +| [D-112](D-112.md) | Derived のバッジは系統ごとに O(opus)と A(aac)を並べ、凡例の高さは窓の下端までにする | +| [D-113](D-113.md) | Inbox の承認で種類(TRACK_TYPE)と除外(PLAYLIST_EXCLUDE)を選び、タグのキーの入力欄に前方一致の候補を出す | +| [D-114](D-114.md) | Inbox の YouTube 取り込みを再判定し、判定したプラグインの版を記録する | +| [D-115](D-115.md) | スマホ版は別の入口(`/m/`)・別のビルドで作り、PC 版に手を入れない | diff --git a/docs/development/README.md b/docs/development/README.md new file mode 100644 index 00000000..147ee291 --- /dev/null +++ b/docs/development/README.md @@ -0,0 +1,18 @@ +# 開発の作業記録 + +開発中の作業用の文書を置く場所。利用者向けの文書ではない。 + +## 新しい機能を作るとき + +1. 設計書(`YYYY-MM-DD-<topic>-design.md`)と実装計画(`YYYY-MM-DD-<topic>.md`)は、作業中だけこのディレクトリに置く +2. 実装が済んだら、要点を [仕様](../spec/README.md) の該当する章へ、判断とその理由を + [設計判断の記録](../decisions/README.md) へ移す +3. 移し終えたら設計書と計画をこのディレクトリから消し、同じ PR に含める + +設計書や計画をリポジトリに残し続けると、仕様と食い違ったまま古くなる。正は仕様と設計判断の記録だけにする。 + +## 置いてあるもの + +| ファイル | 内容 | +|---|---| +| [tasks-history.md](tasks-history.md) | P0〜P5 のタスク表と受け入れ条件の履歴。新しいタスクは書かない | diff --git a/docs/TASKS.md b/docs/development/tasks-history.md similarity index 90% rename from docs/TASKS.md rename to docs/development/tasks-history.md index 92f15077..063c5786 100644 --- a/docs/TASKS.md +++ b/docs/development/tasks-history.md @@ -1,4 +1,8 @@ -# 実装タスク +# 実装タスクの履歴 + +> これは P0〜P5 の実装を進めたときのタスク表と受け入れ条件の**履歴**で、新しいタスクはここに書かない。 +> 今後の進め方は [development/README.md](README.md)。本文の「SPEC §n」「D-n」は +> [仕様](../spec/README.md) と [設計判断](../decisions/README.md) を指す。 各タスクは独立して着手でき、受け入れ条件を満たせば完了とする。 上から順に実施する。依存が明記されているものはそれを先に終わらせる。 @@ -70,7 +74,7 @@ Host だけが一致する(Origin が別)POST が 403 になることをテ - [x] 協調キャンセル(`cancel_requested_at`)、外部プロセスの子グループ kill と tmp 掃除 - [x] 進捗の SSE 配信と DB 永続化 - [x] `GET /api/jobs`(`summary` 込み)、`POST /api/jobs/:id/cancel` / `retry`。 - ジョブ画面(SPEC §12.5)は P0-8 の骨格に載せる + ジョブ画面([SPEC §12.5](../spec/12-ui/05-jobs.md))は P0-8 の骨格に載せる - [x] `track_locks` による同一トラックの直列化。複数ロックは `track_id` 昇順取得、 取れなければ全解放して再キュー - [x] 版付きジョブの stale 判定(payload の版 < 現在値なら no-op で `done`) @@ -95,7 +99,7 @@ Host だけが一致する(Origin が別)POST が 403 になることをテ - [x] FLAC の STREAMINFO から PCM MD5 を読む(デコード不要) - [x] ALAC / WAV は `symphonia` でデコードして算出 - [x] 非可逆の `audio_fp`(エンコード済みパケット列の SHA-256。Opus を含め symphonia で demux、 - デコードも外部プロセスも使わない。D-37) + デコードも外部プロセスも使わない。[D-37](../decisions/D-037.md)) - [x] `tag_hash`(正規化タグ集合の SHA-256)の算出 受け入れ: 単体テストで以下が通ること。 @@ -112,23 +116,23 @@ Host だけが一致する(Origin が別)POST が 403 になることをテ (k) 訪問順を変えた inventory(コピー先を先に stat)でも判定結果が同じ (l) 非可逆ファイルのタグだけを書き換えて size が変わっても `audio_fp` が同じ (m) 対象 TrueNAS 上で corpus(大小文字・NFC/NFD・ß・トルコ語 I)を作り、`O_EXCL` の結果と - key の同値判定の差を記録する(差があれば D-31 の限界として文書化。`#[ignore]`、 - `tests/zfs_corpus.rs`。**移行前(P0-14)に対象 NAS で一度実行して結果を D-37 に追記する**) + key の同値判定の差を記録する(差があれば [D-31](../decisions/D-031.md) の限界として文書化。`#[ignore]`、 + `tests/zfs_corpus.rs`。**移行前(P0-14)に対象 NAS で一度実行して結果を [D-37](../decisions/D-037.md) に追記する**) 依存: P0-2 ### P0-6 スキャナ -- [x] **4 相スキャン**(SPEC §7.1): inventory 固定 → 候補生成 → 決定的 claim → +- [x] **4 相スキャン**([SPEC §7.1](../spec/07-pipeline/01-scan.md)): inventory 固定 → 候補生成 → 決定的 claim → 単一トランザクション commit。`scan_runs` 行と `tracks.seen_run_id` で claim を管理 - [x] `Library/` の再帰走査(symlink は辿らず一覧に出す。SMB 禁止名は対象外として一覧に出す)。 `(dev, inode, size, mtime_ns, ctime_ns)` 変化なしなら `seen_at` / `seen_run_id` 更新のみ - [x] タグ読み取り(`lofty`)、`track_tags` への格納、キャッシュ列(`album` 含む)の更新。 - **版は `tag_hash` / 音声フィンガープリントの実差分があるときだけ進める**(SPEC §6 遷移表) + **版は `tag_hash` / 音声フィンガープリントの実差分があるときだけ進める**([SPEC §6](../spec/06-data-model.md) 遷移表) - [x] path / key の更新は予約済み一時 key 経由の 2 段階(swap / 循環) - [x] `missing_since` は run が `completed` の finalize でだけ立てる。再発見した行は復活 - [x] アルバム(= ディレクトリ)の再構成。**ディレクトリ rename で album id を維持** - (MBID / DiscID が 1 件一致 → 構成トラック過半数の順。候補複数なら寄せない。SPEC §7.1)、 + (MBID / DiscID が 1 件一致 → 構成トラック過半数の順。候補複数なら寄せない。[SPEC §7.1](../spec/07-pipeline/01-scan.md))、 構成 0 の album は `missing_since`。`categories` の自動推定 - [x] deep scan(全件の `tag_hash` と可逆の `audio_md5` を再計算)。`[scan].deep_interval_days` と手動 - [x] 取り残された `.spindle-tmp-*` の回収 @@ -147,24 +151,24 @@ mtime を保存して上書き(`touch -r`)したファイルの変更を検 missing だった行を同じパスに戻すと復活する。 性能の測定条件を固定する: 合成ツリー 1 万件(FLAC 8 割 / Opus 2 割、アルバム 12 曲)、 2 回目は warm cache、対象 NAS または CI の参照マシンを明記 -(`tests/scanner.rs` の `perf_10k_synthetic_tree`(`#[ignore]`、`--release`)。結果は D-38)。 +(`tests/scanner.rs` の `perf_10k_synthetic_tree`(`#[ignore]`、`--release`)。結果は [D-38](../decisions/D-038.md))。 依存: P0-4, P0-5 ### P0-7 トラック一覧 API - [x] `GET /api/tracks` カーソルページング(キーセット)、ソート(ホワイトリスト)、フィルタ - (JSON、ホワイトリスト。D-39)。SPEC §9 のレスポンス形 + (JSON、ホワイトリスト。[D-39](../decisions/D-039.md))。[SPEC §9](../spec/09-http-api.md) のレスポンス形 (`pending_batch_id` / `conflict_batch_id` / `duplicate_group` / `hardlink` / `derived` を 1 クエリで返す。pending / 最新 op は LEFT JOIN、重複は相関 EXISTS) - [x] `selection` の 2 形(`ids` / `filter + exclude_ids`)を解決する共通関数 (`db::tracks::resolve_selection`) - [x] preview で selection をスナップショットし `selection_token`(TTL 15 分)を返す。 - apply は token の集合だけを対象にする(D-33)。`api::selection::snapshot` / `lookup`、 + apply は token の集合だけを対象にする([D-33](../decisions/D-033.md))。`api::selection::snapshot` / `lookup`、 `SelectionStore`(プロセス内、上限 16 件)。エンドポイント本体は P0-10 で載せる - [x] バッジ用の JOIN(最新 op、重複)を `EXPLAIN QUERY PLAN` で確認し、 temp B-tree が出ないことを固定(`tests/tracks_query.rs`。無フィルタの全ソートキー × 昇降 × - 1 / 2 ページ目と `total` の COUNT)。フィルタ付きソートの temp B-tree は許容(D-39) + 1 / 2 ページ目と `total` の COUNT)。フィルタ付きソートの temp B-tree は許容([D-39](../decisions/D-039.md)) - [x] `GET /api/search` FTS5 trigram、3 文字未満は LIKE フォールバック(一覧と同じレスポンス形) - [x] `GET /api/albums` / `:id` - [x] `GET /api/events`(SSE: job / batch / library)。scan 完了時に `library`(200 件以下は @@ -176,7 +180,7 @@ missing だった行を同じパスに戻すと復活する。 preview 後にスキャンで行が増えても apply の対象が増えない。 計測: `tests/tracks_perf.rs`(`#[ignore]`、`cargo test --release --test tracks_perf -- --ignored --nocapture`)。 2026-09-16、TrueNAS ホスト(AMD Ryzen 5 7600、DB は tmpfs)で最悪 53ms(FTS で全行に当たる語)、 -既定ソート 0.75ms。詳細は D-39 +既定ソート 0.75ms。詳細は [D-39](../decisions/D-039.md) 依存: P0-3, P0-6 @@ -185,7 +189,7 @@ preview 後にスキャンで行が増えても apply の対象が増えない - [x] 3 ペイン骨格: 上部ナビ / 左サイドバー(ツリー・プレイリスト・固定フィルタ)/ 表 / 右パネル(折りたたみ・幅永続化)/ 下部バー(ジョブ要約。再生は P1-9)。ログイン画面と 401 → ログインへの復帰、他画面は骨格のプレースホルダ -- [x] TanStack Table + Virtual による仮想スクロール(行はカーソル順に積み、表には窓だけ渡す。D-40) +- [x] TanStack Table + Virtual による仮想スクロール(行はカーソル順に積み、表には窓だけ渡す。[D-40](../decisions/D-040.md)) - [x] 列の表示切替・並べ替え(ヘッダのドラッグ)・幅の永続化(localStorage) - [x] 範囲選択(Shift / Ctrl)、Ctrl+A はフィルタ形 selection。**選択は immutable**(選択時の フィルタを保持。表示フィルタを変えても集合は変わらない)。選択件数・反映待ち件数・表示件数 @@ -200,7 +204,7 @@ Performance で 5 秒間のフレーム落ちを計測)。選択状態がソ ツリー・プレイリスト・フィルタのどれを選んでも同じ表コンポーネントが集合を差し替えるだけ。 計測: 2026-09-16、TrueNAS ホスト(Ryzen 5 7600)の headless Chromium で 6 万件、5 秒の連続スクロールで 60.2 fps・20ms 超のフレーム 0。ソート変更・Ctrl+A・表示フィルタ変更後の選択維持と、スキャンによる -SSE 再取得を同じセッションで確認。詳細と Chrome 安定版での再計測の扱いは D-40。 +SSE 再取得を同じセッションで確認。詳細と Chrome 安定版での再計測の扱いは [D-40](../decisions/D-040.md)。 純粋ロジックは `cd web && npx vitest run`(20 件) 依存: P0-7 @@ -223,9 +227,9 @@ SSE 再取得を同じセッションで確認。詳細と Chrome 安定版で 終端状態は子ジョブ・op の結果から集計(`db::history::aggregate_batch`、SSE `batch`) - [x] 事前条件の再確認と `skipped_conflict` 判定(op 単位。1 フィールドでも不一致なら op 全体)。 外部 rename(rel_path のみ不一致)は tags op なら追随(rename が進める ctime はその場合だけ - 許容。D-41)、宛先 key の占有はスキャナが解決済み。rename op は P0-11 + 許容。[D-41](../decisions/D-041.md))、宛先 key の占有はスキャナが解決済み。rename op は P0-11 - [x] **overlay の解消**: applied 以外の終端になる op は同じトランザクションで DB をファイルの - 現在値へ戻す(読めなければ記録値。D-41) + 現在値へ戻す(読めなければ記録値。[D-41](../decisions/D-041.md)) - [x] 起動時リカバリ: `prepared` / `applying` のバッチの track ジョブ再投入、rename 済み op の 「ファイルの全フィールドが新値と一致すれば applied」確定(`Editor::recover` + `apply_op`) - [x] バッチ単位のキャンセル(子ジョブへ `cancel_requested_at`、未着手 op は failed) @@ -242,7 +246,7 @@ cancel / conflict になった op のトラックが、その直後にファイ ### P0-10 タグ一括編集 - [x] 操作: 固定値代入 / フィールド参照(`%albumartist%`)/ 正規表現置換 / - トラック番号連番 / タグ削除(`domain::tagops`。JSON 形は SPEC §9、D-42) + トラック番号連番 / タグ削除(`domain::tagops`。JSON 形は [SPEC §9](../spec/09-http-api.md)、[D-42](../decisions/D-042.md)) - [x] 右パネル「一括編集」タブ: 操作リストの組み立て、プレビュー結果を表のセルに差分表示 (旧→新、変更なしは薄く)、操作リストは選択を変えても残る (`web/src/components/BatchEditPanel.tsx`、`hooks/useBatchEdit.ts`、`lib/tagops.ts` / `lib/preview.ts`) @@ -255,7 +259,7 @@ cancel / conflict になった op のトラックが、その直後にファイ - [x] ファイル書き込みは tmp + fsync + rename。**成功時に `(dev, inode, mtime_ns)` を更新**(P0-9) - [x] 書き込み前に事前条件を再確認し、外部変更があればスキップして報告(P0-9) - [x] `tag_version` の差分で Derived タグ上書きジョブを投入(`derived_files` が無ければ no-op。 - `transcode` の `kind = "retag"`。ハンドラは P1-10。D-42) + `transcode` の `kind = "retag"`。ハンドラは P1-10。[D-42](../decisions/D-042.md)) 受け入れ: 1000 件の一括編集後、再スキャンで重複が発生しない。 外部で書き換えたファイルが上書きされない。 @@ -267,9 +271,9 @@ tagwrite の途中で kill → 再起動で残りが反映され、二重に版 ### P0-11 パス生成とリネーム - [x] テンプレート展開(`{category}/{albumartist}/{album}/...`)(`domain::pathgen::Template`。 - `[layout]` は設定の読み込み時に検証。single / multi / unsorted の選択は D-43) + `[layout]` は設定の読み込み時に検証。single / multi / unsorted の選択は [D-43](../decisions/D-043.md)) - [x] ファイル名の置換テーブル、SMB 制約、255 バイト切り詰め(`pathgen::sanitize_component`、 - パス全体 240 UTF-16 単位。D-43) + パス全体 240 UTF-16 単位。[D-43](../decisions/D-043.md)) - [x] 衝突時の降格(`{album}` → `{album} ({year})` → `{edition}`)(`pathgen::plan`。降格しても 解決しなければ conflict。既にそのディレクトリにいるリリースは降格しない) - [x] 同一リリース判定(MB Release ID / DiscID)。**同名 ≠ 同一リリース**(`mb:` → `disc:` → @@ -277,15 +281,15 @@ tagwrite の途中で kill → 再起動で残りが反映され、二重に版 - [x] 一括リネームは coordinator の **2 phase**: phase 1 で全 op の source を一時名へ退避、 phase 2 で `ordinal` 順に最終名へ(`RENAME_NOREPLACE`)。`rename` ジョブは**バッチ 1 つに 1 つ**・並列 1。衝突判定は `rel_path_key`、DB の overlay は prepare 時の 2 段階更新、 - 一時名は `spindle-rename-<op_id>.<ext>`(`edit::rename`、`jobs::handlers::rename`。D-43) + 一時名は `spindle-rename-<op_id>.<ext>`(`edit::rename`、`jobs::handlers::rename`。[D-43](../decisions/D-043.md)) - [x] phase 境界でのクラッシュ復旧: 再投入されたジョブが op ごとの所在(最終名 / 一時名 / source)を inode で判定して続きを行う(`Editor::recover` がバッチジョブを再投入) - [x] `edit_ops(kind='rename')` への記録(`expected_rel_path` 含む)と、ファイル rename 後の `(dev, inode)` 追随。外部 rename と衝突した op は `skipped_conflict`(スキャナは一時名 / 最終名を「作業中」として扱う) - [x] album の追随(album 全体の移動は id を維持して `rel_dir` を書き換え、部分移動は新規 album。 - overlay と overlay 解消の両方。D-43) -- [x] `POST /api/rename/preview` / `POST /api/rename/apply`(SPEC §9。UI は P1-12 の操作タブ) + overlay と overlay 解消の両方。[D-43](../decisions/D-043.md)) +- [x] `POST /api/rename/preview` / `POST /api/rename/apply`([SPEC §9](../spec/09-http-api.md)。UI は P1-12 の操作タブ) 受け入れ: 単体テストで置換テーブル・切り詰め・衝突降格を網羅。 既存の ytmusic 出力と同じパスが再現できる。 @@ -294,7 +298,7 @@ A↔B の swap と 3 件の循環リネームが完了し、phase 1 直後に ki (`tests/pathgen.rs` / `tests/rename.rs` / `tests/rename_api.rs`) ~~未決: album 全体を動かした後の旧ディレクトリに残る同梱ファイルの追随~~(P2-8 で決めた。 -`edit::rename::follow_companions`、D-67) +`edit::rename::follow_companions`、[D-67](../decisions/D-067.md)) 依存: P0-6, P0-9 @@ -303,12 +307,12 @@ A↔B の swap と 3 件の循環リネームが完了し、phase 1 直後に ki - [x] `GET /api/history`、バッチ単位の一覧(state / affected / applied / conflict / failed / reverts_batch_id / reverted_by)。`GET /api/history/:id` で op 一覧と edits (`api::history`、`db::history::list_batches` / `get_batch_summary`) -- [x] 履歴画面(SPEC §12.4): 一覧、行を開いて op と conflict の現在値、[巻き戻す] は終端状態のみ、 +- [x] 履歴画面([SPEC §12.4](../spec/12-ui/04-records.md)): 一覧、行を開いて op と conflict の現在値、[巻き戻す] は終端状態のみ、 戻し済みは「#N で戻し済み」、↩ で逆バッチの関係を表示 (`web/src/components/HistoryView.tsx`、`hooks/useHistory.ts`、`lib/history.ts`) - [x] `POST /api/history/:batch/revert` で DB とファイルの両方を戻す。 **tag / rename / delete の 3 種すべて**が対象(`edit::revert`。tags は `prepare_tags_tx`、 - rename は `prepare_rename_tx` に乗り、delete は DB だけなので即終端。D-44) + rename は `prepare_rename_tx` に乗り、delete は DB だけなので即終端。[D-44](../decisions/D-044.md)) - [x] 対象は終端状態のバッチのみ(`prepared` / `applying` は 409。先にキャンセル) - [x] 対象集合 = 元バッチの `applied` op − 既存逆バッチで `applied` 済みの op。空なら 409 `already_reverted` @@ -350,7 +354,7 @@ DB にしか存在しないもの(編集履歴 / プレイリスト / 検証 - [x] `scripts/preflight.py` でブロッカーを解消(hardlink・symlink・衝突・不正 UTF-8・255 バイト超)。 実機 `ssd/musics` は `Opus/` `Original/` とも exit 0(ブロッカーなし、全 NFC) -- [x] 実機の構成を確認し、振り分けを決めて `docs/DECISIONS.md` D-45 に記録 +- [x] 実機の構成を確認し、振り分けを決めて `docs/DECISIONS.md` [D-45](../decisions/D-045.md) に記録 (`Original/` は大半が ALAC ロスレス → Library の master。`AAC/` は移さない。 Archive は `hdd`。Library のロスレスは後で FLAC に統一) - [x] `scripts/migrate_plan.py`: `Opus/` と `Original/` の 1:1 対応から `rsync --files-from` の一覧を @@ -391,7 +395,7 @@ DB にしか存在しないもの(編集履歴 / プレイリスト / 検証 完了条件: **foobar2000 を開かずに日常運用が回る。** 着手順の目安(依存関係と P0-14 の状況から。2026-09-17 時点): -P1-4(ALAC 7,572 本の FLAC 化。D-45 で決めた方針で、Archive 退避の台帳と GC の前提)→ +P1-4(ALAC 7,572 本の FLAC 化。[D-45](../decisions/D-045.md) で決めた方針で、Archive 退避の台帳と GC の前提)→ P1-1 / P1-2(FLAC 化後に一括で RG)→ P1-10(旧 Opus を捨てたので Derived が空。P1-8 の前提)→ P1-9 / P1-3 → P1-6 / P1-7(`Playlists/m3u8` の 28 本を取り込む)→ P1-8 / P1-11 / P1-5。 実データでの検証は P0-14 のリハーサル環境(`ssh truenas`、`/root/spindle-migration/`、 @@ -399,7 +403,7 @@ P1-9 / P1-3 → P1-6 / P1-7(`Playlists/m3u8` の 28 本を取り込む)→ P ### P1-0 初回 deep scan の高速化 -P0-14 の後続課題。リリース時の再移行でも効く(D-50)。 +P0-14 の後続課題。リリース時の再移行でも効く([D-50](../decisions/D-050.md))。 `Scanner` Phase 2 の直列 `audio_md5` を、既存行が md5 で突き合わせを要するときだけ計算するか Phase 3 の並列読みへ回す。Phase 2 中も進捗を出す。`symphonia` / `lofty` のファイルごとの WARN を既定フィルタで落とす(ALAC 7,572 本で 75 分 → 並列度分だけ短縮が目安)。 @@ -421,7 +425,7 @@ deep scan **552 秒**(P0-14 時点の 3,583 秒 → 約 6.5 倍速)、既存 ### P1-1 ReplayGain スキャン -`ebur128`、album は `album_id` 単位、2ch 以外は集計から除外(D-47)。 +`ebur128`、album は `album_id` 単位、2ch 以外は集計から除外([D-47](../decisions/D-047.md))。 - [x] `domain::replaygain`: `LoudnessMeter`(積分ラウドネス + true peak、フレーム端数の持ち越し)、 `album_loudness`(構成トラックの状態をまとめてゲートし直す)、無音は gain 0 @@ -436,12 +440,12 @@ deep scan **552 秒**(P0-14 時点の 3,583 秒 → 約 6.5 倍速)、既存 (FLAC / Opus / WavPack が同じ形で流れる、キャンセル、失敗)、`tests/rg_job.rs`(track / album の 値、6ch の除外、missing の除外、album 無し、失敗で何も書かない、cancel)、`tests/rg_api.rs` -`audio_version` が上がったときの `rg_scanned_at` の扱い(D-47)は P1-13 で解決 +`audio_version` が上がったときの `rg_scanned_at` の扱い([D-47](../decisions/D-047.md))は P1-13 で解決 ### P1-2 RG タグ書き込み -Opus のみ -23 LUFS 基準の Q7.8: `round((G18 - 5.0) * 256)` を符号付き 16bit に飽和。SPEC §6 の -テストベクトルを単体テストに置く。`rg_scanned_at` と `rg_written_at` を分離(D-48)。 +Opus のみ -23 LUFS 基準の Q7.8: `round((G18 - 5.0) * 256)` を符号付き 16bit に飽和。[SPEC §6](../spec/06-data-model.md) の +テストベクトルを単体テストに置く。`rg_scanned_at` と `rg_written_at` を分離([D-48](../decisions/D-048.md))。 - [x] `domain::replaygain`: `opus_r128` / `rg2_gain_db` / `tag_changes`(形式ごとの固定キー集合。 値の無いキーは削除)/ `file_matches` @@ -452,19 +456,19 @@ Opus のみ -23 LUFS 基準の Q7.8: `round((G18 - 5.0) * 256)` を符号付き - [x] `POST /api/rg/write { selection, description?, skip_pending? }`(`api::rg::write`。missing は対象外)、 フィルタ `rg_unwritten`。UI の起動導線は P1-12 の操作タブ -受け入れ: `tests/replaygain.rs`(SPEC §6 のテストベクトル、飽和、書式、形式ごとのキー集合、 +受け入れ: `tests/replaygain.rs`([SPEC §6](../spec/06-data-model.md) のテストベクトル、飽和、書式、形式ごとのキー集合、 `file_matches`)、`tests/rg_write.rs`(FLAC / Opus / MP4 への書き込みと `rg_written_at`、一致済みは バッチ無し、未解析の除外、pending、巻き戻し・手編集・conflict で NULL、再解析後の再書き込み)、 `tests/rg_write_api.rs` -スキャナが外部のタグ変更を取り込んだときの `rg_written_at` の判定(D-48)は P1-13 で解決 +スキャナが外部のタグ変更を取り込んだときの `rg_written_at` の判定([D-48](../decisions/D-048.md))は P1-13 で解決 ### P1-3 アートワーク 読みは埋め込み / 同梱画像の両対応、書きは埋め込み統一で一括差し替え、WebP サムネイル生成と キャッシュ。アルバムグリッド画面 → クリックで表を `album_id` に絞る。 -- [x] 読み側(D-49): `media::artwork`(同梱画像の名前の優先順、ヘッダでの判別、埋め込みの選択、 +- [x] 読み側([D-49](../decisions/D-049.md)): `media::artwork`(同梱画像の名前の優先順、ヘッダでの判別、埋め込みの選択、 `ArtworkStore` = `<data>/thumbs/<hex>/orig.<ext>` + `<size>.webp`) - [x] スキャンの Phase 5(`Scanner::with_artwork`): Phase 4 が新旧 album の再解決を予約 (`artwork_resolved_at = NULL`)し、Phase 5 は予約 + 同梱画像の stat 変化 + 原画像の欠損だけを @@ -474,7 +478,7 @@ Opus のみ -23 LUFS 基準の Q7.8: `round((G18 - 5.0) * 256)` を符号付き - [x] `GET /api/artwork/:hash?size=`(`api::artwork`。未生成なら原画像へ倒す)、 `GET /api/albums` の `artwork_hash` - [x] UI: アルバムグリッド(`AlbumGrid`)→ クリックで一覧を `album_id` に絞る -- [x] 書き側(D-60。埋め込み統一。cover ファイルは書かず抽出も作らない): `POST /api/artwork/upload` +- [x] 書き側([D-60](../decisions/D-060.md)。埋め込み統一。cover ファイルは書かず抽出も作らない): `POST /api/artwork/upload` (ヘッダで判別、32 MiB、`ArtworkStore` + `artwork` 行 + thumbnail)→ `POST /api/artwork/embed` (`Editor::prepare_picture`: `PICTURE` を `[<mime>:<hex>]` にする tags op。全画像を捨てて 1 枚)。 `stage_tags` は書く前に旧画像を store へ退避、`write_tag_changes` に `pictures` を足す。 @@ -498,12 +502,12 @@ AlreadyMatches、album の予約)、`tests/artwork_api.rs`(upload の形式 ### P1-3c トラック単位のアートワーク -D-61。トラックごとに画像が違う album 向け。 +[D-61](../decisions/D-061.md)。トラックごとに画像が違う album 向け。 - [x] マイグレーション 0012 `tracks.artwork_id`。`TrackContent.picture`(Unread / Absent / Found)を スキャナ Phase 3 と tagwrite の読み戻しが埋め、`insert_track` / `update_content` が記録する。 サムネイルが無い画像は Phase 4 が thumbnail ジョブを投入 -- [x] Derived は `COALESCE(tracks.artwork_id, albums.artwork_id)` を埋める(D-51 の改訂) +- [x] Derived は `COALESCE(tracks.artwork_id, albums.artwork_id)` を埋める([D-51](../decisions/D-051.md) の改訂) - [x] GC 区分 E の参照に `tracks.artwork_id` を足す - [x] `TrackRow` / `GET /api/tracks/:id` の `artwork_hash`、左下のアートワークはトラック自身 → album @@ -521,7 +525,7 @@ thumbnail 1,523 本・transcode 再タグ 35 本、errors 0。トラックごと ### P1-4 ロスレス → FLAC 正規化 -WAV / ALAC / AIFF(D-45 / D-46)。変換前後の PCM MD5 照合。不一致なら中止。一致時は元ファイルを +WAV / ALAC / AIFF([D-45](../decisions/D-045.md) / [D-46](../decisions/D-046.md))。変換前後の PCM MD5 照合。不一致なら中止。一致時は元ファイルを `Archive/` へ move し `edit_ops(kind='archive')` と `archived_files` 台帳に記録。**即時削除しない**。 `audio_version` は据え置き。移行で取り込んだ ALAC 7,572 本が主対象。 @@ -557,7 +561,7 @@ Archive の実体が減らない。配置後 / 退避後 / unlink 後のクラ ### P1-5 FLAC 健全性チェック -`flac -t`、MD5 未設定の補填。補填時は `audio_version` 据え置き(D-57)。**補填は P1-5b に切り出し** +`flac -t`、MD5 未設定の補填。補填時は `audio_version` 据え置き([D-57](../decisions/D-057.md))。**補填は P1-5b に切り出し** (実データに FLAC が無く、今後の FLAC は自前の `flac -8 --verify` と CD リップで MD5 が付く)。 - [x] マイグレーション 0010: `tracks.flac_check` / `flac_checked_at` / `flac_check_version` / @@ -583,7 +587,7 @@ P1-5b(MD5 補填)と UI の起動導線(P1-12)は実施済み ### P1-5b FLAC の MD5 補填 `flac_fix_missing_md5`。`md5_missing` のトラックを選んで、デコードした PCM MD5 を STREAMINFO に書く -編集バッチ(D-57 / D-59)。 +編集バッチ([D-57](../decisions/D-057.md) / [D-59](../decisions/D-059.md))。 - [x] `edit_ops.kind` に `md5` を足すマイグレーション 0011。旧値 = 全ゼロを `edits` に残して巻き戻し可、 `audio_version` 据え置き、inode / mtime は追随 @@ -594,7 +598,7 @@ P1-5b(MD5 補填)と UI の起動導線(P1-12)は実施済み ### P1-6 プレイリスト -手動、並べ替え、m3u8 書き出し(D-53)。 +手動、並べ替え、m3u8 書き出し([D-53](../decisions/D-053.md))。 - [x] `db::playlists`: CRUD(名前の一意性は `name_key` = canonical key。マイグレーション 0006)、 項目の追加(同じトラックは 1 回)・除外・移動(`before` の直前 / 末尾)、`position` の振り直し、 @@ -626,12 +630,12 @@ CASCADE、件数と記録、`export_tracks` の missing 除外と delivery)、 stem 一致で ALAC / Opus の Library 行に当たった)。`00_Anime` の android 書き出し 4,741 件も確認 (`Playlists/android/00_Anime.m3u8`、`../../Derived/…`) -自動再書き出しは P1-7(D-54 の `playlist::autoexport`)で実施済み。スマートの表示(`kind='smart'` は +自動再書き出しは P1-7([D-54](../decisions/D-054.md) の `playlist::autoexport`)で実施済み。スマートの表示(`kind='smart'` は ⚙ で出すだけ) ### P1-7 スマートプレイリスト -`docs/DSL.md`。pest → AST → SQL(D-54)。 +`docs/DSL.md`。pest → AST → SQL([D-54](../decisions/D-054.md))。 - [x] `playlist::dsl`: `dsl.pest` の文法(キーワードは大小文字無視、値は引用可、`NOT` > `AND` > `OR`)→ AST(DSL.md の JSON 形で `rule_ast` に保存、原文は `rule_source`)、構文エラーは行・桁付き @@ -639,7 +643,7 @@ stem 一致で ALAC / Opus の Library 行に当たった)。`00_Anime` の an `regexp()` を全コネクションに登録、`missing` を参照しない限り active 限定、型に合わない演算子は 実行前にエラー。`evaluate` で並び付きの id 列 - [x] マイグレーション 0007: `tracks.added_at`(`added` フィールド。スキャナが INSERT 時に設定) -- [x] `filter.dsl`(一覧のプレビュー。WHERE だけ。D-39) +- [x] `filter.dsl`(一覧のプレビュー。WHERE だけ。[D-39](../decisions/D-039.md)) - [x] API: `POST /api/playlists { name, rule }` / `PATCH { rule }`(再評価)、`POST /api/playlists/preview`、 `POST /:id/refresh`、smart への項目操作は 409 - [x] `playlist::autoexport`: library / 終端 batch / 完了 job をデバウンスして全 smart を再評価、 @@ -654,12 +658,12 @@ random、型エラー、値がバインドされる)、`tests/tracks_query.rs` `tests/smart_playlists.rs`(作成で materialize、400 の位置、preview、refresh / ルール差し替え、 項目操作の 409、autoexport のデバウンスと再書き出し・`auto_export = 0`) -foobar Autoplaylist クエリ変換は P1-8 で実施済み、`HAS` の語境界は実機で確認済み(D-55)。 +foobar Autoplaylist クエリ変換は P1-8 で実施済み、`HAS` の語境界は実機で確認済み([D-55](../decisions/D-055.md))。 UI の並び替え(smart は ORDER BY で決まるので表のソート変更は表示だけ) ### P1-8 エクスポートプロファイル -foobar / android / internal、foobar Autoplaylist クエリ生成(D-55)。**依存: P1-10**(`delivery` +foobar / android / internal、foobar Autoplaylist クエリ生成([D-55](../decisions/D-055.md))。**依存: P1-10**(`delivery` プロファイルが Derived を前提)。タグ鮮度が必要な export / 同期は `stale_tags` の件数を明示するか 追随ジョブの完了を待つ。 @@ -679,13 +683,13 @@ random / LIMIT、DSL.md の例)、`tests/smart_playlists.rs`(fb2k_query の (android の `stale_tags`、internal は 0)、`web/src/lib/playlists.test.ts`(通知文) foobar 実機との突き合わせ(2026-09-19): `HAS` は部分一致で spindle と同じ、技術情報フィールドの -`PRESENT` / `MISSING` も効く。二重引用符は挙動を変えず、単一引用符は一致しなくなる(D-55)。 +`PRESENT` / `MISSING` も効く。二重引用符は挙動を変えず、単一引用符は一致しなくなる([D-55](../decisions/D-055.md))。 変換器の変更は不要 ### P1-9 再生 Range 対応、ALAC は既定で Opus 変換、`canPlayType()` によるクライアント能力判定。下部バー左側の -再生 UI: 再生・停止・シーク・音量・RG の off / track / album(D-52)。 +再生 UI: 再生・停止・シーク・音量・RG の off / track / album([D-52](../decisions/D-052.md))。 - [x] `api::stream`: `GET /api/stream/:id`(原本、Range / HEAD / ETag、開いた FD を行と照合して 不一致は 409 `stale`)、`?transcode=opus`(`delivery` が Derived を指せば直送、無ければ ffmpeg で @@ -699,7 +703,7 @@ Range 対応、ALAC は既定で Opus 変換、`canPlayType()` によるクラ Derived 直送 / ffmpeg フォールバックと `start=` / ffmpeg 無し 503)、 `web/src/lib/playback.test.ts` -閉じた未決(2026-09-18。D-52): Safari 向けの AAC 変換(Safari 18.4+ は Ogg Opus をネイティブ +閉じた未決(2026-09-18。[D-52](../decisions/D-052.md)): Safari 向けの AAC 変換(Safari 18.4+ は Ogg Opus をネイティブ 再生する。実機の Safari で確認済み)。ハイレゾのサンプルレート変換。可逆は既定で Derived の Opus(48 kHz)を再生するので、96 kHz(64 本)がそのまま流れるのは「原本」を選んだときだけ。原本を 選んだ人にリサンプルを掛けるのは逆なので作らない @@ -708,7 +712,7 @@ Range 対応、ALAC は既定で Opus 変換、`canPlayType()` によるクラ `audio_version` / `tag_version` 差分判定、Library の移動・削除への追随。`delivery` ビューの版一致 フォールバックの結合テスト。P1-8 の `delivery` プロファイルと Android 同期がこれを前提にするため -P3 から前倒し(D-51)。 +P3 から前倒し([D-51](../decisions/D-051.md))。 - [x] マイグレーション 0005: `derived_files.src_artwork_id` / `src_rg_scanned_at` (タグ版に乗らない 2 つの世代) @@ -743,15 +747,15 @@ P3 から前倒し(D-51)。 Derived は 30 GB(全件に album の WebP カバー入り)。`delivery` は可逆 7,570 が Derived、 非可逆 1,528 が Library 原本 -閉じた未決(2026-09-18。D-51): マルチチャンネルのダウンミックス(全 9,098 トラックが 2ch)、 +閉じた未決(2026-09-18。[D-51](../decisions/D-051.md)): マルチチャンネルのダウンミックス(全 9,098 トラックが 2ch)、 非可逆の `force_transcode`(非可逆は Opus 1,512 / MP3 14 / AAC 2。Opus は変換の意味が無く残りは -16 本)、Derived 側の `cover.jpg` ミラー(全件に WebP を埋め込み済み。Library は D-49 で同梱ファイルを +16 本)、Derived 側の `cover.jpg` ミラー(全件に WebP を埋め込み済み。Library は [D-49](../decisions/D-049.md) で同梱ファイルを 書かない方針なので Derived にだけ書くと方針が割れる)。需要が出たら再開 ### P1-11 GC ジョブ `missing_since` が保持期間(`[gc].retention_days`、既定 7 日。2026-09-25 に 30 から変更)を超えた行、`Archive/` へ退避した WAV、Derived の孤児。**物理削除を行う唯一の経路**。 -dry-run と削除件数のログを必須にする(D-56)。 +dry-run と削除件数のログを必須にする([D-56](../decisions/D-056.md))。 - [x] `gc::plan`(読み取りのみ): A missing トラック(`stat` で実体が無いことを再確認)、B missing アルバム(構成 0)、C `archived_files` の `held` で期限超、D Derived の孤児(`.spindle-tmp-*` と @@ -776,11 +780,11 @@ scan と gc を同時に 5 回投入して両方が完走すること)、 `tests/gc_api.rs` (preview が何も消さない、POST の 202 / 409 / 401、root 無しの 503) -設定画面からの起動は P1-12 (e) で実施済み。Library の同梱ファイルの回収(D-43)は P2-8 で決める +設定画面からの起動は P1-12 (e) で実施済み。Library の同梱ファイルの回収([D-43](../decisions/D-043.md))は P2-8 で決める ### P1-13 スキャナの ReplayGain 追随 -D-47 / D-48 の未決。 +[D-47](../decisions/D-047.md) / [D-48](../decisions/D-048.md) の未決。 - [x] 外部で音声が差し替わった行は解析値を捨てる(`reset_analysis`。tagwrite の overlay 解消も同じ) - [x] 外部のタグ変更を取り込んだ行は `rg_written_at` を判定し直す(`sync_written_at`。 @@ -791,7 +795,7 @@ D-47 / D-48 の未決。 ### P1-12 UI の再構成と起動導線 -foobar2000 のレイアウトに寄せる(D-58)。P1 の完了条件「foobar2000 を開かずに日常運用が回る」に +foobar2000 のレイアウトに寄せる([D-58](../decisions/D-058.md))。P1 の完了条件「foobar2000 を開かずに日常運用が回る」に 対して、API だけで UI が無い機能(リネーム / 正規化 / RG / FLAC 検査 / GC)の起動導線と、ジョブ・ 設定画面を揃える。 @@ -802,8 +806,8 @@ foobar2000 のレイアウトに寄せる(D-58)。P1 の完了条件「fooba `GET /api/tracks/:id` の `detail` - [x] (c) 操作タブ(リネーム / 正規化の preview → 適用、RG 解析 / 書き込み、FLAC 検査、 プレイリストへ追加) -- [x] (d) ジョブ画面(SPEC §12.5)。`GET /api/jobs` に `concurrency`(種別ごとの並列度)を追加 -- [x] (e) 設定画面(SPEC §12.6): `GET /api/config`、再スキャン / deep scan、GC preview → 実行、 +- [x] (d) ジョブ画面([SPEC §12.5](../spec/12-ui/05-jobs.md))。`GET /api/jobs` に `concurrency`(種別ごとの並列度)を追加 +- [x] (e) 設定画面([SPEC §12.6](../spec/12-ui/06-other-screens.md)): `GET /api/config`、再スキャン / deep scan、GC preview → 実行、 `GET /api/archive` 受け入れ: `web/src/lib/tree.test.ts`(パターンのパース・ツリーの構築・`album_ids`)、 @@ -858,7 +862,7 @@ Hybrid Theory JP Enhanced CD。DiscID は MB ドキュメント / `ws/2` で、A ### P2-3 MusicBrainz 照会と候補選択 UI -UA 必須、1req/s(D-64)。 +UA 必須、1req/s([D-64](../decisions/D-064.md))。 - [x] `src/cd/musicbrainz.rs`(`MusicBrainzClient`: DiscID → 404 なら `?toc=` の fuzzy、間隔待ち、503 の 再試行。`parse_lookup`: リリース × medium の候補、exact / 近似)、`Toc::parse`(CTDB 形式 / @@ -866,7 +870,7 @@ UA 必須、1req/s(D-64)。 - [x] UI は CD タブ(`CdView` / `useCdLookup` / `lib/cd.ts`): ドライブが無い間は TOC の貼り付け (`cdrecord -toc` の出力も可)→ 候補一覧(DiscID 一致 / 近似、要約、曲数、長さ)→ 選択でトラック対応 -- [x] DiscID 以外の識別経路(D-64 追記。2026-09-22): ドライブが TOC と同じ回に ISRC / MCN を読み +- [x] DiscID 以外の識別経路([D-64](../decisions/D-064.md) 追記。2026-09-22): ドライブが TOC と同じ回に ISRC / MCN を読み (`cd/device.rs` の `read_ids`、SG_IO READ SUB-CHANNEL。`GET /api/cd/status` の `isrcs` / `mcn`)、 `POST /api/cd/lookup { toc, isrcs?, mcn?, release? }` が TOC 近似 + ISRC 検索 + バーコード検索 + 指定 リリースを束ねる(`MusicBrainzClient::lookup` / `DiscQuery` / `merge_candidates` / `MatchedBy`、 @@ -881,7 +885,7 @@ ISRC は捨てる)、`tests/cd_device.rs`(READ SUB-CHANNEL の解釈、TOC TOC は成立。実機は `#[ignore]`)、`tests/cd_toc.rs`(文字列の往復)、`web/src/lib/cd.test.ts`(バッジ・ 見出し・登録 URL)。実機(嵐「Five」。DiscID・トラック長とも未登録)で ISRC 経路から 2 盤が候補に出るのを確認 -- [x] 接続の経路と診断(D-64 追記 2。2026-09-22): `[musicbrainz].address_family`(auto / ipv6 / ipv4。 +- [x] 接続の経路と診断([D-64](../decisions/D-064.md) 追記 2。2026-09-22): `[musicbrainz].address_family`(auto / ipv6 / ipv4。 `cd::select_addrs` + reqwest の DNS リゾルバ差し替え)、応答前に落ちた要求の 1 回の張り直し、 `cd::error_chain` で原因の末端までログと API 本文に出す。**この回線は MetaBrainz の IPv4 が塞がれて いる**(TCP は通るが TLS で切られる。IPv6 は通る)ので実機の config.toml は `address_family = "ipv6"` @@ -890,7 +894,7 @@ TOC は成立。実機は `#[ignore]`)、`tests/cd_toc.rs`(文字列の往 「取り出す」を主役にし、TOC の貼り付け・各種 ID・リリース URL 指定・用語の凡例は「詳細」へ。候補に MusicBrainz へのリンク・収録構成(`mediaSummary`。候補に `media` を追加)・ディスクとの長さ差を出し、 CD 以外の medium(デジタル配信 / DVD / Blu-ray)は既定で畳む -- [x] 照会結果のキャッシュ(D-64 追記 3。2026-09-22): 入力ごとに 10 分・8 件(`DiscQuery::cache_key`。 +- [x] 照会結果のキャッシュ([D-64](../decisions/D-064.md) 追記 3。2026-09-22): 入力ごとに 10 分・8 件(`DiscQuery::cache_key`。 失敗は覚えない)、`POST /api/cd/lookup` の `refresh` で引き直す。UI はボタンからの照会だけ refresh 受け入れ(接続): `tests/config.rs`(既定 auto、ipv6 / ipv4、未知の値は拒否)、`tests/cd_musicbrainz.rs` @@ -898,22 +902,22 @@ TOC は成立。実機は `#[ignore]`)、`tests/cd_toc.rs`(文字列の往 通る、キャッシュの再利用 / 入力違い / refresh / 期限切れ / 失敗は覚えない)、`tests/cd_lookup_api.rs` (2 回目は上流を引かない、refresh は引く) -- [x] 照会の段階化(D-64 追記 4。2026-09-23。P4-20): TOC 近似は DiscID / ISRC / バーコードで 1 件も +- [x] 照会の段階化([D-64](../decisions/D-064.md) 追記 4。2026-09-23。P4-20): TOC 近似は DiscID / ISRC / バーコードで 1 件も 候補が残らなかったときだけ引く。`widen` で明示的に広げられる 残り: なし(検出は P2-1 で差し替え済み) ### P2-4 照会ゼロ件でも完走できる手入力経路とトラックリスト貼り付け -D-65。候補を写す先は 1 つの下書き(`DiscDraft`。`lib/cd.ts` の `draftFromCandidate` / `emptyDraft`)。 +[D-65](../decisions/D-065.md)。候補を写す先は 1 つの下書き(`DiscDraft`。`lib/cd.ts` の `draftFromCandidate` / `emptyDraft`)。 -**現在形**(P4-20 / D-65 追記 / D-67 追記で上書き済み。以下は履歴として残す): +**現在形**(P4-20 / [D-65](../decisions/D-065.md) 追記 / [D-67](../decisions/D-067.md) 追記で上書き済み。以下は履歴として残す): - CD 画面でできるのは**候補を選ぶ / 写す範囲を変える /「どれも違う」**だけ。**補正と トラックリスト貼り付けは Inbox の承認画面へ移管**した(取り込んだものは Inbox を通る) - 「確定」の段と「空のタイトルを Track NN で埋める」ボタンは廃止。空のタイトルは表のプレースホルダで 見せ、取り込むときに `Track NN` が入る - **P2-5 に渡す契約は名前が空でもよい**(`DiscMetadata::validate` の必須検証は Library へ置くとき - のもの。D-67 追記) + のもの。[D-67](../decisions/D-067.md) 追記) - [x] 行は TOC の音声トラックと 1:1(`POST /api/cd/lookup` の応答に `tracks: [{ number, length_ms }]`。 `Toc::audio_track_sectors`) @@ -929,8 +933,8 @@ D-65。候補を写す先は 1 つの下書き(`DiscDraft`。`lib/cd.ts` の ` タグ名の写像)、`web/src/lib/cdState.test.ts`(ディスク検出 → 照会 → 選択 /「どれも違う」、TOC 編集 / reset で 下の段が消える、照会中もフォームが消えない)、`tests/cd_toc.rs` / `tests/cd_lookup_api.rs`(`tracks`) -残り: なし(空名を許す契約は P2-5 で実装済み(D-67 追記)、トラックリスト貼り付けは P2-10 で Inbox の -承認画面へ移した(D-65 追記 2)。検出は P2-1 / P4-20 で差し替え済み) +残り: なし(空名を許す契約は P2-5 で実装済み([D-67](../decisions/D-067.md) 追記)、トラックリスト貼り付けは P2-10 で Inbox の +承認画面へ移した([D-65](../decisions/D-065.md) 追記 2)。検出は P2-1 / P4-20 で差し替え済み) ### P2-5 吸い出し @@ -944,7 +948,7 @@ CD 画面の「取り込む」→ rip ジョブ → Inbox → 承認 → Library ドライブ表)、`src/jobs/handlers/rip.rs`、`src/api/cd.rs`(`POST /api/cd/rip`、status の `rip_job` / `drive`)、`src/import/sidecar.rs`(`RipEntry`)、`src/import/inbox.rs`(`bind_rip` と検証記録の登録)、 web は `hooks/useCdRip.ts` / `lib/cdRip.ts` / `components/CdView.tsx` -- **判断**: DECISIONS の D-67 追記 2(Inbox 側・サイドカー・DiscID を入れない)、D-83 と追記・追記 2 +- **判断**: DECISIONS の [D-67](../decisions/D-067.md) 追記 2(Inbox 側・サイドカー・DiscID を入れない)、[D-83](../decisions/D-083.md) と追記・追記 2 (オフセットは 設定 → 学習済み → AccurateRip のドライブ表 → 0。照合で見つけたずれを当てて学習) - **レビュー**: codex レビュー済み(すべて LGTM)。コミットは `5df2dc5`〜`ad70ad3`(main、未 push) - **テスト**: `cargo test` 1339 件、web 301 件。実ドライブのテストは `#[ignore]` @@ -1046,14 +1050,14 @@ CD 画面の「取り込む」→ rip ジョブ → Inbox → 承認 → Library (済: 2026-09-23 に同じ 2 トラックの盤で。吸い出しは表の +667・試行 3 回・CTDB mismatch / AR not_found → 承認 → `tracks` 2 行が `cd_rip` / `mismatch`、`album_verifications` が ctdb / accuraterip の 2 行で `source = rip`・`log_path` は移動後の Library 相対、`track_verifications` 4 行に CRC。 - このとき `album_verifications.drive_offset` が NULL だったので、`read_offset` を書くよう直した(D-83 追記 3)。 + このとき `album_verifications.drive_offset` が NULL だったので、`read_offset` を書くよう直した([D-83](../decisions/D-083.md) 追記 3)。 **照合が通る盤での確認(上の項目)は、この盤しか無いため残り**) -- ~~残課題(判断待ち): DiscID の食い違い~~(D-67 追記 3 で解消) +- ~~残課題(判断待ち): DiscID の食い違い~~([D-67](../decisions/D-067.md) 追記 3 で解消) #### やること - [x] 全ディスクを 1 本の PCM として取得 → オフセット適用 → 分割(`cd::rip::read_disc`: `cd-paranoia -e -r`、 - 範囲は TOC から `first-last[mm:ss.ff]`。常にオフセット 0 で読み、`shift_pcm` で当てる。D-83) + 範囲は TOC から `first-last[mm:ss.ff]`。常にオフセット 0 で読み、`shift_pcm` で当てる。[D-83](../decisions/D-083.md)) - [x] `POST /api/cd/rip` と rip ジョブ。CD 画面の「取り込む」を有効にする (`jobs/handlers/rip.rs`、payload `{ toc, metadata }`、dedup `rip`。API は盤がドライブにあり TOC が一致する ときだけ 202、進行中なら 409 `duplicate`。`GET /api/cd/status` の `rip_job` で開き直しても追う。 @@ -1061,19 +1065,19 @@ CD 画面の「取り込む」→ rip ジョブ → Inbox → 承認 → Library - [x] 2 回の走査に CTDB の修復を配線する(P2-7 の残り。直せなければ再リップ / `mismatch`) (`cd::rip::rip_disc`: 照合 → ずれを当てる → CTDB のパリティで修復 → 吸い直し(比べる相手が あるときだけ、`retry_on_mismatch` 回)→ それでも駄目なら mismatch のまま Inbox へ) -- [x] `place_disc` を **Inbox 経由**に作り直す(D-67 追記): FLAC と rip.log / disc.cue / disc.toc を件の +- [x] `place_disc` を **Inbox 経由**に作り直す([D-67](../decisions/D-067.md) 追記): FLAC と rip.log / disc.cue / disc.toc を件の ディレクトリへ、MusicBrainz から写した内容と `RipReport` をサイドカーへ、`inbox_items` に 1 件。 PCM の切り方・エンコード(`flac -N --verify`)・タグの写像・同梱ファイルは既存のものをそのまま使う (隠しディレクトリ `.spindle-rip-<DiscID>` で組み立てて `CD/<名前> [<DiscID>]` へ公開し、`inbox` ジョブを - 投入。件は走査で出る。D-67 追記 2、SPEC §7.2) + 投入。件は走査で出る。[D-67](../decisions/D-067.md) 追記 2、[SPEC §7.2](../spec/07-pipeline/02-cd-rip.md)) - [x] Inbox の承認と配置が、サイドカーの `RipReport` から `album_verifications`(`source = 'rip'`)/ `track_verifications` / `tracks.verification` と `source_type = 'cd_rip'` を入れる。 **対応付けはファイルの basename**(配列順を信じない。Inbox では番号もタイトルも直せる)。 登録は `register_item` と同じトランザクション、`log_path` は移動後の Library 相対 - (`import::inbox::bind_rip` / `register_item`。D-67 追記 2) + (`import::inbox::bind_rip` / `register_item`。[D-67](../decisions/D-067.md) 追記 2) - [x] サイドカー v1 に `RipReport` を入れる形を足す(`import/sidecar.rs` の `RipEntry`。モジュールを - ytmusic から移した)。CD の件は `album_gain = true` で提案する(D-74。web の初期値も提案に従う) -- [x] **リップの開始は空の名前を許す**(D-67 追記)。`DiscMetadata::validate` から名前の必須を外した + ytmusic から移した)。CD の件は `album_gain = true` で提案する([D-74](../decisions/D-074.md)。web の初期値も提案に従う) +- [x] **リップの開始は空の名前を許す**([D-67](../decisions/D-067.md) 追記)。`DiscMetadata::validate` から名前の必須を外した (`EmptyAlbum` / `EmptyAlbumArtist` / `EmptyTitle` を削除。空のタイトルは `with_placeholder_titles` で `Track NN`)。web の `validateDraft` も同じ規則に直した(`POST /api/cd/rip` の前に使う) - [x] 吸い出し中、トラック単位の進捗を SSE の `job` イベントで流す(`JobEvent.detail` に @@ -1084,7 +1088,7 @@ CD 画面の「取り込む」→ rip ジョブ → Inbox → 承認 → Library (`{ phase: read|verify|encode|place, disc_no, track_no, done, total }`)。CD 画面のトラック表の 右端に出る(器は P4-20 の `CdTrackTable` の `RipProgress`)。**相ごとの `track_no` の進み方と、 表の「完了」の表現はここで確定する**(read は 1 本の PCM なのでトラック単位に分かれない) -- [x] 実装後に **SPEC §7.2 の「Library へ直接置いていたときの記述」を現在形に置き換える**(配置の段落は +- [x] 実装後に **[SPEC §7.2](../spec/07-pipeline/02-cd-rip.md) の「Library へ直接置いていたときの記述」を現在形に置き換える**(配置の段落は 置き換えた。吸い出し・修復・再リップの段は rip ジョブの実装で見直す) #### 受け入れ(必ず見る) @@ -1123,7 +1127,7 @@ CD 画面の「取り込む」→ rip ジョブ → Inbox → 承認 → Library AccurateRip は補助。照会は P2-9 で済み。 -- [x] 修復は `src/cd/repair.rs`(D-66。CUETools `CDRepair` / `RsDecode` / `Parity2Syndrome` の定義: +- [x] 修復は `src/cd/repair.rs`([D-66](../decisions/D-066.md)。CUETools `CDRepair` / `RsDecode` / `Parity2Syndrome` の定義: GF(2^16) 0x1100B、stride 11760 語、`SyndromeSampler` → `SyndromeTable`(80 分 3〜5 秒)、 `find_offset`(列 0、±2939)、`plan`(BM → Chien → Forney、列あたり npar/2 個まで、直した後の CRC が 合うときだけ)、`RepairApplier`(2 回目の走査で XOR)、`decode_entry_syndrome`(`syndrome` / 旧 @@ -1140,7 +1144,7 @@ AccurateRip は補助。照会は P2-9 で済み。 ### P2-8 エンコードと配置 -`rip.log` / `disc.cue` / `disc.toc` の出力(D-67)。 +`rip.log` / `disc.cue` / `disc.toc` の出力([D-67](../decisions/D-067.md))。 - [x] `src/cd/place.rs`(`place_disc`: `DiscMetadata` の検証 → トラックごとの PCM MD5 → `pathgen::plan` (category 無しは `unsorted`、複数枚組は `multi_disc`。2 枚目以降は宛先の同名 album に合流)→ @@ -1152,7 +1156,7 @@ AccurateRip は補助。照会は P2-9 で済み。 - [x] `src/cd/riplog.rs`(`RipReport`、`rip.log` / `disc.cue` / `disc.toc` の描画。複数枚組は `disc<N>.*` / `rip<N>.log`) - [x] `GET/POST /api/categories` と確定フォームの category(`useCategories`) -- [x] D-43 の残課題: rename ジョブが commit 後に、album 全体の移動で active な行が無くなった旧ディレクトリの +- [x] [D-43](../decisions/D-043.md) の残課題: rename ジョブが commit 後に、album 全体の移動で active な行が無くなった旧ディレクトリの 既知の同梱ファイルを宛先へ移し、空なら rmdir(`edit::rename::follow_companions`) - [x] スキャナは spindle の rip.log(先頭行の署名)があるディレクトリの新規行を `cd_rip` にする。 verify.log は `data/verify` のまま @@ -1164,11 +1168,11 @@ not_attempted、複数枚組の合流、別リリースの `({year})` 降格、 (同梱ファイルの追随・衝突・巻き戻し)、`tests/categories_api.rs`、`web/src/lib/cd.test.ts` 残り: なし(P2-5 で `place_disc` を **Inbox 経由**に作り直して rip ジョブから配線した。Library への配置・ -DB 行・後続ジョブは Inbox の承認と配置(P2-10)が行う。`POST /api/cd/rip` も P2-5。D-67 追記 2) +DB 行・後続ジョブは Inbox の承認と配置(P2-10)が行う。`POST /api/cd/rip` も P2-5。[D-67](../decisions/D-067.md) 追記 2) ### P2-9 遡及照合 -44.1/16/2ch かつサンプル数が 588 の倍数のときのみ(D-63)。 +44.1/16/2ch かつサンプル数が 588 の倍数のときのみ([D-63](../decisions/D-063.md))。 - [x] `verify` ジョブ(album 単位、並列 2。`src/jobs/handlers/verify.rs`): ディスクごとに STREAMINFO の サンプル数から TOC を再構成 → デコードして CRC 表(`src/cd/crctable.rs`。1 回流して ±2939 の @@ -1193,7 +1197,7 @@ unverifiable / 不完全 / 複数ディスク / 照会失敗 / 再照合の履 ### P2-10 Inbox 取り込み -ステージング → 承認キュー → 配置(D-68)。 +ステージング → 承認キュー → 配置([D-68](../decisions/D-068.md))。 - [x] `src/import/inbox.rs`(`scan_inbox`: `[paths].inbox` を走査して音声のあるディレクトリを 1 件として `inbox_items` / `inbox_files` に登録(root 直下は rel_dir "")。ファイルが変われば読み直して pending に @@ -1207,7 +1211,7 @@ unverifiable / 不完全 / 複数ディスク / 照会失敗 / 再照合の履 - [x] `src/import/placement.rs`(CD の配置と共通の tmp + rename / album の解決 / 行の登録。リリースキーは 登録トランザクションで再検証) - [x] `src/jobs/handlers/inbox.rs`(`inbox` ジョブ = 走査 + 承認済みの配置。`[inbox].poll_interval_secs` - (既定 60、0 で自動なし)の周期投入と「今すぐ確認」)、旧 `db/migrations/0014`(D-88 で `0001_init.sql` へ統合)、`src/db/inbox.rs` + (既定 60、0 で自動なし)の周期投入と「今すぐ確認」)、旧 `db/migrations/0014`([D-88](../decisions/D-088.md) で `0001_init.sql` へ統合)、`src/db/inbox.rs` - [x] `src/api/inbox.rs`(`GET /api/inbox`、`POST /api/inbox/scan`、`POST /api/inbox/{id}/approve|reject|reopen`) - [x] web の Inbox タブ(`lib/inbox.ts` / `useInbox` / `InboxView`: 件の一覧とアルバム単位 + トラック単位の 補正フォーム、placed からアルバムへ) @@ -1218,7 +1222,7 @@ unverifiable / 不完全 / 複数ディスク / 照会失敗 / 再照合の履 落ちた件の回復、コピー前の差し替えの検出、登録前 / 登録後に落ちた後の完了、placed のディレクトリに残った音声、normalize の投入と登録の原子性)、 `tests/inbox_draft.rs`、`tests/inbox_db.rs`、`tests/inbox_api.rs`、`web/src/lib/inbox.test.ts` -- [x] トラックリスト貼り付けを承認画面へ移す(P4-20 追記で CD 画面から外したもの。2026-09-23。D-65 追記 2): +- [x] トラックリスト貼り付けを承認画面へ移す(P4-20 追記で CD 画面から外したもの。2026-09-23。[D-65](../decisions/D-065.md) 追記 2): トラック表の下の畳んだ節。`lib/tracklist.ts` で解析し、`lib/inbox.ts` の `applyTracklist` で選んだディスクの 行へ `track_no` で写す(複数枚組のときだけ写す先を選ぶ。アーティストを貼った行は「そのまま保つ」を外す)。 CD 側の `lib/cd.ts` の `applyTracklist` は消した @@ -1236,7 +1240,7 @@ unverifiable / 不完全 / 複数ディスク / 照会失敗 / 再照合の履 ### P3-1 タイトルパーサ → メタデータプラグインのプロトコル v1 -D-69。タイトルの慣習は spindle に置かず、外部コマンド(`[ytmusic].metadata_command`。参照実装 +[D-69](../decisions/D-069.md)。タイトルの慣習は spindle に置かず、外部コマンド(`[ytmusic].metadata_command`。参照実装 `AkashiSN/spindle-ytmusic-meta`。Python 版 ytmusic のテスト 98 件から生成したフィクスチャで一致を 保証)に JSON で問い合わせる。 @@ -1252,7 +1256,7 @@ COPY されている) ### P3-2 チャンネル定義とカテゴリ写像 -チャンネル定義はプラグイン側(D-69)。 +チャンネル定義はプラグイン側([D-69](../decisions/D-069.md))。 - [x] spindle は `Track` をタグ(`Track::tags(track_no)`: TITLE / ARTIST 多値 / ALBUM / ALBUMARTIST / DATE / TRACKNUMBER + 追加タグ)と `pathgen::TrackFields`(`Track::track_fields`)に写し、`category` は @@ -1262,7 +1266,7 @@ COPY されている) ### P3-3 ダウンローダ -yt-dlp を subprocess で呼び、**Inbox に置くところまで**(D-70。配置・採番・後続は Inbox)。 +yt-dlp を subprocess で呼び、**Inbox に置くところまで**([D-70](../decisions/D-070.md)。配置・採番・後続は Inbox)。 - [x] ジョブ基盤: `JobError::Fatal`(バックオフせず `failed`)、`JobType::Ytdl`(並列 1) - [x] `[ytmusic].download_timeout_secs`、起動時診断(`metadata_command[0]` と yt-dlp の実行可否を警告) @@ -1287,19 +1291,19 @@ playlist / 重複 / Fatal と Failed の区別、dump の解釈、ファイル rg は Inbox の配置で投入済み。 - [x] 残っていた「次のスキャンまで画像が出ない」を、配置の直後にその album だけ解決して thumbnail を - 投入する形で埋めた(`scanner::resolve_album_artwork_now`、`PlaceItemEnv.artwork`。D-68 追記) + 投入する形で埋めた(`scanner::resolve_album_artwork_now`、`PlaceItemEnv.artwork`。[D-68](../decisions/D-068.md) 追記) 受け入れ: `tests/inbox_job.rs`(埋め込み画像から解決して thumbnail 投入、画像なしは「なし」で解決) ### P3-5 偽ハイレゾ検出 -`rustfft`、任意機能(SPEC §7.10、D-71)。表示と絞り込みだけで、判定を消費する自動処理は無い。 +`rustfft`、任意機能([SPEC §7.10](../spec/07-pipeline/10-fake-hires.md)、[D-71](../decisions/D-071.md))。表示と絞り込みだけで、判定を消費する自動処理は無い。 - [x] `media/hires.rs`: `HiresSink`(PcmSink。Hann 8192 / ホップ 8192 の FFT をチャンネルごとに累積、 無音フレーム除外、サンプルの OR)→ `Measurement { cutoff_hz, cliff_db, effective_bits }`(候補は - 1/3 オクターブ平滑化、エッジは平滑化前の段差最大、崖は平滑化前。境界は SPEC §7.10)→ `[hires]` の + 1/3 オクターブ平滑化、エッジは平滑化前の段差最大、崖は平滑化前。境界は [SPEC §7.10](../spec/07-pipeline/10-fake-hires.md))→ `[hires]` の しきい値で `Verdict` -- [x] 旧 `db/migrations/0016_hires_check.sql`(D-88 で `0001_init.sql` へ統合): `tracks.hires_check*` + `hires_cutoff_hz` / `hires_cliff_db` / +- [x] 旧 `db/migrations/0016_hires_check.sql`([D-88](../decisions/D-088.md) で `0001_init.sql` へ統合): `tracks.hires_check*` + `hires_cutoff_hz` / `hires_cliff_db` / `hires_effective_bits`、`jobs.type` に `hirescheck`(0015 と同じ表の作り直し)。`db/hires.rs`(Status / Target / record / enqueue_all_unchecked / enqueue_selection) - [x] `jobs/handlers/hirescheck.rs`(並列 = max(1, コア数 / 2)、`version_field` で stale ゲートと track_locks、 @@ -1327,11 +1331,11 @@ inconclusive で計測値 NULL、2ch は cutoff 最大の ch と対の cliff、 ### P4-1 CPU 系ジョブの共通並列予算 -D-73。2026-09-20。 +[D-73](../decisions/D-073.md)。2026-09-20。 - [x] `worker.rs` に共有 Semaphore(= コア数)、`rg` / `transcode` / `flaccheck` / `hirescheck` が種別の 上限に加えて取る -- [x] `GET /api/jobs` に `cpu_budget`(`concurrency` の兄弟。D-73 追記) +- [x] `GET /api/jobs` に `cpu_budget`(`concurrency` の兄弟。[D-73](../decisions/D-073.md) 追記) 受け入れ: `tests/jobs.rs`(コア数 2 で rg + flaccheck の同時投入で実行中の合計が 2 を超えない、4 種を 固めて投入しても先頭 6 本に 4 種が揃う(ラウンドロビン)、thumbnail は縛られない)、 @@ -1339,19 +1343,19 @@ D-73。2026-09-20。 ### P4-2 CD 確定フォームの「候補から写す範囲」 -D-72。2026-09-21。 +[D-72](../decisions/D-072.md)。2026-09-21。 - [x] 「識別用の最小限」(ALBUM / ALBUMARTIST / DATE / DISCNUMBER / DISCTOTAL / TRACKTOTAL / MB id)と 「全部写す」(+ LABEL / CATALOGNUMBER / BARCODE / トラックのタイトル・アーティスト・ISRC)。 `web/src/lib/cdState.ts` の reducer に 1 アクション -- [x] **既定は P4-20 で「全部写す」に変えた**(D-72 追記 2)。表が主役になり、候補を選んだら +- [x] **既定は P4-20 で「全部写す」に変えた**([D-72](../decisions/D-072.md) 追記 2)。表が主役になり、候補を選んだら トラック名が入るのが期待される動きになったため 受け入れ: `web/src/lib/cdState.test.ts` ### P4-3 プロパティタブのフィールド削除 / 追加 -D-72。2026-09-21。 +[D-72](../decisions/D-072.md)。2026-09-21。 - [x] 右クリック(または行末の ×)で `delete` op、「フィールドを追加」で新しいキーに `set` op。どちらも 選択全体への一括編集(preview → apply) @@ -1360,7 +1364,7 @@ D-72。2026-09-21。 ### P4-4 Inbox 承認画面の忠実表示 -D-70 追記。2026-09-20。 +[D-70](../decisions/D-070.md) 追記。2026-09-20。 - [x] ARTIST の全値を `;` 区切りで見せ、`keep_artists` で「そのまま保つ / 1 値で書く」を明示 - [x] Inbox のファイルの埋め込み画像を返す `GET /api/inbox/:id/artwork/:hash`(`PICTURE` のハッシュで実体を @@ -1370,9 +1374,9 @@ D-70 追記。2026-09-20。 ### P4-5 album gain を album ごとの属性に -D-74。2026-09-20。 +[D-74](../decisions/D-074.md)。2026-09-20。 -- [x] 旧 `db/migrations/0017_album_gain.sql`(D-88 で `0001_init.sql` へ統合)(`albums.album_gain` 既定 0、既存の `tracks.rg_album_*` を NULL) +- [x] 旧 `db/migrations/0017_album_gain.sql`([D-88](../decisions/D-088.md) で `0001_init.sql` へ統合)(`albums.album_gain` 既定 0、既存の `tracks.rg_album_*` を NULL) - [x] rg の投入経路(`POST /api/rg`、承認後、CD 配置後。スキャンは投入しない)は属性で album / track 単位を 選ぶ。`cd/place.rs` は true で作る - [x] 承認画面のチェックボックス(既定 off、追記先 album の現在値が初期値) @@ -1387,7 +1391,7 @@ D-74。2026-09-20。 ### P4-6 アルバム一覧をトラック一覧と同じフィルタで絞る -D-58 追記。2026-09-20。 +[D-58](../decisions/D-058.md) 追記。2026-09-20。 - [x] `GET /api/albums?filter=`(トラック一覧と同じ JSON フィルタ。指定があれば一致する active なトラックを 1 本以上持つ album だけ。`WHERE a.id IN (SELECT t.album_id FROM tracks t … WHERE <db/tracks.rs の既存の @@ -1400,13 +1404,13 @@ D-58 追記。2026-09-20。 ### P4-7 Derived の系統化と Opus 256k -D-9 追記、D-51 追記、D-75、SPEC §7.6。2026-09-20。 +[D-9](../decisions/D-009.md) 追記、[D-51](../decisions/D-051.md) 追記、[D-75](../decisions/D-075.md)、[SPEC §7.6](../spec/07-pipeline/06-derived.md)。2026-09-20。 - [x] 新しい連番のマイグレーションで `delivery` ビューを DROP → `derived_files` を `(track_id, variant)` 主キー + `audio_profile` / `tag_profile` で作り直し(既存行は `variant = 'opus'`、`audio_profile = 'opus:128:v1'`、 `tag_profile = 'opus:v1'`)→ ビューを `variant = 'opus'` で再作成 - [x] `[encode]` を `[encode.derived.opus] { enabled, bitrate }` に改め(`derived_codec` / `derived_bitrate` は - 廃止。deploy/config.example.toml と SPEC §13 を揃える) + 廃止。deploy/config.example.toml と [SPEC §13](../spec/13-config.md) を揃える) - [x] `domain/derived.rs` の `plan()` に variant と `audio_profile`(違えば Encode)/ `tag_profile`(違えば Retag)、`enabled = false` の凍結(Skip)を足す。パスは `Derived/opus/…` - [x] `transcode` の dedup キーとロックに variant、scan 完了時の投入は enabled な系統ごと @@ -1424,7 +1428,7 @@ enabled=false で Skip、旧ルート直下の行は 128k → 256k なら Encode ### P4-8 Apple 向け `aac` 系統 -D-75、D-8 追記、SPEC §7.6。2026-09-20。 +[D-75](../decisions/D-075.md)、[D-8](../decisions/D-008.md) 追記、[SPEC §7.6](../spec/07-pipeline/06-derived.md)。2026-09-20。 - [x] `[encode.derived.aac] { enabled(節省略時 false), bitrate, lossy_sources, multi_value_separator }`、 0019 で `derived_variants` に `lossy_sources` / `multi_value_separator`(`sync_variants(&DerivedConfig)` @@ -1450,7 +1454,7 @@ peak 上限・96k → 48k・opus / aac 原本の再エンコード・RG 世代 ### P4-9 終端を書けなかった `running` の稼働中回収 -SPEC §8、D-76。ジョブの終端状態(done / failed / requeue)を DB に書けなかったとき(2026-09-20 の実機で +[SPEC §8](../spec/08-jobs.md)、[D-76](../decisions/D-076.md)。ジョブの終端状態(done / failed / requeue)を DB に書けなかったとき(2026-09-20 の実機で ディスク満杯により 88 本)、`src/jobs/worker.rs::execute` のフォールバック(failed の記録)も同じ理由で 失敗し、行が `running` のまま次回起動のリカバリまで残る(ロックも残る)。候補 (a) / (b) の両方を入れるのが 妥当。 @@ -1462,11 +1466,11 @@ SPEC §8、D-76。ジョブの終端状態(done / failed / requeue)を DB - [x] docker のログも満杯で落ちるので、回収したことは次に書けたときにログとジョブの `last_error` に残す 受け入れ: `tests/jobs.rs`(終端書き込みを失敗させる DB フック or 読み取り専用化で `running` を作り、 -回収で queued に戻りロックが消える。実行中の本物の running は戻さない)、SPEC §8 に回収の記述 +回収で queued に戻りロックが消える。実行中の本物の running は戻さない)、[SPEC §8](../spec/08-jobs.md) に回収の記述 ### P4-10 ダークテーマ -D-58 追記、SPEC §12 / §12.6。2026-09-21。 +[D-58](../decisions/D-058.md) 追記、[SPEC §12](../spec/12-ui/README.md) / §12.6。2026-09-21。 - [x] `index.css` の直書き色 59 箇所を `:root` の変数に集約(バッジは `--badge-<hue>-bg` / `-fg` の対)、 ダークは `:root[data-theme='dark']` の 1 ブロックで全変数を差し替え(`color-scheme` も) @@ -1483,7 +1487,7 @@ D-58 追記、SPEC §12 / §12.6。2026-09-21。 ### P4-11 Library の MP4(ALAC / AAC)に任意キーを読み書きする -D-77、SPEC §7.5「形式ごとの写像」。2026-09-21。旧 Library 経路(`write_tag_changes` の `FileType::Mp4` → +[D-77](../decisions/D-077.md)、[SPEC §7.5](../spec/07-pipeline/05-tagwrite.md)「形式ごとの写像」。2026-09-21。旧 Library 経路(`write_tag_changes` の `FileType::Mp4` → lofty の generic `Tag` → `apply_generic`)は `ItemKey` の写像表に無いキー(`SPINDLETEST` 等の独自キー。 `CATALOGNUMBER` / `LABEL` / `BARCODE` / MB id は lofty 0.25 が `----:com.apple.iTunes:*` に写像済み)を 書けず、読み戻しの照合で安全側に失敗していた。読み側(`Ilst` → `Tag`)もフリーフォーム atom を捨てていた。 @@ -1502,10 +1506,10 @@ lofty の generic `Tag` → `apply_generic`)は `ItemKey` の写像表に無 ### P4-12 CI / CD の整理と Docker イメージの配布 -2026-09-21。SPEC §14「イメージの配布」、D-79。 +2026-09-21。[SPEC §14](../spec/14-deploy.md)「イメージの配布」、[D-79](../decisions/D-079.md)。 - [x] (1) 置き場は GHCR(`ghcr.io/akashisn/spindle`)。パッケージは public。プラグイン - (`spindle-ytmusic-meta`)は D-70 どおり焼かず、実行時マウントのまま + (`spindle-ytmusic-meta`)は [D-70](../decisions/D-070.md) どおり焼かず、実行時マウントのまま - [x] (2) タグ: `main` への push で `edge` と `sha-<7 桁>`、`vX.Y.Z` タグで `X.Y.Z` / `X.Y` / `latest`。 PR は build と起動確認だけで push しない - [x] (3) CI の docker ジョブで作ったイメージを**そのまま** push する(起動確認に通ったものと同じ digest。 @@ -1519,11 +1523,11 @@ lofty の generic `Tag` → `apply_generic`)は `ItemKey` の写像表に無 `# renovate:` 注釈の custom manager)。`/health` に yt-dlp の版 - [x] (8) `v0.1.0` はまだ切らず `edge` までを完了とする(最初のタグはリリース時。スカッシュは (9) で実施済み)。 ユーザ側の作業: GHCR のパッケージを public に、Renovate の GitHub App をインストール -- [x] (9) スカッシュ: リリース時の再移行の前に `db/migrations` を `0001_init.sql` 1 本へ畳んだ(2026-09-24、D-88)。 +- [x] (9) スカッシュ: リリース時の再移行の前に `db/migrations` を `0001_init.sql` 1 本へ畳んだ(2026-09-24、[D-88](../decisions/D-088.md))。 最終スキーマは 24 本を流した結果と一致(`sqlite_sequence` の空行だけ差)。アップグレード試験は空 DB の スキーマ試験へ移した。最初のタグはリリース時 -- [x] (10) リリース直前の 2 回目のスカッシュ(2026-09-26 ユーザ依頼。D-88 追記): (9) の後に足した `0002`〜 - (いまは 0002 / 0003 / 0004)を `0001_init.sql` へ畳む。やり方と確かめ方は (9) / D-88 と同じ(空 DB の +- [x] (10) リリース直前の 2 回目のスカッシュ(2026-09-26 ユーザ依頼。[D-88](../decisions/D-088.md) 追記): (9) の後に足した `0002`〜 + (いまは 0002 / 0003 / 0004)を `0001_init.sql` へ畳む。やり方と確かめ方は (9) / [D-88](../decisions/D-088.md) と同じ(空 DB の `sqlite_master` と全表の `table_xinfo` / `index_list` / `index_xinfo` / `foreign_key_list` と種データを比べる。 各版の試験は空 DB のスキーマ試験へ移す)。最初の `vX.Y.Z` を切るコミットの直前に行い(その後に版が 増えないように)、MIGRATION §5 の手順 0。タグの後は CLAUDE.md の禁止事項どおり連番を足すだけ。 @@ -1548,7 +1552,7 @@ push されない、実機を `compose pull` で更新して `/health` の `vers しない。実機は `git archive` → ホストで `docker build` → `spindle:local` の手作業(`/root/spindle-migration/ build.sh`)。決めること: (1) 置き場は GHCR(`ghcr.io/akashisn/spindle`。compose が既に指している)。 パッケージを public にして pull にトークンを不要にする。プラグイン(`spindle-ytmusic-meta`)は -D-70 どおり焼かず、実行時マウントのまま。(2) タグ: `main` への push で `edge` と `sha-<7 桁>`、`vX.Y.Z` +[D-70](../decisions/D-070.md) どおり焼かず、実行時マウントのまま。(2) タグ: `main` への push で `edge` と `sha-<7 桁>`、`vX.Y.Z` タグで `X.Y.Z` / `X.Y` / `latest`。PR は build と起動確認だけで push しない。(3) CI の docker ジョブで 作ったイメージを**そのまま** push する(起動確認に通ったものと同じ digest。二度ビルドしない。 `docker/build-push-action` の `load` + `push` か、`docker/metadata-action` でタグを組んで最後に push)。 @@ -1578,10 +1582,10 @@ GitHub API から取り、`ARG` を書き換える PR を自動で作る(Depen ### P4-13 YouTube の導線を独立した画面に -D-70 追記、SPEC §12.6。2026-09-21。それまでは右パネル「操作」タブの中の `YouTube` 節(URL 欄 + +[D-70](../decisions/D-070.md) 追記、[SPEC §12.6](../spec/12-ui/06-other-screens.md)。2026-09-21。それまでは右パネル「操作」タブの中の `YouTube` 節(URL 欄 + ダウンロード)で、選択したトラックへの操作と混ざって見つけにくく、投入した後の行方(ジョブ → Inbox)も 自分で探す必要があった。上部バーを `一覧 / アルバム / Inbox / CD / YouTube / ジョブ` にして `YouTube` -画面を置く(取り込み元は Inbox / CD / YouTube で横並び、結果は Inbox に集まる。SPEC §7.7、D-70)。 +画面を置く(取り込み元は Inbox / CD / YouTube で横並び、結果は Inbox に集まる。[SPEC §7.7](../spec/07-pipeline/07-ytmusic.md)、[D-70](../decisions/D-070.md))。 - [x] (1) URL 欄(1 行 1 つ。動画 / playlist。playlist は entries ごとに展開される)と「ダウンロード」。 再生リストの展開時に `SOURCE_URL` のある動画は投入しない = 実機で再生リストを貼ると新しいものだけ @@ -1611,7 +1615,7 @@ D-70 追記、SPEC §12.6。2026-09-21。それまでは右パネル「操作」 ### P4-14 既存の webm 由来トラックへの `SOURCE_URL` 補填 -一度きり。D-70 の重複防止は `SOURCE_URL` を見るが、移行で取り込んだ webm 由来の Opus 1,512 本(実機。P3 で +一度きり。[D-70](../decisions/D-070.md) の重複防止は `SOURCE_URL` を見るが、移行で取り込んだ webm 由来の Opus 1,512 本(実機。P3 で 取り込んだ 1 本を除く)には無く、旧 `Original/*.webm` のメタデータにも id は無い(`encoder=google/video-file` のみ)。手掛かりはユーザが YouTube 側で保守してきた**アーティストごとの再生リスト**: 旧パイプラインは 「再生リスト名 = アルバム名(`花譜のお歌` 等 10 アルバム)、再生リスト内の位置 = `TRACKNUMBER`」で並べて @@ -1619,7 +1623,7 @@ D-70 追記、SPEC §12.6。2026-09-21。それまでは右パネル「操作」 動画もリストには `[Private video]` として id と位置が残る。再生リストの URL 一覧と計画 CSV はリポジトリ外に 保管する(公開リポジトリに置かない。リリース時の再移行で `SOURCE_URL` は消えるので、同じ入力で再適用する)。 -- [x] `set_rows` op(D-42 追記): `ops` に `{"op":"set_rows","key":…,"rows":[{"id":…,"value":[…]}]}` の行ごとの +- [x] `set_rows` op([D-42](../decisions/D-042.md) 追記): `ops` に `{"op":"set_rows","key":…,"rows":[{"id":…,"value":[…]}]}` の行ごとの 値を足す(tagops の 1 op、preview で差分が見える、巻き戻しは 1 回)。書き込みは通常の tagwrite (tmp + rename、`tag_version` +1)なので Derived の opus / aac がタグ上書きで追随する(1,512 × 2 本。 音声は変えない) @@ -1655,7 +1659,7 @@ D-70 追記、SPEC §12.6。2026-09-21。それまでは右パネル「操作」 ### P4-16 再生リストの購読と同期 -2026-09-21。P4-15「番号を再生リストの順に揃える」を吸収。SPEC §7.7「再生リストの購読と同期」、D-78。 +2026-09-21。P4-15「番号を再生リストの順に揃える」を吸収。[SPEC §7.7](../spec/07-pipeline/07-ytmusic.md)「再生リストの購読と同期」、[D-78](../decisions/D-078.md)。 `SOURCE_URL`(P4-14)で「再生リストのどこまで持っているか」が分かるので、URL を貼る運用をなくす。 - [x] (1) **購読**: マイグレーションで `playlist_subscriptions`(`list_id` / URL / 追記先 `album_id`(無ければ @@ -1676,7 +1680,7 @@ D-70 追記、SPEC §12.6。2026-09-21。それまでは右パネル「操作」 次の同期で普通の未取り込みとして拾う。番号揃え (4) もこれらの位置を数える(Library に無い非公開の分は番号が飛ぶ。 既存の `花譜のお歌` が 244 曲で 1〜246 なのと同じ規則)。「非公開 / 削除の別」は現行 yt-dlp では 出力から付かないので `kind: private | deleted | unknown` の hint に読み替え -- [x] (3) **承認はそのまま**(D-70。判断は Inbox で人が行う)。宛先 album と位置が分かっているので、承認画面の +- [x] (3) **承認はそのまま**([D-70](../decisions/D-070.md)。判断は Inbox で人が行う)。宛先 album と位置が分かっているので、承認画面の 初期値(アルバムアーティスト / アルバム / category)は購読から埋め、プラグインの判定は補助にする - [x] (4) **番号揃え**: 配置(Inbox 承認)の後、同期ジョブ(または承認の後続)が「再生リストの位置 ↔ 現在の `TRACKNUMBER`」の ずれを `SOURCE_URL` で計算し(目標番号 = 再生リストの位置。非公開・削除・未取り込みの位置も数えるので、 @@ -1705,7 +1709,7 @@ set_rows + rename バッチ。非公開・未取り込みの位置は番号が ### P4-17 一覧のキーボード操作 -2026-09-22。SPEC §12.2「キーボード」、D-80。 +2026-09-22。[SPEC §12.2](../spec/12-ui/02-track-list.md)「キーボード」、[D-80](../decisions/D-080.md)。 - [x] 表にフォーカスがあるとき ↓ / ↑ / PageDown / PageUp / Home / End でカーソル行を動かし、素の移動はその 行だけを選択、**Shift + 移動は anchor からカーソルまでの範囲そのもの**(縮む)、Ctrl + 移動はカーソル @@ -1722,7 +1726,7 @@ Shift+↑、Esc → Shift+↓) ### P4-18 ジョブ一覧の衛生 -2026-09-22。D-81、D-68 追記。 +2026-09-22。[D-81](../decisions/D-081.md)、[D-68](../decisions/D-068.md) 追記。 - [x] (1) Inbox の周期監視をジョブの外へ: 指紋(`import::inbox::fingerprint`)が前回投入時と違うとき・ 配置待ち・期限切れの placed・起動直後だけ投入(`jobs::handlers::inbox::spawn_watcher`。Duplicate なら @@ -1740,7 +1744,7 @@ Shift+↑、Esc → Shift+↓) ### P4-19 Inbox の同名の警告 -2026-09-22。D-70 追記、SPEC §7.8 / §9 / §12.6。`SOURCE_URL` の補填漏れ・別 URL の再アップロードによる +2026-09-22。[D-70](../decisions/D-070.md) 追記、[SPEC §7.8](../spec/07-pipeline/08-inbox.md) / §9 / §12.6。`SOURCE_URL` の補填漏れ・別 URL の再アップロードによる 二重取り込みを人が気づけるようにするもので、リリース後の再移行(MIGRATION §5-3)の取りこぼし対策。 - [x] 追記先の album に同じタイトル鍵(`import::inbox::title_key` = NFKD + casefold + 空白の畳み込み。注記は @@ -1755,18 +1759,18 @@ Shift+↑、Esc → Shift+↓) ### P4-20 CD 画面の作り直しと取り込みの導線 -2026-09-23。ユーザ要望。SPEC §7.2 / §12.1 / §12.6、D-64 追記 4、D-82。 +2026-09-23。ユーザ要望。[SPEC §7.2](../spec/07-pipeline/02-cd-rip.md) / §12.1 / §12.6、[D-64](../decisions/D-064.md) 追記 4、[D-82](../decisions/D-082.md)。 - [x] MusicBrainz の照会を 3 段の打ち切りに(`discid` → `ids` → `toc`)。`POST /api/cd/lookup` に `widen`、応答に `stage` / `can_widen` - [x] `GET /api/cd/status` に TOC の音声トラック(照会の前から表を出す) -- [x] `GET /api/cd/cover/{release_id}`(Cover Art Archive の中継。D-82) +- [x] `GET /api/cd/cover/{release_id}`(Cover Art Archive の中継。[D-82](../decisions/D-082.md)) - [x] `GET /api/inbox/summary`(上部バーのバッジ用。一覧を読まない固定 SQL の集計) - [x] CD 画面: トラック表を主役に(タイトルは `Track NN` のプレースホルダ)、候補は左にジャケットを 付けて下へ、「確定」段を廃止(`CdView` を 4 ファイルに分割) -- [x] 候補から写す範囲の既定を「全部写す」に(D-72 追記 2。2026-09-23) +- [x] 候補から写す範囲の既定を「全部写す」に([D-72](../decisions/D-072.md) 追記 2。2026-09-23) - [x] **CD 画面から編集を外し、読み取り専用のライブラリ風の表に**(2026-09-23。ユーザ要望)。 - 値を直すのは Inbox の承認画面に一本化する(D-67 追記。取り込み先を Inbox にする実装は P2-5)。 + 値を直すのは Inbox の承認画面に一本化する([D-67](../decisions/D-067.md) 追記。取り込み先を Inbox にする実装は P2-5)。 `CdAlbumFields` → `CdAlbumSummary`、`CategoryField` は `components/CategoryField.tsx` へ、 cdState から編集系のアクションと paste の state を削除 - [x] 上部バー: `ライブラリ / アルバム / CD / YouTube / Inbox`、☰ に `ジョブ / 履歴 / 設定`。 @@ -1787,7 +1791,7 @@ Shift+↑、Esc → Shift+↓) **実機でしか出なかった不具合**: ジャケットが IPv6 固定では取れなかった(coverartarchive.org から archive.org へ飛ぶ二段構えで、飛び先に AAAA が無い)。`CoverArtClient` は族を引数で受けず `Auto` -固定にした(D-82 追記)。ユニットテストはローカルの HTTP サーバを模していたので素通りしていた。 +固定にした([D-82](../decisions/D-082.md) 追記)。ユニットテストはローカルの HTTP サーバを模していたので素通りしていた。 残り: なし(トラックごとの進捗・Inbox 経由の取り込み・「取り込む」ボタンは P2-5 で入れた。実機で 「取り込む」→ Inbox に件が出るまでを確認済み(P2-5 の受け入れ)) @@ -1796,7 +1800,7 @@ archive.org へ飛ぶ二段構えで、飛び先に AAAA が無い)。`CoverAr 候補ゼロや「どれも違う」で Inbox に置いた CD は MBID を持たない。後から MusicBrainz に DiscID を登録した・ リリースを見つけたときに、Inbox の承認画面から引き直して候補を選べるようにする(2026-09-23 のユーザ要望。 -D-84)。YouTube の件は対象外(曲の身元は `SOURCE_URL`。D-70) +[D-84](../decisions/D-084.md))。YouTube の件は対象外(曲の身元は `SOURCE_URL`。[D-70](../decisions/D-070.md)) - [x] 吸い出しでドライブの ISRC / MCN をサイドカーに残す(`POST /api/cd/rip` がドライブの状態から payload の `ids` に載せ、`rip_disc` → `PlaceInput.ids` → `RipEntry.isrcs` / `mcn`。旧サイドカー・旧 payload は空で読める) @@ -1821,7 +1825,7 @@ D-84)。YouTube の件は対象外(曲の身元は `SOURCE_URL`。D-70) ### P4-22 リリース前: 2 回目のクリーンリハーサルで見つかった修正 2026-09-24〜25 のリハーサル(MIGRATION §0〜§5 をカスタムアプリで通し)で見つかったもの。リリースの再移行の前に -入れる。リハーサル中に直したもの(`backfill_source_url.py` の同名曲、MIGRATION / OPERATIONS の抜け、D-89 の +入れる。リハーサル中に直したもの(`backfill_source_url.py` の同名曲、MIGRATION / OPERATIONS の抜け、[D-89](../decisions/D-089.md) の ALAC 末尾の長さ 0 のサンプル)は済み - [x] Inbox の承認画面: 購読由来の件の番号の表示。今は宛先の文言が常に「番号は <max+1> から」で、購読由来の件 @@ -1845,7 +1849,7 @@ ALAC 末尾の長さ 0 のサンプル)は済み とする(2026-09-25 ユーザの判断。購読は人が選んだ再生リストなので、除外リストの仕組みは作らない)。 リハーサル環境は外した後に 224 番の `SOURCE_URL` を `XTVG_tEhKAw` に付け替え(バッチ 21)、再同期で 224 件中 224・変更 0。SOURCE_URL の無い状態からの計画でも 224 番は `XTVG_tEhKAw` に verified で付く -- [x] Inbox の却下した件のファイルを片付ける(2026-09-25 ユーザの決定。D-90。`tests/inbox_discard.rs`、 +- [x] Inbox の却下した件のファイルを片付ける(2026-09-25 ユーザの決定。[D-90](../decisions/D-090.md)。`tests/inbox_discard.rs`、 `tests/inbox_api.rs` の discard)。今は却下してもファイルが Inbox に残り続け、 人が SMB で消すしかない。二段にする: (1) 却下(従来どおり。ファイルは残し「下書きに戻す」で戻せる)、 (2) 却下した行の「削除」で破棄待ちにする(`inbox_items` に破棄要求の時刻。ファイルはまだ消さない。一覧は @@ -1864,27 +1868,27 @@ ALAC 末尾の長さ 0 のサンプル)は済み 90.0%・ARTIST 一致 95.4%(直す前は 38.9% / 25.2% / 25.6%)。`[ytmusic].ytdlp_args` に `youtube:lang=ja` が要る(無いと 翻訳タイトルの英語が返る)。実機で明透「再会」・理芽「Tropical Therapy」を落とし直し、手直し無しで承認 → 配置 → 再同期で 9 本とも差分 0 を確認 -- [x] Inbox の既存 album への追記で `DISCNUMBER` を宛先に合わせる(D-70 追記。`omits_disc`)。YouTube の件は提案が disc 1 固定で、配置が +- [x] Inbox の既存 album への追記で `DISCNUMBER` を宛先に合わせる([D-70](../decisions/D-070.md) 追記。`omits_disc`)。YouTube の件は提案が disc 1 固定で、配置が `DISCNUMBER=1` を書くため、ディスク番号の無い既存 album(明透 178 曲・理芽 233 曲など。Library 全体で 6,659 曲が disc なし)に追記すると、並べ替え(disc → track)で追記した曲だけが末尾に回った(2026-09-25 実機。バッチ 22 で 2 曲の `DISCNUMBER` を消して復旧)。宛先の album の曲が全部 disc なしなら書かない / 提案も空にする。CD の件(複数枚)は従来どおり - [x] 同じディレクトリでファイル名の書式が混ざる(旧パイプラインの `83. タイトル` と、spindle の `165 タイトル`)→ 2026-09-25 ユーザの決定: `[layout]` を `{track:02}. {title}` にして既存の多数派に合わせ、混ざった分は album ごとの - リネームで揃える(D-43 追記。config.example / SPEC を更新。実機の config.toml とリネームは配備で行う) -- [x] `audio_version` が上がった行の RG を**自動で解析し直す**(2026-09-25 ユーザの決定。D-47 追記 2)。スキャナの + リネームで揃える([D-43](../decisions/D-043.md) 追記。config.example / SPEC を更新。実機の config.toml とリネームは配備で行う) +- [x] `audio_version` が上がった行の RG を**自動で解析し直す**(2026-09-25 ユーザの決定。[D-47](../decisions/D-047.md) 追記 2)。スキャナの 外部の差し替え(deep scan での `audio_md5` の変化を含む)と tagwrite の overlay 解消で値を捨てるとき、同じ トランザクションで rg ジョブを積む(`db::replaygain::reset_and_reanalyze`。投入単位と dedup は `POST /api/rg` と - 同じ)。リハーサルでは D-89 で 7 本の `audio_md5` が変わったとき手で `POST /api/rg` した。`tests/rg_write.rs` + 同じ)。リハーサルでは [D-89](../decisions/D-089.md) で 7 本の `audio_md5` が変わったとき手で `POST /api/rg` した。`tests/rg_write.rs` (track 単位・album gain on の album 単位・タグだけの変更では積まない・tagwrite の経路) -- [x] CD の取り込みの表の画像を承認画面の初期値に入れる(2026-09-25 ユーザの決定。D-91)。CD 画面では Cover Art +- [x] CD の取り込みの表の画像を承認画面の初期値に入れる(2026-09-25 ユーザの決定。[D-91](../decisions/D-091.md))。CD 画面では Cover Art Archive のジャケットが見えていたのに、吸い出したファイルには画像が無く、承認画面で「Cover Art Archive から取る」を 押さないと入らなかった(実機の嵐「Five」)。inbox ジョブの走査の後、サイドカーの `rip.metadata.release_id` が - ある承認前の取り込みに一度だけ front を取り(旧マイグレーション 0003 の `inbox_items.caa_picture` / `caa_tries`。D-88 追記で `0001_init.sql` へ統合)、 + ある承認前の取り込みに一度だけ front を取り(旧マイグレーション 0003 の `inbox_items.caa_picture` / `caa_tries`。[D-88](../decisions/D-088.md) 追記で `0001_init.sql` へ統合)、 提案の全曲の `picture` に入れる(保存した下書きが勝つ)。404・リリースなしは 1 回で打ち止め、上流の失敗は もう 1 回だけ。承認画面は「Cover Art Archive の表の画像(…自動で取った)」と「画像を外す」。`tests/inbox_cover.rs` (取得 → 提案・一度だけ・GC しない、404、失敗の再試行と上限、リリースなし / CD でないは取りに行かない、下書きが 勝つ、inbox ジョブの配線と既存の取り込み)、vitest の `usesCaaPicture` -- [x] Library 直下のディレクトリ名をスキャナが自動で category の語彙にする(2026-09-25 ユーザの決定。D-92)。作り直した +- [x] Library 直下のディレクトリ名をスキャナが自動で category の語彙にする(2026-09-25 ユーザの決定。[D-92](../decisions/D-092.md))。作り直した DB は語彙が空で、実機の 721 album 中 707 が category なし(Inbox / 購読 / CD の選択肢に Anime 等が出ない、 一括リネームが 7,505 曲を `_Unsorted/` へ移す提案をした)。スキャンで直下の名前を登録し album に付け、 NULL のまま残った album も次のスキャンで埋める(人が付けた値は変えない)。`_Unsorted` は登録しない。 @@ -1892,9 +1896,9 @@ ALAC 末尾の長さ 0 のサンプル)は済み `changed_ids`、大小・NFD 違いの重複なし、`[layout].unsorted` の先頭、Phase 3 中の API の追加、部分索引の EXPLAIN QUERY PLAN)、`tests/scan_job.rs`(語彙だけ増えた run の bulk)、`tests/categories_api.rs`(使われていない語彙だけ削除)、`web/src/lib/categories.test.ts`。 codex の指摘で、語彙の読み直し・`library` イベントでの一覧の取り直し・使われていない語彙の削除(設定画面)・ - 部分索引(旧 0004。D-88 追記で `0001_init.sql` へ統合)を足した + 部分索引(旧 0004。[D-88](../decisions/D-088.md) 追記で `0001_init.sql` へ統合)を足した - [x] album の edition を `EDITION` タグから入れ、片方だけが edition を持つ同名の衝突では持たない方を元の名前に - する(2026-09-25 ユーザの決定。D-43 追記)。スキャナが `albums.edition` を一度も埋めておらず、実機の + する(2026-09-25 ユーザの決定。[D-43](../decisions/D-043.md) 追記)。スキャナが `albums.edition` を一度も埋めておらず、実機の IM@S CM Solo の 2 枚(通常版 + `Hi-Res/`)がリネームで「降格に必要な値が無い: edition」だった。Hi-Res 側の 2 曲にはバッチ 26 で `EDITION=Hi-Res` を付け済み。受け入れ: `tests/pathgen.rs`(通常版 + Hi-Res、edition 無しが 2 つ、edition 同士が同じ、年で分かれる)、`tests/rename_api.rs`(スキャンで edition が入る・既存 DB の NULL が @@ -1904,7 +1908,7 @@ ALAC 末尾の長さ 0 のサンプル)は済み 画像置き場 `/api/artwork/:hash` から出す(`lib/inbox.ts` の `artworkUrl` / `trackPictureUrl` / `itemThumbUrl`)。 受け入れ: `web/src/lib/inbox.test.ts` -- [x] Inbox の配置で画像を埋め込んだ曲の `tracks.artwork_id` が空のまま(2026-09-25 実機。D-91 で CD の取り込みに +- [x] Inbox の配置で画像を埋め込んだ曲の `tracks.artwork_id` が空のまま(2026-09-25 実機。[D-91](../decisions/D-091.md) で CD の取り込みに Cover Art Archive の表の画像を自動で入れた嵐「Five」の 2 曲 = track 9101 / 9102)。ファイルには 500×500 の JPEG が 埋め込まれ、album の `artwork_id`(2244)と Derived の `src_artwork_id` は正しいが、曲の行は `artwork_id = NULL`・ `artwork_dirty = 0`。配置の曲の登録が、画像を埋め込む**前**の Inbox のファイル(画像なし)の状態を記録しているとみられる @@ -1922,9 +1926,9 @@ ALAC 末尾の長さ 0 のサンプル)は済み ### P4-23 表の画像を同じリリースグループの別の版から取る 2026-09-25 ユーザの要望。取り込む通常盤に Cover Art Archive の表の画像が無く、同じリリースグループの BD 付きや -初回限定盤にはあるとき、メタデータは通常盤のまま画像だけ別の版から取る(D-93) +初回限定盤にはあるとき、メタデータは通常盤のまま画像だけ別の版から取る([D-93](../decisions/D-093.md)) -- [x] 自動: D-91 の取得でリリースの front が 404 なら、サイドカーの `release_group_id` のリリースグループの front を +- [x] 自動: [D-91](../decisions/D-091.md) の取得でリリースの front が 404 なら、サイドカーの `release_group_id` のリリースグループの front を 同じ回で 1 度だけ試す(`CoverArtClient::group_front`、`import::cover::fetch_front`)。`tests/inbox_cover.rs` (グループへの補完・リリースに画像があればグループを引かない・両方 404 で打ち止め・グループの失敗の再試行と上限) - [x] `POST /api/artwork/from-caa` が `{ release_group_id }` と、リリースの URL の貼り付けを受ける。 @@ -1936,8 +1940,8 @@ ALAC 末尾の長さ 0 のサンプル)は済み ### P4-24 ALAC のプロセス内デコードの範囲を本番の ffmpeg に揃える -D-89 追記。2026-09-26 の本番の再移行で、FLAC 正規化の 46 本が「PCM MD5 が一致しない」で止まった(照合が働き、元の -ALAC は無傷)。D-89 は「ffmpeg は `stts` 末尾の長さ 0 のサンプルを宣言どおり捨てる」前提で symphonia 側を宣言長で +[D-89](../decisions/D-089.md) 追記。2026-09-26 の本番の再移行で、FLAC 正規化の 46 本が「PCM MD5 が一致しない」で止まった(照合が働き、元の +ALAC は無傷)。[D-89](../decisions/D-089.md) は「ffmpeg は `stts` 末尾の長さ 0 のサンプルを宣言どおり捨てる」前提で symphonia 側を宣言長で 打ち切ったが、ffmpeg は edit list(`elst`)に従っていて、46 本は終端が宣言長を 3〜64 サンプル超えていたため最後の パケットを丸ごとデコードしていた(うち 3 本は末尾に −53〜−83 dBFS のフェード)。ユーザは「ffmpeg の実際のデコード長に 合わせる」案を選び、実在しない形も実 ALAC で確かめながら再現するよう指示した @@ -1951,17 +1955,17 @@ ALAC は無傷)。D-89 は「ffmpeg は `stts` 末尾の長さ 0 のサンプ - [x] `FrameLimit`: 本番の ffmpeg 5.1 の出力範囲を再現する(先頭を media_time 削る、終端以降で始まるパケットを 読まない、終端をまたぐパケットは丸ごと)。edit list が無い・単純な形でないときは何も削らない。 `decoded_pcm_md5` / `decode_s16` / `media::decode` のプロセス内経路で、symphonia に渡す前に同じファイルから読む -- [x] 試験: `zero_last_stts_delta` に `EditEnd`(`AtDeclared` = D-89 の 7 本の形 / `Beyond` = 今回の 46 本の形 / +- [x] 試験: `zero_last_stts_delta` に `EditEnd`(`AtDeclared` = [D-89](../decisions/D-089.md) の 7 本の形 / `Beyond` = 今回の 46 本の形 / `Removed`)と `set_edit`。版に依らない形(長さ 0 のパケット、stream copy の `-ss` / `-t`、先頭の削り)は 手元の ffmpeg と比べ、版で違う形(終端をまたぐパケット、丸めの境界、「空の編集 + 区間」)は 5.1 で確かめた値に 固定する(`tests/fingerprint.rs`、`tests/decode.rs`)。先頭の音声トラックが未知の codec で 2 本目の ALAC を symphonia が選ぶ MP4 で、先頭トラックの edit list を当てないこと(トラック ID の照合を外すと落ちることを確認) -- [x] codex のレビュー(2026-09-26): トラックの取り違え・moov 内の確保量・トップレベルの走査量(major 3)と、D-89 の +- [x] codex のレビュー(2026-09-26): トラックの取り違え・moov 内の確保量・トップレベルの走査量(major 3)と、[D-89](../decisions/D-089.md) の 見出しが旧い規則のまま(minor 1)に対応 - [x] CI の rust ジョブを `rust:1-bookworm` のコンテナで走らせ、ffmpeg を本番のイメージ(Debian bookworm、5.1)と 揃える(それまでは Ubuntu 24.04 の 6.1 で、本番の挙動を CI で確かめられなかった)。`opus-tools` も本番と同じく入れる - [x] 本番の ffmpeg 5.1 との照合(本番コンテナの ffmpeg で作った FLAC の STREAMINFO MD5 と `decoded_pcm_md5`): - 実 ALAC 53 本(D-89 の 7 本 + 今回の 46 本)、実 ALAC から作った 31 の形、`elst` 付きで長さ 0 のサンプルを + 実 ALAC 53 本([D-89](../decisions/D-089.md) の 7 本 + 今回の 46 本)、実 ALAC から作った 31 の形、`elst` 付きで長さ 0 のサンプルを 持たない実 ALAC 9 本がすべて一致 - [x] 実機に入れて deep scan(46 本の `audio_md5` が変わり `audio_version` が上がる → RG / Derived がその 46 本だけ やり直される)→ 46 本だけを選んで正規化 → 全件 applied を確認。2026-09-26 に `v0.1.1` で実施: deep scan で @@ -1973,7 +1977,7 @@ ALAC は無傷)。D-89 は「ffmpeg は `stts` 末尾の長さ 0 のサンプ ### P4-25 CD の取り込みで品番(と JAN)を入れて版を識別する 2026-09-26 のユーザ要望。DiscID で当たった候補が手元の盤と別の版(通常盤に対して初回限定盤)で、取り込むと -どの盤かが落ちる。品番を手で入れて照会に使い、MusicBrainz に手元の版が無いときも品番をタグに残す(D-94) +どの盤かが落ちる。品番を手で入れて照会に使い、MusicBrainz に手元の版が無いときも品番をタグに残す([D-94](../decisions/D-094.md)) - [x] `POST /api/cd/lookup` に `catno?` / `barcode?`(手入力の JAN。盤の MCN が無いときだけ使う)。`catno` は段に 関係なく常に `ws/2/release?query=catno:"…"` を引き(上限 3 件)、label-info の品番が正規化して一致する候補に @@ -1983,7 +1987,7 @@ ALAC は無傷)。D-89 は「ffmpeg は `stts` 末尾の長さ 0 のサンプ - [x] 候補を写すとき(`applyTyped`。reducer の `withDraft` がいつも通す): 品番が一致すれば候補の表記とその レーベル、食い違えば入力値にして `BARCODE` を引き継がない(JAN の入力があればそれ)。写す範囲が「最小限」でも 入力値は書く。食い違いの注意を ③ に(`catnoMismatch`)。品番で当たった候補が 1 件なら既定で選ぶ -- [x] SPEC §7.2 / §12.6 と API 表(`/api/cd/lookup`)を更新 +- [x] [SPEC §7.2](../spec/07-pipeline/02-cd-rip.md) / §12.6 と API 表(`/api/cd/lookup`)を更新 - [ ] 実機で、DiscID が初回盤にしか付いていない通常盤を品番で引き、通常盤の候補が先頭に出ること(MusicBrainz に 無い版なら、食い違いの注意が出て `CATALOGNUMBER` が入力値・`BARCODE` が空で Inbox に届くこと) @@ -1999,15 +2003,15 @@ ALAC は無傷)。D-89 は「ffmpeg は `stts` 末尾の長さ 0 のサンプ - ~~Inbox 経由で置いた CD の album は `albums.discid` が NULL だが、DB 再構築後はスキャナがタグの `MUSICBRAINZ_DISCID` から復元する~~(2026-09-23。`albums.discid` を落とし、リリースの同一性を - MBID → album 行にした。CD とそれ以外は Inbox の追記先で混ぜない。D-67 追記 3、マイグレーション 0024) + MBID → album 行にした。CD とそれ以外は Inbox の追記先で混ぜない。[D-67](../decisions/D-067.md) 追記 3、マイグレーション 0024) -- ~~Discogs / VGMdb 連携の要否~~(2026-09-20。作らない。D-72) -- 品番で MusicBrainz に当たらない盤の予備の取得先(Discogs の `catno=` 検索)。D-72 で却下済み。P4-25 を入れたあと、 - 当たらない盤がどれくらいあるかを見て再検討する(2026-09-26。D-94) -- ~~`.fpl` 書き出しの要否~~(2026-09-20。作らない。D-72) -- ~~Inbox のポーリング間隔~~(P2-10 で決めた。60 秒 + 手動。D-68) +- ~~Discogs / VGMdb 連携の要否~~(2026-09-20。作らない。[D-72](../decisions/D-072.md)) +- 品番で MusicBrainz に当たらない盤の予備の取得先(Discogs の `catno=` 検索)。[D-72](../decisions/D-072.md) で却下済み。P4-25 を入れたあと、 + 当たらない盤がどれくらいあるかを見て再検討する(2026-09-26。[D-94](../decisions/D-094.md)) +- ~~`.fpl` 書き出しの要否~~(2026-09-20。作らない。[D-72](../decisions/D-072.md)) +- ~~Inbox のポーリング間隔~~(P2-10 で決めた。60 秒 + 手動。[D-68](../decisions/D-068.md)) - ~~一括リネーム後の旧ディレクトリに残る同梱ファイル(cover.jpg / disc.cue / rip.log)と - 空ディレクトリの扱い~~(P2-8 で決めた。D-67) + 空ディレクトリの扱い~~(P2-8 で決めた。[D-67](../decisions/D-067.md)) - ~~Library の ALAC(m4a)に任意キーを書けない~~(2026-09-21 に P4-3 の実機確認で観測 → P4-11 に昇格) - ~~Inbox の承認画面で「Library に同名の曲がある」警告~~(2026-09-22 に P4-19 で実装。判定は 「追記先の album の中で同じタイトル鍵」に絞った。albumartist 単位だと 720 行が該当して無視されるため) @@ -2030,42 +2034,42 @@ ALAC は無傷)。D-89 は「ffmpeg は `stts` 末尾の長さ 0 のサンプ - [x] P5-3b `device_scan` / `device_sync` / `device_verify` ジョブ、`adb track-devices` の常駐監視、 登録・同期・再開・破棄・検証の API、端末タブの Android 部分、`[bin].adb` / `[devices]`、 adb サイドカーと compose、実機での固定(shell v2 の stdin、toybox、Poweramp のレシーバ) - (実機の通し確認は D-98) + (実機の通し確認は [D-98](../decisions/D-098.md)) - [x] P5-4a エージェント API(pair-code・pair・manifest・ファイル・計画の確定 / 再開 / 破棄・報告)と - 端末タブの iPhone 部分(計画 docs/superpowers/plans/2026-10-01-p5-4a-agent-api.md、D-99) + 端末タブの iPhone 部分(計画 docs/superpowers/plans/2026-10-01-p5-4a-agent-api.md、[D-99](../decisions/D-099.md)) - [x] P5-4b `spindle-agent`(`agent/` クレート): state・回復・再発見・バッチ・プレイリストの入れ替え・ resolve を、ミュージック.app の操作を trait で差し替えて試す - (D-100。Music は偽実装と `UnsupportedMusic`、トークンはファイル。JXA・Keychain・コピー設定の読み取りは P5-4c) + ([D-100](../decisions/D-100.md)。Music は偽実装と `UnsupportedMusic`、トークンはファイル。JXA・Keychain・コピー設定の読み取りは P5-4c) - [x] P5-4c 実機の Mac で JXA を固定(`add` / `refresh` / `location` の付け替え / persistent ID / コピー設定 / TCC)し、CI の macOS ランナーでバイナリを作って Release に置く。 `Music` の JXA 実装、`Secrets` の Keychain 実装、コピー設定の読み取り - (D-101。実機 mbp14 で JXA を固定、Release に macOS arm64 バイナリ) -- [x] P5-5 文書の改訂(SPEC §1・§7.6・§7.7・§9・§10・§12・§13・§14、DSL.md、CLAUDE.md の禁止事項の注記) - (SPEC §7.11 を新設) + ([D-101](../decisions/D-101.md)。実機 検証用の Mac で JXA を固定、Release に macOS arm64 バイナリ) +- [x] P5-5 文書の改訂([SPEC §1](../spec/01-scope.md)・§7.6・§7.7・§9・§10・§12・§13・§14、DSL.md、CLAUDE.md の禁止事項の注記) + ([SPEC §7.11](../spec/07-pipeline/11-device-delivery.md) を新設) - [x] P5-6 端末配信の残課題のうち小さい 2 件: 印のついたプレイリストの削除を印の変更と同じ規則にする (open な作業中は 409 `open_plan`、通れば `generation` を進める)、終端した計画を GC が回収する - (`/api/gc/preview` に `plans`)(D-102) + (`/api/gc/preview` に `plans`)([D-102](../decisions/D-102.md)) - [x] P5-7 Android の保存先の引き継ぎ(DB 全損後): 端末タブの「既存の保存先を引き継ぐ」(`POST /api/devices` の `adopt: true`、プレビュー `GET /api/devices/adb/adopt-preview`)。manifest に `rel_path` / `source` を足し、 `rel_path` の `canonical_key` で `track_id` / `playlist_id` を付け替え(外れた項目は管理外)、印を復元して `device_scan` を投入する。トークンは `ADOPTED_TOKEN` で、同期は同じパス・サイズ・sha256 なら転送せず - トークンだけ書き換える(差分の op `adopted`)。Mac と id の再利用は残す(D-103) + トークンだけ書き換える(差分の op `adopted`)。Mac と id の再利用は残す([D-103](../decisions/D-103.md)) - [x] P5-8 Mac の `pair --adopt`: 新しい行の pair コードで実行すると、pair が行の uuid を前の登録(state)の uuid へ差し替え(`adopt_uuid`。行が端末の状態を持てば 409 `not_adoptable` でコードは消費しない)、 `POST /api/agent/adopt` が state の曲を `rel_path` で付け替えてキャッシュへ直接書く。state と manifest に `rel_path` / `source` を足し、`ADOPTED_TOKEN` の近道で送り直さずにトークンだけ書き換える。ミュージック.app の track(再生回数・レート)は保たれる。pending が残っていれば止め、途中で落ちたら次の `sync` が続きを行う。 - 端末タブの pair の案内に `--adopt` を添える。`--adopt` 無しの不一致は今どおり止める(D-104) + 端末タブの pair の案内に `--adopt` を添える。`--adopt` 無しの不一致は今どおり止める([D-104](../decisions/D-104.md)) - [x] id の再利用の対策(2026-10-14): GC が `tracks` の最大 id の行を消さない(SQLite は max(rowid)+1 を振る)。 missing で期限を過ぎていても `/api/gc/preview` の一覧と削除の両方から外し、削除の SQL でも再確認する。 - 後で新しい曲が入れば次の GC で消える。Mac で再生回数が別の曲に移るのを防ぐ(`tests/gc.rs`。D-105)。 + 後で新しい曲が入れば次の GC で消える。Mac で再生回数が別の曲に移るのを防ぐ(`tests/gc.rs`。[D-105](../decisions/D-105.md))。 古いバックアップへの復元は採番も巻き戻るので、手順で扱う(同期する前に端末を消して引き継ぎし直す。 `docs/OPERATIONS.md`「端末は引き継ぎし直す」) -- [x] 記録画面(2026-10-15。D-106): ☰ の「履歴」を「記録」(編集履歴 / 退避ファイル / ログ)にした。退避ファイルは設定から移して +- [x] 記録画面(2026-10-15。[D-106](../decisions/D-106.md)): ☰ の「履歴」を「記録」(編集履歴 / 退避ファイル / ログ)にした。退避ファイルは設定から移して 保持中を既定に 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` +- [x] Inbox の再判定(2026-10-18。[D-114](../decisions/D-114.md)): pending の件の YouTube 由来の曲を `POST /api/inbox/:id/rejudge`(`inbox_rejudge` ジョブ)で今のプラグインに問い合わせ直し、タグ・ファイル名・置き場所・サイドカーを初回と同じ形に書き直す。 未判定 → ok は本来の件へ移す(移動先が pending のときだけ)。Response に任意の `plugin`、サイドカーに `item` / `plugin_version` / `judged_at` / `plugin_tags` / `rejudge_error` を足し、判定の版を件の見出しと判定列に出す。 diff --git a/docs/spec/01-scope.md b/docs/spec/01-scope.md new file mode 100644 index 00000000..6dd16f8f --- /dev/null +++ b/docs/spec/01-scope.md @@ -0,0 +1,21 @@ +# 1. 目的とスコープ + +TrueNAS 上で動作する単一コンテナのWebアプリケーション。CDリッピング、メディアライブラリ管理、 +メタデータ一括編集、ReplayGain、プレイリスト管理、簡易再生、端末への配信を提供する。 +端末への配信は、Android へは USB の ADB で直接、iPhone へは Mac の `spindle-agent` が +ミュージック.app へ反映する([D-95](../decisions/D-095.md)。[§7.11](07-pipeline/11-device-delivery.md))。 +Windows版 foobar2000 の実用機能を代替し、既存CLIツール `ytmusic` を統合する。 + +## 非目標 + +- 高度な再生機能(ギャップレス、DSP チェーン、クロスフェード、ASIO/WASAPI 相当) +- マルチユーザ、権限管理、公開インターネットへの露出 +- モバイルアプリ(ブラウザで足りる) +- 音楽配信サーバ(Subsonic API 互換等)— **非対応で確定。** +- DSD / SACD ISO の取り扱い + +## 規模想定 + +- 総容量 300GB、1〜6万トラック +- 単一ユーザ、LAN 内アクセスのみ +- 常時稼働(NAS) diff --git a/docs/spec/02-glossary.md b/docs/spec/02-glossary.md new file mode 100644 index 00000000..57bea659 --- /dev/null +++ b/docs/spec/02-glossary.md @@ -0,0 +1,16 @@ +# 2. 用語 + +| 用語 | 定義 | +|---|---| +| Library | 唯一の正となるメディアツリー。1トラック=1ファイル | +| Derived | Library から生成される配布用の非可逆(`opus` 系統と Apple 向け `aac` 系統。[§7.6](07-pipeline/06-derived.md))。破棄・再生成可能 | +| Archive | アプリが再生成できない生データ(YouTube webm 等)。追記のみ | +| 配布ビュー (delivery) | 可逆なら Derived、非可逆なら Library を指す解決規則 | +| Category | パス最上位階層。統制語彙。GENRE タグとは別概念 | +| master | そのトラックについて手元にある最高品質のファイル(= Library の実体) | +| 端末 (device) | 配信先。Android(ADB 直結)か iPhone(Mac のエージェント経由)の 1 台。系統(`opus` / `aac`)と対象(全曲 / プレイリスト)を持つ([§7.11](07-pipeline/11-device-delivery.md)、[D-95](../decisions/D-095.md)) | +| マニフェスト(あるべき状態) | 端末に置くべき曲とプレイリストの一覧。保存せず毎回 Library から計算する。端末側の実態が正([D-95](../decisions/D-095.md)) | +| 写し(端末上の曲) | 端末(Mac の `~/Music/spindle` を含む)に置いた曲の複製。Library から再生成できるので、物理削除してよい([D-95](../decisions/D-095.md)) | +| 差分 | マニフェストと端末の実態の違い。曲ごとに add / update / move / update_move / delete に分け、確認してから送る([§7.11](07-pipeline/11-device-delivery.md)) | +| 計画 | 差分から作る、端末へ反映する操作の列。差分を見てからボタンで実行する(自動では送らない。[D-95](../decisions/D-095.md)) | +| エージェント (spindle-agent) | Mac で動く常駐しない CLI。spindle から計画を受け取り、ミュージック.app へ反映して結果を報告する([§7.11](07-pipeline/11-device-delivery.md)、[D-95](../decisions/D-095.md)) | diff --git a/docs/spec/03-principles.md b/docs/spec/03-principles.md new file mode 100644 index 00000000..98c51514 --- /dev/null +++ b/docs/spec/03-principles.md @@ -0,0 +1,13 @@ +# 3. 基本原則 + +1. **ファイルが正、DBはキャッシュ。** DB を消してもファイルから再構築できること。 + ただし以下は DB にしか存在しないため個別にバックアップする: + プレイリスト / 編集履歴 / 検証結果 / ジョブ履歴 / 端末の設定・印・計画・エージェントのトークン + ([§6](06-data-model.md)「端末への配信の表」。端末上の写しは Library から再生成できる複製で、これには含めない) +2. **パスは識別子ではない。** リネームは日常操作。同一性は inode と audio_md5 で解決する +3. **音声とタグを分けて版管理する。** タグ編集で再エンコードを起こさないため +4. **すべての破壊的操作はバッチ単位で巻き戻せる。** ZFS スナップショットは最後の砦であり、 + 一括編集の取り消しには粒度が粗すぎる +5. **外部からの変更を前提とする。** SMB 経由で foobar2000 や他プレイヤーが同じファイルを + 触る。アプリはファイルを排他ロックせず、再スキャンで調停する +6. **ジョブは冪等。** コンテナ再起動、電源断、途中キャンセルから安全に再開できること diff --git a/docs/spec/04-architecture.md b/docs/spec/04-architecture.md new file mode 100644 index 00000000..f1555431 --- /dev/null +++ b/docs/spec/04-architecture.md @@ -0,0 +1,45 @@ +# 4. アーキテクチャ + +``` +┌─────────────────────────────────────────────────┐ +│ Browser (React SPA, rust-embed で同梱) │ +└───────────────┬─────────────────────────────────┘ + │ REST + SSE +┌───────────────▼─────────────────────────────────┐ +│ axum HTTP server │ +│ ├── API handlers │ +│ ├── streaming (Range / on-the-fly transcode) │ +│ └── SSE event bus │ +├─────────────────────────────────────────────────┤ +│ Job scheduler (tokio) │ +│ ├── queue: rip(並列1) / scan / rg / │ +│ │ transcode / tagwrite / verify / │ +│ │ normalize / thumbnail │ +│ └── recovery on startup │ +├─────────────────────────────────────────────────┤ +│ Domain │ +│ scanner / tagger / pathgen / rg / │ +│ cdrom / accuraterip / musicbrainz / parser │ +├─────────────────────────────────────────────────┤ +│ SQLite (WAL) 外部プロセス: │ +│ cd-paranoia / ffmpeg / │ +│ yt-dlp │ +└─────────────────────────────────────────────────┘ +``` + +## 技術スタック + +| 領域 | 選定 | 備考 | +|---|---|---| +| 言語 | Rust (edition 2021+) | タグ書き込み・EBU R128・デコードが揃う唯一の現実解 | +| HTTP | axum + tokio + tower-http | | +| DB | rusqlite (bundled) + FTS5 | 書き込みは単一コネクション、読み込みはプール | +| タグ | lofty | FLAC / Opus / MP4 / WAV を一貫 API | +| デコード | symphonia | FLAC/ALAC/AAC/WAV。**Opus のデコードは非対応 → ffmpeg 経由**(demux は Opus も symphonia で可。`audio_fp` はこれを使う) | +| ラウドネス | ebur128 | libebur128 の純 Rust 移植 | +| 正規表現 | fancy-regex | 後方参照・先読みが必要(ytmusic パーサ) | +| HTTP client | reqwest | MusicBrainz / CTDB | +| FFT | rustfft | 偽ハイレゾ検出(P3、任意) | +| ファイル操作 | rustix | `openat2(RESOLVE_BENEATH \| RESOLVE_NO_SYMLINKS)`、dirfd 基準の open / rename | +| フロント | React + TypeScript + TanStack Table/Virtual | | +| 外部バイナリ | cd-paranoia, cdrdao, ffmpeg, yt-dlp, flac | コンテナイメージに同梱 | diff --git a/docs/spec/05-library-layout.md b/docs/spec/05-library-layout.md new file mode 100644 index 00000000..8f0607a9 --- /dev/null +++ b/docs/spec/05-library-layout.md @@ -0,0 +1,134 @@ +# 5. ライブラリレイアウト + +``` +/mnt/ssd/media/ (実機のプールは ssd / hdd。D-45) +├── Library/ [dataset] snapshot: 毎日 + 編集前 +│ └── <Category>/<AlbumArtist>/<Album>/ +│ ├── 1-01 Title.flac +│ ├── cover.jpg +│ ├── disc.cue CD リップ時のみ(複数枚組は disc<N>.cue。D-67) +│ ├── disc.toc CD リップ時のみ(同 disc<N>.toc) +│ └── rip.log 自前リップ時のみ(同 rip<N>.log。先頭行 `spindle rip log v1`) +├── Derived/ [dataset] snapshot: なし +│ ├── opus/<Category>/<AlbumArtist>/<Album>/1-01 Title.opus Android・Web 再生・配布ビュー +│ └── aac/<Category>/<AlbumArtist>/<Album>/1-01 Title.m4a Apple 向け(P4-8。D-75) +├── Archive/ → /mnt/hdd/media/Archive [dataset, hdd] snapshot: 週次 +│ ├── youtube/<id>.webm YouTube の原本(id 名。DB に行は作らない。D-70) +│ └── (FLAC 正規化で退避した WAV / ALAC / AIFF もここ。GC まで保持) +├── Inbox/ [dataset] snapshot: なし +│ ├── (承認前の一時領域。ハイレゾ購入分などをここへ置く) +│ └── youtube/<AlbumArtist>/<Album>/ ytdl ジョブが置く(判定できないものは youtube/_unmatched/<channel>/。 +│ spindle-inbox.json を同梱。D-70) +└── Playlists/ + ├── m3u8/ 旧ライブラリから移した m3u8(取り込み元) + └── <profile>/<name>.m3u8 書き出し(internal / android / foobar。D-53) + +/mnt/ssd/apps/spindle/ [dataset] snapshot: 毎日 +├── spindle.db SQLite +├── backup/ VACUUM INTO による日次バックアップ +├── thumbs/ ハッシュアドレスのサムネイルキャッシュ +├── verify/<album_id>.log 遡及照合の verify.log(Library には置かない。D-63) +├── tmp/ リップ・変換の作業領域 +└── config.toml +``` + +## パステンプレート + +```toml +[layout] +multi_disc = "{category}/{albumartist}/{album}/{disc}-{track:02}. {title}" +single_disc = "{category}/{albumartist}/{album}/{track:02}. {title}" +unsorted = "_Unsorted/{albumartist}/{album}/{track:02}. {title}" +``` + +- 階層は Artist ではなく **AlbumArtist**。コンピレーションは `Various Artists` +- マルチディスクはサブフォルダを作らず `1-01` 前置き(アルバム = 1ディレクトリを維持) +- 発売年はパスに含めない。DATE / ORIGINALDATE タグは必ず保持する +- パス衝突時のみ自動降格: `{album}` → `{album} ({year})` → `{album} ({edition})`。最後の段で edition を + 持たないリリースは元の `{album}` のまま(通常版と Hi-Res 版のように片方だけが EDITION を持つ同名の組は、持つ + 側だけが分かれる。edition の無いリリースが 2 つ以上、または edition が同じなら conflict)。edition は + album の構成トラックの `EDITION` タグの最頻値(`albums.edition`。スキャナ・Inbox の配置が入れ、変化の無い + 既存の album は NULL のときだけ次のスキャンで埋める。[D-43](../decisions/D-043.md) 追記) +- **衝突 = マージではない。** 配置前に MusicBrainz Release ID(無ければ album 行)で + 同一リリース判定を行い、異なる場合は必ず別ディレクトリにする。DiscID は 1 枚ごとの値なので + リリースの鍵にしない([D-67](../decisions/D-067.md) 追記 3) + +## ファイル名正規化 + +ytmusic の foo_fileops 互換置換テーブルを継承し、以下を追加: + +``` +~ → ~ * → * ∕ → / : → : > → > < → < ? → ? +Ø → O À → A ô → o è → e é → e ë → e ゔ → う +``` + +- タグ値は NFC 正規化のみ(原文字を保持)、ファイル名にのみ置換テーブルを適用 +- 表に無い禁止文字 `/` `\` `|` `"` も同じ流儀で全角化(`/` `\` `|` `"`)、制御文字は除去([D-43](../decisions/D-043.md)) +- SMB 制約: 末尾のドット・スペース禁止(削る)、予約名 (CON, PRN, AUX, NUL, COM1-9, LPT1-9) 回避 + (`_` を後置: `CON` → `CON_`)。空になった要素は `_` +- Android 側 exFAT 制約: パス長上限、`|` `"` 禁止 +- **各コンポーネント**は 255 バイト以下(ZFS / SMB の上限。パス全体の上限ではない)。 + パス全体は Windows / Android 互換のため **240 文字(UTF-16 単位)**を上限とし、 + 超える場合はタイトル部を省略記号付きで切り詰める +- 値が無いときのフォールバック: `albumartist` → `artist` → `Unknown Artist`、`album` → + `Unknown Album`、`title` → 現在のファイル名、`track` → 0、`disc` → 1([D-43](../decisions/D-043.md)) +- テンプレートの選択: category が無ければ `unsorted`、複数ディスク(`disc_count > 1` または + 構成トラックの `disc_no` の最大が 2 以上)なら `multi_disc`、それ以外は `single_disc` + +## パスの表現と境界 + +DB・API・テンプレート展開・プレイリスト出力で扱うパスはすべて **root(Library / Derived / +Archive / Inbox / Playlists のいずれか)からの相対パス**で、次を満たす文字列だけを受け付ける。 +満たさない入力は API では 400、スキャンでは「対象外」としてログに出す。 + +- 区切りは `/`。先頭 `/` なし、空コンポーネントなし、`.` / `..` コンポーネントなし、 + NUL なし、`\` を含まない +- 各コンポーネントは §「ファイル名正規化」の SMB / exFAT 制約を満たす +- 比較用に `casefold(NFD(path))` を **canonical key** として別列(`rel_path_key` / + `rel_dir_key`、Archive の `archived_files.rel_path_key` も同様)に持ち UNIQUE にする。 + ZFS は `insensitive` + `formD` なので、SQLite の BINARY 比較で別と見えるパスが同じ + ファイルを指し得る。衝突判定・リネームの一意性・2 段階更新の一時パスはすべて key 側で + 行う。表示は原文のまま +- この key は **spindle 側の保守的な同値規則**であり、OpenZFS の `u8_textprep` と同一とは + 言い切れない(ß、トルコ語の I、合字、Unicode バージョン差)。DB の key はあくまで + 事前判定で、**最終的な衝突判定はファイルシステムに任せる**: 作成は `O_EXCL`、rename は + `RENAME_NOREPLACE` を使い、失敗したら衝突として扱う。導入時に対象 TrueNAS 上で + 代表ケースの corpus を作成して両者の差を確認するテストを P0-5 に置く + +ファイルを開くときは**パス文字列を結合して open しない**。root の dirfd を起点に +`openat2(RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS)`(`rustix`)で開く。canonicalize +してから prefix を比較する方式は symlink の差し替え(TOCTOU)を防げない([D-31](../decisions/D-031.md))。 +本番は Linux 5.6+ を前提とし、`openat2` が使えなければ**起動時に失敗**する(fail-fast)。 +非 Linux の開発機では実ファイルを触る結合テストを skip し、Linux の CI で走らせる。 +`canonicalize` + prefix 比較へのフォールバックは設けない。 + +- スキャナは symlink を**辿らない**(ディレクトリもファイルも)。見つけたら対象外として + 一覧に出す。hardlink(`nlink > 1`)は inode も md5 も同一性に使わず、`rel_path_key` だけで + 解決して警告バッジを出す([D-26](../decisions/D-026.md)) +- SMB / exFAT 制約に反する名前(禁止文字・末尾ドット/スペース・予約名)は**スキャナが + 対象外にする**。したがって移行前の preflight でもブロッカーにする([§14](14-deploy.md)、P0-14) +- 書き込みの一時ファイルは**対象と同じディレクトリ**に `.spindle-tmp-<random>` で + `O_EXCL` 作成し、fsync → rename。取り残された `.spindle-tmp-*` はスキャンが回収する +- 外部コマンドは `sh -c` を使わず引数配列で起動する。パスは対応するツールでは `--` の後、 + 非対応なら `./` を前置して先頭 `-` を無害化する。可能ならファイルを開いて + stdin / stdout で渡す。タイムアウト・終了コード検査・stderr のログ出力・ + プロセスグループごとの kill を必須にする(規約) + +## Category + +- **パス決定に使う `category` と、ファイルに書く `GENRE` タグは別フィールド** + - GENRE は多値を取り得るがパスは単一値 + - MusicBrainz 由来のジャンル文字列は表記揺れが激しい + - ジャンルは後から変わる。パス直結だと数千ファイルが動く +- `categories` テーブルで統制語彙として管理、`genre_category_map` で自動推定 +- **Library 直下のディレクトリ名はスキャナが自動で語彙にする**([D-92](../decisions/D-092.md))。音声を含む album ディレクトリ + (直下 + 1 段以上。`Anime/<albumartist>/<album>`)の親になっている直下の名前を、canonical key で語彙に + 無ければ登録し、その album の category にする。`_Unsorted`(`[layout].unsorted` の先頭)と、直下の + ディレクトリそのもの・root 直下に置かれた曲は対象外。category が NULL のまま残っている album(語彙が + 後から入った既存の DB)も次のスキャンで直下の名前から埋める(NULL だけ。人が付けた値は変えない。 + 部分索引 `idx_albums_category_null` で NULL の行だけを読む)。埋めた album のトラックは `library` イベントで + 知らせ(語彙だけが増えた run は `bulk`)、画面の category の一覧は `library` イベントのたびと語彙の追加・削除のたびに取り直す。自動登録は追加だけで、不要になった + 語彙は使われていなければ `DELETE /api/categories/:id`(設定画面)で消す。 + album の category はディレクトリ名 → GENRE → `genre_category_map` の順 +- 語彙にマッチしないものは `_Unsorted/` に配置し、取り込みを止めない +- ytmusic 由来のレーベル軸カテゴリ(神椿Studio 等)も同じ語彙表に混在させてよい diff --git a/docs/spec/06-data-model.md b/docs/spec/06-data-model.md new file mode 100644 index 00000000..0ea23f1a --- /dev/null +++ b/docs/spec/06-data-model.md @@ -0,0 +1,213 @@ +# 6. データモデル + +`db/migrations/0001_init.sql` を参照。主要な設計判断のみ以下に記す。 + +## 同一性解決の優先順位 + +1. `(dev, inode)` — 外部の rename や in-place のタグ書き換えでは inode は変わらない。 + spindle 自身の tagwrite(tmp + rename、[§7.5](07-pipeline/05-tagwrite.md))は inode を変えるので、成功トランザクションで + DB を追随させる +2. `audio_md5` — FLAC は STREAMINFO に非圧縮音声の MD5 を持つ。タグ変更で不変。 + ALAC/WAV はデコードして算出。非可逆は算出しない(識別子としては使わない) +3. `rel_path_key` 一致 +4. いずれも当たらなければ新規トラック + +**どの識別子も「トラック実体の一意 ID」ではない**ので、各段で候補を検証する([D-30](../decisions/D-030.md))。 +そのために走査は**多相**で行い、判定は inventory 全体が揃ってから下す([§7.1](07-pipeline/01-scan.md)): + +| 段 | 候補の採用条件 | 外れる例 | +|---|---|---| +| inode | inventory 内でその `(dev, inode)` を持つパスが **1 つだけ**(2 つ以上なら hardlink とみなし、全候補で inode 段を無効化)、候補行が未 claim、`nlink = 1`、かつ `size` か `mtime_ns` のどちらかが一致。両方違えば `audio_md5` を計算して一致を要求 | inode 再利用(削除後に別ファイルが同じ inode を得る)、hardlink | +| inode(dev の付け替え) | 同じ `dev` の候補が無いとき、**別の `dev` に同じ `inode` を持つ行がちょうど 1 つ**あり、`size` / `mtime_ns` / `ctime_ns` が**すべて**一致。同じ実体とみなし「変更なし」で `dev` だけ現在値へ直す([D-62](../decisions/D-062.md)) | 属性が 1 つでも違う(inode 再利用と区別できない)、候補が複数 | +| audio_md5 | 候補が**ちょうど 1 行**で、その行の `rel_path_key` が **inventory 全体に存在しない**(= 移動元が消えている。走査途中の未訪問ではなく、全 stat が終わった後に判定する) | コピー元が残っている、ベスト盤との重複、無音トラック | +| rel_path_key | 候補が未 claim | — | + +- claim は `scan_runs.id` 単位(`tracks.seen_run_id`)。`seen_at` は時刻で、claim 判定には使わない + (epoch 秒では同秒の再実行と区別できない) +- 複数のパスが同じ候補行を取り合ったら `rel_path_key` の昇順で先のものが取り、後は次の段へ落ちる。 + 順序が固定なので並列に stat しても結果は決定的 +- audio_md5 の候補が複数、または元パスが inventory にまだ存在する場合は**移動ではなく新規トラック** + として登録する。自動マージはしない。同一 `audio_md5` の active 行が 2 つ以上あるものは + `duplicate_groups` ビューに現れ、UI がバッジと一覧で示す([D-29](../decisions/D-029.md)) +- `nlink > 1`、または inventory 内で同じ inode を複数パスが持つファイルは **inode 段と md5 段を飛ばし + `rel_path_key` だけで解決**し、警告バッジを出す。移行時は preflight のブロッカー([D-26](../decisions/D-026.md)) +- **復活**: どの段で採用されても、候補行に `missing_since` があれば NULL に戻す +- **missing の確定**: `scan_runs.state = 'completed'`(root を開けて walk がエラーなく完了)の finalize + でだけ、その run で claim されなかった active 行に `missing_since` を立てる。途中失敗・キャンセルの + run では立てない(SMB 一時切断で全曲が missing になる事故を防ぐ) + +## 変更検出と版の遷移 + +音声の同一性を見るフィンガープリントは形式で分ける: + +| 形式 | 音声フィンガープリント | 用途 | +|---|---|---| +| FLAC | STREAMINFO の MD5(`audio_md5`) | 同一性(移動検出)と音声版 | +| ALAC / WAV | デコードした PCM の MD5(`audio_md5`) | 同上 | +| 非可逆(opus / aac / mp3 / ogg) | エンコード済みパケット列の SHA-256(`audio_fp`。symphonia で demux のみ、デコードしない。Opus も同様) | **音声版のみ**。同一性には使わない | + +| 観測 | 判定 | 版 | +|---|---|---| +| `(dev, inode, size, mtime_ns, ctime_ns)` すべて一致 | 変更なし | `seen_at` / `seen_run_id` のみ | +| `dev` だけ違う(inode 段の「dev の付け替え」で採用) | 変更なし | `dev` を現在値へ直し、`seen_at` / `seen_run_id` | +| いずれか変化 → タグを読み `tag_hash` を再計算、音声フィンガープリントを再計算 | どちらも同じ | 版は動かさない | +| 〃 | `tag_hash` のみ変化 | `tag_version++` | +| 〃 | 音声フィンガープリントのみ変化 | `audio_version++` | +| 〃 | 両方変化 | 両方 `++` | +| spindle 自身の tagwrite / rename / RG 書き込み / MD5 補填([§7.9](07-pipeline/09-flac-check.md)) | 音声は不変と**既知** | フィンガープリントを再計算せず `audio_version` 据え置き(tagwrite は `tag_version++` 済み) | + +非可逆のタグ書き換えはコンテナサイズを普通に変えるので、`size` の変化を音声の変化と +みなしてはならない(不変条件 3)。パケット列のハッシュは demux だけで済み、外部変更が +あったファイルにしか走らない。 + +`ctime_ns` を含めるのは、mtime を保存して書き戻すタグツールや `cp -p` を検出するため。 +ZFS のスナップショット rollback は ctime も戻すので検出できない。これは **deep scan** +(全ファイルの `tag_hash` と音声フィンガープリントを再計算。`[scan].deep_interval_days` 既定 30 日、 +UI から手動実行可)で吸収する。rollback を行ったら deep scan を手動で回すこと(運用手順)。 + +`tag_version` は「同じ値の書き直し」で進めない。tagwrite の再実行や外部ツールによる +同値の再保存で Derived の追随が空回りしないため。 + +## 出自の3属性(直交) + +| 属性 | 値 | +|---|---| +| `source_type` | cd_rip / download / youtube / unknown | +| `lossless` | コーデックから自動判定 | +| `verification` | verified_ar / verified_ctdb / mismatch / **unverifiable** / not_attempted | + +`unverifiable` はハイレゾ・配信音源など TOC が存在しない音源。 +「未検証」とは意味が異なるため UI でも別バッジとする。 + +## 版管理 + +- `audio_version`: 音声バイト列が変わったら ++ → Derived 再エンコードが必要 +- `tag_version`: タグのみ変更で ++ → Derived はタグ上書きのみ + +`derived_files` に生成時の両版番号を記録し、差分で必要な処理を判定する。 +数千件の一括タグ編集後に再エンコードが走らないための中核機構。 + +配布ビュー `delivery` は Derived の存在だけでなく**版の一致**を見る: + +| 状態 | 配布するもの | +|---|---| +| `src_audio_version = audio_version` かつ `src_tag_version = tag_version` | Derived | +| 音声版は一致、タグ版のみ不一致 | Derived(`stale_tags = 1`。タグ上書きジョブが追随する) | +| 音声版が不一致(再エンコード待ち) | **Library 原本へフォールバック** | + +音声が古い Derived を配ると、修復・差し替え前の音声を正常品として配ることになる。 +タグだけ古い場合に原本へ落とさないのは、容量の大きい可逆を配布してしまうため。 + +## 全文検索(FTS5) + +`tracks_fts` は external content 方式(`content='tracks'`)。索引対象の +`title / artist_display / album / albumartist` は**すべて `tracks` に実在する列**で +なければならない(列値の取得と `rebuild` が content 表を直接読むため)。 +このため `tracks.album` を `albums.album` の複製キャッシュとして持ち、トリガで同期する。 +FTS の更新トリガは索引対象列の `UPDATE OF` にだけ張る。`seen_at` 更新のたびに +削除・再挿入が走ると、スキャンの最速パスが FTS 書き込みで律速される。 + +## 論理削除 + +`missing_since` による論理削除。SMB 一時切断やスキャン中のマウント欠落で行を物理削除すると、 +プレイリストと編集履歴が巻き添えになる。既定 7 日経過後に GC(`gc` ジョブ。missing トラック・ +アルバムの行、`Archive/` の退避ファイル、Derived の孤児、アートワークの孤児、却下して「削除」した +Inbox の取り込みのファイル([D-90](../decisions/D-090.md))を回収する。 +1 日 1 回自動、`POST /api/gc` で手動、`GET /api/gc/preview` が dry-run。[D-56](../decisions/D-056.md))。GC は保持期間を過ぎた +終端の**ジョブ行**も消す(done / cancelled は `[gc].jobs_done_days`、failed は `jobs_failed_days`。0 で +消さない。P4-18、[D-81](../decisions/D-081.md))。端末の同期の計画(`device_sync_plans`)も、終端(`completed` / `abandoned`)して +`[gc].retention_days` を過ぎたものを消す。端末ごとに最後の終端 1 件と open な計画は残す([D-102](../decisions/D-102.md))。 +GC は `tracks` の**最大 id の行を消さない**(missing で期限を過ぎていても一覧・削除の両方から外す)。 +`tracks.id` は `AUTOINCREMENT` ではなく、SQLite は次の行に `max(rowid)+1` を振るので、最大 id の行を +消すと次の曲が同じ id を得て、端末の写し(`device_items`・Mac の state)が別の曲を指す。残した行は +後で新しい曲が入れば最大でなくなり、次の GC で消える([D-105](../decisions/D-105.md))。 + +## ReplayGain の内部表現 + +- 内部は **RG 2.0 / -18 LUFS 基準の dB 値**で一元管理 +- 書き出し時にフォーマットごとへ変換: + - Opus: `R128_TRACK_GAIN` / `R128_ALBUM_GAIN`(Q7.8 固定小数、**-23 LUFS 基準**) + - FLAC / Ogg: `REPLAYGAIN_TRACK_GAIN` 等の Vorbis Comment + - MP4 (AAC/ALAC): RG 互換タグ +- Opus への変換式(`G18` = 内部の dB 値 = `-18 - 測定 LUFS`): + `R128 値 = round((G18 - 5.0) * 256)`、符号付き 16bit に飽和(-32768..32767)。 + 基準が 5 dB 低いので**必ず減算**する。テストベクトル: 測定 -18 LUFS → `G18 = 0` + → `R128 = -1280`。測定 -23 LUFS → `G18 = +5` → `R128 = 0`。 + `OpusHead` の output gain は 0 のまま触らない(合成すると再解析時に二重に掛かる) +- `rg_scanned_at` と `rg_written_at` を分離。数万件のスキャン後に書き込みが中断しても + 再スキャンなしで書き込みのみ再開できる +- **解析が済むと自動でタグに書く**(`[replaygain].write_tags = true` のとき。[D-97](../decisions/D-097.md))。書くべき行は + `tracks.rg_write_due` の印で持つ(rg の保存で確認が成り立たなくなった行と album gain の off で立ち、書き込みの + バッチを記録したときに下りる。巻き戻し・外部の変更では立たないので勝手に書き直さない。印が立った後に外部が + RG のキーを書き換えたら走査で下ろす)。Library 全体で 1 本の `rgwrite` ジョブが印の付いた active な行を album + ごとに 1 つの編集バッチにする(同じ album の rg が残っている album と反映待ちの編集がある album は後回しにし、 + キー付きの後継を 1 本だけ積む)。`write_tags = false` はタグに一切書かない(自動も手動も) +- `rg_written_at` は `rg_scanned_at` より小さくしない(`rg_scanned_at` は「前の値 + 1」へ進んで壁時計より先に + ありうる。同じ秒の確認を未書き込みに見せない。[D-97](../decisions/D-097.md)) +- **タグ書き込みは通常の編集バッチ**(`POST /api/rg/write { selection }` → tags op。旧値の + 記録・overlay・巻き戻しはタグ編集と同じ)。書くキーは形式ごとに固定で、値の無いキーは消す + (Opus: `R128_TRACK_GAIN` / `R128_ALBUM_GAIN` を書き `REPLAYGAIN_*` を消す。他形式: + `REPLAYGAIN_TRACK_GAIN` `+0.00 dB` / `REPLAYGAIN_TRACK_PEAK` `0.000000`(線形、true peak)/ + `REPLAYGAIN_ALBUM_*`。album の値が無いトラックは album のキーを消す。[D-48](../decisions/D-048.md)) +- **`rg_written_at` は「ファイルの RG タグが解析値と一致していると確認した時刻」**。書き込み + バッチの applied だけでなく、DB をファイルの現在値へ揃えるすべての経路(overlay の解消・ + 外部変更の追随・巻き戻し)で判定し直し、一致しなければ NULL に戻す。再解析で値が変わった + 行も NULL にする(秒単位の時刻では同じ秒の再解析を `<` で検出できない)。DB のタグが既に + 一致している行は op にせず `rg_written_at` だけ立てる。スキャナも外部のタグ変更を取り込んだ + 行(`tag_hash` が変わった行)で判定し直す([D-48](../decisions/D-048.md)) +- **外部で音声が差し替わった(`audio_version` が進んだ)行の解析値は捨てる**(`rg_*` と + `rg_scanned_at` / `rg_written_at` を NULL。スキャナと tagwrite の overlay 解消の両方。[D-47](../decisions/D-047.md))。 + 古い解析値を Derived や再生に使わない。album の他のトラックの `rg_album_*` は次の album 解析で揃う。 + 捨てた行は**同じトランザクションで rg ジョブを積んで解析し直す**(投入単位と dedup は `POST /api/rg` と + 同じ。タグへの書き込みは `[replaygain].write_tags` に従う。Derived は解析と書き込みが揃ってから作られる。 + [D-47](../decisions/D-047.md) 追記 2、P4-22) +- **スキャンで新規に登録したトラックにも rg を積む**(album の照合の後に投入単位を決める。既存の未解析の行は + 拾わない。Inbox を通さずに置かれた曲も漏れなく解析 → 書き込み → Derived に乗せるため。[D-97](../decisions/D-097.md)) +- album gain は `album_id` 単位で、**`albums.album_gain` が true の album だけ**計算・書き出しする(既定 + false。CD 取り込みは true、Inbox の承認画面で選ぶ。[D-74](../decisions/D-074.md)、P4-5)。**2ch 以外は album 集計から除外**(判定はデコード結果の + チャンネル数。除外されたトラックの album の値は NULL)。構成トラックが 1 本でも + デコードできなければ album 全体を書かない([D-47](../decisions/D-047.md)) +- 無音(絶対ゲート以下、積分ラウドネス `-inf`)の gain は 0 dB + +## 端末への配信の表(§7.11) + +`db/migrations/0002_devices.sql`(P5-1、[D-95](../decisions/D-095.md))。端末に何があるかの正は**端末側**(Android は +`<root>/.spindle/manifest.json`・ジャーナル・実ファイル、iPhone は Mac のエージェントの +state とミュージック.app。spindle へは報告で届く)で、DB の曲とプレイリストの表はそのキャッシュ(不変条件 +1 を端末にも当てはめる)。 + +| 表 | 中身 | 正 / キャッシュ | +|---|---|---| +| `devices` | 端末 1 台 1 行。`uuid`(128 bit 乱数。端末側の manifest / Mac の `.spindle-device` との照合に使い、再利用しない)、`name` / `name_key`(`canonical_key`。一意)、`transport`(`adb` / `agent`)、`variant`(`opus` / `aac`)、`selection`(`all` / `playlists`)、`generation`(下記)、adb は `adb_serial`(一意)/ `adb_volume`(`emulated` か SD の UUID)/ `adb_root`(ボリューム内の相対パス。登録時は `Music/spindle`)、agent はトークン(`agent_selector` とシークレットの SHA-256)とワンタイムコード(`pair_selector`、argon2id の `pair_code_hash`、期限、試行回数。[D-99](../decisions/D-099.md))、`last_synced_at`(adb は同期の完了、agent は報告の受理) | DB にしか無い(端末の設定) | +| `device_playlists` | 端末 × プレイリストの印 | DB にしか無い | +| `device_items` | 端末に反映済みの曲: `track_id`、`dest_path`(root 相対)/ `dest_path_key`(端末内で一意)、配信トークン、`size`、`sha256`、`synced_at`。`track_id` は FK にしない(GC で `tracks` の行が消えても削除の差分を出すため) | キャッシュ | +| `device_playlist_state` | 反映済みのプレイリスト: `dest_path`(adb は `Playlists/<名前>.m3u8`、agent はプレイリスト名そのもの。[D-99](../decisions/D-099.md))とトークン。`playlist_id` は FK にしない | キャッシュ | +| `device_errors` | 最後の同期・報告で出た項目(`track` / `playlist`)ごとのエラーの理由。差分表の理由に出す | キャッシュ(同期の完了・エージェントの報告と破棄で全置換。差分の計算だけの `device_scan` は前回の分を残す) | +| `source_hashes` | 送る元(`master` / `opus` / `aac`)ごとの SHA-256 と、取ったときの意味トークン・物理同一性(inode・size・mtime_ns・ctime_ns。dev は持たない。[D-62](../decisions/D-062.md)) | キャッシュ(`source_hash` ジョブで取り直せる) | +| `device_sync_plans` | 確定した計画。`plan` は不変の JSON(版 `v`・`generation`・`plan_token` と、曲の操作(`op_id`・種類・`track_id`・from・to・トークン・size・sha256)とプレイリストの操作(`op_id`・種類・`playlist_id`・from・to・トークン)の列)、`state`(`open` / `completed` / `abandoned`)、`job_id`(adb)、`report_digest`(agent の報告の再送の照合)。端末ごとに `open` は 1 つまで(部分 UNIQUE 索引) | DB にしか無い | + +- `devices.generation` は端末の選曲に効く設定(`selection` / `variant` / 印)を API で変えるたびに 1 進める + (名前の変更では進めない。印のついたプレイリストの削除でも、open な作業が無ければ進める。[D-102](../decisions/D-102.md))。計画とジョブと報告はそれを持ち、食い違えば実行・受理しない([§7.11](07-pipeline/11-device-delivery.md)「計画」) +- `device_items` / `device_playlist_state` は端末側の正本を読んだ時点で**全置換**する(adb は回復の後と同期の + 完了、agent は報告・破棄の受理)。部分的な更新はしない +- 端末の行と計画・キャッシュが消えるのは端末の削除(`ON DELETE CASCADE`。端末上のファイルには触らない)。 + 印はそれに加えてプレイリストの削除でも消える(`device_playlists.playlist_id` も CASCADE)。印のついた + プレイリストの削除は印の変更と同じ規則で、印のある端末に open な作業(open な計画か待ち・実行中の + `device_sync`)があれば 409 `open_plan`、通れば印のある端末の `generation` を進める(同じトランザクション。 + [D-102](../decisions/D-102.md))。`source_hashes` は `tracks` の行の削除で消える。GC は終端した計画のうち、閉じてから + `[gc].retention_days` を過ぎたものを消す。端末ごとに最後の終端 1 件(報告の再送を 200 で受ける保険)と + open な計画は残す([D-102](../decisions/D-102.md)) +- 同じマイグレーションで `playlists.evaluated_at`(スマートプレイリストを評価した時刻。差分画面に出す)を + 足し、`jobs.type` に `source_hash` / `device_scan` / `device_sync` / `device_verify` を足した + (`playlist_sync` も同時)。0003 の `inbox_item_tracks` は Inbox の件で配置した曲で、件の「配置 → RG → + 系統 → 端末」の段に使う([D-95](../decisions/D-095.md) の P5-2 追記) +- **キャッシュ表だけを失い、`devices` の行と認証情報が残った場合**は、曲とプレイリストのキャッシュは次の回復・ + 報告で戻る。**DB 全体を失うと** `devices` の行・`devices.uuid`・エージェントのトークンも失われ、端末側の + manifest / `.spindle-device` と照合できず、回復・報告を受ける行も無い。**Android** は端末タブの「前の登録の + 保存先(引き継げる)」で、manifest の uuid の行を作り、曲の `rel_path` で `track_id` を付け替える + ([§7.11](07-pipeline/11-device-delivery.md)「引き継ぎ」、[D-103](../decisions/D-103.md))。 + **Mac** は端末タブで新しく作った行の pair コードで `spindle-agent pair --adopt` を実行し、行の uuid を + エージェントの state の uuid へ差し替えてから、state の曲の `rel_path` で `track_id` を付け替える + ([§7.11](07-pipeline/11-device-delivery.md)「iPhone」の「引き継ぎ」、[D-104](../decisions/D-104.md))。ミュージック.app の track(再生回数・レート・追加日)はそのまま残る。 + Android でバックアップを復元して行の uuid が + 端末の manifest と食い違うときは、端末を削除(端末上のファイルには触らない)してから引き継ぐ diff --git a/docs/spec/07-pipeline/01-scan.md b/docs/spec/07-pipeline/01-scan.md new file mode 100644 index 00000000..8da69858 --- /dev/null +++ b/docs/spec/07-pipeline/01-scan.md @@ -0,0 +1,80 @@ +# 7.1 スキャン + +走査は 4 相。同一性の判定は inventory 全体が揃ってから行う(走査途中では「移動元が消えた」 +を判定できない)。DB への書き込みは最終相で単一 writer トランザクションにまとめる。 + +``` +scan_runs に行を作る(state='running') +Phase 1 inventory: walk(Library) を並列に stat(symlink は辿らず一覧へ)。 + (rel_path_key, dev, inode, nlink, size, mtime_ns, ctime_ns) をメモリに固定 +Phase 2 candidates: inventory の各エントリと既存行の候補を、inode → audio_md5 → rel_path_key の + 順で列挙(§6 の採用条件。md5 段の「移動元が消えている」は inventory 全体で判定)。 + 解決が要求しうる audio_md5(inode 一致で size も mtime も違う行が md5 を持つ / + 移動候補があるときの未決エントリ)だけを先に並列で計算し、進捗を出す。初回や + 移動の無い増分では要求が無く、可逆のデコードは Phase 3 に回る(D-50) +Phase 3 claim: rel_path_key 昇順で決定的に採用。取り合いに負けたエントリは次の段へ。 + 変更ありのエントリはここでタグ読込・フィンガープリント計算(並列) +Phase 4 commit: 1 トランザクションで + a. 採用行の rel_path / rel_path_key を「予約済み一時 key」へ全件移す + b. 最終値へ全件移す(swap rename A↔B や循環でも UNIQUE を踏まない) + c. 物理属性・タグ・版・seen_run_id・復活(missing_since=NULL)を反映 + d. 新規行の挿入 + e. album 照合(下記)。album の rel_dir も同じ 2 段階で更新 + f. state='completed' なら、この run で claim されなかった active 行に + missing_since を立てる。failed / cancelled では立てない + FTS はトリガで追随 +``` + +**アルバムの照合**(ディレクトリ rename で album id を失わないため。[D-32](../../decisions/D-032.md)): + +``` +ディレクトリ D に今回見つかったトラック集合 T(D) について + 1. T(D) の行が直前まで属していた album を数える + 2. mb_release_id が一致する既存 album が**ちょうど 1 つ**で、その旧 rel_dir が + inventory に無ければそれ(複数一致なら自動では寄せない → 3 へ) + 3. なければ、T(D) の過半数が属していた album A で、A の旧 rel_dir が inventory に無いもの + → A.rel_dir を D に書き換える(id 維持。verifications / artwork が残る) + 4. どれにも当たらなければ新規 album +分割(一部だけ別ディレクトリへ): 移った側は新規 album、残った側は id 維持 +統合(2 ディレクトリが 1 つに): 最多の album が id を維持、他は構成 0 になり + albums.missing_since を立てる(行は消さない。GC が回収) +``` + +- 初回フルスキャンは並列度 = CPU コア数。以降は最速パス([§6](../06-data-model.md))で大半を飛ばす +- 外部(foobar2000 等)による書き換えを検出したら DB を上書きする。 + **ファイルが常に勝つ**(原則1) +- **例外: ファイル反映待ちのトラック(`edit_ops.result = 'pending'` がある)は、 + pending の op が所有する論理フィールドを再評価しない。** 一括編集は DB を先に更新し、 + ファイルへの反映は非同期なので([§7.5](05-tagwrite.md))、この窓で「ファイルが勝つ」を適用すると + 編集が DB から巻き戻され、後続の tagwrite と食い違う。 + ただし**物理的な所在は pending 中も常に追随する**: `(dev, inode)` で見つけた + ファイルの `rel_path` / `size` / `mtime_ns` / `ctime_ns` は更新する。外部 rename は inode も + mtime も変えないので、これを止めると tagwrite が旧パスを開けなくなる。 + 抑止するのは `tags` op ならタグとキャッシュ列・`tag_version`、`rename` op なら + `rel_path`(外部 rename と衝突したら op を `skipped_conflict` にし、同じトランザクションで + `rel_path` を実在パスへ追随させる。[D-43](../../decisions/D-043.md))だけ。 + 外部 rename の**宛先が別の行(missing 行を含む)に占有されていた**場合は、Phase 4 で + その行の missing 確定と key の入れ替えを同じトランザクションで行う +- 走査対象は Library のみ。Derived / Archive はスキャンしない + +**アートワーク**(Phase 5、commit の後。[D-49](../../decisions/D-049.md)): + +- album のアートワークは、ディレクトリの**同梱カバー画像**(`cover` / `folder` / `front` × + `jpg` / `jpeg` / `png` / `webp`。名前 → 拡張子の優先順、大文字小文字は区別しない)があれば + それ、無ければ**構成トラックを `disc_no` / `track_no` / `rel_path` 順に見て最初に見つかる埋め込み + 画像**(front cover を優先)。形式はバイト列のヘッダで判別し、拡張子やタグの MIME は信用しない +- 画像は SHA-256 でハッシュアドレスし(`artwork` 表。行は消さない)、原画像を**元の形式のまま** + `<data>/thumbs/<hex>/orig.<ext>` に置く。サムネイル(`<size>.webp`、256 / 768。長辺、拡大なし)は + `thumbnail` ジョブが ffmpeg で作る。Library には何も書かない +- Phase 4 は行が変わったトラックの現在の album と直前まで属していた album の + `albums.artwork_resolved_at` を NULL にして再解決を**予約**する(同じトランザクション)。 + Phase 5 が解決し直すのは deep なら全 album、それ以外は「予約された(NULL)」「同梱画像の有無・ + stat(inode / size / mtime / ctime。`albums.cover_*`)が前回と違う」「参照中の原画像がキャッシュに + 無い」album。missing の album は触らない。決められない album(I/O 失敗、読んでいる間に同梱画像が + 変わった、構成トラックを読めない)は状態を動かさず次回やり直す。同梱画像が画像として認識できない + ときは埋め込みへ倒し、stat は記録する(変わるまで読み直さない) +- Phase 4 の commit 後なので、Phase 5 の cancel / 失敗は run の状態(completed)と missing の確定を + 戻さない。予約が残るので次のスキャンで続きを行う +- **トラック自身の画像**([D-61](../../decisions/D-061.md)): Phase 3 が各トラックの埋め込み画像(front cover 優先)を読んで + キャッシュへ置き、Phase 4 が `tracks.artwork_id` に記録する(無ければ NULL)。tagwrite の書き戻しも + 同じ。変更なしの行は読まないので、既存行は deep scan で埋まる diff --git a/docs/spec/07-pipeline/02-cd-rip.md b/docs/spec/07-pipeline/02-cd-rip.md new file mode 100644 index 00000000..24ff54b4 --- /dev/null +++ b/docs/spec/07-pipeline/02-cd-rip.md @@ -0,0 +1,136 @@ +# 7.2 CD 取り込み + +``` +[ディスク検出] CDROM_DRIVE_STATUS ioctl を 2 秒間隔ポーリング(`cd/device.rs`) + ↓ (udev がコンテナに届かないため)。デバイスを開けなければ no_drive として起動は続ける +[TOC 取得] CDROMREADTOCHDR / CDROMREADTOCENTRY ioctl(LBA 形式。中身は READ TOC コマンドで、 + ↓ /dev/sg* も外部プロセスも要らない)。DiscOk になってから読めるまで毎周回試し、 + ↓ 読めたらディスクが抜かれるまで読み直さない。`GET /api/cd/status` が CTDB 形式の + ↓ 文字列で返し、UI は新しいディスクの TOC が出たら照会を自動で始める + ↓ +[ID 算出] MusicBrainz DiscID / AccurateRip id1,id2 / FreeDB ID + ↓ すべて TOC からの整数演算。libdiscid FFI 不要 +[メタデータ照会] MusicBrainz → 候補提示 → ユーザ確認・手動補正 + ↓ 候補は「リリース × medium」(DiscID を持つ medium は exact、トラック数の合う medium は + ↓ 近似。D-64)。**経路は 3 段で、上の段で候補が残れば下は引かない**(D-64 追記 4): + ↓ + ↓ 段 引くもの 次の段へ進む条件 + ↓ discid ws/2/discid/<id> 404、または 200 でも候補 0 件 + ↓ ids ISRC(recording 検索)+ MCN = バーコード 候補 0 件 + ↓ (release 検索) + ↓ toc ws/2/discid/<id>?toc=(TOC 近似) — + ↓ + ↓ 「候補」はトラック数で絞ったあとの数で数える。DiscID が 200 でも候補 0 件なら次の段へ + ↓ 落とすが、exact は真のままにする(DiscID は登録済みなので登録を勧めない)。ユーザが + ↓ 貼ったリリース URL / MBID は段に関係なく常に足し、toc へ進むかの判定では候補に数える + ↓ (ids は引く)。ユーザが盤の帯・背から読んで入れた品番(`ws/2/release?query=catno:"…"`)も + ↓ 段に関係なく常に引き、カタログ番号が正規化して一致するリリースだけを経路 catno の候補にして + ↓ 先頭に並べる(DiscID は収録が同じ版を区別できないため。D-94)。手入力の JAN は盤の MCN が + ↓ 無いときだけ ids の段のバーコード検索に使う。 + ↓ 同じ medium は 1 件に束ねて経路(matched_by)を付ける(D-64 追記)。 + ↓ TOC 近似はトラック長の近い別の盤を大量に返すので最後の手段。応答の stage / can_widen で + ↓ 画面に段を見せ、「さらに広げて探す」(widen)で明示的に下の段まで引ける。候補を選んだら DiscID の登録リンク + ↓ ※同人・VTuber・インディーズ国内盤は MusicBrainz 未登録が常態。 + ↓ 照会結果ゼロでもウィザードが完走できることを必須要件とする。 + ↓ 候補を写す先は 1 つのフォーム(D-65。写す範囲は既定で「全部写す」、「識別用の + ↓ 最小限」を選べる。D-72 追記 2、P4-2 / P4-20)。**CD 画面では直せない**(D-67 追記。 + ↓ 補正とトラックリスト貼り付けは Inbox の承認画面)。例外は手入力の品番と JAN(D-94)で、 + ↓ 写す範囲に関わらず下書きに重ねる。品番が選んだ候補と食い違えば(手元の版が MusicBrainz に + ↓ 無い)品番は入力値にし、候補の JAN は写さない。MB の id は近い版を指す値として残す。これが + ↓ DiscMetadata(album / album_artist / date / label / catalog_number / barcode / + ↓ disc_no / disc_count / tracks[{ number, title, artist, mb }]、source)になる。 + ↓ トラックリスト貼り付け(通販ページ等からのテキストを行解析して + ↓ トラック番号・タイトル・アーティストへ割り付け。web/src/lib/tracklist.ts)は + ↓ **Inbox の承認画面の**一級の入力経路とする(D-67 追記。CD 画面からは外した)。 + ↓ 行は TOC の音声トラックと 1:1 で、番号と長さは TOC から + ↓ +[オフセット決定] INQUIRY でドライブ型番取得 → 学習済みの値(drive_offsets)→ AccurateRip の + ↓ ドライブ表(DriveOffsets.bin を実行時に取得・保存)→ 0(D-83 / 追記 2) + ↓ `[rip].drive_offset` の整数で上書き可。照合で見つかったずれは PCM に当てて学習する +[吸い出し] cd-paranoia -e -r -d <dev> -- <first>-<last>[mm:ss.ff] で音声トラック全体を 1 本の PCM に + ↓ (オフセット 0 で読み、範囲は TOC の長さで明示。`cd::rip::read_disc`。D-83) + ↓ ※トラック単位で吸うとオフセット補正が境界をまたげない +[オフセット適用 → トラック分割] + ↓ +[CRC 計算] ARv1 / ARv2 / CTDB CRC32 + ↓ 先頭トラック冒頭・末尾トラック終端の除外規則を厳守 +[照合] CTDB を主、AccurateRip を補助 + ↓ +[エンコード] FLAC (master) を **Inbox へ**(D-67 追記。Library へ直行しない)。 + ↓ tmp の PCM は検証完了まで保持 +[ログ出力] rip.log / disc.cue / disc.toc を取り込みのディレクトリへ。MusicBrainz から写した内容と + ↓ RipReport(照合結果)は サイドカー spindle-inbox.json に書く(D-70 の仕組み) +[承認] Inbox の承認画面で名前と category を直して「承認して配置」 + ↓ → 配置が Library へ運び、source_type = cd_rip と検証記録を入れる +[後続ジョブ投入] rg(album 単位。配置した album は album gain on。D-74)→ transcode(Derived) → thumbnail +``` + +Inbox への配置(`src/cd/place.rs` の `place_disc`、[D-67](../../decisions/D-067.md) 追記、P2-5): rip ジョブの最終段。 +入力は `Toc`、吸い出しを始めたときの `DiscMetadata`(**名前は空でもよい**。`validate` は TOC との +行の対応・日付・ディスク番号・category だけを見る。必須の検証は Inbox の承認が担う)、オフセット +適用済みの tmp の PCM、`RipReport`。PCM を `TrackLayout` で切り、raw のまま `flac -N --verify` で +エンコード(トラックごとの PCM の MD5 と STREAMINFO の MD5 が一致するときだけ成果物)、タグは +下書きの写像([D-65](../../decisions/D-065.md))+ `TRACKTOTAL` / `MUSICBRAINZ_DISCID`。空のタイトルは `Track NN`、空の名前は +タグに書かない。`category` はタグに書かず(パス専用。[SPEC §5](../05-library-layout.md))サイドカーで渡す。 + +- **組み立ててから公開する。** Inbox 直下の隠しディレクトリ `.spindle-rip-<DiscID>` に `NN.flac` + (TOC の番号。名前での対応付けの鍵なので承認で変わる値を入れない)、同梱ファイル、サイドカー + (`category` と `rip`。[§7.8](08-inbox.md))を置き、`CD/<albumartist - album> [<DiscID>]`(名前が空なら + `CD/[<DiscID>]`。DiscID は `.` で始まり得るので括弧で包む)へディレクトリごと `RENAME_NOREPLACE`。 + 走査は `.` で始まるディレクトリを見ないので、揃う前の盤が取り込みとして見えて承認されることはない。 + 公開したら `inbox` ジョブを投入する。複数枚組は DiscID が違うのでディスクごとに別の取り込みになる +- **冪等性。** 公開先が既にあり、全トラックの STREAMINFO の MD5 が自分の PCM と一致し、サイドカーの + 記録が同じファイル名を持てば自分の成果物(公開の後に落ちた再実行)として組み立てを飛ばす。違えば + 衝突。前の実行の組み立ての残骸は消して作り直し、公開が衝突したら組み立てたものを消す +- `albums` / `tracks` / 検証記録の登録、パスの計画(`[layout]`)、`library` の排他、`rg` / `transcode` の + 投入は承認後の Inbox の配置([§7.8](08-inbox.md))が行う + +同梱ファイルは 1 枚なら `disc.cue` / +`disc.toc` / `rip.log`、複数枚組は `disc<N>.cue` / `disc<N>.toc` / `rip<N>.log`。`disc.cue` は +EAC 流の複数ファイル cue(ギャップは前トラック末尾。INDEX 00 は書かない)、`disc.toc` は `Toc` から +cdrdao 構文で生成、`rip.log` は先頭行 `spindle rip log v1` の自前形式でドライブ・オフセット・ +トラックごとの CRC と照合結果を持つ。トラック表の「ずれ」は、cd-paranoia が読み取り位置のずれ(ドライブの +ジッター)を検出・補正した回数(drift / dropped / duped。`TrackRead.slips`。補正の通知なので回数だけでは誤りを +意味しないが、多発と照合の不一致が併発した)。1 回でもあれば表の下に合計と +意味を、試行ごとの合計(`RipReport.attempt_slips`)を「試行ごとのずれ」に出す。照合が通らずずれがある取り込みは、 +Inbox の承認画面の警告にも出る(P2-5 の調査。TASKS)。スキャナはこの rip.log のあるディレクトリで新規に登録する行を +`cd_rip` にする(DB を消しても出自が戻る。検証は [§7.3](03-retro-verify.md) で付け直す)。 + +同梱ファイル(cover / disc.cue / disc.toc / rip.log)は album 全体の一括リネームに追随する: rename +ジョブが phase 2 の後、commit トランザクションの中で既知の名前のファイルを新ディレクトリへ移し、空になった +旧ディレクトリを消す([D-67](../../decisions/D-067.md)。`edits` には記録せず、巻き戻しは逆向きの移動で戻る)。 + +不一致時の既定動作: 自動再リップ(最大2回)→ CTDB 修復データ適用 → +それでも不一致なら `mismatch` フラグ付きで取り込み、UI で要確認表示。 + +CTDB の修復(`src/cd/repair.rs`、[D-66](../../decisions/D-066.md)): ディスクの 16 bit 語を 11760 語ずつの行に並べ、列ごとに +GF(2^16) の Reed-Solomon 符号語とみなす(データ行は先頭 1 行と末尾 1 行 + 端数を除く。CTDB の +ディスク CRC の範囲と同じ)。1 回目の走査でシンドローム表(`SyndromeSampler` → `SyndromeTable`。 +80 分で 3〜5 秒)を作り、エントリの `syndrome`(列 0)でオフセットを探す(`find_offset`、±2939)。 +誤りがあれば `hasparity` のパリティファイル(各列のシンドローム。`CtdbClient::fetch_syndromes` が +Range で先頭 npar 面だけ取る)と突き合わせ、列ごとに Berlekamp-Massey → Chien → Forney で位置と値を +出す(列あたり npar/2 個まで。1 セクタ丸ごとの傷は 1176 列に 1 個ずつなので直る)。直した後の +ディスク CRC がエントリの値に一致するときだけ計画(`RepairPlan`)を採用し、2 回目の走査で +`RepairApplier` が語を XOR する。直せなければ再リップ / `mismatch` へ。吸い出しジョブへの配線は +P2-5 で行う + +**ドライブは物理的に1台なので rip キューの並列度は 1 に固定する。** + +**進捗**(P4-20 で形を決め、P2-5 で確定): rip ジョブは SSE の `job` イベントの `detail` に `RipProgress` +(`src/cd/rip.rs`。DB には書かない)を流す。 + +``` +{ "phase": "read|verify|repair|encode|place", "attempt": 1, "disc_no": 1, "track_no": 3 | null, + "done": 2, "total": 12 } +``` + +- `phase` は相。`read` はドライブから全ディスクを 1 本で読む(`done` / `total` はセクタ、`track_no` は読んでいる + 位置のトラック)、`verify` は CRC を取って照合(バイト。トラックに分かれない)、`repair` は CTDB のパリティで直す + (バイト)、`encode` は FLAC にする(トラック。`track_no` は**エンコードし終えた**トラックで、`done` が 0 の最初の + 報告はまだどれも終えていない)、`place` は Inbox に置く +- `attempt` は何回目の吸い出しか(1 始まり。照合が通らず吸い直すと `read` から増える)。`disc_no` は複数枚組の + ディスク番号(`rip<N>.log` / `disc<N>.cue` と揃える。[D-67](../../decisions/D-067.md)) +- CD 画面のトラック表([§12.6](../12-ui/06-other-screens.md))は進捗が来ている間だけ右端に `進捗` 列を出す(`lib/cdRip.ts`)。 + 読み取り中は読み終えた行に「読んだ」・読んでいる行に「読み取り中」、照合・修復は全行に「照合中」/「修復中」、 + エンコードは終えた行に「FLAC 済」・次の行に「エンコード中」、置いたら全行に「Inbox へ」。状態の一行は + 「読み取り 45%」「エンコード 60% 3/5 曲」の形で、2 回目以降は吸い直している旨を添える diff --git a/docs/spec/07-pipeline/03-retro-verify.md b/docs/spec/07-pipeline/03-retro-verify.md new file mode 100644 index 00000000..cd537c94 --- /dev/null +++ b/docs/spec/07-pipeline/03-retro-verify.md @@ -0,0 +1,50 @@ +# 7.3 遡及照合(ログなし既存 FLAC) + +CUETools の "verify from files" 相当。 + +``` +アルバムをディスク(disc_no)ごとに分け、全トラックが揃っているか確認 + → 各トラックのサンプル数(STREAMINFO)から TOC 再構成 + offset = 150 + Σ(前トラックのセクタ数) + → AccurateRip DiscID / MusicBrainz DiscID を算出 + → デコードして CRC 表を作る(1 回流すだけで ±2939 サンプルの全オフセットの CRC が出る) + → CTDB / AccurateRip 照会 → オフセットを探して照合 + → verify.log 出力(data/verify/<album_id>.log。rip.log とは別物) +``` + +照合ログは album ごとに最新 1 回分しか残らない。記録画面のログタブ([§12.4](../12-ui/04-records.md))が `GET /api/logs` で実行ごとに +並べ、本文を `GET /api/logs/{key}/text` で読む(古い実行は「上書きされた」と出す。[D-106](../../decisions/D-106.md))。 + +`verify` ジョブ(album 単位、並列 2。`POST /api/verify { selection }`)。読むだけでファイルは +書かない。ファイルは root の FD を fstat して DB の行と照合してから読む([D-62](../../decisions/D-062.md))。 +照会の 404 は「登録なし」(0 件)として扱い、失敗にしない(AccurateRip は `dBAR-*.bin` が無い、 +CTDB は `lookup2.php` が登録の無い TOC に本文 `Not Found` で返す)。それ以外の非 2xx は照会失敗で再試行。 + +適用条件: + +- **44.1kHz / 16bit / 2ch の FLAC のみ。** それ以外を含むディスクは `unverifiable` + (TOC が存在しない音源。品質の劣後ではない) +- 各トラックのサンプル数が **588 の倍数**(1セクタ)であること。 + 端数があれば CD 由来でないか加工済みと判定して `unverifiable` +- トラック番号が 1 から連続していること(不完全なディスクは TOC を作れないので何もしない) +- **オフセットは探す**([D-63](../../decisions/D-063.md))。DB に登録された値は他人のドライブで吸ったもので、読み取り + オフセットの補正が違えば同じ盤でも数十サンプルずれる。CUETools と同じ ±(5×588−1) の範囲で + 「一致したトラック数 → 信頼度の和 → 0 に近い」順に 1 つ選び、`detected_offset` に残す + (ディスクで 1 つ。トラックごとに別のオフセットは採らない)。AccurateRip v2 はオフセットに + 対して線形でないので 0 だけ、それ以外は v1 で比べる +- CTDB は `fuzzy=1` で別リリースも返るので、音声部分の長さと音声トラック数が同じエントリだけを + 候補にする。AccurateRip の ID は CUETools / dBpoweramp 式(Enhanced CD でも実リードアウト) +- トラックごとに: CTDB 一致 → `verified_ctdb`、AccurateRip だけ一致 → `verified_ar`、 + 候補はあるが不一致 → `mismatch`、どちらにも候補なし → 据え置き(`not_attempted`)。 + 結果は `album_verifications`(手法 × ディスク。履歴として積む)と `track_verifications` + (自分の CRC と一致の有無)に残す +- 照会に失敗したらジョブを失敗させて再試行し、何も記録しない(不一致を「照会できなかった」で + 汚さない)。記録は 1 トランザクション(ディスク × 手法の行、トラックの行、`tracks.verification`)で、 + 照合を始めたときの `audio_version` が 1 本でも進んでいれば何も書かない。verify.log は tmp に書き、 + トランザクションの中で本来の名前に rename してから commit する。`album_verifications.job_id` で + 同じジョブの再実行(commit の後に落ちた場合)を見分け、何もしない(履歴もログも初回のまま) +- ジョブの結果 1 行(`jobs.note`)にディスクごと・手法ごとの結果(一致数と信頼度 / 登録なし / + 検証不能・照合しなかった理由)を残し、操作タブが投入したジョブを追って出す([D-96](../../decisions/D-096.md))。結果 1 行は記録と + 同じトランザクションで `jobs.note` に書き、commit の後に落ちた再実行はそれをそのまま返す +- **不一致は不良を意味しない。** ギャップ処理差、隠しトラック、データトラックの存在で + 普通に外れる。`mismatch` は「要確認」として扱い、警告色で表示しない diff --git a/docs/spec/07-pipeline/04-lossless-normalize.md b/docs/spec/07-pipeline/04-lossless-normalize.md new file mode 100644 index 00000000..faa44e8d --- /dev/null +++ b/docs/spec/07-pipeline/04-lossless-normalize.md @@ -0,0 +1,53 @@ +# 7.4 ロスレス正規化(WAV / ALAC / AIFF → FLAC) + +Library のロスレスは FLAC に統一する([D-45](../../decisions/D-045.md))。移行で取り込んだ ALAC も、取り込まれた WAV / +AIFF も、同じ機構で FLAC にする。 + +``` +POST /api/normalize/preview → /apply(selection。rename と同型。D-46) + → edit_batches + edit_ops(kind='archive', pending) + edits(rel_path 旧→新, codec 旧→'flac') + を記録し、track 単位の normalize ジョブを投入。DB は先行更新しない +normalize ジョブ(track をロック) + → 事前条件(stat + tag_hash)を確認。外れていれば skipped_conflict + → symphonia でデコードして PCM MD5 算出 + → ffmpeg でデコード → flac -8 --verify でエンコード(data/tmp)。タグと画像は lofty で移す + → 生成 FLAC の STREAMINFO MD5 と突き合わせ + ├ 一致 → Library の同じディレクトリに tmp → fsync → RENAME_NOREPLACE で <name>.flac を置く + │ → 元ファイルを同じディレクトリの一時名 spindle-normalize-<op_id>.<ext> へ退避 + │ (inode を確認。外部の tmp + rename と分離する) + │ → Archive/ の同じ相対パスへ実コピー(SHA-256 を照合し、edits に記録) + │ → unlink の直前に同じ FD の stat とバイト列を再照合 → 一時名を unlink + │ → 1 トランザクションで op を applied、rel_path / 物理属性 / codec を追随、 + │ archived_files に台帳(eligible_after = now + retention)を追加、 + │ original_codec('wav' | 'alac' | 'aiff')、normalized_at 記録。 + │ audio_md5 は同じ PCM なので変わらず、audio_version も上げない(Derived は据え置き)。 + │ tag_version は写したタグの tag_hash が元と違うときだけ進める + └ 不一致 → op は failed、元ファイルを残す(生成した FLAC は捨てる) +``` + +宛先は拡張子を `.flac` に置き換えた同じパス。宛先を別のトラックが占有していれば計画の時点で +conflict。反映の直前に宛先へ別のファイルが現れていれば `skipped_conflict`(音声 MD5 が期待値と +一致するときだけ自分の成果物とみなして続きを行う。クラッシュ後の再投入も同じ判定)。 +スキャナは pending の archive op の元・一時名・宛先のパスを「作業中」として扱い、新規登録も +missing 判定もしない。unlink の前に元ファイルが外部で更新・差し替えされていれば conflict にして +元パスへ戻す(自分が置いた宛先と Archive のコピーは消す)。cancel は Library を触る前まで +(変換中の子プロセスは止める)。 + +WAV は RIFF INFO / ID3 のどちらを使うかがソフトごとに異なり、ReplayGain タグの +互換性も低い。ALAC(MP4 ilst)は複数値タグが弱く、Safari 以外のブラウザで再生できない。 +可逆変換なので情報は失われない。読み込み互換のため WAV / ALAC / AIFF の再生・ +取り込み自体は引き続きサポートする。 + +**元ファイルは即時削除しない。** 物理削除はユーザデータ全般と同じく GC ジョブのみが行う +(禁止事項)。退避した元ファイルは `archived_files`(state='held')を台帳として GC が +`eligible_after` 経過後に回収し state='deleted' にする。それまでは履歴の巻き戻しで +Library へ戻せる: 元ファイルは Archive から Library へ**コピー**で戻し(Archive の実体は残る。 +state='restored')、Library にあった FLAC は Archive へ move して台帳に `reason='restore'` の行を +足す(GC の対象)。やり直し(逆バッチの revert)はその逆で、同じパスの台帳行は作り直さず `held` に +戻す。復元元が GC 済みの op は `skipped_conflict`([D-46](../../decisions/D-046.md))。 +編集履歴は revert / redo で状態が動くので GC の台帳には使わない。 +Archive の「追記のみ」の例外はこの GC だけ。 + +PCM MD5 の一致が保証するのは**音声サンプルの同一性だけ**で、WAV の RIFF INFO / +ID3 / 未知チャンク / コンテナのバイト列は FLAC から再生成できない。保持期間後に +コンテナを不可逆に捨てるのは意図した決定([D-10](../../decisions/D-010.md))。 diff --git a/docs/spec/07-pipeline/05-tagwrite.md b/docs/spec/07-pipeline/05-tagwrite.md new file mode 100644 index 00000000..1c688909 --- /dev/null +++ b/docs/spec/07-pipeline/05-tagwrite.md @@ -0,0 +1,122 @@ +# 7.5 タグ書き込み(編集バッチ) + +``` +編集バッチ確定 + → プレビュー(dry-run)。サーバは selection のスナップショット(対象 track_id と各行の + tag_version / 事前条件)を作り selection_token を返す(§9、D-33) + → 適用は selection_token の集合だけを対象にする。preview 後にスキャンで増えた行は含まれない + → 対象トラックに pending の op があれば 409 で拒否(件数と track_id を返す)。 + `skip_pending: true` なら該当トラックを除外して続行(UI の「除外して適用」) + → 1 トランザクションで: + edit_batches(state='prepared') + edit_ops(result='pending') … トラック 1 本につき 1 op。事前条件 + (expected_dev / inode / size / mtime_ns / ctime_ns / + tag_hash / rel_path) を記録 + edits … op 配下にフィールドごとの旧値・新値(JSON) + → DB(track_tags / キャッシュ列)を新値へ更新、tag_version をトラックごとに 1 回 ++ + → track 単位の tagwrite ジョブを投入(edit_batch_id で紐づけ) + → 各 tagwrite ジョブ(並列 4): バッチを state='applying' に + rel_path で open → その FD の fstat で inode/size/mtime_ns/ctime_ns を確認 + (dev は照合しない。D-62)、同じ FD からタグを読んで tag_hash を確認 + ├ いずれか不一致 → result='skipped_conflict'。ファイルは触らない。 + │ 同一トランザクションでその FD の内容から DB を戻す(下記「overlay の解消」) + ├ rel_path のみ不一致(外部 rename。スキャナが inode で新パスを追随済み) + │ → 新パスの rel_path_key が他の行に占有されていなければ tags op は追随して続行。 + │ 占有されていれば skipped_conflict。rename op は常に skipped_conflict。 + │ rename は ctime を進めるので、このとき ctime_ns だけの不一致は許容する(D-41) + └ 一致 → その FD の親ディレクトリに tmp を O_EXCL で作成 → 内容をコピー → + op 配下の全フィールドを lofty で書き換え → fsync → rename → + 同一トランザクションで (dev, inode, size, mtime_ns, ctime_ns, tag_hash) 更新、 + op を result='applied' + → 全 op が終端になったらバッチを集計: applied / partial / failed / cancelled + → Derived のタグ上書きジョブを投入(tag_version の差分で判定) +``` + +- **ファイル反映の単位は op(トラック)。** フィールドごとに tmp+rename すると + 1 件目で inode が変わり 2 件目以降の事前条件が必ず外れる。同一トラックの全差分を + 1 回の書き込みで反映し、`tag_version` はトラックごと・バッチごとに 1 回だけ進める。 + 結果(`result`)は op にだけ持ち、**フィールド単位の部分適用はしない** + (1 フィールドでも事前条件に合わなければ op 全体が conflict) +- **形式ごとの写像は読み書きで 1 つ。** FLAC / Opus / Ogg は Vorbis Comment に任意キー・多値を + そのまま書く。MP4(ALAC / AAC)は Vorbis 名を lofty の `ItemKey` に写像して ilst の標準 atom + (`©nam` `trkn` `disk` `----:com.apple.iTunes:CATALOGNUMBER` 等)に、写像表に無いキーは + `----:com.apple.iTunes:<KEY>` のフリーフォーム atom に書く(多値は 1 atom の複数値。`iTunNORM` は + 綴りを固定。[D-77](../../decisions/D-077.md))。読み側は同じ写像で、フリーフォーム atom を名前を大文字化したキーとして取り込む。 + 置き換え・削除は名前の大小文字を無視して既存 atom を消す(外部ツールが `MyKey` で書いた atom を + `MYKEY` で上書きしても二重化しない)。`trkn` / `disk` は番号と総数が同居するので、片側だけの変更は + 相方を保つ。写像と書き手(`apply_ilst`)は Derived の `aac` 系統([§7.6](06-derived.md))と共有する。MP3 は ID3v2 の + 標準フレーム(lofty が名前を知っている TXXX を含む)に写像し、写像表に無いキーは description = キーの + TXXX に書く(多値は NUL 区切りの 1 フレーム。ID3v2.4)。読み側は TXXX の description を大文字化した + キーとして取り込み、置き換え・削除は description の大小文字を無視して既存の TXXX を消す。ブロック + (ID3v2 / ID3v1 / APE)ごとに split / merge で書き、写像できないフレームを保つ([D-110](../../decisions/D-110.md))。WAV / AIFF 等は + lofty の generic `Tag` 経由で、写像できないキーは書けない(読み戻し照合で failed に閉じる) +- **事前条件に `ctime_ns` と `tag_hash` を含める。** inode も mtime も保ったままの in-place + 更新(`touch -r` を伴うタグツール)は dev/inode/mtime では見えない。ただし外部 rename も + ctime を進めるため、rel_path が記録時点と違う(スキャナが追随した)ときに限り ctime_ns だけの + 不一致は許容する([D-41](../../decisions/D-041.md)) +- **`expected_dev` は記録するが照合しない。** dev 番号はマウントのたびに振り直されうる(ZFS は + ホスト再起動で変わる)ので、記録の後に再起動があると全 op が外れる。実体の同一性は + inode / size / mtime_ns / ctime_ns / tag_hash で確認する。rename / normalize の所在判定 + (`same_inode`)、flaccheck / RG / 再生の「行と FD の照合」も同じ扱い([D-62](../../decisions/D-062.md)) +- **DB 先行更新 + pending 記録**を採る。DB の値は「確定した真実」ではなく + **書き込み意図のオーバーレイ**で、ファイル反映が終わるまで暫定。UI は pending を + バッジで見せる。DB を失うと未反映の意図も失われる(ファイルは旧値のまま残るので + データは壊れない)。DB を後から確定する案は、6 万件の一括編集で UI が数分間 + 古いままになるため却下([D-24](../../decisions/D-024.md)) +- **overlay の解消**: op が `applied` 以外の終端(skipped_conflict / failed / cancelled)に + なるときは、**同じトランザクションで**そのトラックの DB 値をファイルの現在値に戻す + (タグ・キャッシュ列・`tag_hash`・物理属性)。次回スキャン任せにしない。`tag_version` は + 既に進んでいるので据え置く(Derived の追随ジョブはファイルの現在値で書くだけなので無害)。 + ファイルを読めない(消えている・壊れている)ときだけ `edits.old_value` と事前条件の物理属性へ + 戻す([D-41](../../decisions/D-041.md)) +- **pending 中の再編集は 409 で拒否する。** `edit_ops(track_id) WHERE result='pending'` の + UNIQUE で DB 側でも保証する。後続バッチが先行 intent を統合する方式(`superseded`)は + 予約のみで P0 では実装しない +- **tmp + rename を採用**する。電源断でファイルが壊れないことを優先。 + 代償として inode が変わるので、書き込み成功時に必ず DB を追随させる +- **リカバリ**: 起動時に `prepared` / `applying` のバッチについて、`pending` の op に + 対応する track ジョブを再投入する(dedup で二重にはならない)。クラッシュ前に + rename まで済んでいた op は inode 不一致になるので、ファイルのタグを読み + **全フィールドが新値と一致すれば `applied` として確定**、そうでなければ + `skipped_conflict`(overlay の解消を伴う)。この確定は再投入されたジョブが通常の反映経路で + 行う(起動時の特別処理ではない)。同じジョブを何度再実行しても結果は変わらない。 + バックオフの上限に達する失敗では、ハンドラが op を `failed` に閉じて overlay を解消する([D-41](../../decisions/D-041.md)) +- **stale ジョブ**: tagwrite の `dedup_key` は `tagwrite:<track_id>:<tag_version>`。 + 409 により pending 中の再編集は起きないので、通常は stale にならない。 + それでも payload の版 < 現在値なら no-op で `done` にする(防御) +- **キャンセル**: バッチ単位。子ジョブに `cancel_requested_at` を立て、未着手の op は + `failed`(error='cancelled'、overlay の解消を伴う)、進行中の op は完了を待つ。全 op が + 終端になったらバッチを `cancelled` に。applied になった op はそのまま残り、revert で戻せる +- **巻き戻し**([§9](../09-http-api.md) `/api/history/:batch/revert`): + - 対象は**終端状態**(applied / partial / failed / cancelled)のバッチのみ。 + `prepared` / `applying` はまずキャンセルする + - 対象 op 集合 = 元バッチで `applied` になった op − 既存の逆バッチ(`reverts_batch_id` = 元)で + 既に `applied` になっている op。**この集合が空なら 409 `already_reverted`** + - 各 op について**全フィールドの現在値が元バッチの `new_value` と一致する場合だけ** + `old_value` へ戻す(op 単位。1 フィールドでも違えば `skipped_conflict`)。 + 一致しないものは外部変更または後続バッチの変更なので UI に件数を出す + - 元バッチの `reverted_at` は、**逆バッチが終端になり、対象集合が全件 `applied` になったとき** + だけ立てる。逆バッチが partial なら立てず、再度 revert すると残りだけが対象になる + - やり直し(redo)= 逆バッチを revert する。同じ規則で処理される + - 巻き戻しも通常のバッチなので、対象トラックに pending があれば 409 +- **埋め込み画像の差し替えも tags op**(P1-3 書き側、[D-60](../../decisions/D-060.md))。キー `PICTURE`(値は `tag_hash` と同じ + `<mime>:<sha256hex>` の配列。`track_tags` にもこの形で入っている)を `Editor::prepare_picture` が + 「全画像を捨てて上げた 1 枚を front cover にする」変更として記録する。ユーザの JSON タグ操作は + `PICTURE` を受け付けない。実体は `ArtworkStore`(`thumbs/<hex>/orig.<ext>`)にあり、tagwrite は + **書く前に捨てる旧画像を同じ store へ退避**してから書く(退避できなければ `failed`。新画像が store に + 無ければ `failed`)。巻き戻しは通常の逆 op(旧画像は退避済み)。applied のとき album の再解決を予約し + 増分スキャンを投入する。spindle は同梱の cover ファイルを書かない(埋め込み統一。[D-60](../../decisions/D-060.md)) +- リネーム(P0-11)と論理削除も同じ機構に乗せる(`kind = rename / delete`)。 + 巻き戻しの対象は tag に限らない。**一括リネームは coordinator が 2 phase で行う**([D-43](../../decisions/D-043.md)): + prepare で DB の `rel_path` を 2 段階更新で新値にし(overlay。`expected_rel_path` が記録時点の + 物理パス)、album の所属も追随させ、**バッチ 1 つに `rename` ジョブ 1 つ**を投入する。ジョブは + phase 1 で全 op の source を同じディレクトリの一時名 `spindle-rename-<op_id>.<ext>` へ + `RENAME_NOREPLACE` で退避(事前条件は inode / size / mtime_ns / ctime_ns。dev は照合しない、 + [D-62](../../decisions/D-062.md)。外れていれば + `skipped_conflict` で触らない)、phase 2 で `ordinal` 順に最終名へ置く(宛先ディレクトリは作る。 + 宛先が取られていれば source へ戻して `skipped_conflict`)。最後に 1 トランザクションで op を + 終端にし、`rel_path` をファイルの所在へ揃え、物理属性を追随し、バッチを集計する。 + swap / 循環はこれで解ける。phase 境界でのクラッシュは、再投入されたジョブが op ごとの所在 + (最終名 / 一時名 / source)を inode で判定して続きを行う。cancel は phase 2 に入るまで + (退避済みを戻して全 op を `failed('cancelled')`)。スキャナは pending の rename op があるトラックの + 所在が source / 一時名 / 最終名のいずれかなら衝突にしない diff --git a/docs/spec/07-pipeline/06-derived.md b/docs/spec/07-pipeline/06-derived.md new file mode 100644 index 00000000..4f5fb0e0 --- /dev/null +++ b/docs/spec/07-pipeline/06-derived.md @@ -0,0 +1,69 @@ +# 7.6 Derived 生成 + +Derived は**系統(variant)**ごとに 1 本ずつ作る。系統は `opus`(Android の同期・Web 再生・配布ビュー)と +`aac`(Mac のミュージック.app へ取り込む Apple 向け)の 2 つで固定(P4-7 で系統化、P4-8 で `aac` の実体。 +[D-9](../../decisions/D-009.md) 追記、[D-75](../../decisions/D-075.md))。系統の設定は `config.toml` が正で、起動時に `derived_variants` 表へ写す(投入判定は +どこからでも同じ接続で設定を引ける。[D-55](../../decisions/D-055.md) の `fb2k_prefix` と同じ流儀)。設定に無い系統は投入も生成も +しない。 + +| 項目 | 規則 | +|---|---| +| パス | `Derived/<variant>/` 以下に Library と完全ミラー(拡張子だけ `.opus` / `.m4a`)。`paths.derived` は 1 つ | +| 設定 | `[encode.derived.<variant>]` に `enabled` と `bitrate`(下記)。節を省略したときの `enabled` は **`opus` が true、`aac` が false**(既存の config をそのまま新版で起動しても aac は始まらない。RG 全件解析 → 有効化の順序を崩さない)。`enabled = false` の系統は**凍結**: 新しく作らず、既存の行とファイルは Move / Retag / Encode のどれも行わず、配布ビュー・バッジ・`has_derived` は既存行をそのまま使う。GC は系統を区別せず `Derived/` 全体で「行に無いファイル = 孤児」を回収する(凍結でも行は残るので消えない) | +| 出力仕様の世代 | 系統ごとに 2 つの文字列を設定から作り、行に保存する。**`audio_profile`**(音声に効く設定: codec / bitrate / サンプルレート規則 / RG 焼き込み方式の版。例 `opus:256:v1`、`aac:256:48k:bake1`)と **`tag_profile`**(タグに効く設定: `multi_value_separator` / iTunNORM 規則の版。例 `aac:sep= & :itunnorm0:v1`)。エンコーダの引数や規則を変えるときは版を上げる | +| 判定 | `audio_version` 差分・**行の `audio_profile` が設定と食い違う** → 再エンコード / `tag_version`・埋めた画像(`src_artwork_id`)・RG の解析世代(`src_rg_scanned_at`)・**`tag_profile`** の差分のみ → タグ上書き(**`aac` は RG を音声に焼き込むので RG 世代の差分は再エンコード**)/ パスの差分のみ → rename。優先順はこの順(再エンコードは残りを兼ねる) | +| RG の前提 | **どの系統も RG が揃ってから作る**: 解析済み(`rg_scanned_at` と `rg_track_gain` / `rg_track_peak`)で、`[replaygain].write_tags = true` なら Library のタグへの書き込みも確認済み(`rg_written_at >= rg_scanned_at`)。揃っていない間は凍結と同じく既存の行とファイルに触らない。順序は rg → rgwrite → tagwrite の applied → transcode([D-97](../../decisions/D-097.md)) | +| 投入 | scan ジョブの完了時に食い違う全トラック × 系統、tagwrite / rename の applied、RG 解析の保存([D-51](../../decisions/D-051.md))。ジョブは `transcode`(`(track_id, variant)` 単位、`audio_version` で dedup)で、ハンドラが現在値から必要な処理を決める。決めた処理(エンコード / タグ更新 / 移動 / 移動 + タグ更新)を実行中から `jobs.note` に書き、完了時は実際に行った処理(移動・タグ更新が Derived の消失でエンコードに倒れたら「エンコード」、何もしなければ「変更なし」「対象外」)を残す。種別はどれも「Derived 生成」なので、ジョブ画面の結果列で見分ける([D-111](../../decisions/D-111.md))。P4-1 の共通並列予算の対象 | +| 追随 | Library の移動に追随(Derived を rename)。削除には追随せず(missing は可逆)、`retention_days` 超の回収と孤児(行に無いファイル。`Derived/` 全体)は GC ジョブ | +| マルチch | どの系統も既定で対象外(チャンネル数不明も対象外)。トラック単位の `-ac 2` ダウンミックスは需要が出たら([D-51](../../decisions/D-051.md)。実データは全件 2ch) | +| 配布ビュー | `delivery` は **`opus` 系統に固定**(Android の m3u8、`?transcode=opus`、`has_derived`)。`aac` は配布ビューを持たない。端末へは、Android に `opus` 系統、iPhone に `aac` 系統を既定で送る(端末ごとに選べる)。iPhone へは Mac のエージェントが `aac` を取り込み、曲は `~/Music/spindle` に置き、プレイリストはミュージック.app の「spindle」フォルダに作る([§7.11](11-device-delivery.md)、[D-75](../../decisions/D-075.md)、[D-95](../../decisions/D-095.md)) | + +**`opus` 系統** + +| 項目 | 規則 | +|---|---| +| 対象 | Library 内の可逆のみ(flac / alac / wav)。opus / aac / mp3 は原本をそのまま配布([D-8](../../decisions/D-008.md)) | +| 出力 | `opusenc --vbr --music --bitrate <bitrate>`。既定 **256 kbps**([D-9](../../decisions/D-009.md) 追記。可逆 237 GB で約 58 GB) | +| RG | 再解析しない。Library 側の解析値を `R128_*` へ変換して埋める(`REPLAYGAIN_*` は書かない)。RG が揃うまで作らない(上の「RG の前提」) | +| 画像 | トラック自身の埋め込み画像(`tracks.artwork_id`。[D-61](../../decisions/D-061.md))、無ければ album のアートワーク([§7.1](01-scan.md))の長辺 768 の WebP を 1 枚だけ埋める([D-51](../../decisions/D-051.md)) | + +**`aac` 系統**(Apple 向け。[D-75](../../decisions/D-075.md)) + +| 項目 | 規則 | +|---|---| +| 対象 | 可逆(flac / alac / wav)に加え、`lossy_sources = true` なら**非可逆も**(opus / ogg / mp3 / aac → AAC。世代劣化は承知の上で、ミュージック.app が Opus を読めないため。**[D-8](../../decisions/D-008.md) の例外**)。原本が AAC でも同じ経路で再エンコードする(stream copy では RG の焼き込みとリサンプルができない。[D-75](../../decisions/D-075.md))。`lossy_sources` を true → false にしても既存の非可逆の行とファイルは消さない(対象外 = Skip で凍結と同じ扱い。物理削除は GC のみ) | +| 出力 | ffmpeg **1 パス**(中間 WAV なし。FD を stdin に繋ぐのは opus と同じ): `-i /dev/stdin -map 0:a:0 -vn -map_metadata -1 -af volume=<gain>dB [-ar 48000] -c:a aac -b:a <bitrate>k -f mp4`(内蔵エンコーダ。既定 256 kbps)。48 kHz 超は 48 kHz へ落とす(`-ar 48000`)、44.1 / 48 は据え置き。`audio_profile` は `aac:<bitrate>:48k:bake1`、`tag_profile` は `aac:sep=<区切り>:itunnorm0:v1` | +| RG | **track gain を音声に焼き込む**(`-af volume=<gain>dB`。gain は `min(rg_track_gain, −20·log10(rg_track_peak))` で**エンコーダ入力を 0 dBTP 以下に抑える**(peak は true peak なので 1.0 超なら減衰側に倒れる。AAC 再符号化後のオーバーシュートまでは保証しない。gain が有限でなければ 0、peak は有限かつ > 0 のときだけ上限を掛ける)。album gain は使わない)。**RG 未解析のトラックは作らず待つ**(「解析済み」= `rg_scanned_at` と `rg_track_gain` / `rg_track_peak` の 3 つが揃っていること。時刻だけ残った行は対象外。`write_tags` ならタグへの書き込みも待つ(上の「RG の前提」。[D-97](../../decisions/D-097.md))。書き込みの tagwrite の applied で投入される。二度エンコードの回避)。RG の解析世代(`src_rg_scanned_at`)の差分は**再エンコード**(album gain の on / off も世代を進めるので、その album の aac は作り直される。track gain しか使わないが値ベースの判定に列を足すより単純で、まれな操作なので許容。[D-75](../../decisions/D-075.md))。タグには `iTunNORM` を **0 dB 相当**で書き、端末のサウンドチェック ON でも二重に掛からないようにする。値は 10 個の 8 桁 16 進を空白区切り(先頭にも空白)で、1〜2 値目(基準 1/1000)は `000003E8`、3〜4 値目(同じ量の基準 1/2500 の表現)は `000009C4`、残り 6 値は `00000000`: `" 000003E8 000003E8 000009C4 000009C4 00000000 00000000 00000000 00000000 00000000 00000000"`。`REPLAYGAIN_*` / `R128_*` は書かない | +| タグ | Library のタグを写す(`REPLAYGAIN_*` / `R128_*` / 既存の `ITUNNORM` は落とす)。**同じキーの複数値は出現順に `multi_value_separator`(既定 `" & "`)で 1 値に結合**(ミュージック.app は複数値の 1 つしか見せない。ARTIST / ALBUMARTIST / GENRE / COMPOSER など多値になり得る全フィールド)。写像は Library の tagwrite と同じ([§7.5](05-tagwrite.md)「形式ごとの写像」): Vorbis 名を lofty の `ItemKey` に写像して ilst の標準 atom(`©ART` `trkn` `disk` `©gen` 等)に書き、写像できないキーは `----:com.apple.iTunes:<KEY>` のフリーフォーム。`iTunNORM` は内部キーが大文字化されても atom 名を `iTunNORM` に固定する(大小文字を special-case) | +| 画像 | `opus` と同じ選び方で、長辺 768 の **JPEG**(ミュージック.app は `covr` の WebP を読まない)。`thumbs/<hex>/768.jpg` をキャッシュに足す(thumbnail ジョブと同じ変換に形式を足したもの。`-pix_fmt yuvj420p -q:v 2`) | + +```toml +[encode] +flac_compression = 8 + +[encode.derived.opus] +enabled = true +bitrate = 256 # opusenc --vbr --music --bitrate + +[encode.derived.aac] +enabled = true +bitrate = 256 # ffmpeg -c:a aac -b:a +lossy_sources = true # 非可逆原本も AAC へ(D-8 の例外。aac 原本も再エンコード) +multi_value_separator = " & " # 多値フィールドの結合 +``` + +`derived_variants` の `lossy_sources` / `multi_value_separator` は aac 系統の設定(`eligible` が前者を、ハンドラが後者を +表から引く。設定は config が正で、起動時に両系統を写す)。 + +`derived_files` の主キーは `(track_id, variant)` で、配布ビュー `delivery` は `variant = 'opus'` の行を見る。 +系統を持つ前の行(ルート直下・`audio_profile = 'opus:128:v1'`)は、既定の 256k では次の transcode が +`audio_profile` の差分で再エンコードして `opus/` 配下へ置き、旧ファイルは transcode の退避経路で消える。 +`bitrate = 128` のまま(`audio_profile` が一致)ならパスの差分だけなので Move で `opus/` 配下へ移る([D-75](../../decisions/D-075.md))。 + +`has_derived`(DSL / フラグ / バッジ)は **`opus` 系統の行**の有無(= `opus` 系統を生成済みか。音声版が古い間は +配布ビューが原本へ倒れるが、`has_derived` は行の有無のまま)。 +`aac` しか無いトラック(非可逆原本)は `has_derived` にならない。`GET /api/tracks` の `derived` は系統ごとに +集約して返す(2 系統あっても行は 1 つ)。 + +容量逼迫時のみ、トラック単位で `force_transcode` を手動指定可能(**元が 256kbps 以上の場合のみ**) +という `opus` 系統の例外は、需要が出るまで実装しない([D-51](../../decisions/D-051.md)。実データの非可逆は Opus が大半で削減にならない)。 diff --git a/docs/spec/07-pipeline/07-ytmusic.md b/docs/spec/07-pipeline/07-ytmusic.md new file mode 100644 index 00000000..220edec9 --- /dev/null +++ b/docs/spec/07-pipeline/07-ytmusic.md @@ -0,0 +1,205 @@ +# 7.7 ytmusic 統合 + +| 元モジュール | 移行先 | 備考 | +|---|---|---| +| `parser.py`(タイトルパーサ)/ チャンネル定義 | **外部のメタデータプラグイン**(別リポジトリ) | spindle はタイトルの慣習を知らない([D-69](../../decisions/D-069.md)) | +| `models.py` 正規化 | Rust | 置換テーブルは [D-43](../../decisions/D-043.md) で 1 本化済み | +| `tagger.py` (mutagen) | lofty | | +| `audio.py` R128 | ebur128 | ffmpeg loudnorm より高精度 | +| `organizer.py` / `playlist.py` | Rust | m3u8 生成は継続 | +| `downloader.py` | Rust(yt-dlp を subprocess) | | +| `sync.py` (ADB) | **移植する**(端末同期として再設計) | [§7.11](11-device-delivery.md)、[D-95](../../decisions/D-095.md) | + +**メタデータプラグインのプロトコル v1**(`src/import/ytmusic/metadata.rs`、P3-1 / P3-2、[D-69](../../decisions/D-069.md))。 +動画のタイトルからトラックのメタデータ(タイトル・アーティスト・アルバム・category)を決める知識は +利用者固有(チャンネル名、タイトルの慣習、パターンのルール)なので spindle には置かず、外部コマンドに +問い合わせる。spindle が知る契約はこのプロトコルだけ。 + +- **起動**: `[ytmusic].metadata_command`(引数配列。`sh -c` は使わない)をアイテム 1 件ごとに起動し、stdin に + Request を 1 つ書き、stdout の Response(JSON 1 つ)を読む。`metadata_timeout_secs`(既定 30)で kill。 + stderr はログに出す。終了コードが非ゼロ・stdout が JSON(厳密な UTF-8)でない・`protocol` が違う・ + 必須の値が空(`title` / `albumartist` / `album` / `artists`)・`ok: false` なのに `reason` / `message` が + 無い・`category` がディレクトリ名として不正(`POST /api/categories` と同じ規則: 前後の空白、禁止文字、 + 末尾のドット、予約名は不可)・`tags` に spindle が決めるキー(TITLE / ARTIST / ALBUM / ALBUMARTIST / + DATE / TRACKNUMBER / DISCNUMBER、画像の PICTURE / METADATA_BLOCK_PICTURE、ReplayGain の + REPLAYGAIN_* / R128_*)や不正なキー・空の値があるものは + **プラグインの故障**として取り込みを止める +- **Request**(未知のフィールドはプラグインが無視する。前方互換) + ```jsonc + { "protocol": 1, "op": "metadata", + "item": { "source": "youtube", // 提供元 + "channel": "<設定のチャンネル識別子>", + "channel_title": "…" | null, // 提供元での表示名 + "id": "<動画 id>" | null, "url": "…" | null, + "title": "<動画タイトル>", // 必須 + "uploaded_at": "YYYY-MM-DD" | null, "duration_ms": 123 | null } } + ``` +- **Response**(判定できたかに関わらず終了コード 0) + ```jsonc + { "protocol": 1, "ok": true, + "track": { "title": "…", "artists": ["…"], "albumartist": "…", "album": "…", + "category": "<統制語彙の名前>" | null, // null は未分類(_Unsorted) + "date": "YYYY[-MM[-DD]]" | null, + "tags": [["ORIGINALARTIST", "…"]] }, // 追加のタグ(キー, 値) + "plugin": { "name": "spindle-ytmusic-meta", "version": "v0.1.0-rc.2" } } // 任意。判定したプラグインの版([D-114](../../decisions/D-114.md)) + { "protocol": 1, "ok": false, + "reason": "unmatched" | "unknown_channel" | "skip" | "unsupported", // 未知の値も通す(前方互換) + "message": "…", // unmatched / unknown_channel は要対応(メッセージをそのまま見せる)、skip は取り込まない。 + // reason / message は必須 + "plugin": { "name": "…", "version": "…" } } // 任意(ok の応答にも同じ) + ``` + `plugin` は**任意**(未知の欄はプラグインが無視する規則と同じく後方互換)。無い・形が違う(name / version が空でない + 文字列でない)ときは「不明」として扱い、応答は不正にしない。spindle は応答の `plugin.version` をサイドカーに残し、 + Inbox に表示する(再判定、[§7.8](08-inbox.md)、[D-114](../../decisions/D-114.md))。`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 の語彙が無ければ追加する(プラグインの定義が正。 + 初回起動の空 DB でも動く)。トラック番号と配置は Inbox が行う(下記、[§7.8](08-inbox.md)、[D-70](../../decisions/D-070.md)) +- 参照実装: `AkashiSN/spindle-ytmusic-meta`(private。ルール TOML + フィクスチャ + チャンネル定義を同梱した + Rust のバイナリ。新パターンは Claude がそのリポジトリでルールとフィクスチャを 1 件ずつ足す運用)。 + イメージには同梱せず、static バイナリをホストに置いて compose でマウントする([§14](../14-deploy.md)、[D-70](../../decisions/D-070.md)) + +**ダウンローダ**(`src/import/ytmusic/downloader.rs`、`ytdl` ジョブ、P3-3、[D-70](../../decisions/D-070.md))。Library には触らず、 +**Inbox に置くところまで**。配置・登録・後続ジョブは Inbox([§7.8](08-inbox.md))が担い、プラグインが判定したものも +人が一度見てから Library に入る。 + +- **入口**: `POST /api/ytmusic/download { urls }` が URL ごとに `ytdl` ジョブ(並列 1、dedup `ytdl:<url>`)を + 投入。YouTube 画面(1 行 1 URL。[§12.6](../12-ui/06-other-screens.md))から呼ぶ +- **手順**(ジョブ 1 件 = URL 1 件。作業領域は `[paths].data/tmp/ytdl/<job_id>/`。全部引数配列、`--` の後に URL) + 1. `yt-dlp --dump-single-json --flat-playlist --no-download -- <url>`(120 秒)。`_type` が playlist なら + `entries` の `url` ごとに `ytdl` を投入して終わり(展開だけ)。動画なら `id` / `webpage_url` / `uploader` + (channel)/ `channel`(channel_title)/ `title` / `upload_date` / `duration` を取る。stderr が + `Unsupported URL` / `is not a valid URL` なら `Fatal`、他の失敗は `Failed`(再試行) + 2. プラグインに問い合わせ(Request は上記。`channel` は `uploader`)。`ok` → 宛先 + `Inbox/youtube/<albumartist>/<album>/`(`sanitize_component`)。`ok: false` の `skip` → ダウンロードせず + `done`(ログに message)。それ以外の reason(`unmatched` / `unknown_channel` / 未知)→ 宛先 + `Inbox/youtube/_unmatched/<channel>/`(受け皿)。プラグインの故障(`ProviderError`)→ `Fatal`。 + ここで Inbox のファイル名(8.)まで決まる + 3. 取り込み済みチェック: `SOURCE_URL = webpage_url` が Library(`track_tags`)にあれば**やることが無い**ので + `done`(note に「取り込み済み(Library): <パス>」。失敗ではない。P4-18、[D-81](../../decisions/D-081.md))。Inbox(`inbox_files.tags`)にあり、それが自分の宛先そのもの + (`rel_path_key` で比べる)なら「置いた後に落ちて走査が先に拾った」再実行の可能性があるので、 + **実ファイルの `SOURCE_URL` を読み直して**同じならダウンロードせずに 8. の仕上げ(サイドカーと投入) + だけ済ませて `done`。違えば `Fatal`(同名で別の内容)、ファイルが消えていれば普通に置き直す + (行はキャッシュ、ファイルが正)。Inbox の別の場所なら `done`(「取り込み済み(Inbox): <パス>」) + 4. `yt-dlp -f "ba[ext=webm]" --no-playlist --write-thumbnail --convert-thumbnails jpg -o <tmp>/%(id)s.%(ext)s -- <url>` + (`[ytmusic].download_timeout_secs`、既定 900)。webm の音声が無ければ `Fatal`。ネットワーク等の失敗は + `Failed`(再試行) + 5. `ffmpeg -nostdin -y -i <tmp>/<id>.webm -vn -c:a copy -map_metadata -1 <tmp>/<id>.opus`(再エンコードなし) + 6. lofty でタグ。`ok`: `Track::tags(なし)` の TITLE / ARTIST / ALBUM / ALBUMARTIST / DATE / 追加 tags + (TRACKNUMBER は書かない。採番は Inbox)+ PICTURE(サムネイル jpg)+ `SOURCE_URL`。`ok: false`: TITLE = + 動画タイトル + PICTURE + `SOURCE_URL` だけ(他は Inbox の警告で人が埋める) + 7. webm を `Archive/youtube/<id>.webm` へ(tmp + `RENAME_NOREPLACE`。既にあれば同じ id なので採用) + 8. `.opus` を宛先へ `<YYYYMMDD> <title> [<id>].opus`(title は `sanitize_component`。名前順 = 公開順 = + 採番順。`RENAME_NOREPLACE`。既にあれば、その `SOURCE_URL` が同じときだけ自分の成果物(置いた後に + 落ちた再実行)として採用し、違えば `Fatal`)。仕上げ: 同じディレクトリの `spindle-inbox.json` を読んで + このファイルの項を足し tmp + rename で書く → ディレクトリを fsync → `inbox` ジョブを投入(すぐ取り込みが + 出る)。仕上げの失敗は `Failed`(再試行。次の実行は 3. か 8. の採用でここへ戻る) +- **サイドカー `spindle-inbox.json`**(タグに載らない情報を Inbox へ渡す。Inbox の DB には書かない) + ```jsonc + { "version": 1, + "category": "<統制語彙の名前>" | null, // 取り込みの category(プラグインの判定。最後に書いたものが勝つ) + "files": { "<ファイル名>": { "source": "youtube", "url": "<webpage_url>", "channel": "<uploader>", + "verdict": "ok" | "unmatched" | "unknown_channel" | "skip" | "<未知の reason>", + "message": "…" | null, + "subscription_id": 3, "position": 17, // 購読由来のときだけ(P4-16) + "item": { … }, // プラグインへ渡した Item(再判定の材料。[D-114](../../decisions/D-114.md)) + "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](08-inbox.md))が書き換える +- **失敗の区分**: `Fatal`(再試行なし)は webm の音声なし・プラグインの故障・対応していない URL・宛先の + 同名で別の内容(取り込み済みは失敗でなく `done` + note)。それ以外(yt-dlp / ffmpeg の非ゼロ終了、I/O、仕上げ)は `Failed` で指数 + バックオフ。作業領域はどの終わり方でも消す。**再実行は冪等**: Archive は同じ id なら採用、Inbox は + `SOURCE_URL` で自分の成果物を見分けて続きから +- `POST /api/ytmusic/download` の URL は http / https でホストがあり空白・制御文字を含まないものだけ受ける + (ホストは限定しない。対応していなければジョブが `Fatal` で伝える) +- yt-dlp は YouTube の抽出に JS ランタイムを要求する版があるため、runtime イメージに deno を同梱する([§14](../14-deploy.md)) + +**再生リストの購読と同期**(`playlist_subscriptions`、`playlist_sync` ジョブ、P4-16、[D-78](../../decisions/D-078.md))。URL を貼る +運用をなくす。再生リスト 1 本 → Library の album 1 つ(追記先)。`SOURCE_URL`(P4-14 で補填、ytdl が +書く)で「再生リストのどこまで持っているか」が分かる。 + +- **購読**(`db::subscriptions`、`/api/ytmusic/subscriptions`): `id` は再利用しない(AUTOINCREMENT。 + ジョブの payload とサイドカーが裸の id を持つので、DELETE 直後に作った購読へ旧ジョブが誤帰属しない)、 + `list_id`(URL の `list=`。YouTube のホストだけ。UNIQUE)、`albumartist` / `album` / `category`(追記先の初期値と表示用)、`album_id` + (**追記先の同一性**。登録時は NULL。同期か配置が `import::inbox::destination_of`(Inbox の追記先と + 同じ規則)で引けたときに CAS(`album_id IS NULL` のときだけ)で束ねる。album 全体の移動は id を + 維持する([D-32](../../decisions/D-032.md))ので以後は album 行の値が正。album が消えれば SET NULL で再解決。albumartist / + album / category を PATCH で変えると NULL に戻す)、`align`(番号揃えをするか。既定 on)、`enabled` + (定期と承認の後続の対象か。手動の同期は enabled に関わらず受ける)、`max_enqueue`(1 回の同期で + 投入する上限。既定 50。誤登録した巨大なリストで数百本落とさない)。同じ追記先の購読は 1 つだけ + (`target_key` = `sanitize_component` した albumartist / album の canonical key、UNIQUE。`album_id` も + 非 NULL の間 UNIQUE = 束ね同士の競合は DB で片方が失敗する)。Inbox の `youtube/<albumartist>/<album>` + = 1 購読 = 1 category になるので、サイドカーの category が dir 単位でも他の購読に波及しない +- **同期ジョブ `playlist_sync`**(並列 1、dedup `playlist_sync:<id>`、payload `{ subscription_id }`)。 + 順序は **列挙 → 追記先 → 照合 → 番号揃え → ytdl 投入**(購読由来の ytdl は TRACKNUMBER = 位置を書くので、 + 先に既存の行を揃えて隙間を空ける): + 1. `last_attempted_at = now`、latch(`sync_requested_at`)を NULL に。`yt-dlp --flat-playlist + --dump-single-json`(`[ytmusic].ytdlp_args` 付き)で列挙。entry の位置(1 始まり)が再生リストの位置。 + `entries < playlist_count` なら**古い yt-dlp の取りこぼし**として `Failed`(再試行)で何も投入しない + 2. 追記先: `album_id` があればその album(missing なら `Fatal`)、無ければ引いて束ねる(別の購読が束ねて + いれば `Fatal`。読んでから束ねるまでに配置が束ねていればそちらが正)。まだ無ければ揃えは無し(最初の + 配置で束ねる)。同期が queued / running の間は PATCH / DELETE を 409 `sync_running` で拒む(走行中の同期が + 古い追記先で揃えたり、消えた購読の album を揃えたり、死んだ id で投入したりしない。検査と UPDATE は同じ書き込み閉包 = 投入と直列)。加えて各段の + 前に購読を読み直し、消えていれば `Fatal`、`updated_at` が進んでいれば `Failed`(直接 DB を書いた場合の + 控え) + 3. entry ごとに正規 URL `https://www.youtube.com/watch?v=<id>` で Library の active 行を**全件**引く + (`find_source_url` の LIMIT 1 でなく 0 / 1 / 2 件以上を区別)。追記先にあれば「Library」、別の album + なら「別の album にある」(触らない、投入しない)、Inbox にあれば「取り込み中」(投入しない) + 4. **番号揃え**(`align` が on。`import::ytmusic::playlist::plan_align`): 対象 = 追記先の active 行で + `SOURCE_URL` が entry に一致し disc 1(NULL 含む)で単一のもの。目標番号 = 位置。**固定行** = それ + 以外(`SOURCE_URL` 無し・再生リストに無い・`SOURCE_URL` 重複)の disc 1 の行で、その番号は塞がる。 + 目標番号が塞がれた対象と disc 2 以降・重複の対象は動かさず「揃えられない」一覧へ(理由付き)。 + **同じ動画が再生リストに複数回ある**ときも表現できない(1 行は 1 番号)ので、その行は固定して「揃え + られない」(`duplicate_entry`、位置の一覧)に出し、投入は最初の位置でだけ行う。対象 + 同士の swap / cycle は可(番号は UNIQUE でなく、rename は一時パス経由)。非公開・削除・未取り込みの + 位置も数えるので、それらの分は番号が飛ぶ。 + **各段の前に追記先の active 行に pending の op が無くなるまで待ち**(2 秒間隔、上限 10 分。超えたら + `Failed`)、差分は待った後の DB から計算し直す(phase を永続化しない: 落ちて再実行しても、失敗した op は + overlay がファイルの値に戻るので次回また差分になり、適用済みなら差分ゼロで通る)。ずれた行だけ + `Editor::prepare_tags`(`TRACKNUMBER`。説明「再生リスト『…』に番号を揃える」)→ 終端待ち → album を + 読み直し「**現在の TRACKNUMBER が目標位置と一致する対象行のうち、ファイル名の先頭の番号が合っていない + 行**」を `plan_rename` → **ファイル名だけ**テンプレートのものにして(ディレクトリは今のまま。テンプレートの + dir で動かすと category 未推定の album を `_Unsorted` へ移してしまう)`prepare_rename` → 終端待ち + (今回のバッチの applied 集合には依らない = tags 適用後・rename 前に落ちた境界を再実行で拾う。名前の + 書式だけ違う行は触らない)。計画の時点の衝突(同名のファイルがある等)は続く状態なので失敗にせず + 「改名できない」として報告だけ。どちらも履歴に載り巻き戻せる。Derived(opus / aac)はタグ上書き・移動で + 追随する。子バッチの終端は **batch_id の pending = 0** で待つ(album の active 行で見ると、対象が + 走査で missing になったときに pending の op を見落とす)。**全件 applied でなければ**(pending が残る、 + 外部の書き換えによる衝突・失敗・キャンセル、applied ≠ total)揃えは終わっていないので `Failed`(再試行で差分から取り直す)。揃え終わる前の失敗・ + キャンセルでは投入しない(そこまでのバッチ id と件数は結果に残す) + 5. Library / Inbox / 別 album に無く、**取れる** entry を位置順に `max_enqueue` まで `ytdl` 投入(payload + `{ url, subscription_id, position }`、dedup は今までどおり `ytdl:<url>`)。超えた分は「次回」。 + Duplicate(手動貼り付け・別の購読の投入が走行中)は**満たしたと数えず「別の投入が走行中」**として + 結果に出すだけ(そのジョブには購読の情報が無い。承認されれば次の同期が Library で拾い、失敗すれば + dedup が空いて購読の情報付きで再投入。同じ動画が複数の購読に出る場合も同じ規則で、先に取り込んだ側の + album に置かれ他方には「別の album にある」) + 6. **取れない entry**(`title` が無い、旧版の `[Private video]` / `[Deleted video]`): 投入しないが位置は + 占める。Library にあれば(補填済み、公開だった頃に取り込んだ)何もしない。無ければ「取れない」一覧 + `{ position, id, kind: private | deleted | unknown }`(現行の yt-dlp は `title: null` で来るので区別 + できず `unknown`。旧版の題名から分かるときだけ private / deleted)。後で公開に戻れば次の同期で拾う + 7. `last_result`(結果 JSON: entries / playlist_count / in_library / in_inbox / elsewhere / enqueued / + running / deferred / unavailable / align{moved, unchanged, blocked, outsiders, unnumbered, tags, renamed, + rename}、state = done / failed / cancelled)、成功なら `last_synced_at`、`jobs.note` に 1 行。終わる前に + latch が立っていれば(走行中に承認の後続・手動要求が来た)`Outcome::Requeue`(試行回数を数えず同じ + ジョブがもう一度走る) +- **ytdl の購読由来**(payload に `subscription_id`): ALBUMARTIST / ALBUM / category は購読の追記先(束ねて + あれば album 行の値)、プラグインの判定は TITLE / ARTIST に使う。**skip / 判定不能でも投入**(再生リストは + 人が選んだもの。TITLE = 動画タイトル、ARTIST = albumartist、verdict はサイドカーに残す)。宛先は + `Inbox/youtube/<albumartist>/<album>/`。`align` が on なら **TRACKNUMBER = 位置**を書く(同期が先に既存の + 行を揃えて隙間を空けている。承認画面の初期値がそのまま正しい番号になり、塞がっていれば承認の既存検査 + (400)で人が直す)。off なら書かず Inbox が max+1 を振る。購読が消えていれば通常の ytdl として振る舞う(skip も通常どおり。 + 購読の有無はプラグインを呼ぶ前に引く) +- **承認はそのまま**([D-70](../../decisions/D-070.md)。判断は Inbox で人が行う)。配置(`place_item`)はサイドカーを消す前に取り込みの + `subscription_id` を集め、inbox ジョブが配置後に追記先を束ね(CAS)、latch を立ててから `playlist_sync` + を投入する(走行中なら Duplicate だが latch は残る) +- **dispatcher**(常駐、30 秒ごと。`[ytmusic].enabled` のとき): (a) latch の立った購読を投入(enabled に + 関わらず。active なジョブがあれば Duplicate で何もせず、終端になった次の tick で投入される = handler の + 最終確認から終端までの窓に立った latch、Failed / Cancelled で残った latch も必ず回収される)、(b) + `[ytmusic].sync_interval_hours`(既定 0 = 手動と承認の後続だけ)が 0 でなければ enabled で + `last_attempted_at`(成否を問わない開始時刻)から interval 経った購読を投入(最終 retry が failed に + なっても interval までは新しいジョブを作らない) +- yt-dlp を定期的に叩くので、ブロック時の `ytdlp_args`(P4-13)と yt-dlp の更新(P4-12)が前提 diff --git a/docs/spec/07-pipeline/08-inbox.md b/docs/spec/07-pipeline/08-inbox.md new file mode 100644 index 00000000..a86ee9ad --- /dev/null +++ b/docs/spec/07-pipeline/08-inbox.md @@ -0,0 +1,209 @@ +# 7.8 Inbox 取り込み + +取り込み対象ディレクトリを増やす方式は採らない。外部ディレクトリを直接 +スキャン対象にすると、そのツリーのパス規約とタグ品質がそのまま Library に +混入するため。Inbox をステージングとして挟む。 + +``` +Inbox/ に配置(ポーリング検出) + → ステージング: タグ解析、コーデック判定、アルバム単位でグルーピング + → 承認キュー: UI で category / albumartist / album を確認・補正 + → ※メタデータ不足のまま Library に入れない + → 配置: テンプレート展開 → Library へ move + → 後続ジョブ: normalize(WAV) → rg → transcode → thumbnail +``` + +承認キューを挟むことが要点で、これがないと `_Unsorted` が際限なく育つ。 + +実装(`src/import/inbox.rs`、`inbox` ジョブ、[D-68](../../decisions/D-068.md)): + +- **検出**は `inbox` ジョブ(並列 1・固定キー)。周期の**監視**が `[inbox].poll_interval_secs`(既定 60、0 で + 自動なし)ごとに Inbox の指紋(音声ファイルの集合: パス・inode・size・mtime・ctime。非音声と空ディレクトリは + 効かない)を取り、**前回投入時と違うとき**と、配置待ち(`approved`)・期限切れの `placed` があるときだけ + 投入する(変化の無い毎分のジョブ行で一覧を埋めない。投入が Duplicate なら次の周回で投入し直す。起動直後は + 必ず 1 回。P4-18、[D-81](../../decisions/D-081.md)。inotify は使わない: [D-68](../../decisions/D-068.md) 追記)。`POST /api/inbox/scan` は常に投入。 + `GET /api/inbox` の `watch` に最後に確認した時刻と間隔(Inbox 画面が「最後に確認: HH:MM:SS」を出す)。 + ジョブは Inbox を歩き、音声ファイルのあるディレクトリを + 1 つの取り込み(アルバム候補。root 直下の音声は `""` の 1 つ)として `inbox_items` / `inbox_files` に写す。 + stat(inode / size / mtime / ctime)が変わったファイルだけタグを読み直す。**正は Inbox のファイル**で、 + 行はキャッシュ: ディレクトリが消えれば行も消す(`placed` は 24 時間残して結果を見せる)。`approved` の取り込みで + ファイルが変わっていたら `pending` に戻す(再承認) +- **CD の取り込みの表の画像**([D-91](../../decisions/D-091.md)): 走査と承認済みの配置の後、承認前(`pending` / `failed`)で画像がまだ無い取り込みのうち、 + サイドカーの `rip.metadata.release_id`(吸い出しで選んだリリース)があるものは、Cover Art Archive の front + 画像を**一度だけ**取り(`POST /api/artwork/from-caa` と同じ経路・境界。`CoverArtClient` は `address_family` + を `Auto` 固定)、`artwork` の置き場に置いて `inbox_items.caa_picture`(`<mime>:<sha256hex>`)に記録する。 + そのリリースに front が無く(404)、サイドカーに `rip.metadata.release_group_id` があれば、同じ回の中で + リリースグループの front(`release-group/{id}/front-500`。CAA がグループの代表に選んだ版の表の画像)を 1 度だけ + 試す([D-93](../../decisions/D-093.md)。通常盤に画像が無く初回限定盤・BD 付きの版にある盤。回数は 1 回と数え、グループの 404 はリリースの + 404 と同じ打ち止め、グループの上流の失敗はリリースの失敗と同じ再試行)。 + 提案(`proposal`)は全曲の `picture` の初期値をその画像にする(保存した下書きがあればそちらが勝つ)。 + 画像の無い盤(404)とリリースの無い取り込み(候補を選ばずに吸い出した・CD でない)は 1 回で打ち止め、上流の + 失敗は次の走査でもう 1 回だけ試す(`caa_tries`、上限 2。回数は外へ出る前に CAS で進めて永続化する)。1 回の + 実行で 4 件・90 秒まで、残りは周期の監視が次の周回で投入する(`needs_attention`)。取り消しは通信の待ちの間も + 効く。どれも取り込みは失敗させず画像なしのまま。 + 承認画面を開くたび・`GET /api/inbox` のたびには外へ出ない。`caa_picture` の画像は GC しない(区分 E の参照) +- **却下した取り込みの削除**([D-90](../../decisions/D-090.md)): `rejected` の取り込みに「削除」で `discard_requested_at`(破棄待ち)を入れる。ファイルは + すぐには消さず、GC が `[gc].retention_days` 経過後に取り込みのディレクトリの直下の**走査が写した音声**(stat が一致 + するもの)・既知の同梱ファイル・サイドカーを消し、ディレクトリが空なら消し、行を消す(サブディレクトリ = 別の取り込みと + 知らないファイルは残す)。それまでは「削除を取り消す」で `rejected` に戻る。`rejected` から出る遷移(下書きに + 戻す)でも破棄待ちは解ける。走査が破棄待ちの取り込みでファイルの変化(足された・差し替えられた)を見たら、人が + 見ていないものを消さないよう破棄待ちを解いて `rejected` のまま理由を `error` に残す(GC も確かめてから消す: + 音声は `inbox_files` と、同梱ファイル・サイドカーは ctime が破棄の要求より後でないかで照合し、合わなければ + 何も消さずに解く。消す途中で stat が変われば止めて解き、消せないものがあれば止めて行と破棄待ちを残し次の GC で + 続ける。[D-90](../../decisions/D-090.md)) +- **同名の警告**(P4-19、[D-70](../../decisions/D-070.md) 追記): 追記先の album に**同じタイトル**の active な行があれば、そのトラックに + `same_title: [{ track_id, rel_path, duration_ms }]` を付ける(承認は止めない。画面は「⚠ Library に同名: + <ファイル名>(長さ)」と、取り込みの見出しに「同名 N」)。鍵は NFKD + casefold + 空白の畳み込み(全角・半角の + 揺れは同じ、`(Cover)` / `【… Live ver.】` の注記は**落とさない** = 別曲扱い)。狙いは `SOURCE_URL` の + 補填漏れや別 URL の再アップロードによる二重取り込みで、判断は人が行う(同じ曲名の別テイクは正当なので、 + 長さを添えて見分けられるようにする) +- **承認キュー**は `GET /api/inbox`(`items` と監視の状態 `watch: { checked_at, poll_interval_secs }`)。取り込みごとにタグから作った下書き(`proposal`: albumartist / album / date の + 最頻値、category は GENRE → `genre_category_map`、トラックは TRACKNUMBER / DISCNUMBER / TITLE / ARTIST)と + 不足の警告を返し、UI の Inbox タブで category / albumartist / album / date と各トラックの + disc_no / track_no / title / artist を補正して `POST /api/inbox/:id/approve { draft }`。検証(album / + albumartist / 各 title が空でない、`(disc_no, track_no)` が 1 以上で重複なし、rel_path が取り込みのファイルと + 一致)に通らなければ 400 で、メタデータ不足のまま Library に入れない。`reject` / `reopen` で状態を戻す +- **トラックのタグの変更と画像の差し替え**([D-86](../../decisions/D-086.md)): 下書きのトラックは `tags`(キー → 値の配列、`null` で + そのタグを消す。書くのはここにあるキーだけ)と `picture`(`<mime>:<sha256hex>`。`POST /api/artwork/upload` + か `/from-caa` で置いた画像)を持てる(どちらも省略可。旧い下書きはそのまま読む)。キーは大文字・空でなく + `=` と制御文字を含まない(tagops と同じ文字の規則)。上の欄が扱うキー(TITLE / ARTIST / ALBUM / + ALBUMARTIST / DATE / TRACKNUMBER / DISCNUMBER / DISCTOTAL / PICTURE)と、曲・盤の同一性に使うキー + (`SOURCE_URL` / `MUSICBRAINZ_*`)は受け付けない。承認は差し替える画像の `artwork` 行が無ければ 400。 + 配置は `tags` の変更を補正のタグに足して書き(現在値と同じものは書かない)、`picture` のある曲だけ埋め込み + 画像を全部捨ててその 1 枚(front cover)にする。宛先に自分の成果物(同じ音声。途中で落ちた前回の配置)が + あれば置き換えずに再利用するが、今回の補正(タグ・画像)がそのファイルに入っていなければ Conflict で failed + (補正を黙って捨てない。外部の変更も上書きしない)。画像が store から消えていれば配置せず + failed。入力欄は Enter / Esc / blur のどれで決着しても 1 回だけ確定か取り消しをする(`lib/editSession.ts`)。 + 下書きが参照する画像は GC しない(区分 E の「参照」に Inbox の下書きを足す。[D-56](../../decisions/D-056.md) / [D-86](../../decisions/D-086.md))。`POST /api/inbox/:id/preview { draft }` は音声を読まずに + 配置の計画を引いて置き場所の見込みを返す(承認画面の ④) +- **配置**は `approved` の取り込みを `inbox` ジョブが順に処理する。`library` の排他(scan / gc / CD の配置と + 同じ)を取れなければ Requeue。draft から各ファイルの `TrackFields` を作り `pathgen::plan`(category 無しは + `_Unsorted`、`disc_no` の最大 ≥ 2 なら `multi_disc`、リリースキーは MUSICBRAINZ_ALBUMID の最頻値があれば + `mb:`、無ければ取り込みごとの新規)→ Inbox からハッシュを取りながら Library の tmp へコピー → 補正で変わる + タグだけ `write_tag_changes` で書く(ファイルが正のまま再スキャンしても DB と一致する)→ fsync → + `RENAME_NOREPLACE` → 読み戻して 1 トランザクション登録(`source_type = 'download'`。宛先 album の + リリースキー再検証と同パス行の MD5 検証は [§7.2](02-cd-rip.md) の配置と同じ)→ Inbox 側を unlink → 既知の同梱ファイル + (cover 画像 / cue / toc / log)も移し、空になった Inbox のディレクトリを消す。コピーに使う FD を + fstat して承認時の行(inode / size / mtime / ctime)と照合し、コピーの後にも同じ FD を照合し、置いた + ファイルの音声の指紋が承認時に読んだものと一致することを確かめる。どれかが外れたら `Changed`(取り込みは + `pending` に戻して再承認)。衝突・不足は取り込みを `failed` にして理由を残し、この呼び出しで置いたファイルは + 片付ける。 +- **状態遷移は CAS。** `approve` / `reject` / `reopen` と worker の `approved → placing` は + `UPDATE … WHERE id = ? AND state IN (…)` で行い(`db::inbox::transition`)、読んでから書くまでの間に + 他(API / worker / 走査)が動かした取り込みを上書きしない(API は 409 `state`、worker はその取り込みを飛ばす)。 + 前のプロセスが配置の途中で落ちて `placing` のまま残った取り込みは、次の `inbox` ジョブの先頭で `approved` に + 戻して配置し直す(並列 1 なので、そこで見える `placing` は必ず前の実行の残り。配置は音声の指紋で自分の + 成果物を採用するので冪等)。取り込みの `placed` と、WAV / ALAC / AIFF の normalize バッチ(`[normalize].wav_to_flac`)も登録 + トランザクションの中で確定する(commit の直後に落ちても `placing` が残らず、投入も欠けない)。`placed` の取り込みのディレクトリに走査で音声が見えたら(消せなかった原本、 + 配置の後に置かれたファイル)`pending` に戻して取り込みとして出し直す。 + Inbox は Library と別データセットなので move は実コピー([§5](../05-library-layout.md)) +- **後続**は `rg`(album の属性が on なら album 単位、off なら登録した track ごと。[D-74](../../decisions/D-074.md))と `transcode`。WAV / ALAC / AIFF は `[normalize].wav_to_flac` なら `normalize` の + 編集バッチを作って投入する([D-46](../../decisions/D-046.md) の予告)。**アートワークは配置の直後にその album だけ解決する** + (`scanner::resolve_album_artwork_now`。同梱カバー画像 → 構成トラックの埋め込み画像というスキャンの + Phase 5 と同じ規則・同じ DB 反映で、thumbnail もここで投入。**`library` の排他を持ったまま**行う(解放して + からだと並行する scan の予約 → 解決をこちらの古い読み取りで上書きし得る)。始める前に + `artwork_resolved_at = NULL` で予約するので、決められなくても(同梱カバーの探索や読み取りの I/O 失敗を + 含む)次のスキャンが拾う。P3-4) +- **既存の album への追記**([D-70](../../decisions/D-070.md)): リリースキーは MUSICBRAINZ_ALBUMID の最頻値があれば `mb:`、自分の成果物の + album があればそれ、**無ければ宛先ディレクトリに active な album があり、その album にも MB キーが + 無ければその album を採用**(`album:<id>`)、それも無ければ取り込みごとの新規。MB キー同士が違えば従来どおり + 降格か衝突。**CD とそれ以外は混ぜない**([D-67](../../decisions/D-067.md) 追記 3): トラックのタグに `MUSICBRAINZ_DISCID` があるものを + CD とし、CD の取り込みは CD の album にだけ、しかも album にまだ無い `disc_no` のときだけ採用する(MBID の無い + 複数枚組の 2 枚目が 1 枚目に合流する。同じ番号は同名の別の盤)。CD でない取り込みは CD でない album にだけ採用する。 + 同じ判定を再実行の「自分の成果物」と登録のトランザクションでも通し、外れたら `failed`。購読([§7.7](07-ytmusic.md))の + 束ね先が CD の album になっていたら同期は失敗する。採用する album は `GET /api/inbox` の `destination`(`{ album_id, album, track_count, + max_track_no, album_gain }` | null。下書きの category / albumartist / album と取り込みのファイルから引く。取り込みに + MUSICBRAINZ_ALBUMID があれば別リリースなので null)で見せる +- **album gain**([D-74](../../decisions/D-074.md)): 下書きの `album_gain`(既定 false。承認画面のチェックボックス。追記先があればその + 現在値が初期値)を配置時に album の属性へ書く(追記先の属性も上書きし、off にすれば album の値を消す) +- **ARTIST の多値**(P4-4、[D-70](../../decisions/D-070.md)): 提案のトラックは `artist`(ファイルの ARTIST の全値を `"; "` で結合した + 表示文字列。1 値なら同じ)と **`keep_artists`**(ファイルの ARTIST が多値なら true)を持つ。配置は + `keep_artists` が true なら**現在の ARTIST の個数に関係なく** ARTIST に触れない(`artist` は表示用で、値は + 見ない。承認後にファイルが 1 値に変わっていても、そのまま)。false なら `artist`(空ならアルバムアーティスト) + の 1 値で置き換える(`;` で分割はしない)。提案と画面が true にするのは多値のときだけ(保存済みの + 下書きを再表示するとき、ファイルが多値でなくなっていれば画面は false に戻す)。`keep_artists` を持たない + 旧下書きは旧規則(`artist` が先頭値のままなら保つ)で解釈する。保つときのパス生成の `{artist}` は + Library の `artist_display` と同じ `", "` 結合(ARTIST が無ければ `artist` → アルバムアーティストの順) +- **埋め込み画像**(P4-4、[D-70](../../decisions/D-070.md)): 走査は各ファイルの `PICTURE`(`"<mime>:<sha256>"`)を、Library の + `pick_embedded` と同じ規則(front cover 優先、無ければ先頭)で選んだ画像が**先頭**になる順で記録する。 + 各ファイルの代表画像はその先頭。取り込みの見出しに出す画像は各ファイルの代表の最頻(同数なら先に現れた + もの)で、**取り込みの中の多数派を目安に見せる要約**。配置後の album の代表(同梱カバー → 最初のトラックの + 埋め込み、追記先なら既存の代表)と同じとは限らない。忠実なのはトラックごとのサムネイル + `GET /api/inbox/:id/artwork/:hash` は、取り込みがあれば `PICTURE` にその sha256 を持つファイルを走査順に + 開き(openat2。symlink / 境界外 / 消失は次の候補へ)、埋め込み画像の内容の sha256 が一致するものを + 探す。見つからなければ 404(取り込みに無い hash、ファイルが消えた、画像が書き換わった)。見つかれば + `If-None-Match` が ETag `"<hash>-orig"` に一致すれば 304、でなければ原寸を返す(MIME はタグの値ではなく + 内容の sniff。sniff できなければ 404。`Cache-Control: public, max-age=31536000, immutable`)。 + **304 の判定は実体の照合の後**(DB に `PICTURE` が残っていても実体が無ければ 404)。取り込みの状態は見ない + (rejected / failed でも実体があれば返す。placed は実体が消えているので普通 404。**画面は配置済みの取り込みの + 画像を Library の画像置き場 `/api/artwork/:hash` から出す**(配置で同じ画像が登録されている。`lib/inbox.ts` の + `artworkUrl` / `trackPictureUrl` / `itemThumbUrl`))。セッション必須 + (allowlist 無し)。その他の I/O 失敗は 500。サムネイルは作らない(画面で縮小)。同梱の `cover.jpg` + 等は配置時に埋め込みより優先されるが([§7.1](01-scan.md) の規則)、承認画面が見せるのは埋め込み画像だけ +- **採番**: 下書きの提案で TRACKNUMBER の無いファイルは、採用する album の active な `track_no` の最大 + 1 から + ファイル名順に振る(無ければ 1 から)。承認の検証に「採用する album の active なトラックと `(disc_no, track_no)` + が重ならない」を加え、配置で失敗する前に 400 で直させる。配置の登録トランザクションでも同じ検証をする + (承認と配置の間に足された分。外れたら `failed`) +- **サイドカー `spindle-inbox.json`**([§7.7](07-ytmusic.md)、[D-70](../../decisions/D-070.md)): 取り込みのディレクトリにあれば `GET /api/inbox` が読み、 + `category` を提案の category(語彙に同じ canonical key があるときだけ)に、`files` の `verdict` / `message` / + `url` / `channel` を各トラックに付ける。走査は音声でないので無視し、配置の成功時に消す(Library へ + 持っていかない)。壊れていれば無いものとして扱い、取り込みの `warnings` に載せる。モジュールは `import/sidecar.rs` + (ダウンローダと CD の吸い出しが共有する) +- **CD の吸い出しの取り込み**([D-67](../../decisions/D-067.md) 追記、P2-5): サイドカーの `rip`(`RipEntry`: CTDB 形式の `toc`、吸い出し開始時の + `metadata`(`DiscMetadata`。名前は空でもよい)、音声トラック順のファイル名 `files`、ドライブが読んだ `isrcs` / + `mcn`(P4-21。旧サイドカーには無い)、rip.log の名前 `log`、`report`(`RipReport`))を持つ。提案は `album_gain = true`([D-74](../../decisions/D-074.md)。保存した下書きがあればそちら)。配置は + 下書きの各トラックを **basename で `files` の位置へ結びつけ**(`bind_rip`。大小文字・正規化の違いは同じ名前)、 + 記録の形(件数が音声トラック数と揃う、名前の重複なし)・取り込みのファイルとの 1 対 1・下書きの `disc_no` が 1 つに + 揃うこと(1 取り込み = 1 枚)を確かめ、外れたら配置せず `failed`(提案の `warnings` にも出す)。登録は + `register_item` と同じトランザクションで `source_type = 'cd_rip'`、`album_verifications`(`source = 'rip'`、 + `disc_no` は下書きの値、`job_id` は NULL、`drive_offset` はレポートの `read_offset`(PCM に当てた + 読み取りオフセット。遡及照合の行は NULL。[D-83](../../decisions/D-083.md) 追記 3)、`log_path` は移した rip.log の Library 相対。移せなければ NULL)/ + `track_verifications` / `tracks.verification`(写像は [§7.3](03-retro-verify.md) と同じ)。全トラックに `rip` の記録が既にあれば + 書かない(commit の後に落ちて再配置したとき)。サイドカーは読んだ FD の inode / size / mtime / ctime を + 登録の直前と消す前に照合し、変わっていれば登録せず `pending`(登録後なら消さずに残す) +- **承認後にファイルが増えた取り込み**(同じ album への追加ダウンロード)は既存の規則で `pending` に戻る。そのとき + 提案は「保存した下書き(既知のファイルの分)+ 新しいファイルの提案」を merge して返す(補正をやり直させない) + +- **再判定**(`inbox_rejudge` ジョブ、`src/import/ytmusic/rejudge.rs`、[D-114](../../decisions/D-114.md)): プラグインのルールを直した後に、承認前の + 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` diff --git a/docs/spec/07-pipeline/09-flac-check.md b/docs/spec/07-pipeline/09-flac-check.md new file mode 100644 index 00000000..2f2b29cd --- /dev/null +++ b/docs/spec/07-pipeline/09-flac-check.md @@ -0,0 +1,29 @@ +# 7.9 FLAC 健全性チェック(移行時 + 任意) + +``` +flac -t で検証 + ├ STREAMINFO MD5 が未設定(全ゼロ) → デコードした PCM MD5 を STREAMINFO に補填(再エンコードしない) + │ 一部の古いエンコーダや配信由来の FLAC で実際に起きる。 + │ 未設定だと同一性解決の第 2 手段が使えず、遡及照合も不可能 + ├ デコードエラー → 要対応としてフラグ + └ 正常 → そのまま +``` + +**圧縮レベルを揃えるための一括再エンコードは行わない。** +`flac -8` は圧縮率だけの違いでデコード結果は完全に同一であり、削減は +概ね 0.5〜1.5%。対価として 200GB を書き直すことになり、ZFS スナップショットが +旧ブロックを掴むため保持期間中は実効使用量が倍増する。 +`flac -8` は新規生成物(CD リップ、WAV 正規化)にのみ適用する。 + +MD5 補填のための書き換えでは `audio_version` を据え置く +(音声内容が変わらないため Derived の再生成は不要)。inode と mtime は変わるので +DB の追随は必要。補填は再エンコードではなく STREAMINFO の MD5 16 バイトだけを書き換える +(P1-5b、[D-57](../../decisions/D-057.md) / [D-59](../../decisions/D-059.md))。補填は編集バッチ(`edit_ops.kind = 'md5'`、`edits.key = 'audio_md5'`、 +値は hex で全ゼロ = 未設定。`new_value` は反映時に計算値を書く)として記録し、tagwrite ジョブが +tmp + rename で反映する。巻き戻しは全ゼロを書き戻す(`audio_md5` は NULL、`flac_check` は +`md5_missing` に戻る)。 + +検査(`flaccheck` ジョブ)は読むだけで、結果を `tracks.flac_check`(`ok` / `md5_missing` / +`decode_error`)に検査時の `audio_version` 付きで記録する。版が進めば結果は古い扱いになり、 +`[normalize].flac_verify_on_import` ならスキャン完了時に結果の無い FLAC を自動で検査する。 +手動は `POST /api/flaccheck { selection }`。一覧の固定フィルタ `flac_unchecked` / `flac_error`。 diff --git a/docs/spec/07-pipeline/10-fake-hires.md b/docs/spec/07-pipeline/10-fake-hires.md new file mode 100644 index 00000000..d65e0ea1 --- /dev/null +++ b/docs/spec/07-pipeline/10-fake-hires.md @@ -0,0 +1,75 @@ +# 7.10 偽ハイレゾ検出(任意) + +配信で買ったハイレゾ音源には、CD 由来の AccurateRip / CTDB のような品質の裏付けが無い +(`verification` は `unverifiable` のまま)。44.1 kHz / 16 bit のマスターをアップサンプリング・ +ビット深度の水増しで「24/96」として売る例があり、移行データにも `Album/Hi-Res/01.m4a`(24/96)と +アルバム直下の 16/44 が別トラックとして両方入っている。どちらを残すかの判断材料として、 +可逆かつ **`sample_rate > 48000` または `bit_depth > 16`** のトラックを解析し、結果を表示と +絞り込みに出す。**判定を消費する自動処理は無い**(Derived・配布ビュー・RG・リネームは判定に +依らない。[D-71](../../decisions/D-071.md))。非可逆由来の可逆(44.1 kHz の FLAC が 16 kHz で切れている等)は対象外。 + +``` +hirescheck ジョブ(読むだけ。track_id + audio_version、並列 = max(1, CPU コア数 / 2)) + root の dirfd で開き fstat を行と照合 → media::decode で PCM を流す(PcmSink) + ├ スペクトル(sample_rate > 48000 のとき) + │ チャンネルごとに Hann 窓 8192 点 / ホップ 8192 の FFT(rustfft)。パワーはフルスケール正弦波が + │ 0 dB になるよう正規化(|X_k|² × (2 / Σw)²)し、線形のまま累積して有音フレーム数で割る + │ RMS が -70 dBFS 未満のフレームは無音として捨てる + │ 終了時(有音フレームが 1 以上のとき): + │ P[k] = 平均線形パワー(未平滑)。dB は 10·log10(max(P, 1e-20))(下限 -200 dB) + │ S[k] = 各ビンを中心に ±1/6 オクターブ(= 1/3 オクターブ幅)の P を線形平均して dB。 + │ 窓は [0, Nyquist] で切る(端は片側だけ) + │ floor_db = max(Nyquist 直下 5% のビンの S の中央値, −150 dB)。完全ゼロの帯域で床が + │ −200 dB に張り付き、遠いサイドローブまで拾うのを防ぐ(−150 dB は 24 bit の + │ 量子化ノイズ(1 ビン約 −186 dB)より上、16 bit のディザの直下) + │ 候補 f_s = S[k] > floor_db + 10 dB を満たす最高ビンの中心周波数(S は窓の下端 + │ f_s / 2^(1/6) まで信号を引きずるので、これ自体は上へ最大 1/6 オクターブずれる)。 + │ 該当ビンが無ければ(上端まで信号がある) cutoff_hz = Nyquist、cliff_db = NULL + │ cutoff_hz = 平滑化前の P_dB に戻り、[f_s / 2^(1/3), f_s] の範囲で 200 Hz 幅の dB 平均の段差 + │ mean(P_dB[k−m, k)) − mean(P_dB[k, k+m)) が最大のビンの周波数(推定エッジ)。 + │ brickwall では段差が真のエッジで最大になり、Hann 窓の漏れの量に依らない + │ (合成信号で 22.05 kHz → 22,090 Hz、24 kHz → 24,035 Hz) + │ cliff_db = 10·log10(mean(P[cutoff−1 kHz, cutoff)) / mean(P[cutoff, cutoff+1 kHz]))。 + │ **平滑化前の P で、cutoff と同じ推定エッジを中心に測る**(S で測ると崖を自分で + │ ぼかす)。帯域は [0, Nyquist] で切り、どちらかの帯域にビンが無ければ(cutoff が + │ Nyquist 直下・1 kHz 未満)NULL。分母が 0 なら +200 dB に飽和 + │ チャンネルが複数なら cutoff が最大のチャンネルを採り、**cliff もその同じチャンネルの値**を対で + │ 採る(片側だけ本物なら本物。cutoff と cliff を別々に最大化しない) + │ 有音フレームが 0 なら cutoff_hz / cliff_db は NULL + ├ ビット(bit_depth ≤ 24 のとき) + │ round(sample × 2^(bit_depth−1)) を i32 に戻して全チャンネル・全サンプルを OR + │ effective_bits = bit_depth − OR の末尾ゼロビット数。OR = 0(全無音)なら NULL + │ 32 bit は f32 で正確でないので NULL + └ 判定(上から順に最初に当たったもの。計測しなかった側は NULL) + decode_error : デコード失敗(hires_check_error に stderr / メッセージの先頭 500 文字) + inconclusive : 計測できたものが無い(cutoff_hz と effective_bits がともに NULL。全無音、 + 32 bit かつ ≤ 48 kHz) + both : upsampled かつ padded + upsampled : cutoff_hz ≤ [hires].cutoff_hz かつ(cliff_db ≥ [hires].cliff_db または + cutoff_hz ≤ [hires].hard_cutoff_hz)。後者は 44.1 kHz の Nyquist + 余裕で、 + >48 kHz のファイルでそこから上が空なら崖の有無を問わない(D-71 追記) + padded : effective_bits ≤ 16 + inconclusive : cutoff_hz ≤ [hires].cutoff_hz だが崖が無い(cliff_db が NULL か閾値未満。 + 自然なロールオフ。本物の可能性あり) + ok : いずれでもない +``` + +カットオフだけで判定しない理由: 本物でも録音や マスタリングの都合で自然に減衰する帯域がある。 +SRC のローパスは 1 kHz 以内に段差(実測 12〜21 dB。エッジ直下の音楽の残りと上げた後の床の差で +決まり、30 dB には届かない)を作るので、それを条件に加えて誤検出を避ける。ただし 44.1 kHz の +Nyquist(22.05 kHz)の下で床に沈むものは、段差が無くても本物の >48 kHz 録音ではあり得ない +(実機の 106 本の本物はすべて 25.4 kHz 以上まで伸びていた)ので `hard_cutoff_hz` で拾う。 +22.5〜25 kHz の帯(48 kHz マスター)で崖の無いものは `inconclusive` として人が見る。 + +結果は `tracks.hires_check` に検査時の `audio_version` 付きで記録し(`hires_checked_at` / +`hires_check_version` / `hires_check_error`)、**計測値 `hires_cutoff_hz` / `hires_cliff_db` / +`hires_effective_bits` も残す**(しきい値を変えたときや目視の判断に使う。判定は検査時に確定し、しきい値の変更は既存の +結果を書き換えない。再判定は手動投入)。版が進めば結果は古い扱い(`stale`)。 +`[hires].check_on_import` ならスキャン完了時に結果の無い対象を自動で投入する(flaccheck と同型)。 +手動は `POST /api/hirescheck { selection }`(対象外・missing は `skipped`)。 +一覧の固定フィルタ `hires_unchecked` / `hires_suspect`(`upsampled` / `padded` / `both`)。 +DSL は `hirescheck`(文字列)、`cutoff`(数値、Hz)、`cliff`(数値、dB)、`effectivebits`(数値)。 + +開いた FD の fstat が行と一致しない・missing なら何も書かず `Outcome::Done` で終える(flaccheck と +同じ。API の `skipped` は投入時に対象外だった数で、ジョブの終端とは別)。次のスキャンで版が進めば +再投入される。`record` は `WHERE audio_version = ?` で、検査中に版が進んでいれば書かない。 diff --git a/docs/spec/07-pipeline/11-device-delivery.md b/docs/spec/07-pipeline/11-device-delivery.md new file mode 100644 index 00000000..35f1420f --- /dev/null +++ b/docs/spec/07-pipeline/11-device-delivery.md @@ -0,0 +1,381 @@ +# 7.11 端末への配信 + +Library の曲とプレイリストを端末へ写す(P5、[D-95](../../decisions/D-095.md))。Android(Poweramp)へは spindle が USB の +ADB で直接書き、iPhone へは Mac の `spindle-agent` がミュージック.app へ反映し、その先は Finder +同期に任せる(Linux から純正ミュージックへ直接書く手段が無いため)。**自動では送らない**: +端末ごとに差分を出し、利用者が確かめてから計画として確定し、確定した計画だけを実行する。表は +[§6](../06-data-model.md)「端末への配信の表」、ジョブは [§8](../08-jobs.md)、API は [§9](../09-http-api.md)、端末タブは [§12](../12-ui/README.md)、設定は [§13](../13-config.md) の `[devices]`、adb +サイドカーは [§14](../14-deploy.md)。送る系統は端末ごとに選ぶ(既定は Android が `opus`、iPhone が `aac`。[§7.6](06-derived.md))。 + +``` +CD ─┐ ┌─ device_sync ─ adb ─ Android(Poweramp) + ├→ Inbox → 承認・配置 → Library → RG → Derived ─┬─ opus ─┘ +YT ─┘ └─ aac ─→ spindle-agent(Mac)→ ミュージック.app → iPhone + ↑ 端末ごとのマニフェスト(あるべき状態)を spindle が毎回計算する +``` + +| | Android(`transport = 'adb'`) | iPhone(`transport = 'agent'`) | +|---|---|---| +| 運び手 | spindle の `device_scan` / `device_sync` / `device_verify` ジョブ。adb サーバは host ネットワークのサイドカーで、Unix ソケットだけで待ち受ける([D-98](../../decisions/D-098.md)) | Mac の `spindle-agent`(常駐しない CLI)。`/api/agent/*` を Bearer トークンで叩く([D-99](../../decisions/D-099.md)) | +| 端末側の正本 | `<root>/.spindle/manifest.json` とジャーナル、実ファイル | エージェントの `state.json` とミュージック.app の track。spindle へは報告で届く | +| 差分の確認と確定 | 端末タブの差分表 →「同期」 | `spindle-agent sync` が差分を表示 → `y/N` → 確定 | +| キャッシュへの取り込み | 回復の後(各ジョブの最初・破棄)と同期の完了 | 報告・破棄の受理 | +| 接続の検出 | `adb track-devices` の常駐監視。つながると `device_scan`(回復と差分の計算だけ)を投入し、待っている同期・検証を前倒しする | 無い(利用者が Mac で `sync` を実行する) | +| 端末タブの「反映済み」 | 端末上 | **Mac に反映済み**(`~/Music/spindle` とミュージック.app まで。Finder 同期の結果は spindle から見えない) | + +**マニフェスト(あるべき状態)** + +保存せず、差分を見るたびに DB から計算し直す([D-78](../../decisions/D-078.md) と同じ流儀。`domain::device` の純粋関数、 +`db::devices::compute` が 1 つの読み取りトランザクションで入力を読む)。曲ごとに **desired / hold / remove** +のどれかに入れる。全端末の計算結果(スナップショット)は `Db` が書き込みの通番で無効化するキャッシュとして +持ち、表示だけの用途(一覧・詳細の行の端末の状態、Inbox の段、端末一覧の件数)は計算から 2 秒以内なら +使い回す。差分・`plan_token`・確定・DSL の未反映の集合は通番の一致を要する([D-95](../../decisions/D-095.md) の P5-2 追記)。 + +| 項目 | 規則 | +|---|---| +| 選曲 | `selection = 'all'` は全曲、`'playlists'` は印(`device_playlists`)の付いたプレイリストの曲の和集合(スマートは materialize 済みの結果。[D-54](../../decisions/D-054.md))。どちらも missing と、チャンネル数が 1・2 以外(不明を含む)の曲は対象外(= remove)。印の付いたプレイリストは `all` でも端末へ書き出す | +| 循環の禁止 | `on_device` / `device_pending`(端末の状態を引く DSL のフィールド)を使うスマートプレイリストには印を付けられない(400)。印の付いたプレイリストのルールの保存でも同じ検査をする(同期のたびに選曲が変わるため。[D-95](../../decisions/D-095.md) の P5-2 追記) | +| 評価待ち | スマートプレイリストの再評価は debounce される([D-54](../../decisions/D-054.md))ので、印の付いたスマートプレイリストがある `playlists` の端末は、評価待ちの間は確定を 409 `pending_reevaluation` で断る。差分画面には各プレイリストの評価時刻(`playlists.evaluated_at`)と評価待ちの印を、エージェントの manifest には評価待ちの印を出す | +| 送る元 | 系統(`devices.variant`)の Derived の行があり、`src_audio_version = audio_version`、かつ系統が有効なら `audio_profile` が設定と一致 → **Derived**(タグ版だけ古い `stale_tags` でも送る。タグの上書きでトークンが変わり、更新として送り直す)。系統が `opus` で原本が非可逆 → **Library の原本**([D-8](../../decisions/D-008.md)。`aac` は非可逆原本も Derived に入るので原本は送らない)。それ以外 → **待ち**(理由は「Derived 未生成」、`aac` で RG が揃わなければ「RG 未解析」、音声版か profile が違えば「Derived の音声が古い」)。系統が設定に無い・凍結中は行の profile をそのまま有効とみなす([D-75](../../decisions/D-075.md) の凍結と同じ) | +| 送る元のハッシュ | `source_hashes` に送る元の**意味トークンが同じ行**があるときだけ送れる。原本はさらに行の物理同一性(inode・size・mtime_ns・ctime_ns)が `tracks` の現在の値と一致すること(外部ツールが同じタグで再保存した原本を「変化なし」としないため)。無ければ「待ち: ハッシュ計算中」で、差分を計算した側が `source_hash` ジョブを投入する(下記) | +| 端末上のパス | Library の `rel_path` と同じ相対パスで、拡張子だけ送る元に合わせる(`J-Pop/YOASOBI/THE BOOK/1-01 群青.opus`)。Derived と原本が 1 つの root に並ぶので、m3u8 を root 相対で書ける | +| パスの検査 | 端末の root を前置きした長さが 240 UTF-16 単位([§5](../05-library-layout.md))を超えれば「エラー: パスが長すぎる」。前置きは adb が `/storage/<volume>/<root>/`(内部ストレージは `/storage/emulated/0/<root>/`)、agent が `Music/spindle/`。別の曲と `dest_path_key`(`canonical_key` = casefold + NFD)が重なれば**どちらも**「エラー: パス衝突」(黙って片方を勝たせない。Library のリネームで解消する)。行き先を「パスを変えない管理下の曲」(保留中の曲を含む)が占めていれば、その曲も「パス衝突」で保留にし、不動点まで繰り返す(入れ替えの片側が保留になれば、もう片側も保留。[D-95](../../decisions/D-095.md) の P5-1 追記) | +| プレイリスト | 印の付いたプレイリストを、衝突を解いた後の desired と hold から組み立てる。載せるのは**同期の後に端末に実在する曲**だけ: desired は今回のパス、hold で既存の写しがある曲は `device_items` のパス。adb は `Playlists/<名前>.m3u8`(`#EXTM3U` と `../<root 相対パス>` の行、UTF-8)、agent はミュージック.app の「spindle」フォルダ直下の同名のプレイリスト。名前の `canonical_key` が重なる・予約名(`.spindle`)と同じものはエラーで、端末上の既存のものに触らない | + +| 集合 | 条件 | 端末上の既存の写し | +|---|---|---| +| **desired** | 選曲に入り、送る元とハッシュが揃い、エラーが無い | 追加 / 更新 / 移動 / 更新 + 移動 / 変化なし | +| **hold** | 選曲に入るが、待ち(上の理由)かエラー(パス衝突・パス長) | **触らない**(古い写しがあればそのまま残る) | +| **remove** | 選曲から外れた・missing・マルチチャンネル | 削除 | + +**トークン**は構成要素の正準 JSON(キー昇順・空白なし・スキーマ版 `v` を含む)の SHA-256 で、 +解析しない不透明な値: + +- **意味トークン**: 送る元の版。Derived は行に記録した版(`variant`・`src_audio_version`・ + `src_tag_version`・`src_artwork_id`・`src_rg_scanned_at`・`audio_profile`・`tag_profile`)、原本は + `audio_version`・`tag_version`。`source_hashes` がどの版のハッシュかを示す +- **配信トークン**: 意味トークン + sha256。端末の中身の同一性と、エージェントのファイル取得の強い + ETag はこれで判定する(版が進まないまま外部ツールがバイト列を変えても、更新として送り直す) +- プレイリストのトークン: `playlist_id`・名前・生成したバイト列の sha256 + +**差分**は desired と `device_items` を `track_id` で突き合わせる。トークンもパスも同じなら変化なし、 +トークンだけ違えば**更新**、パスだけ違えば**移動**、両方違えば**更新 + 移動**、`device_items` に +無ければ**追加**、remove に入る既存の写しが**削除**。hold は件数と理由だけを出す。プレイリストは +`device_playlist_state` と突き合わせて追加 / 更新(中身か置き場所が違う。改名を含む)/ 削除。 +各曲の端末の状態(`synced` / `pending` / `waiting` / `error` / `removing`)は一覧・詳細と DSL の +`device_pending` に使う([D-95](../../decisions/D-095.md) の P5-2 追記)。 + +**`source_hash` ジョブ**(曲 × 送る元の単位、dedup `source_hash:<track_id>:<source>`、 +CPU の共通並列予算の対象で並列度 2。[D-73](../../decisions/D-073.md)): 送る元を root の dirfd から `openat2` で開き、開いた +FD の物理同一性を記録してから全体を読んでハッシュし、読み終えた後の `fstat` が開いた時と同じで、 +意味トークンも変わっていなければ保存する(途中で差し替わったら捨てて再試行)。原本は開いた FD が +`tracks` の物理同一性と食い違えば読まず、増分スキャンを投入して「スキャン待ち」で終える(同じ物理同一性の +間は再投入しない)。Derived の実ファイルが無ければ([D-51](../../decisions/D-051.md) の drift)`derived_files` の行を消して +transcode を投入する。投入は差分の計算(端末一覧と差分)のたびに、未完了と抑制中のものを除いて 1 つの +SAVEPOINT で行う([D-95](../../decisions/D-095.md) の P5-1・P5-2 追記)。初回は全曲分が走る。 + +**配信時の照合**(ADB の転送・エージェントのファイル取得の両方): 送る元を開いた FD の物理同一性が +`source_hashes` の行と一致しなければ送らない(ADB はその曲を項目のエラーにし、エージェントへは +412 `source_changed`。どちらも行を捨てて次の差分で取り直させる)。ADB は送りながら spindle +側でもハッシュを取り、期待値と違えば同じく取り直させる。Derived の差し替えや Library の外部更新と +競合しても、**開いた FD の中身とハッシュの組**がずれない。 + +**計画** + +- **確定**: 差分の応答は、`generation` と各操作(種類・track_id / playlist_id・from・to・トークン)を正準 + JSON にした `plan_token` を持つ。確定の要求はそれを添え、サーバが計算し直した差分の + `plan_token` と一致したときだけ、計画を `device_sync_plans` に**不変のまま**保存する(違えば + 409 `plan_changed`。画面・エージェントは差分を取り直して再確認させる)。差分を見てからボタンを押すまでに + 増えた削除を黙って実行しないため。adb は確定と `device_sync` の投入を 1 つのトランザクションで行う。 + agent は最初の副作用の前にエージェントが確定する +- **端末ごとに open な計画は 1 つ**。同じ `plan_token` の確定は同じ計画を返し(冪等)、違えば + 409 `open_plan_exists`。open な計画か待ち・実行中の `device_sync` がある間は、名前以外の PATCH・ + 印の変更(PUT)・pair コードの発行を 409 `open_plan` にする(`generation` を動かさないため)。 + 印のついたプレイリストの削除も同じ規則で、印のある端末のどれかに open な作業があれば 409 `open_plan` + (端末名を message に出す)。通れば印のある端末の `generation` を削除と同じトランザクションで進める + ([D-102](../../decisions/D-102.md))。プレイリストの中身・名前・ルールの変更は止めない(次の差分で反映される) +- **開始済みの計画の再開**: 実行するのは計画の部分集合だけ。今の状態で満たされている操作は済みとして + 飛ばし、今の差分にも**同じ操作・同じトークン・同じパス**で残っているものだけを実行する。差分から消えた・ + 変わった操作と、計画に無い操作(とくに新しい削除)は実行せず、次の差分に回す。パス変更は入れ替え・ + 循環の成分ごとに全員残すか全員外す。例外は始まったパス変更のバッチ(下記)と、 + 旧パスの削除の後で切れたプレイリストの改名(追加として続ける。[D-95](../../decisions/D-095.md) の P5-3a 追記) +- **終端**: adb はジョブの成功で `completed`。失敗・キャンセル・未接続では `open` のまま残り、 + 端末タブが「続きを実行」(同じ計画で同期を投入し直す)と「破棄」を出す。agent は報告の受理で + `completed`、エージェントの `abandon` で `abandoned`。終端した計画への報告は受けない(同じ報告の + 再送だけは `report_digest` の一致で成功を返す。[D-99](../../decisions/D-099.md))。終端した計画は GC が保持期間後に消すので、それより + 遅れて届いた再送は 404 `no_plan` になる(エージェントは `plan_id` を外して止まり、次の sync が報告だけを + 取り直して回復する。端末ごとの最後の終端 1 件は残すので、通常の再送は影響を受けない。[D-102](../../decisions/D-102.md)) +- **端末が戻らないときの逃げ道**([D-98](../../decisions/D-098.md)): 破棄に `force` を付けると、端末につながず回復もせずに計画を + `abandoned` にし、待ちの同期を取り消す(実行中の同期・端末のロック中は 409 `busy`)。adb で安全なのは、 + 次につないだときの回復が計画と無関係に端末のジャーナルから封印済みバッチを完遂・取り消すため。iPhone の + Mac が失われたときもこれで閉じる。端末の削除も open な計画があっても通す(端末のジョブが実行中か + ロック中なら 409 `busy`。待ちのジョブは取り消す)。印のついたプレイリストの削除が `open_plan` で止まるときも、これで開く + +**Android(ADB)** + +- **接続**: adb のサブコマンドは `shell`(shell v2)だけを使う。pty を割り当てなければ stdin / + stdout がバイナリ安全で終了コードも返る(`exec-in` / `exec-out` は終了コードを返さない)。 + ジャーナルの行と転送する中身は stdin で流し、端末側のスクリプトに埋め込むパスは + 1 か所の関数で単引用符クォートする(`sh -c` はローカルでは使わない)。未接続は adb クライアントの文言か + `get-state` で判定する([D-95](../../decisions/D-095.md) の P5-3a 追記、[D-98](../../decisions/D-098.md)) +- **端末のファイルシステムの性質**(実機で確認。[D-98](../../decisions/D-098.md)): 大小文字だけの改名は `mv` が成功を返して + 何もしない(内部共有ストレージ・SD とも)ので、パス変更は必ず `.spindle/moving` の一時名を経由する。 + ディレクトリ名の大小だけの変更は `mkdir -p` が既存を使うので、端末上の表示は旧い大小のまま残る(キーで + 比べるので差分は出ない)。正規化は内部共有ストレージが区別せず(NFD の名前で NFC のファイルに当たる)、 + SD(exFAT)は区別する。キーは casefold + NFD なので安全側(別名が衝突として保留になり得るだけ) +- **登録**(端末タブの「未登録の Android」): 保存先は内部共有ストレージか SD カードの + `Music/spindle`。**root が無いか空のときだけ**登録でき、`.spindle/manifest.json` を作る(空でなければ + 409 `not_empty`。既存のファイルの扱いを推測しない。片付けるのは、中身が `.spindle` の下だけで + **回復に要る物が無い**(`.spindle/moving` の下にファイルが無く、ジャーナルが無いか空)前の登録の失敗の残りで、 + manifest が無い・読めない・空のものだけ。それ以外は 409 `not_empty`。曲かプレイリストを指す manifest は捨てず 409 `adoptable` で + 引き継ぎを案内する。対象は `volume` が登録先と同じで、uuid が DB に無いものに限る)。 + 保存先は PATCH で変えない(削除して登録し直す)。Poweramp の + スキャン範囲はそのフォルダ設定なので、保存先はその下に置く +- **引き継ぎ**(DB 全損後。`POST /api/devices` の `adopt: true`。[D-103](../../decisions/D-103.md)): 未登録の一覧は、root の manifest が + 読めて、`volume` が一致し、uuid が DB に無く(32 桁の小文字 16 進)、曲かプレイリストを指す + (空の manifest は引き継げない)ボリュームを `adoptable` にする。プレビュー + (`GET /api/devices/adb/adopt-preview`)は書かずに付け替える曲・外れる曲・プレイリスト・管理外の件数と + `library_busy` を返す(ジャーナルは回復しないので件数は目安)。確定は `register_lock` の下で、 + Library の `scan` ジョブが待ち・実行中なら 409 `library_busy`(DB の作り直し途中だと大半が外れる)→ + manifest を読む(無い・読めない・`volume` 違い・空・uuid の形が不正・回復で manifest の不整合が + 見つかったときは 409 `not_adoptable`、uuid が DB にあれば 409 `uuid_registered`。端末との通信の失敗は + 既存のエラー応答の 502 `device_failed` / 409 `not_connected`)→ + `scan` が待ち・実行中でないことと remap の入力(Library の曲とプレイリスト)を 1 つの読み取り + トランザクションで読む(スキャン中なら 409 `library_busy`。別々に読むと間に始まったスキャンの途中の曲で + remap し得る)→ 回復(`recover`。DB は見ない)→ remap → `compact` で manifest を書き直す → + 1 トランザクションで manifest の uuid の行を作り、付け替えたプレイリストに印を付け直す → + `device_scan` を投入する。`generation` は作成時の既定のまま(印の復元で進めない)。印を付け直すのは、 + 端末に m3u8 があるのはそのプレイリストに印が付いていたからで、付けないと `selection=playlists` の端末の + 最初の差分が全削除になるため。remap は曲を manifest の `rel_path` の `canonical_key` で今の tracks + (missing を除く)から引いて `track_id` を付け替え、プレイリストは `Playlists/<名前>.m3u8` の名前で付け替える。 + 1 つのキーに複数の曲、1 曲に複数の項目が当たるものは採らない。**採らなかった項目は manifest から外して管理外に + する(ファイルは消さない)**。曲のトークンは `ADOPTED_TOKEN`(`"adopted"`)、プレイリストは `STALE_TOKEN` + (回復が先に `STALE_TOKEN` にした曲は変えない)。remap は冪等で、途中で落ちてもやり直せば同じ結果になる。 + Android 以外の transport は 400。Mac は `pair --adopt` で、同じ付け替えの規則をサーバで計算する + (下の「iPhone」の「引き継ぎ」、[D-104](../../decisions/D-104.md)) +- **端末側の正本**: manifest は `format`・`device_uuid`・`volume` と、曲(track_id・path・配信トークン・ + size・sha256・`rel_path`・`source`)とプレイリスト(playlist_id・path・トークン)の一覧。知らない `format`、id とパスの重複、 + root 外のパス、10 万件超は読まずに止める。`device_uuid` と `volume` が端末の行と揃わなければ止める(別の + 端末のカード・カードの差し替え)。**manifest に無いファイルには触らない**(手で置いたものは + 「管理外」として件数を出し、置き先を塞いでいれば「管理外のファイルと衝突」でその曲を保留にする)。曲の `rel_path` は送った元の Library の + `tracks.rel_path`、`source` は送る元の種類(`SourceKind::as_str()` の `master` / `opus` / `aac`)で、 + どちらも必須(引き継ぎの付け替えの鍵。ジャーナルの曲の意図にも任意で載せ、回復で取り込む)。`format` は 1 + のまま、この 2 項目の無い旧 manifest は読めない(リリース前の変更。保存先を消して登録し直す) +- **ジャーナル**(`.spindle/journal`、1 行 1 レコードの NDJSON): 各操作は**意図を耐久化してから**副作用を + 起こす。追記 → `sync` の後に副作用、ファイルの置き換えは同じディレクトリの tmp → `sync` → `mv -f` → + `sync`、圧縮は manifest を書き直して確定させてからジャーナルを空にする(逆だと回復できない)。 + バッチ以外の意図は `put` と `rm` だけ。完了は次の意図と同じ追記に載せ、manifest の書き直しは完了 + 50 件ごとと同期の最後に行う(1 曲ごとに 2 回 `sync` すると初回の全曲転送が数時間延びる。[D-95](../../decisions/D-095.md) の P5-3a + 追記) +- **パス変更(移動・更新 + 移動)は計画の全件を 1 つのバッチ**にし、大域的な 2 段で進める(入れ替え・ + 循環・大小文字だけの改名で上書きしないため。Library の一括リネームと同じ考え方): メンバー全員を記録して + `sealed` → 更新 + 移動の新しい内容を一時ファイルへ転送して照合(`prepared`)→ 全員の旧パスを空ける + (`vacating` → `vacated`)→ 全員の行き先を埋める(`placing` → `done`)。一時ファイルは + `.spindle/moving/<batch_id>-<op_id>`(`.new`)。`prepared` 以前は副作用が一時ファイルだけなので破棄・ + 縮小できる(先に `batch_abort` を耐久化)。 + **`vacating` 以降はキャンセルも `generation` の不一致も効かせず、記録した内容のまま完遂する**(一部だけ + 止めると入れ替えが壊れる) +- **回復**(各ジョブの最初と破棄。DB は見ない): manifest とジャーナルを読み、完了の無い `put` は置き先の + sha256 が意図と一致すれば反映済みとして取り込む。封印済みで `vacating` 以降のバッチは、メンバーの位置を + ファイルの在否で判定して前進で完遂し(`from` にも `staging` にも無いメンバーは手で消されたとみなし、 + manifest から外す)、`prepared` 以前のバッチは破棄する。実ファイルが無い・サイズが違う曲は manifest から + 外さずトークンを空(`STALE_TOKEN`)にする(外すと管理外になって上書きも削除もできない。次の差分で「更新」に + 戻る)。ただし実ファイルを見失った曲は、`.spindle/moving/` に残ったファイルと sha256 で照合できれば元の + パスへ戻す。最後に manifest を書き直してジャーナルを空にし、その結果で `device_items` / + `device_playlist_state` を全置換する([D-95](../../decisions/D-095.md) の P5-3a 追記) +- **同期**(`device_sync`、payload は端末と計画の id、dedup は端末ごと、最大 3 回): + 端末ごとの排他(`job_mutexes` の `device:<id>` とプロセス内のロック)を取り、計画がまだ + open かをロックの後で確かめ直し、回復してから再開の規則で実行する計画の部分集合を決める。順序は + **削除 → パス変更のバッチ → 更新 → 追加 → プレイリスト**(先に空きを作る。プレイリストは旧パスの + 削除を全部先に行ってから書く)。始める前に、計画の順に見た追加量の最大値 + プレイリストと manifest の + 大きさ + 64 MiB を空き(`stat -f`)と比べ、足りなければ送らず失敗する。転送は送る元の FD を stdin に + 繋いで端末の `<to>.spindle-tmp` へ書き、端末の `sha256sum` で照合してから `mv -f` する。`vacating` より + 前の書き込みの直前にキャンセルと `generation` を確かめる(不一致は失敗で、再試行しない)。完了で manifest + からキャッシュを、この同期のエラーから `device_errors` を全置換し、計画を `completed` にする(同期の途中で + 計画が閉じられていたら、キャッシュも `last_synced_at` も進めずに失敗) + - **進捗**([D-109](../../decisions/D-109.md)): 項目の始めと終わりに `progress` を呼ぶ。件数は `JobEvent` の `done` / `total`、 + SSE の `job` イベントの detail は `{"kind":"device_sync","phase","current","bytes_done","bytes_total","rate"}` + (`phase` は `delete` / `move` / `send` / `playlist`、`current` は送り先のファイル名(削除は元のファイル名)、プレイリストは + そのファイル名)。`bytes_total` は端末へ中身を送る曲(`Add` / `Update` / `UpdateMove`)の `size` の和で、 + 引き継ぎの照合で済む曲と純粋な `Move` は含めない。`bytes_done` は曲を 1 曲処理し終えるごとに増やし + (曲の途中では増えない)、保留・失敗になった曲の `size` も処理し終えた量として足す。パス変更のバッチは + 失敗した曲を外してやり直すので、バッチの中では送り終えた量の最大値を見せて減らさず、バッチを終えたら + 全体を確定させる。バッチの仕上げ(空ける・置く)でもメンバーごとに `current`(送り先のファイル名)を出し、 + 置くたびに件数を 1 進め、終えたら件数を「バッチの開始時 + バッチの項目数」に確定させる。`rate` は直近 5 秒の標本(基準は 5 秒前以前で最新の標本、無ければ 0)からの + バイト差 ÷ 経過秒で、`JobControl` が持つ。detail は SSE にだけ流し DB には書かない(DB への進捗の書き込みは + `JobContext` が 250 ms に間引く) +- **引き継いだ写しの近道**: `Update` でパスが同じ(`from` 無し)、book の項目のトークンが `ADOPTED_TOKEN`、 + `sha256` と `size` が操作と一致する曲は、転送もジャーナルも省いてトークンだけを操作のものに書き換える + (信頼の水準は日常の差分と同じ。manifest の `sha256` は転送時に端末上で測って照合した値)。ただし送る元は + 通常の転送と同じく開いて identity を照合し、変わっていれば項目エラー + ハッシュの取り直し、開けなければ + 項目エラーにしてトークンを進めない。この曲は転送量の + 見積もりと空き容量の確認から外し、差分では op `adopted`(UI は「引き継ぎの照合」)として分ける。回復や + `device_verify` が付けた `STALE_TOKEN` が優先されるので、壊れた写しは送り直す。同期の終わり(最後の + `compact` の前)に、パスのキーが今の desired と一致する全項目の `rel_path` / `source` を今の値へ書き直す + (移動の操作が出ない `rel_path` の変化があっても新しく保つため) +- **Poweramp**: 同期の最後に `am broadcast -a com.maxmpz.audioplayer.ACTION_SCAN_DIRS` を API + レシーバへ明示(`-n`)で送る。extras は付けない(パスを指定する extra は無く、 + `eraseTags` は全タグ消去の副作用がある)。Poweramp が無ければ何もせず、失敗は警告に留めて同期は成功扱い + ([D-98](../../decisions/D-098.md)) +- **内容の検証**(`device_verify`): 日常の差分は**サイズまでしか見ない**(全曲の + sha256 を毎回取ると数十分かかる)ので、同じサイズの破損や端末上の書き換えは日常の差分では見つからない。 + 端末タブの「内容を検証」が全曲の `sha256sum` を取り、食い違った・無くなった曲を `STALE_TOKEN` にして + manifest を書き直し、`device_items` も全置換する(次の差分で「更新」になる)。 + 回復済み(ジャーナルが空)の正本にだけ行う +- **未接続**: 同期・検証は attempts を数えずに 300 秒後へ回し(`RequeueAfter`)、 + `track-devices` がその端末の `device` を見たら前倒しする。`device_scan` は待たずに終わる(次の接続で + 投入される)。端末のロックが取れなければ 15 秒後に回す。`[devices].adb_server` が空なら ADB の操作は + 503 `adb_disabled`(強制破棄と端末の削除は動く。[D-98](../../decisions/D-098.md)) + +**iPhone(Mac の spindle-agent)** + +エージェントはリポジトリ内の別クレート `agent/`(ワイヤ型は serde だけに依存する +`agent-proto` で本体と共有)。導入・前提・困ったときは `agent/README.md`、エンジンの判断は [D-100](../../decisions/D-100.md)、 +ミュージック.app と配布の判断は [D-101](../../decisions/D-101.md)。 + +- **コマンド**: `pair <URL> <コード> [--insecure-http] [--adopt]`(端末タブで発行した 10 分・1 回限り・ + 5 回まで試せるワンタイムコードでトークンを受け取る。発行すると旧トークンは失効。`--adopt` は下の + 「引き継ぎ」)、`sync`、`status`(引き継ぎが途中なら `adopt_pending` を出す)、 + `resolve`、`abandon`。多重起動は state のディレクトリの `lock`(`flock`)で防ぐ。平文の http は + `--insecure-http` なしでは拒む +- **保存先の所有権**: ローカルの root は `~/Music/spindle`(起動時にシンボリックリンクを解いた + 実パスにする。ミュージック.app が track の場所をそう持つため。root の中のリンクは辿らない)。 + pair のときに**無いか空のときだけ**使い、`.spindle-device` に `device_uuid` と nonce を書いて + sync のたびに照合する。ミュージック.app 側はエージェントが作った「spindle」フォルダで、衝突しない一時名 + `spindle-setup-<nonce>` で作ってから改名する(既存の同名フォルダは採らずに止める)。 + **管理対象は state と `pending_ops` / `pending_batches` にある persistent ID とパスだけ**で、管理外の + ファイル・同名のプレイリストは上書きも削除もせず「管理外と衝突」で保留する +- **前提の検査**(副作用の前): ミュージック.app の「ファイルを[ミュージック]フォルダにコピー」は + **オフ**が前提。外から読めないので、`add` が返した track の location が期待パスと違うことで検出し、 + その track を消して止める。root かその祖先がミュージックのメディアフォルダ(直下に + `Automatically Add to Music.localized` か `.Media Preferences.plist` がある)なら pair も + sync も止める(メディアフォルダの中の曲は削除でファイルまで消え、コピー設定が ON でも複製されず、 + 前提が崩れる。[D-101](../../decisions/D-101.md)) +- **sync の順序**: 前提の検査 → root の印と pair の初期化の続き → `pending_batches` と + `pending_ops` の回復 → 途中の引き継ぎ(`adopt_pending`)の続き → manifest の取得 → **再発見**(ミュージック.app の file track のうち location が + root の下を指すものを state と突き合わせる)→ open な計画の照合 → 差分の表示と `y/N` → 確定 → 反映 → + 報告。バッチの途中は location が `.moving/` を指すので、回復を再発見より先に行う。 + open な計画を再開するのは、自分が `y/N` を経て確定し state に `plan_id` を記録した計画だけ(それ以外は + 実行せずに報告して閉じる)。回復か再発見で state が変われば、何も実行しない計画を確定して報告だけを + 行い、表示する差分をエージェントの実状態に合わせる([D-100](../../decisions/D-100.md))。差分が空なら確認せずに終わり、 + 評価待ちなら差分を表示して反映しない。確定が 409 `plan_changed` なら manifest を取り直して表示し直す(3 回まで。 + 変わり続ければ止める)。Ctrl-C は扱わない(次の `sync` の回復が続きを行う) +- **受け取り**: `GET /api/agent/files/:track_id` に常に `If-Match: "<配信トークン>"`(強い ETag)を付け、 + 続きは同じ `If-Match` に `Range` を足す(`If-Range` は不一致で全文が返り、途中のファイルと混ざりうるので + 使わない)。版が変わっていれば 412 で、その操作は次の差分に回す。受信が切れたら同じ `If-Match` + `Range` で + 1 回の実行の中で 3 回まで続きを取る(実行をまたいだ再開はしない。回復が一時ファイルを消す)。受け取ったら size と sha256 を確かめ、 + 同じディレクトリの tmp → fsync → **上書きしない rename**(macOS は `renameatx_np(RENAME_EXCL)`。 + 対応しない FS では止める)→ 親の fsync +- **ミュージック.app への反映**は JXA(`osascript -l JavaScript`。trait のメソッド 1 回 = 起動 1 回、既定 + 120 秒で打ち切り)。追加は `add`、更新は同じパスへ差し替えて `refresh`(再生回数を保つ)、削除は + track を消してからファイルを消す。パス変更は ADB と同じバッチと相で、旧ファイルを + `.moving/<batch_id>-<op_id>` へ動かして location を付け替える(track は残す)。 + プレイリストの中身は一時プレイリスト `.tmp-<op_id>` を作って埋め、旧を消して改名する(途中で落ちても空や + 重複のものが本名で残らない)。state の書き込みは tmp → fsync → rename → 親の fsync で、 + 1 つの遷移で変わるもの(曲の更新・バッチの相・`pending_*` からの除去)は 1 回の書き込みに含める +- **外で変わった写し**: size・sha256 が state と違う曲は track を残してトークンを空にし(ADB の + `STALE_TOKEN` と同じ)、ミュージック.app から track が消えた曲は state から外して自分のファイルも消す + (次の差分で追加になる)。コピー設定 ON のまま `add` の直後に落ちたときの複製は件数にかかわらず自動では + 消さず、候補を表示して `resolve <op_id> --delete-track <persistent_id>` か `--no-copy-created` を明示で + 選ばせる +- **state の曲の出どころ**: state(`state.json`)の曲は persistent ID・パス・トークン・size・sha256・ + inode / mtime に加えて、`rel_path`(送った元の Library の `tracks.rel_path`)と `source`(送る元の種類。 + `master` / `opus` / `aac`)を必須で持つ(引き継ぎの付け替えの鍵。§7.11「端末側の正本」の manifest と同じ)。 + 値は manifest(`GET /api/agent/manifest` の `items[].rel_path` / `source`)の同じ `track_id` の項目から取り、 + manifest に無い曲の追加・更新は項目のエラー(「出どころが無い(manifest に無い曲)」)にする。曲の + `pending_ops` には任意で、パス変更のバッチのメンバーには必須で持たせ、回復で写す。`STATE_VERSION` と + `PROTO_VERSION` は 1 のまま、この 2 項目の無い旧 state は読めない(リリース前の変更。作り直す) +- **進捗**([D-108](../../decisions/D-108.md)): `spindle-agent sync` は確認の後の実行中(削除 → パス変更 → 更新 → 追加 → プレイリスト)の + 進捗を、Mac の端末とサーバに出す。件数は 1 件を扱い終えるたびに進め(見送り・エラーも数える)、バイト数は + 受け取りが要る項目の `size` の和に対する受信量(失敗・見送りは残りを足して合計に届かせる)。速度は直近 5 秒の + 移動平均、残り時間は残りバイト ÷ 速度。サーバへは `POST /api/agent/plans/:id/progress` を 2 秒に 1 回まで送る + (件数が変わらなくても速度と生存のために送る。項目の始めにも送る機会を作り、CLI では別スレッドの心拍が、 + 1 項目が長く掛かる間も前回の送信から 2 秒空けば最新の値を送り直す。間隔・失敗の数・打ち切りはエンジンと共有し、 + 前の計画の送信の結果は次の計画に記さない。速度は送る時点で計り直す。最後の 1 回は心拍の送信の終わりを待って送る)。**サーバはメモリだけに持つ**(端末ごとに 1 件。再起動で消え、次の + 送信で戻る)。計画が閉じる(報告・破棄・強制破棄)と消して `device_progress`(`progress: null`)を流す。 + 端末の応答の `sync_progress` は、保持している進捗の `plan_id` が今の open な計画と一致するときだけ出す。 + 受理後に計画を確かめ直し、閉じていたら消して null を流す(閉じる側の消去との競合)。端末の削除でも消す。 + 送信の間隔は前回の送信が終わってから数える。送信の失敗(通信エラー・タイムアウト・5xx)は同期を止めず、 + その回を捨てて間隔を 2 秒・4 秒・8 秒…(上限 60 秒)と延ばし、3 回続けて失敗したらその計画では以後送らない + (詰まったサーバのタイムアウトで受信を止めないため)。404 / 409 を受けたらその計画では以後送らない(古いサーバの + 404 もこれ)。次の計画(再開した計画の後の新しい計画)ではまた送る。終わりの要約(`完了: …`)は最後まで実行した + ときだけ出し、途中で止めた(`Stop`)ときは進捗の行を消して止めた理由だけを出す。 + Android(adb)はこの経路を使わず、ジョブの `job` イベントの detail で流す(上の「Android(ADB)」の「同期」の進捗、[D-109](../../decisions/D-109.md)) + - **Mac の端末の表示**: 標準エラーが TTY なら 1 行を `\r` で上書きする(0.2 秒に 1 回まで)。形は + `[12/92] 85.3 MB / 634.8 MB 12.4 MB/s 残り 0:42 受信中: <今の項目>`(バイト数は受け取りが要る項目が + あるときだけ)。段の動詞は 削除中 / 移動中 / 受信中 / 追加中(ミュージック.app へ)/ プレイリスト、今の項目は + `dest_path`(プレイリストは名前)。端末の幅(取れなければ `COLUMNS`、それも無ければ 80 桁)に収まるよう + 今の項目を前から省き(`…` を前置)、それでも足りなければ残り時間・速度・バイト数の順に省く。全角は幅 2 で + 数える。TTY でなければ上書きせず、10 秒か 10 件ごとに 1 行出す。ほかのメッセージは進捗の行を消してから出す。 + 終わりの要約は `完了: 92 件(634.8 MB)を 0:51 で(平均 12.4 MB/s)`(受信が無ければバイト数と速度を省く)。 + バイトは 1024 で割った KB / MB / GB(Web の表示と揃える) +- **引き継ぎ**(`pair --adopt <URL> <コード>`。DB 全損後。[D-104](../../decisions/D-104.md)): DB を作り直して端末タブで作った新しい + 行の pair コードで、この Mac の保存先とミュージック.app の track をそのまま引き継ぐ。 + 1. 前提(コードを使う前): 通常の pair と同じ検査(コピー設定・メディアフォルダ・Automation・トークンの + 置き場)、前の登録の state(初期化が済み、サーバの情報がある)があること(無ければ「引き継ぐ前の登録が + ありません」で止める)、root の `.spindle-device` が state の uuid と一致すること、ローカルの回復 + (サーバを使わない)で `pending_ops` / `pending_batches` が空になること(残れば止める) + 2. `POST /api/agent/pair` に `adopt_uuid`(state の uuid)を付ける。サーバは 32 桁の小文字 16 進でなければ + コードに触らずに 400 `bad_request`。コードの消費と同じトランザクションで、行が端末の状態を一度も持って + いない(`last_synced_at` が NULL、計画が 1 件も無い、`device_items` と `device_playlist_state` が空)うえ + `adopt_uuid` が他の行に無いときだけ行の uuid を差し替える。行の uuid が既に `adopt_uuid` なら、報告も + 計画も無い(`last_synced_at` が NULL、計画が 1 件も無い)ときだけ差し替え済みのやり直しとして通す + (前の adopt が `device_items` などを書いた後で落ちたやり直しを含む)。報告か計画があれば DB を失って + いない端末なので断る。それ以外は 409 `not_adoptable` で、コードは消費せず、予約した試行の枠も返す + (断られても 5 回の試行に数えない) + 3. エージェントは応答の uuid が state と同じことを確かめ、サーバの URL・名前と `adopt_pending = true` を + 保存してから、トークンを保存する(逆順だと間で落ちたとき、新しいトークンと付け替える前の state が + 組み合わさる。この順なら次の `sync` は古いトークンで止まり、`pair --adopt` のやり直しで続く)。続く adopt + はこの新しいトークンで送る + 4. `POST /api/agent/adopt` に state の曲(`track_id`・パス・`rel_path`・`source`・size・sha256・ + トークンが `STALE_TOKEN` か)とプレイリスト(`playlist_id`・名前)を送り、付け替え表(外れたものは + `to: null`)を受け取る。付け替えは Android と同じ規則(曲は `rel_path`、プレイリストは名前の + `canonical_key`。曖昧なものは採らない)。サーバはそのまま `device_items`(トークンは `stale` なら + `STALE_TOKEN`、それ以外は `ADOPTED_TOKEN`)・`device_playlist_state`(`dest_path` は名前、トークンは + `STALE_TOKEN`)を全置換し、付け替えたプレイリストに印を付け足し(既存の印は外さない)、`device_errors` を空にする。 + `generation` と `last_synced_at` は進めない。冪等で、同じ入力を送り直しても同じ結果になる + 5. エージェントは state の `tracks` / `playlists` のキーを付け替え、曲のトークンを `ADOPTED_TOKEN` + (`STALE_TOKEN` の曲はそのまま)、プレイリストのトークンを `STALE_TOKEN`(サーバと同じ。最初の同期で + 中身を同じ名前のまま書き直す)にし、`plan_id` を消し、`needs_report` と `adopt_pending` を下ろして + 1 回で保存する。**外れた曲・プレイリストは state から外すだけで、ファイルもミュージック.app の track も + 消さない**(管理外になる) + 3 と 5 の間で落ちたら、次の `sync` が回復の後・manifest の取得の前に 4 と 5 をやり直す(`pair --adopt` の + 打ち直しでもよい。サーバの差し替えはやり直しを受け付ける)。`--adopt` 無しの pair で uuid が state と + 食い違ったときは今どおり止め、文言で `pair --adopt` を案内する(同じ端末の pair し直しと区別できないので、 + コードを使う前には検出しない) +- **引き継いだ曲の近道**: `Update`(`from` 無し)で、state の曲のトークンが `ADOPTED_TOKEN`、パスの鍵・ + size・sha256 が計画の項目と一致すれば、受け取りもミュージック.app の操作もせず、トークン(と `rel_path` / + `source`)だけを書き換える。報告はそのトークンで行う(計画に含まれるので検証を通る)。受け取らないので + 実行前の空き容量の確認からも外す。信頼の水準は日常の差分と同じ(state の sha256 は再発見の inode / + mtime のキャッシュと読み直しで保たれている) +- **報告**(`POST /api/agent/report`): `generation`・`plan_id`・再発見で確かめた state 全体・ + 失敗した項目を送る。サーバは計画がその端末の open な計画で、`generation` が一致し、各項目が「現在の + desired」「既存の `device_items` と同じ値」「その計画で許された遷移の結果」のどれかに完全一致すること + (トークンが空の曲は `(track_id, パスの鍵)` の一致だけ)、件数(10 万)とパスの形を確かめ、 + **1 件でも外れれば全体を 400 で拒否**する。通れば 1 つのトランザクションでキャッシュと `device_errors` を + 全置換し、計画を `completed` にして `report_digest` を記録する([D-99](../../decisions/D-099.md))。`abandon` も同じ検証を通り、 + `pending_ops` と `pending_batches` が空のときだけ受ける +- **トークンの保管**は macOS の Keychain(サービス `spindle-agent`、アカウント `token`)。ssh + 越しでは使えないので、pair はコードを使う前に保存できるかを確かめる。`SPINDLE_AGENT_SECRETS=file` で + state のディレクトリのファイル(0600)に置ける。サーバはトークンのシークレットを + SHA-256 で持つ(256 bit の乱数なので総当たりに耐え、ファイル取得のたびに argon2id を払わない。[D-99](../../decisions/D-099.md)) +- **配布**: macOS(arm64)のバイナリは CI で作り、**署名・公証せず** tar.gz を GitHub + Release に置く(README に `xattr -d com.apple.quarantine` と Automation の許可を書く。[D-101](../../decisions/D-101.md)) + +**不変条件との関係(明示する例外)** + +(DB にしかない端末の設定・印・計画・トークンの扱いは [§3](../03-principles.md)・[§14](../14-deploy.md)。) + +CLAUDE.md の不変条件と禁止事項は Library(ユーザデータ)を対象にしている。端末上の写しと Mac の +`~/Music/spindle` は **Library から再生成できる複製**なので、次を例外とする([D-95](../../decisions/D-095.md))。 + +- **端末の写しの削除は物理削除でよい**(`missing_since` の猶予を持たない)。Library の曲が + missing になれば次の同期で端末から消え、missing が解ければ追加で戻る。ただし spindle が消すのは端末側の + 正本(manifest / state)にある管理下のものだけで、手で置いたファイルには触らない +- **端末への書き込みは `edit_batches` / `edits` に載せない**。巻き戻しは「Library を巻き戻して同期し + 直す」。中断からの安全は、意図を先に耐久化する端末側のジャーナル(Android)と state の + `pending_*`(Mac)、計画の再開の規則で保つ(不変条件 6) +- **「ファイルが正」は端末にも当てはめる**: 端末側の正本と実ファイルが正で、`device_items` などは + キャッシュ。食い違えば端末側を採る(回復・再発見・内容の検証の結果で全置換する) +- **生 SQL の禁止は変わらない**。DSL の `on_device` / `device_pending` は端末名を値としてバインドし、 + 未反映の集合は JSON 配列をバインドして `json_each` で展開する([D-95](../../decisions/D-095.md) の P5-2 追記) diff --git a/docs/spec/07-pipeline/README.md b/docs/spec/07-pipeline/README.md new file mode 100644 index 00000000..5ffa7578 --- /dev/null +++ b/docs/spec/07-pipeline/README.md @@ -0,0 +1,13 @@ +# 7. パイプライン + +- [§7.1 スキャン](01-scan.md) +- [§7.2 CD 取り込み](02-cd-rip.md) +- [§7.3 遡及照合(ログなし既存 FLAC)](03-retro-verify.md) +- [§7.4 ロスレス正規化(WAV / ALAC / AIFF → FLAC)](04-lossless-normalize.md) +- [§7.5 タグ書き込み(編集バッチ)](05-tagwrite.md) +- [§7.6 Derived 生成](06-derived.md) +- [§7.7 ytmusic 統合](07-ytmusic.md) +- [§7.8 Inbox 取り込み](08-inbox.md) +- [§7.9 FLAC 健全性チェック(移行時 + 任意)](09-flac-check.md) +- [§7.10 偽ハイレゾ検出(任意)](10-fake-hires.md) +- [§7.11 端末への配信](11-device-delivery.md) diff --git a/docs/spec/08-jobs.md b/docs/spec/08-jobs.md new file mode 100644 index 00000000..9a2c62b7 --- /dev/null +++ b/docs/spec/08-jobs.md @@ -0,0 +1,77 @@ +# 8. ジョブシステム + +| type | 並列度 | 冪等キー | +|---|---|---| +| `scan` | 1 | 固定 | +| `rip` | **1**(物理ドライブ1台) | discid | +| `verify` | 2 | album_id | +| `rg` | CPU コア数 | album_id | +| `rgwrite` | 1 | 固定(`rgwrite`。開始時にキーを外すので、実行中の投入は次のジョブになる)。`rg_write_due` の行を album ごとの編集バッチにする([D-97](../decisions/D-097.md)) | +| `transcode` | CPU コア数 - 1 | track_id + variant + audio_version(variant は P4-7 から。[§7.6](07-pipeline/06-derived.md)) | +| `tagwrite` | 4 | track_id + tag_version(`edit_batch_id` でバッチに紐づく) | +| `rename` | 1 | batch_id(バッチ 1 つに 1 ジョブ。2 phase の順序を守るため直列。[D-43](../decisions/D-043.md)) | +| `normalize` | 2 | track_id + op_id(同じトラックの直列化は track_locks) | +| `thumbnail` | 4 | artwork_id | +| `flaccheck` | CPU コア数 | track_id + audio_version(版付き。[D-57](../decisions/D-057.md)) | +| `hirescheck` | max(1, CPU コア数 / 2) | track_id + audio_version(版付き。[§7.10](07-pipeline/10-fake-hires.md)、[D-71](../decisions/D-071.md)。rg / transcode / flaccheck と重なる分を抑える) | +| `inbox` | 1 | 固定 | +| `ytdl` | 1 | `ytdl:<url>`(playlist の展開・購読の同期で投入する分も同じ。[D-70](../decisions/D-070.md)、[D-78](../decisions/D-078.md)) | +| `playlist_sync` | 1 | `playlist_sync:<subscription_id>`(列挙 → 番号揃え → ytdl 投入。[§7.7](07-pipeline/07-ytmusic.md)「再生リストの購読と同期」、[D-78](../decisions/D-078.md)) | +| `inbox_rejudge` | 1 | `inbox_rejudge:<item_id>`(Inbox の件の再判定。`max_attempts` 1。[§7.8](07-pipeline/08-inbox.md)、[D-114](../decisions/D-114.md)) | +| `source_hash` | 2(CPU 系の共通予算も取る) | `source_hash:<track_id>:<source>`(source = `master` / `opus` / `aac`。送る元の SHA-256。[§7.11](07-pipeline/11-device-delivery.md)、[D-95](../decisions/D-095.md)) | +| `device_scan` | 2(同じ端末は排他。下記) | `device_scan:<device_id>`(回復してキャッシュを置き換える。未接続なら待たずに終わる。[§7.11](07-pipeline/11-device-delivery.md)) | +| `device_sync` | 2(同上) | `device_sync:<device_id>`(payload に `plan_id`。最大 3 回。確定した計画を実行。[§7.11](07-pipeline/11-device-delivery.md)「同期」) | +| `device_verify` | 2(同上) | `device_verify:<device_id>`(全曲の sha256。[§7.11](07-pipeline/11-device-delivery.md)「内容の検証」) | +| `gc` | 1 | 固定(scan と同じ排他 `library` を取れなければ Requeue。[D-56](../decisions/D-056.md)) | +| `backup` | 1 | 固定 | + +- **CPU 系の共通予算**(`rg` / `transcode` / `flaccheck` / `hirescheck` / `source_hash`。[D-73](../decisions/D-073.md)、P4-1): + 種別の並列度に加えて共有の予算(= CPU コア数)を取ってから走る。種別単独なら今までどおり、複数種別が + 同時に走るときだけ実行中の合計がコア数に収まる。取得順は種別 → 共通で、共通が取れなければ claim せず次の周回で試す + (ジョブは queued のまま。種別内の順序は変えない)。予算を分け合う種別は 1 件ずつラウンドロビンで + claim する(次の周は最後に claim した種別の次から。1 種別が予算を独占しない)。`GET /api/jobs` の `cpu_budget` +- **端末ジョブ**(`device_scan` / `device_sync` / `device_verify`。[D-98](../decisions/D-098.md)): 端末ごとの排他(`job_mutexes` の + `device:<id>` とプロセス内のロック)を取ってから、最初に端末側の回復を行う(別の端末なら並べてよい)。 + ロックが取れなければ 15 秒後、未接続なら `device_sync` / `device_verify` は attempts を数えずに 300 秒後へ + 回し(`RequeueAfter`)、`adb track-devices` がその端末を見たら `run_after` を前倒しする。`device_scan` は + 未接続なら待たずに `DoneWith("未接続")` で終わる(次の接続でまた投入される)。`generation` の不一致と空き不足は + 再試行しても変わらないので直ちに `failed`。端末が削除されていれば何もせずに終わる。詳細は [§7.11](07-pipeline/11-device-delivery.md) +- 起動時リカバリ: `running` を `queued` へ戻し、`track_locks` / `derived_path_locks` / `job_mutexes` を + **全件削除**する + (ロックはプロセス生存中しか意味を持たない)。単一インスタンス前提。同じ DB を + 複数プロセスで開くことは想定しない(compose で replicas を増やさない) +- **終端遷移の書き込みの再試行**([D-76](../decisions/D-076.md)、P4-9): ハンドラの結果を DB に書く終端トランザクション + (進捗の flush・ロック解放・`done` / `failed` / `cancelled` / 再キュー)が失敗したら、種別の許可と + CPU 予算を**持ったまま** 1, 2, 4, 8, 16, 32 秒の間隔で再試行する(6 回、約 1 分。ディスク満杯や + 一時的なロックなら自力で復帰し、仕事の結果を失わない)。使い切ったら `failed` としての記録を 1 度 + 試み、それも駄目なら `running` のまま手放す(下の稼働中の回収が拾う) +- **稼働中の回収**(起動時リカバリのループ版。[D-76](../decisions/D-076.md)、P4-9): ワーカーは周回ごとに、DB で `running` + だが**プロセス内で実行中でない**行(終端を書けなかった行)を `queued` へ戻し(`cancel_requested_at` + が立っていれば `cancelled`)、その行の `track_locks` / `derived_path_locks` / `job_mutexes` を消す。 + `started_at` は NULL、`attempts` / `run_after` は触らない(起動時リカバリと同じ)。回収した旨を + `last_error` とログに残す。実行中の登録は claim と同じスケジューラの流れで spawn の前に行い、 + 「claim 済みだが未登録」の瞬間を作らない +- `dedup_key` は **`queued` / `running` の間だけ**一意(partial unique index)。 + 列 UNIQUE にすると `done` / `failed` 後に同じキー(`scan` の固定キー、同 version の + 手動再試行)を永久に投入できない。キーは `type` を含めて構成する + (`tagwrite:<track_id>:<tag_version>` 等) +- 失敗は `attempts` をインクリメントし、`run_after` に次回時刻を書いて指数バックオフ + (再起動を跨いでも待ち時間が保たれる)。`attempts >= max_attempts` で `failed`。再試行しても変わらない + 失敗(`JobError::Fatal`。対応していない URL 等)はバックオフせず直ちに `failed`([D-70](../decisions/D-070.md)) +- 終端の行の片付け(P4-18、[D-81](../decisions/D-081.md)): `DELETE /api/jobs/:id`(failed / done / cancelled だけ。queued / running は + 409)、`DELETE /api/jobs?state=failed`(失敗をまとめて)。上部バーの赤丸は `summary.failed > 0` なので、 + 確認済みの失敗を消せば消える。保持期間を過ぎた終端の行は GC が消す(`[gc].jobs_done_days` / + `jobs_failed_days`) +- キャンセルは `cancel_requested_at` を立てる協調方式。ハンドラは進捗更新のたびに + 確認して自発的に止め、`cancelled` へ遷移する。外部プロセス(ffmpeg 等)は + 子プロセスグループごと kill し、tmp の成果物を消す +- 進捗は SSE で配信。DB にも永続化してリロードに耐える +- 同一トラックに対する競合ジョブは `dedup_key` と track 単位の + advisory lock テーブルで直列化。複数トラックを掴むジョブ(album 単位の `rg` 等)は + `track_id` 昇順に取得し、1 つでも取れなければ全解放して再キュー(デッドロック回避) +- 版を持つジョブ(`tagwrite` / `transcode`)は開始直前に payload の版と現在値を比較し、 + 古ければ no-op で `done`([§7.5](07-pipeline/05-tagwrite.md) stale ジョブ) +- **編集バッチは coordinator + 子ジョブ。** `tagwrite` ジョブは track 単位、`rename` ジョブは + バッチ単位で投入し `jobs.edit_batch_id` でバッチに紐づける。バッチ自体はジョブではなく、 + 終端状態は子ジョブ・op の結果から集計する(最後に終端になった子ジョブが集計する)。 + 起動時リカバリ・キャンセルはバッチ配下の子ジョブに対して行う diff --git a/docs/spec/09-http-api.md b/docs/spec/09-http-api.md new file mode 100644 index 00000000..1ebebe09 --- /dev/null +++ b/docs/spec/09-http-api.md @@ -0,0 +1,600 @@ +# 9. HTTP API + +``` +GET /health 認証の外。{ status: "ok" | "locked", version, ytdlp } + version = ビルド時に焼いた版(CI の git describe --tags --always。 + `spindle --version` と同じ。P4-12、D-79)、ytdlp = 起動時診断で + 取った yt-dlp の版(取れなければ null) +GET /api/tracks?filter=&sort=&cursor=&limit= カーソルページング(D-39) + filter = URL エンコードした JSON(下記)、 + sort = album(既定) | title | artist | album_title | + albumartist | date | duration | codec | rel_path | id + (`-` 前置で降順)、limit = 1..=1000(既定 100) +GET /api/tracks/:id セッション有りは一覧と同じ行に detail(タグ全部・物理属性・ + verifications = 手法ごとの最新の照合。D-58 / D-96)を足す。 + trusted_cidrs からのセッション無しは限定フィールド(D-27 / D-39) +PATCH /api/tracks/batch 一括編集(dry_run フラグ) +POST /api/tracks/batch/preview 変更プレビュー +POST /api/rename/preview テンプレート適用結果(selection_token を発行) +POST /api/rename/apply token の集合をリネームバッチとして記録 +POST /api/normalize/preview ロスレス → FLAC 正規化の宛先(selection_token を発行) +POST /api/normalize/apply token の集合を正規化バッチとして記録(§7.4) +POST /api/rg { selection }。rg ジョブを投入(D-47)。`albums.album_gain` が on の + album は album 単位(rg:album:<id>)、それ以外は track 単位(rg:track:<id>。D-74) +POST /api/rg/write { selection, description?, skip_pending? }。解析値を + タグとして書く編集バッチを記録(§6、D-48)。 + preview 段階は無い(値は DB から決まる)。解析後は + rgwrite が自動で書くので、これは自動で書かれなかった + 行(外部で書き換えた・巻き戻した等)の書き直し用(D-97) +POST /api/flaccheck { selection }。active な FLAC ごとに flaccheck ジョブを + 投入(§7.9、D-57。読むだけで preview は無い) +POST /api/hirescheck { selection }。対象(可逆かつ >48 kHz または >16 bit)ごとに + hirescheck ジョブを投入(§7.10、D-71。読むだけで preview は無い) + → 202 { tracks, skipped, duplicates, job_ids } | 409 no_changes +POST /api/md5fill { selection, description?, skip_pending? }。flac_check = md5_missing + の FLAC に md5 op の編集バッチを記録(§7.9、D-59) + → 201 { batch_id, affected, skipped, pending_excluded } + → 409 pending | no_changes | md5_fill_disabled +POST /api/verify { selection }。selection のトラックが属する album ごとに + verify ジョブ(遡及照合)を投入(§7.3、D-13 / D-63。読むだけ) + → 202 { albums, duplicates, job_ids } + → 409 no_changes + +GET /api/albums / :id 全件(ページングなし)。track_count / duration_ms は active のみ。 + `?filter=`(/api/tracks と同じ JSON)で一致するトラックを持つ album だけ(P4-6) + /api/tracks の行と /api/tracks/:id には artwork_hash(トラック自身の + 埋め込み画像。無ければ null。D-61)。行に album_gain(D-74) +PATCH /api/albums/:id { album_gain }。album gain の属性(D-74)。true → album 単位の rg を投入、 + false → rg_album_* を NULL にして未書込に戻し、Derived の追随を投入。 + 同じ値なら何もしない。200 { album, job_id | null, derived_jobs }。 + 404 not_found / 400 bad_request +GET /api/categories, POST /api/categories 統制語彙 { "items": [{ id, name, albums, genres, subscriptions, inbox_drafts }] } + (4 つは使用数。削除できるのは全部 0 の語彙。D-106)。POST は { name }(重複は 409)。 + CD 取り込みの確定フォームの category に使う(D-67) +DELETE /api/categories/:id 使われていない語彙だけ消す(D-92)。204 / 404 / 409 `in_use` + (active な album・GENRE の写像・購読・Inbox の下書きのどれかが + 使っている。`message` に理由)。missing の album は SET NULL +GET /api/tags/keys ライブラリにあるタグのキーと、そのキーを持つ曲数 → { items: [{ key, tracks }] } + (曲数の多い順、同数はキーの順。多値も 1 曲、missing の曲と PICTURE は数えない)。 + タグのキーの入力欄の前方一致の候補(D-113) +GET /api/search?q= FTS5 trigram(3 文字未満は LIKE)。/api/tracks と同じ + レスポンス形で、filter / sort / cursor / limit も受ける + +GET /api/stream/:id Range 対応。原本 +GET /api/stream/:id?transcode=opus オンザフライ変換 +GET /api/stream/:id?transcode=aac AAC(スマホ版の試し聴きの退避。§11、§12.7、D-115) +GET /api/artwork/:hash?size= size = 256 | 768 で WebP のサムネイル、無しで原画像 + (元の MIME)。hash は albums.artwork_hash。未生成なら + 原画像へ倒す(no-cache)。ハッシュアドレスなので immutable +POST /api/artwork/upload 生の画像バイト列(Content-Type: image/*、上限 32 MiB。形式は + ヘッダで判別。JPEG / PNG / WebP 以外は 400 unsupported_image)を + ArtworkStore と artwork 行に置き thumbnail ジョブを投入 + → 201 { sha256, mime, width, height, bytes }(D-60) +POST /api/artwork/embed { selection, sha256, description?, skip_pending? }。selection の + active 全行の埋め込み画像をその 1 枚に差し替える tags op + (PICTURE)の編集バッチを記録(§7.5、D-60) + → 201 { batch_id, affected, unchanged, pending_excluded } +POST /api/artwork/from-caa { release_id } か { release_group_id }(どちらか片方。D-93)。release_id は MBID + (大小無視)か musicbrainz.org の /release/<MBID> の URL、release_group_id は + MBID。Cover Art Archive の front 画像(500px)を + 取り、upload と同じく置く → 201(upload と同じ応答)。両方・どちらも無し・形が + 違えば 400 bad_request。画像の無い盤は 404、 + 上流の失敗は 502 lookup_failed、未構成は 503 coverart_unavailable(D-86) + → 404 artwork_not_found、409 pending | no_changes + +GET /api/playlists, POST, PATCH, DELETE プレイリストの CRUD(D-53)。DELETE は印のある端末に open な作業があれば + 409 open_plan(D-102)。POST / PATCH に rule があれば + スマート(D-54。評価結果は playlist_items に書く)。並びは + /api/tracks?filter={"playlist_id":N}&sort=position +POST /api/playlists/:id/items { selection, sort? } を末尾に追加(同じトラックは 1 回) +DELETE /api/playlists/:id/items { track_ids?, selection? } を外す +POST /api/playlists/:id/items/move { track_ids, before } before の直前(null は末尾)へ +POST /api/playlists/preview { rule } スマートルールの検証と評価件数(保存しない。D-54) +POST /api/playlists/:id/refresh スマートを今の DB で再評価(項目を書き直す) +GET /api/playlists/:id/export?profile= m3u8 の本文(trusted CIDR で認証スキップ) +POST /api/playlists/:id/export?profile= Playlists/<profile>/<name>.m3u8 へ書き出し。応答に + count / skipped_missing / stale_tags(delivery のタグ追随待ち) +GET /api/playlists/import, POST Playlists root 下の m3u8 の一覧 / { path, name? } で取り込み +GET /api/playlists/:id/fb2k_query foobar Autoplaylist 用の { query, sort, notes }(smart のみ。D-55) + +GET /api/devices 端末の一覧 → { items: [Device] }(下の「レスポンス形」。P5-2、D-95)。ハッシュの無い送る元の + source_hash をここと差分で投入する(§7.11) +POST /api/devices { name, transport: adb | agent, variant: opus | aac, selection: all | playlists, serial?, volume? } + → 201 Device。agent は行を作るだけ。adb は serial と volume(`emulated` か SD の UUID)が要り、 + 接続を確かめ、保存先 `Music/spindle` が無いか空(曲もプレイリストも指さない空の manifest の + 前の登録の失敗の残りは片付ける)なら manifest を作って device_scan を投入する(§7.11「登録」)。 + `adopt: true`(adb のみ。他の transport は 400)は既存の保存先の引き継ぎで、manifest の uuid の + 行を作る(§7.11「引き継ぎ」、D-103)。400 bad_request、 + 409 duplicate(名前)| serial_registered | not_empty | not_connected | + adoptable(引き継げる manifest がある。引き継ぎを案内)| not_adoptable | uuid_registered | + library_busy(scan が待ち・実行中)、502 device_failed、503 adb_disabled +PATCH /api/devices/:id { name?, selection?, variant? } → 200 Device。名前以外は open な計画か待ち・実行中の + device_sync がある間 409 open_plan。409 duplicate。保存先は変えない(削除して登録し直す) +DELETE /api/devices/:id → 204。端末上のファイルには触らない。open な計画も行と一緒に消え、待ちの端末ジョブは + 取り消す。404 / 409 busy(端末のジョブが実行中か端末のロック中。D-98) +PUT /api/devices/:id/playlists { playlist_ids }(印の全置換)→ 200 Device。400 bad_request(無いプレイリスト)| + cycle(on_device / device_pending を使うスマート)、409 open_plan +GET /api/devices/:id/diff 差分・見積もり・評価時刻と plan_token(下の「レスポンス形」) +GET /api/devices/:id/estimate?selection=&playlist_ids=1,2 + 選曲タブの見積もり(保存しない)→ { tracks, bytes, estimated, unhashed }(bytes は確定と + 推定の和、estimated = 推定で数えた曲数、unhashed = 推定もできず + bytes に含まない曲数。D-107) +GET /api/devices/adb/unregistered 接続中で未登録の Android → { items: [{ serial, model, state, volumes: [{ volume, path, free, + state: missing | empty | nonempty | adoptable }], error }] }(volumes は state = device の端末だけ + 調べる。adoptable = 読める manifest が曲かプレイリストを指し、volume が一致し、uuid が DB に無い)。503 adb_disabled +GET /api/devices/adb/adopt-preview?serial=&volume= + 引き継ぎのプレビュー(読むだけ。ジャーナルは回復しないので件数は目安)→ { device_uuid, + tracks_adopted, tracks_dropped, playlists_adopted, playlists_dropped, unmanaged, library_busy } + (playlists_adopted は件数)。409 not_adoptable | uuid_registered、503 adb_disabled +POST /api/devices/:id/sync { plan_token }(adb のみ)。計画の確定と device_sync の投入を 1 トランザクションで行う → + 202 { job_id }(同じ plan_token の open な計画があればそれで投入し直す。未接続でも 202 で、 + ジョブが接続を待つ)。409 plan_changed(本文に今の plan_token)| open_plan_exists | + pending_reevaluation | plan_unreadable、400(agent の端末)、503 adb_disabled +POST /api/devices/:id/plans/open/resume adb の open な計画の同期を投入し直す → 202 { job_id }。404 no_open_plan、409 plan_unreadable +POST /api/devices/:id/plans/open/abandon { force? }。adb は接続して回復し、キャッシュを置き換えてから計画を abandoned にする → + 200 Device。409 not_connected | busy、404 no_open_plan。agent は force 無しでは 400 + (Mac の spindle-agent から破棄する)。force: true は端末につながず回復もせずに閉じる + (agent も可。adb_disabled でも動く。409 busy。D-98 / D-99) +POST /api/devices/:id/verify adb のみ。device_verify を投入 → 202 { job_id } +POST /api/devices/:id/pair-code agent のみ。10 分・1 回限り・5 回まで試せるコードを発行 → 201 { code, expires_at } + (旧トークンと旧コードは即失効)。400 not_agent、409 open_plan +// Android の操作(登録・未登録の一覧・同期・再開・通常の破棄・検証)は `[devices].adb_server` が空なら +// 503 adb_disabled(強制破棄と端末の削除は adb を使わないので動く。D-98) + +// エージェントの経路(認証は下の「認証」。セッション・CSRF・trusted_cidrs は通らない。D-99) +POST /api/agent/pair { code, adopt_uuid? }(Bearer なし。端末タブで発行した `<selector>.<secret>`)→ 200 { device_uuid, + device_name, token }。不一致・期限切れ・試行超過・引けないセレクタは一律 401 invalid_code。 + adopt_uuid(`pair --adopt`。32 桁の小文字 16 進。形の不正はコードに触らず 400 bad_request)は + コードの消費と同じトランザクションで行の uuid を差し替える。行が端末の状態を持つか(uuid が + 既に adopt_uuid なら報告か計画があるか)、uuid が他の行にあれば 409 not_adoptable(コードは消費せず、試行にも数えない。§7.11「引き継ぎ」、D-104) +GET /api/agent/manifest → { device_uuid, device_name, generation, plan_token, pending_reevaluation, + items: [{ track_id, dest_path, token, size, sha256, rel_path, source }], + playlists: [{ playlist_id, name, token, tracks: [track_id] }], + diff: { items: [{ op, track_id, from, to, token, size, sha256 }], + held: [{ track_id, reason, waiting, has_copy }], + playlists: [{ op, playlist_id, from, to, token }], playlist_errors: [{ playlist_id, reason }] } } + (差分は UI と同じスナップショットから作る。D-99) +GET /api/agent/files/:track_id If-Match: "<配信トークン>"(強い ETag)必須、続きは Range を足す(HEAD も可)→ 200 / 206。 + 428 if_match_required、412 precondition_failed(トークン不一致・弱い ETag・`*`・並記・ + ハッシュ未計算)| source_changed(送る元が変わった。行を捨てて次の差分で取り直させる)、 + 404(その端末の desired に無い)、416、503 files_unavailable。パスはクライアントから受け取らない +POST /api/agent/plans { plan_token } → 201 Plan(新しく確定)| 200 Plan(同じ plan_token の open な計画)。 + 409 plan_changed(本文に今の plan_token)| open_plan_exists | pending_reevaluation | plan_unreadable +GET /api/agent/plans/open → 200 Plan | 404 no_open_plan | 409 plan_unreadable +POST /api/agent/plans/:id/abandon Report に { pending_ops, pending_batches }(件数)を足した本文 → 200 {}。報告と同じ検証を通り、 + キャッシュを全置換して計画を abandoned にする。409 pending_work(どちらかが 0 でない)、 + ほかは report と同じ +POST /api/agent/report Report → 200 {}(計画を completed にする。同じ報告の再送も 200)。400 invalid_report + (1 件でも外れれば全体を拒否。§7.11「報告」)| bad_request、404 no_plan、409 generation_mismatch | + plan_closed | plan_unreadable。本文の上限は report と abandon と adopt だけ 64 MiB +POST /api/agent/plans/:id/progress { generation, done, total, bytes_done, bytes_total, rate, phase, current? } → 204。実行中の進捗を + メモリだけに置く(DB には書かず、SSE `device_progress` で流す。§7.11「進捗」、D-108)。phase は + delete | move | fetch | add | playlist、rate はバイト / 秒、current は 1,024 バイトまで。 + 404 no_plan(その端末の open な計画でない: 閉じた・破棄済み・別の端末・無い)、 + 409 generation_mismatch、400 invalid(done > total・bytes_done > bytes_total・未知の phase・ + 負の値・current 超過)。本文の上限は 4 KiB。エージェントは 404 / 409 で以後送らない +POST /api/agent/adopt { tracks: [{ track_id, path, rel_path, source, size, sha256, stale }], + playlists: [{ playlist_id, name }] } → 200 { tracks: [{ from, to }], playlists: [{ from, to }] } + (`to` は付け替え先の id、外れたものは null)。DB 全損後の `pair --adopt` で、付け替えた状態を + キャッシュへ直接書く(`generation` と `last_synced_at` は進めない。冪等。§7.11「引き継ぎ」、D-104)。 + 400 bad_request(件数の上限・パスの形・未知の source・id とパスの鍵の重複・空のプレイリスト名・ + sha256 が小文字 16 進 64 桁でない(空は `stale` のときだけ許す)・size が i64 を超える)、 + 409 library_busy(スキャン中)| open_plan | not_adoptable(既に報告を受けた端末) +// Report = { generation, plan_id, state: { tracks: [{ track_id, dest_path, token, size, sha256 }], +// playlists: [{ playlist_id, name, token }] }, errors: [{ kind: track | playlist, ref_id, reason }] } +// Plan = { plan_id, generation, plan_token, items: [{ op_id, op, track_id, from, to, token, size, sha256 }], +// playlists: [{ op_id, op, playlist_id, from, to, token }] } +// op = add | update | move | update_move | delete(プレイリストは add | update | delete) +// ワイヤ型は agent-proto クレートで本体とエージェントが共有する + +POST /api/auth/login, POST /api/auth/logout +GET /api/auth/session + +GET /api/cd/status { state: unknown | no_drive | no_disc | tray_open | not_ready | disc_ok, + toc: CTDB 形式の文字列 | null, isrcs: [音声トラック順。無ければ null], + mcn: JAN/UPC | null, error: 直近の失敗 | null, checked_at, + tracks: [{ number, length_ms }], rip_job: 進行中の rip ジョブ | null, + drive: { model, offset, offset_source: manual | learned | table | unknown } | null + (型番はディスクが無くても読む。D-83 追記 2)} + (P2-1。ポーラの状態で、ドライブは叩かない。TOC は下の lookup に渡す + 文字列と同じ形。ドライブ未配線なら 503 cd_unavailable) +POST /api/cd/lookup { toc, isrcs?, mcn?, release?, catno?, barcode?, refresh?, widen? }。TOC 文字列(CTDB 形式 0:13915:…:leadout か + MusicBrainz 形式 1 12 leadout+150 offset+150…)から各種 DiscID を出し、 + MusicBrainz に照会(P2-3、D-21 / D-64)。isrcs / mcn は status が読んだもの + (null は捨てる)、release は貼ったリリース URL か MBID。catno / barcode は + ユーザが盤から読んで入れた品番(英数字・ハイフン・空白、32 文字まで)と + JAN / UPC(数字 8・12・13 桁。mcn が無いときだけ使う)。D-94。→ 200 { discid, + mb_toc, accuraterip_id, ctdb_toc_id, exact, candidates: [リリース × medium。 + matched_by: [catno | discid | release | isrc | barcode | toc] を強い順に持ち、その順に並ぶ。 + media: [{ position, format, track_count }](リリース全体の収録構成)], + notes: [候補に入れられなかった理由], tracks: [{ number, length_ms }](TOC の + 音声トラック。手入力フォームの行。D-65), stage: discid | ids | toc(止まった段), can_widen(stage が ids の + ときだけ真)}。widen: true は段を打ち切らずに TOC 近似まで引く(discid で + 止まったときは無視。§7.2、D-64 追記 4)。同じ入力の照会結果は + 10 分覚えていて MusicBrainz を引き直さない(D-64 追記 3。widen は鍵に入る)。 + refresh: true で捨てて引き直す(画面の「MusicBrainz に照会」)。 + 400 bad_request(TOC・ISRC・MCN・品番・JAN の形)、502 lookup_failed(届かない・応答が壊れている)、 + 503 musicbrainz_unavailable(再試行しても 503 の負荷制限、または未構成) +POST /api/cd/library { toc, release_id? }。いまの盤がライブラリにあるか(§12.6 CD の帯、D-85)。 + DiscID はサーバが TOC から出し、active なトラックの MUSICBRAINZ_DISCID + タグで引く。当たらず release_id があれば active な album の mb_release_id + (大文字小文字を区別しない。D-84)で引く。DB だけを読む(MusicBrainz は + 引かない)→ 200 { discid, disc: AlbumRef | null, release: AlbumRef | null } + (AlbumRef = { album_id, rel_dir, album, albumartist }。複数あれば id の + 小さいもの。disc が当たれば release は引かない)。400 bad_request(TOC) +GET /api/cd/cover/{release_id} 候補のジャケット。Cover Art Archive の front(500px)の中継(D-82)。 + 画像の無い盤は 404、上流の失敗は 502 lookup_failed、未構成は 503 coverart_unavailable。 + release_id が MBID(8-4-4-4-12)でなければ 400。200 は上流の image/* をそのまま + `Cache-Control: private, max-age=600` と `X-Content-Type-Options: nosniff` 付きで返す + (サーバ側のキャッシュは無い) +GET /api/cd/release-group/{id}/releases リリースグループの版(D-93)。MusicBrainz の browse + (ws/2/release?release-group=…&inc=media+labels、先頭 100 件)→ 200 { releases: + [{ release_id, title, disambiguation, date, country, status, formats: [媒体の形式], + label, catalog_number, front: CAA に表の画像があるか }](front のある版が先、 + 同じなら日付の古い順), total }。知らないグループは 404、MBID でなければ 400、 + 502 lookup_failed、503 musicbrainz_unavailable(負荷制限・未構成) +POST /api/cd/rip { toc, metadata }(P2-5)。metadata は CD 画面の下書き(DiscMetadata。名前は空でもよい)。 + 盤がドライブにあり TOC が一致するときだけ rip ジョブを投入 → 202 { job_id }。 + 400 bad_toc / bad_metadata、409 disc_mismatch(盤が違う・無い)/ duplicate + (進行中の吸い出しがある。ドライブは 1 台)、503 cd_unavailable。進捗は SSE の + job イベントの detail(RipProgress。§7.2)、結果の一行はジョブの note +POST /api/cd/eject トレイを開けて状態を見直す → 204。失敗は 500 eject_failed(理由付き)。 + 先に CDROM_LOCKDOOR 0 で扉のロックを外す(外さないとドライブが + CHECK CONDITION で拒む機種がある)。吸い出し中(rip が running)は 409 ripping + +GET /api/jobs, POST /api/jobs/:id/cancel, POST /api/jobs/:id/retry +DELETE /api/jobs/:id 終端(failed / done / cancelled)のジョブ行を消す(P4-18。queued / running は 409 not_terminal) +DELETE /api/jobs?state=failed 失敗をまとめて消す → { "deleted": N }(state=failed 以外は 400) +GET /api/config 読み込んだ config.toml の原文 { "path", "text" }(設定画面 §12.6。秘密は config に無い) +GET /api/archive?state=held|restored|deleted|all&limit=&cursor= + 退避台帳 { "items": [ archived_files の行 + "batch_id" ], "next_cursor", "counts" } + (新しい順。`state` 省略は held、`limit` は 1..=500・省略 100、`cursor` は + `id < cursor`。`counts` は状態ごとの件数。復元は batch の巻き戻し。D-106) +GET /api/logs?kind=all|retro|rip&album_id=&limit=&cursor= + 照合・吸い出しのログの実行ごとの一覧 { "items", "next_cursor" }。`limit` は + 1..=200・省略 50、`cursor` は不透明(offset)。実行は照合なら + (album_id, source, job_id)、`job_id` が無い吸い出しなら (album_id, source, + verified_at) でまとめ、key は `retro-<album>-j<job>` / `rip-<album>-t<verified_at>`。 + `log` は latest | superseded | none(照合ログは album ごとに最新 1 回分)。 + 各項目は { key, album_id, albumartist, album, source, job_id, verified_at(まとめた中の最大), + discs: [{ disc_no, method, result }](手法 × ディスクの内訳), log }。不正な kind は 400 +GET /api/logs/{key}/text?disc=N ログ本文(text/plain; charset=utf-8。UTF-8 でなければ置換文字)。照合は + `[paths].data/verify/<album_id>.log`(`superseded` は 404)、吸い出しは + その album・disc の active な曲の親ディレクトリ + `log_path` のファイル名 + (`rip.log` / `rip<N>.log` 以外は 404。disc 省略は最初のディスク)。 + root の dirfd から O_NONBLOCK で開き、通常ファイル以外 + (ディレクトリ・FIFO)と ENOTDIR は 404 `log_not_found`、1 MiB 超は 413 + `log_too_large`、key が不正なら 400、`data/verify`(吸い出しは Library)を開けなければ 503 + `logs_unavailable`(D-106) +POST /api/scan {"kind": "incremental" | "deep"}。scan ジョブを投入 +GET /api/gc/preview GC の dry-run(区分ごとの件数・バイト数・先頭 50 件、`jobs: { done, failed }` は掃除するジョブ行の数、`plans` は消す終端した計画の数。何も消さない。D-56、D-102) +GET /api/inbox 承認キュー { "items": [{ id, rel_dir, state, detected_at, error, placed_album_id, + 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, 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 の + 項 { source, url, channel, verdict, message, subscription_id?, position?, item?, plugin_version?, + judged_at?, plugin_tags?, rejudge_error? } | null。item 以降は再判定の記録(§7.7、D-114)。 + destination.numbers は宛先の既存の番号 [[disc, track], …](昇順)。subscriptions は + 取り込みのトラックが参照する購読の直近の同期の要約(last_result の align から。1 回で読む)) + 承認済み(approved / placing / placed)の件には stages: [{ key, label, status: done | running | + todo | na, done, total }](配置 → RG → 系統 → 端末の段。§12.6、P5-2)。 + rip は CD の件の照会の材料 { toc, isrcs, mcn } | null(P4-21)、rip_files は件のディレクトリに + ある吸い出しのテキストの名前(`rip*.log` / `disc*.cue` / `disc*.toc`。D-106) +GET /api/inbox/:id/files/:name 取り込みの吸い出しファイル本文(`rip*.log` / `disc*.cue` / `disc*.toc` だけ。 + `GET /api/inbox` の各項目の `rip_files` が名前の一覧。D-106)。Inbox の root の dirfd から + 件の `rel_dir` を `openat2` で辿る。許可外の名前・件が無い・ファイルが無いは 404 + `log_not_found`、1 MiB 超は 413 `log_too_large`(ログ本文と同じ) +GET /api/inbox/:id/artwork/:hash 取り込みのファイルの埋め込み画像(PICTURE の sha256 で実体を照合してから ETag / 304。 + 原寸、MIME は sniff、immutable。取り込みに無い / 実体消失 / 不一致は 404。状態非依存。 + セッション必須。§7.8、P4-4) +GET /api/inbox/summary 上部バーのバッジ用 { pending, failed }(状態ごとの件数の固定 SQL だけ。一覧は読まない。 + `[paths].inbox` が無ければ 503 `inbox_unavailable`。§12.1、スマホ版の購読タブ §12.7) +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 + + ジョブ投入。件の再判定ジョブが 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/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, + video_url?, located?: { location: library|inbox, path }, list_id?, subscription?: { id, albumartist, album } }] } + (YouTube 画面 ① の照合。DB だけ。`list=` 付きは再生リスト。D-87) +POST /api/ytmusic/playlist { url }(再生リスト)→ { list_id, title, entries, unavailable, in_library, in_inbox, new, truncated, subscription? } + | 400 | 502 ytdlp_failed(yt-dlp で列挙して所在ごとに数える。読むだけ。同時 2 本。D-87) +GET /api/ytmusic/subscriptions → { items: [Subscription] }(albumartist / album 順。P4-16、D-78) +POST /api/ytmusic/subscriptions { url, albumartist, album, category?, align? = true, enabled? = true, max_enqueue? = 50 } + → 201 Subscription | 400(YouTube の list= 付き URL でない、空、max_enqueue が 1〜1000 外) + | 409 { error: "duplicate_list" | "duplicate_target" } +PATCH /api/ytmusic/subscriptions/:id { albumartist?, album?, category? (null で消す), align?, enabled?, max_enqueue? } + → 200 Subscription | 404 | 409 { error: "duplicate_target" | "sync_running" } + 追記先を変えると album_id は NULL に戻る。同期が queued / running の間は sync_running +DELETE /api/ytmusic/subscriptions/:id → 204 | 404 | 409 sync_running(PATCH と同じ。Library のファイルは消えない) +POST /api/ytmusic/subscriptions/:id/sync → 202 { job_id } | 404 | 409 { error: "duplicate" }(走行中。latch は残るので終わった後に走る) +GET /api/ytmusic/subscriptions/:id/progress → { sync: Job | null, items: [Job] } | 404(直近の playlist_sync と、それより後に作られた(jobs.id が大きい)その購読の ytdl を position 昇順で。created_at は秒単位で前回の同期の終わった件と同じ秒になりうるので、境界は id で取る。投入が走行中の古い ytdl に dedup されて新しい行ができない同期もあるので、この購読の ytdl で queued / running のものは作られた順によらず含める。スマホ版の購読の「完了」の判定に使う。§12.7) + [ytmusic].enabled でなければすべて 404 +// Subscription = { id, list_id, url, album_id, albumartist, album, category, align, enabled, max_enqueue, +// created_at, updated_at, last_attempted_at, last_synced_at, sync_requested_at, +// last_result: { state, error?, synced_at, title?, entries, playlist_count?, album_id?, in_library, in_inbox, +// elsewhere: [{position, id, track_id, rel_path}], enqueued: [position], running: [position], +// deferred, unavailable: [{position, id, kind}], align?: { moved, unchanged, blocked: [{track_id, +// position, current_no, reason}], outsiders, unnumbered, tags?, renamed, rename? } } | null } + → 202 { job_ids }。`[ytmusic].enabled` でなければ 404(§7.7、D-70) +POST /api/gc gc ジョブを投入(未完了があれば 409) + (202 + job_id。queued / running があれば 409 duplicate) +GET /api/events SSE: ジョブ進捗・ライブラリ変更 + +GET /api/history, POST /api/history/:batch/revert + 巻き戻しは新バッチとして記録(§7.5)。 + 終端状態のバッチのみ。pending 衝突・二重 revert は 409 +POST /api/history/:batch/cancel 反映中バッチのキャンセル +``` + +- 一括編集は必ず preview → apply の 2 段階 +- SSE は単一チャネル。イベント種別で多重化 + +## レスポンス形(UI が依存するもの) + +```jsonc +// GET /api/tracks?filter=...&sort=title&cursor=...&limit=100 +// filter はホワイトリストのキーだけを持つ JSON(未知キーは 400。D-39): +// { "category": "J-Pop", // ツリー: categories.name +// "albumartist": "…", // ツリー: tracks.albumartist の完全一致 +// "album_id": 12, // ツリー +// "playlist_id": 3, // プレイリスト所属 +// "flags": ["missing", "pending"], // 固定フィルタ(AND): unverified | duplicate | missing | +// // no_rg | rg_unwritten | pending | conflict | hardlink | +// // flac_unchecked | flac_error(§7.9)| hires_unchecked | hires_suspect(§7.10) +// "q": "情緒", // 検索語(3 文字以上 FTS5 / 未満 LIKE) +// "device_pending": 2 } // 端末に未反映(端末 id。追加・更新・移動・更新 + 移動で、前回の失敗も含む。DSL の device_pending と同じ定義。§7.11) +// cursor は前ページの next_cursor をそのまま返す不透明文字列(キーセット)。sort が変わったら +// 捨てる(別ソートで発行したカーソルは 400)。 +// total はフィルタに一致する全件数(同じ読み取りスナップショットで数える) +{ "items": [ { "id": 1, "title": "...", "artist_display": "...", "album": "...", "albumartist": "...", + "track_no": 1, "disc_no": 1, "date": "2024", "category": "J-Pop", + "duration_ms": 280000, "codec": "flac", "lossless": true, + "verification": "verified_ctdb", "rg_scanned_at": 1, "rg_written_at": 1, + "derived": { "opus": { "codec": "opus", "stale_tags": false }, "aac": null }, + // 系統ごと。無ければ null(§7.6) + "flac_check": { "status": "ok", "checked_at": 1700000000, "stale": false, "error": null }, + // 未検査なら null(§7.9) + "hires_check": { "status": "upsampled", "checked_at": 1700000000, "stale": false, + "error": null, "cutoff_hz": 22050, "cliff_db": 48.3, "effective_bits": 16 }, + // 対象外・未検査なら null(§7.10) + "pending_batch_id": 42, // または null + "conflict_batch_id": 41, // または null + "duplicate_group": "a1b2…", // audio_md5 hex または null + "hardlink": false, "missing_since": null, + "rel_path": "J-Pop/…/01 ….flac", + "devices": [ { "device_id": 2, "state": "synced", "synced_at": 1700000000 }, + { "device_id": 3, "state": "pending", "op": "add", "reason": null } ] } ], + // devices は端末ごとの状態(P5-2。その曲が端末の対象外で端末にも無いときは、その端末の要素を省き、端末が 1 台も無ければキーごと + // 省く)。state = synced { synced_at } | pending { op: add | update | move | update_move, reason } + // | waiting { reason, has_copy } | error { reason, has_copy } | removing。バッジ列とプロパティの「端末」が使う + "next_cursor": "…", "total": 61234 } + +// 選択は 2 形。Ctrl+A はフィルタ形で送る(ID 列挙にしない) +// { "ids": [1, 2, 3] } または { "filter": "<選択時点のフィルタ式>", "exclude_ids": [7] } +// フィルタ形は「選択した時点のフィルタ」を immutable に保持する(表示中のフィルタとは別物) + +// POST /api/tracks/batch/preview { "selection": {...}, "ops": [...], "sort": "title" } +// サーバは selection を解決して対象 track_id と各行の tag_version / 事前条件を +// スナップショットに保存し、selection_token(TTL 15 分)を返す(D-33)。 +// ops は上から順に適用する操作の配列(D-42): +// { "op": "set", "key": "TITLE", "value": "…" | ["…", "…"] } // 空は削除 +// { "op": "ref", "key": "ALBUMARTIST", "template": "%artist%" } // 先頭値で展開 +// { "op": "replace", "key": "TITLE", "pattern": "…", "replacement": "$1" } +// { "op": "number", "key": "TRACKNUMBER", "start": 1, "pad": 0 } // sort 順に連番 +// { "op": "delete", "key": "COMMENT" } +// { "op": "set_rows","key": "SOURCE_URL", "rows": { "<track_id>": "…" | ["…"] } } +// 行ごとに違う値(API 専用。UI には出さない。rows に無い行は変更なし。1〜10,000 行。P4-14) +// items は値が変わる行だけ。反映待ちの行は評価せず pending_excluded に数える +{ "selection_token": "…", "count": 1207, + "changed": 1180, "unchanged": 24, "pending_excluded": 3, + "items": [ { "id": 1, "changes": { "TITLE": { "old": ["…"], "new": ["…"] } } } ] } + +// PATCH /api/tracks/batch { "selection_token", "ops", "description", "skip_pending": false } +// 対象は token のスナップショット集合だけ。preview 後にスキャンで増えた行は含まれない。 +// スナップショット時と tag_version が変わった行は skipped_conflict になる +// 201 { "batch_id": 42, "affected": 1180 } +// 409 { "error": "pending", "track_ids": [ … ], "count": 3 } // skip_pending=true で除外して続行 +// 409 { "error": "preview_stale" } // token 期限切れ・ops 不一致 +// 409 { "error": "no_changes" } // 値が変わる行が無い +// 409 の応答では token を消費しない(同じ token でやり直せる)。201 で消費する + +// POST /api/rename/preview { "selection": {...}, "sort"? } +// selection を解決して固定し selection_token を返す(D-33)。各行に [layout] のテンプレートを +// 適用した宛先を返す。items は宛先が変わる行と衝突した行だけ(変更なし・反映待ちは件数) +{ "selection_token": "…", "count": 980, + "changed": 975, "unchanged": 2, "conflict": 2, "pending_excluded": 1, + "items": [ { "id": 1, "old": "old/a.flac", "new": "J-Pop/花譜/魔法/03 過去を喰らう.flac" }, + { "id": 2, "old": "old/b.flac", "new": null, "reason": "同名の別リリースと衝突(…)" } ] } + +// POST /api/rename/apply { "selection_token", "description"?, "skip_pending": false } +// token の集合で計画を取り直してバッチを記録する。衝突した行と preview の後にタグが変わった行は +// skipped_conflict の op として記録だけする(affected に含む) +// 201 { "batch_id": 43, "affected": 977, "conflict": 2 } +// 409 { "error": "pending" | "preview_stale" | "no_changes" } // 規則は PATCH /api/tracks/batch と同じ + +// POST /api/normalize/preview { "selection": {...}, "sort"? } +// rename と同型。items は宛先が決まる行(old / codec / new)と衝突した行(new: null, reason)。 +// 既に FLAC・非可逆は unchanged に数える +{ "selection_token": "…", "count": 7572, + "changed": 7570, "unchanged": 0, "conflict": 2, "pending_excluded": 0, + "items": [ { "id": 1, "old": "J-Pop/…/01 ….m4a", "codec": "alac", "new": "J-Pop/…/01 ….flac" } ] } +// POST /api/normalize/apply { "selection_token", "description"?, "skip_pending": false } +// 201 { "batch_id": 44, "affected": 7572, "conflict": 2 } +// 409 { "error": "pending" | "preview_stale" | "no_changes" | "normalize_disabled" } + +// GET /api/history +{ "items": [ { "id": 42, "created_at": 1, "description": "…", "kind": "tags", + "state": "partial", "affected": 312, "applied": 309, "conflict": 3, "failed": 0, + "reverts_batch_id": null, "reverted_by": 39, "finished_at": 1 } ] } +// reverted_by は reverted_at が立っているときだけ(戻した逆バッチの id)。reverted_at も返す +// GET /api/history/:id → 上 + "ops": [ { "id", "track_id", "kind", "result", "error", "rel_path", +// "edits": { "TITLE": { "old": [...], "new": [...] } }, +// "current": { "TITLE": [...] } } ] // skipped_conflict のときだけ +// (編集キーの現在値 = ファイルの再読込結果。rename は rel_path) +// POST /api/history/:id/revert { "description"? } → 201 { "batch_id": 43, "affected": 50, "conflict": 1 } +// → 404 | 409 { "error": "not_terminal" | "already_reverted" | "pending" } +// POST /api/history/:id/cancel → 202 | 404 | 409 { "error": "not_cancellable" } + +// GET /api/jobs?type= // type(任意): 種別で items を絞る(`ytdl` 等。上限も種別内で数える。YouTube 画面)。 + // summary / by_type は全件のまま。不明な種別は 400 +{ "items": [ { "id", "type", "state", "progress", "done", "total", "attempts", "last_error", + "run_after", "edit_batch_id", "created_at", "started_at", "finished_at", + "note", // 完了時の結果 1 行(ハンドラが返す。ytdl: "Inbox に置いた: <path>" / + // "プラグインが skip: <理由>" / "取り込み済み(Library|Inbox): <パス>" / + // "再生リストを展開した: N 件を投入、M 件は取り込み済み"。 + // verify: 手法ごとの結果("CTDB 全 12 曲一致(信頼度 34) / AccurateRip + // 12 曲中 10 曲一致"、複数ディスクは "disc N: " 付きで「。」区切り。D-96)。 + // playlist_sync: 同期の要約。無ければ null) + "subject" } ], // subject = 対象の表示用文字列(track_id → Library のパス、album_id → ディレクトリ、 + // batch_id → 説明、transcode は " [<variant>]" 付き、scan は kind、ytdl は url、 + // thumbnail は "artwork #id"、端末ジョブは "device #id"(source_hash は track_id と同じ)。 + // 行が消えていれば "track #id"、対象の無い種別は null) + // 状態ごとに上限付き(`limits`): 実行中・待ち 1,000(実行中 → 待ち。取り出し順 = priority 降順・ + // 作成順・id 昇順)→ 完了 300(新しい順)→ 失敗・取り消し 300(新しい順)。1 本の並びで切ると + // 待ちが数千件のとき完了・失敗が届かない + "summary": { "running": 3, "queued": 12, "done": 15300, "failed": 0, "cancelled": 0, "pending_ops": 1204 }, + "by_type": { "transcode": { "queued": 6470, "running": 11, "done": 1089, "failed": 0, "cancelled": 0 }, … }, + // 種別ごとの全件集計(0 件の種別は無し) + "concurrency": { "scan": 1, "rg": 12, "transcode": 11, … }, // 種別ごとの並列度(§8) + "cpu_budget": 12, // CPU 系の共通予算(= コア数。D-73) + "limits": { "active": 1000, "done": 300, "failed": 300 } } // items の状態ごとの上限 +// POST /api/jobs/:id/cancel → 202(queued は即 cancelled、running は cancel_requested_at を立てる) +// → 404 | 409 { "error": "not_cancellable" } // 既に終端 +// POST /api/jobs/:id/retry → 202(failed / cancelled を attempts=0 で queued に戻す) +// → 404 | 409 { "error": "not_retryable" | "duplicate" } // D-36 +// DELETE /api/jobs/:id → 204(failed / done / cancelled の行を消す。P4-18) +// → 404 | 409 { "error": "not_terminal" } +// DELETE /api/jobs?state=failed → 200 { "deleted": N }。state=failed 以外は 400 { "error": "invalid_state" } + +// SSE /api/events event 種別: job | batch | library | resync | device_progress +// job: { "id", "state", "progress", "done", "total", "detail"? } +// detail は種別ごとの進捗の詳細(SSE にだけ流し DB には書かない。rip は RipProgress。§7.2) +// batch: { "id", "state", "applied", "conflict", "failed" } +// library: { "scan_run_id", "kind": "ids", "track_ids": [ … ] } // 変更が 200 行以下 +// { "scan_run_id", "kind": "bulk" } // それ以上。ページを無効化 +// scan の完了時に 1 回(commit 後)。変更行 0 件なら流さない(D-39) +// device_progress: { "device_id", "progress": SyncProgress | null } // agent 端末の同期の進捗(null は消去: 計画が閉じた) +// resync: { "skipped": n } // サーバ側で取りこぼした。一覧(jobs / history / 表示ページ)を再取得 +// クライアントは SSE を開いてから一覧を取得する(逆順だと開く前のイベントを失う。D-36) +``` + +`library` イベントを受けたクライアントは、**表示中のページ(クエリ + カーソル)を無効化して +再取得**する。行の差し替えだけでは、変更行がフィルタに出入りしたりソートキーが変わったり +カーソル境界を跨いだときに集合と順序が壊れる。`ids` のときは表示中に含まれる id があれば +再取得、`bulk` は無条件に再取得。選択(`selection`)はイベントで変えない。 + +`pending_batch_id` / `conflict_batch_id` / `duplicate_group` / `hardlink` は一覧の +バッジ列([§12.2](12-ui/02-track-list.md))が直接使う。`GET /api/tracks` は 1 クエリで返す: `edit_ops` の pending と +最新 op は LEFT JOIN、重複は `audio_md5` 索引への相関 EXISTS で行ごとに引く +(`duplicate_groups` ビューを JOIN すると毎回 GROUP BY の実体化が走る。[D-39](../decisions/D-039.md))。 + +// GET /api/albums +{ "items": [ { "id": 1, "rel_dir": "J-Pop/…/…", "category": "J-Pop", "albumartist": "…", "album": "…", + "date": "2024", "original_date": null, "edition": null, "mb_release_id": null, + "disc_count": null, "artwork_id": null, "artwork_hash": null, // SHA-256 hex + "track_count": 12, "duration_ms": 2800000, "missing_since": null } ] } +// GET /api/albums/:id → 上の 1 要素 | 404 + +```jsonc +// GET /api/devices → { "items": [Device] }。POST / PATCH / PUT playlists / 破棄も Device を返す(端末タブ。§12.6) +{ "id": 2, "name": "Xperia", "transport": "adb", "variant": "opus", "selection": "playlists", + "generation": 5, + "connected": true, // adb: 監視が見た状態が device か(adb 無効なら false)。agent は null + "adb_state": "device", // adb の生の状態(unauthorized / offline …)。見えていない・agent は null + "adb_volume": "1A2B-3C4D", "adb_root": "Music/spindle", // agent は null + "counts": { "add": 3, "update": 0, "move": 1, "delete": 0, "waiting": 12, "error": 0, "synced": 210 }, + // 曲ごとの状態の件数。更新 + 移動は move に数える。ナビの黄色い件数は + // add + update + move + delete + error の全端末の合計(待ちは含めない) + "last_synced_at": 1700000000, // adb は同期の完了、agent は報告の受理 + "playlist_ids": [1, 4], // 印(device_playlists) + "playlist_states": [ // プレイリストごとの同期の状況(表示用のスナップショットから。少し古いことがある) + { "playlist_id": 1, "state": "synced", "pending": 0, "waiting": 0, "errors": 0, "reason": null }, + { "playlist_id": 4, "state": "pending", "pending": 3, "waiting": 1, "errors": 0, "reason": null } ], + // 選曲の各プレイリストと、外したが端末に残るもの(removing)。state は + // error(送れない収録曲があるか、ファイル自体のエラー。reason)> pending(次の同期で + // 送る収録曲・準備待ちの収録曲・ファイルの追加か更新のどれかがある)> synced の順に + // 決める。pending は送る曲、waiting は準備待ち(Derived 未生成など)、errors は送れない + // 曲の数(ライブラリから消えた曲は数えない) + "open_plan": false, // open な計画か待ち・実行中の device_sync がある(設定を変えられない) + "plan_open": false, // open な計画がある(「続きを実行」「破棄」の対象) + "sync_job": null, // 待ち・実行中の device_sync { id, state } か null + "sync_progress": null } // agent の同期の進捗か null。open な計画の plan_id と一致するものだけ(下記) + +// SyncProgress: { "plan_id", "done", "total", "bytes_done", "bytes_total", "rate", "phase", "current", +// "updated_at", "age" } // age = サーバが受けてからの秒数(画面は端末の時計に頼らない) + +// GET /api/devices/:id/diff +{ "generation": 5, "plan_token": "…", // 同期(adb)と計画の確定(agent)に添える + "pending_reevaluation": false, // 印の付いたスマートの評価待ち(確定は 409) + "items": [ { "op": "add", "track_id": 1, "title": "…", "artist": "…", "from": null, + "dest_path": "J-Pop/…/1-01 群青.opus", "reason": null, "size": 8123456, "has_copy": false } ], + // op = add | update | move | update_move | delete | adopted | waiting | error。waiting / error は hold + // (reason に理由、has_copy は既存の写しが残るか)と、反映済みで前回の同期・報告が失敗した曲。 + // adopted = 引き継いだ写しの照合(トークンの書き換えだけで転送しない。estimate に含めない) + "playlists": [ { "op": "update", "playlist_id": 4, "name": "通勤", "dest_path": "Playlists/通勤.m3u8", + "reason": null } ], // op = add | update | delete | error + "estimate": { "transfer_bytes": 25000000, "peak_bytes": 24000000, "free": 51200000000 }, + // 送る量、実行順に見た「今より増える量」の最大値、端末の空き(adb が最後に測った値。無ければ null) + "evaluations": [ { "playlist_id": 4, "name": "通勤", "evaluated_at": 1700000000, "pending": false } ], + // 印の付いたスマートプレイリストの評価時刻 + "counts": { … } } // Device の counts と同じ +``` + +## 認証 + +LAN 限定でも必須とする。攻撃者対策というより事故対策で、この API は 300GB の +ライブラリをリネーム・削除・タグ上書きできる。認証がないと、別タブのスクリプト、 +家族の端末、うっかり有効にしたポートフォワードがそのまま破壊的操作に届く。 + +- 単一パスワード。argon2id でハッシュして DB に保存 +- **初期パスワードは環境変数 `SPINDLE_INITIAL_PASSWORD`** で与える([D-28](../decisions/D-028.md))。DB に + パスワードが無い初回起動でハッシュ化して保存し、以後は無視する。環境変数も DB も + 無い場合は**ロックモード**で起動し、SPA の配信を含めて `/health` 以外は 503 で + 短い JSON(設定手順)を返す。`/health` は `{"status":"locked"}` を返して機械可読にする。 + 「先着で設定できる初回セットアップ画面」は置かない。compose のサンプルは + `${SPINDLE_INITIAL_PASSWORD:?...}` 形式で、値を与えないと起動できないようにする +- セッション Cookie(HttpOnly / SameSite=Lax / TLS 時のみ Secure)。Cookie 値はランダム + 32 バイトで、DB には **SHA-256 のみ**保存(`sessions.token_hash`)。失敗ログインは + IP 単位でレート制限 +- **変更系リクエスト(POST / PATCH / DELETE)の CSRF 検証**: `Origin` ヘッダが**あれば** + scheme / host / port が自分自身と完全一致することを必須にする。`Origin` が無い場合だけ + `Sec-Fetch-Site` が `same-origin` / `none` であることを要求し、それも無ければ拒否する。 + **`Host` の一致は判定に使わない**(クロスサイト POST でも Host は送信先になるため防御に + ならない)。reverse proxy 越しの外部 origin は `trusted_proxies` からの `X-Forwarded-Host` / + `X-Forwarded-Proto` だけで構成する +- CORS は全面禁止(SPA は同一オリジンで配信するため不要) +- `trusted_cidrs` からの接続は **route allowlist だけ認証をスキップ**する([D-27](../decisions/D-027.md)): + `GET /api/stream/:id`、`GET /api/artwork/:hash`、`GET /api/tracks/:id`(限定フィールド)、 + `GET /api/playlists/:id/export`。それ以外(一覧・検索・SSE・history・jobs・設定・session)は + CIDR 内でもセッション必須。変更系は当然セッション必須。用途は他プレイヤーや curl からの + ストリーム参照であり、履歴やジョブのエラー文(パスを含む)を LAN 全体に見せる理由はない。 + 判定に使うのは接続元 socket のアドレスで、`X-Forwarded-For` は `trusted_proxies` に列挙した + proxy からのものだけ採用する +- `/health` と `/api/agent/*` 以外の全ルート(SSE / stream / artwork 含む)が同じ認証ミドルウェアを通る +- **エージェントの経路**(`/api/agent/*`。Mac の `spindle-agent`、[D-99](../decisions/D-099.md)): セッションの認証ミドルウェアの + **外**に置き、`Authorization: Bearer <selector>.<secret>` だけで通す専用のミドルウェアを載せる。 + Cookie・CSRF・`trusted_cidrs` はどれも見ず、逆にセッション側は Bearer では通らない(同じ経路に分岐を + 足すと、どちらかの検査が緩む事故が起きやすい)。セレクタで端末の行を 1 つ引き、シークレットの SHA-256 を + 定数時間で比べる(256 bit の乱数なので argon2id は要らない)。外れれば 401 `unauthenticated` と + `WWW-Authenticate: Bearer`。トークンは端末タブの「pair コードを発行」(`POST /api/devices/:id/pair-code`。 + セッション必須)で出したワンタイムコード(`<selector>.<secret>`、10 分・1 回限り、argon2id で保存、 + 試行は検証の前に予約して 5 回まで)を `POST /api/agent/pair` で引き換えて受け取る。pair だけは Bearer を + 要らない明示的な例外。コードを発行し直すと旧トークンは即失効する。ロックモードでは pair を含めて 503。 + トークンとコードはログに出さない +- Subsonic 非対応が確定したため、salt+md5 方式との併存を考慮する必要はない diff --git a/docs/spec/10-playlists-export.md b/docs/spec/10-playlists-export.md new file mode 100644 index 00000000..45f3863b --- /dev/null +++ b/docs/spec/10-playlists-export.md @@ -0,0 +1,73 @@ +# 10. プレイリストとエクスポート + +## スマートプレイリスト DSL + +foobar2000 風の文法を採用し、`pest` でパースして AST(JSON) を DB に保存、 +実行時にパラメータ化 SQL へ変換する。生 SQL を保存しないのは、スキーマ変更で +全ルールが壊れることと、インジェクション面を抱え込むことを避けるため。 + +``` +%albumartist% IS ヰ世界情緒 AND %verification% IS verified_ctdb + AND NOT %category% IS _Unsorted +ORDER BY %date% DESC LIMIT 100 +``` + +- 演算子: `IS` / `HAS`(部分一致) / `GREATER` / `LESS` / `MATCHES`(正規表現) / + `MISSING` / `PRESENT` +- 論理: `AND` / `OR` / `NOT` / 括弧 +- 拡張フィールド: `verification` `lossless` `codec` `samplerate` `bitdepth` + `channels` `category` `added` `duration` `has_derived` `missing` `hirescheck` `cutoff` `cliff` + `effectivebits`、端末の状態を引く `on_device` / `device_pending`(値は端末名、`IS` だけ。端末名はバインドし、 + 未反映の集合は JSON 配列をバインドして `json_each` で展開する。これらを使うスマートプレイリストは端末の + 選曲に載せられない。[§7.11](07-pipeline/11-device-delivery.md)、[DSL](../user-guide/dsl.md)) +- 独自拡張(foobar に無い): `MATCHES` / `LIMIT` / `ORDER BY random` +- SQL 生成はホワイトリスト列へのマッピング。任意タグは + `EXISTS (SELECT 1 FROM track_tags ...)` に展開。値は全てバインドパラメータ +- 評価結果(ORDER BY / LIMIT 適用後)は `playlist_items` に書く([D-54](../decisions/D-054.md))。表・書き出しは手動と同じ経路。 + ライブラリ変更をトリガに常駐タスクがデバウンス後に再評価する + +## エクスポート形式 + +| 出力先 | 形式 | 内容 | +|---|---|---| +| foobar(静的) | `.m3u8` | 評価結果のトラック一覧。UTF-8 / BOM なし | +| foobar(動的) | クリップボード | **クエリ文字列とソートパターン**。Autoplaylist 作成時に貼る(`.txt` は作らない。[D-55](../decisions/D-055.md)) | +| Android | `.m3u8` | 配布ビュー(Derived 優先)で解決したパス。タグ追随待ちの Derived は件数(`stale_tags`)で示す | +| 汎用 | `.pls` | 任意(未実装。`export_profiles.format` に列挙だけしてある) | + +`.fpl` は非対応。非公開のバイナリ形式で foobar 1.x と 2.x で構造が異なり、 +書き損じると foobar 側の状態を壊す。 + +## foobar クエリへの変換 + +1. **フィールド名の写像。** foobar はスペース区切り: `ALBUMARTIST` → `%album artist%`、 + `TRACKNUMBER` → `%tracknumber%`。写像表を持つ([DSL](../user-guide/dsl.md))。技術情報(`codec` `samplerate` + `bitrate` `channels` `bitdepth` `duration`)は foobar の技術フィールドへ、spindle 固有 + (`verification` `category` `source_type` `lossless` `added` `has_derived` `missing` `hirescheck` + `cutoff` `cliff` `effectivebits` `on_device` `device_pending`)と `MATCHES` は + 変換不能としてその項を落とし `notes` に出す +2. **`ORDER BY` は分離。** foobar の Autoplaylist はソートをクエリに書かず、 + 別欄のタイトルフォーマット文字列で指定する。「クエリ」「ソートパターン」の + 2 本を出力する +3. **`LIMIT` と `random`、降順は変換不能。** `notes` に明示する + +`HAS` は foobar でも部分一致で、技術情報フィールドの `PRESENT` / `MISSING` も効く +(2026-09-19 に実機で確認。[D-55](../decisions/D-055.md))。 + +## パスマッピング(エクスポートプロファイル) + +NAS 上の `/library/...` をそのまま書いても foobar からは開けない。 +出力先ごとにプロファイルを持つ(`export_profiles` テーブル)。3 つは固定で、CRUD の API は +持たない。`foobar` の `prefix` だけ `[export].fb2k_prefix` を正として起動時に揃える([D-55](../decisions/D-055.md))。 + +| プロファイル | source | path_style | prefix | sep | +|---|---|---|---|---| +| `foobar` | master (Library) | absolute | `\\TRUENAS\music\` | `\` | +| `android` | delivery (Derived 優先) | relative | — | `/` | +| `internal` | master | relative | — | `/` | + +書き出し先は `Playlists/<profile>/<name>.m3u8`(プロファイルごとにディレクトリを分ける。[D-53](../decisions/D-053.md))。 +`Playlists/<profile>/` は `Library/` と同じ深さなので、相対パスは `../../Library/...` で +正しく解決される。旧ライブラリから移した `Playlists/m3u8/` は取り込み元として残す。 +スマートプレイリストはライブラリ変更をトリガに、 +デバウンス(既定 30 秒)を挟んで自動再エクスポートする。 diff --git a/docs/spec/11-playback.md b/docs/spec/11-playback.md new file mode 100644 index 00000000..168b57fe --- /dev/null +++ b/docs/spec/11-playback.md @@ -0,0 +1,20 @@ +# 11. 再生とトランスコード + +| コーデック | ブラウザ | 方針 | +|---|---|---| +| FLAC | Chrome / Firefox / Safari 対応 | 直送 | +| Opus | Chrome / Firefox / Safari 18.4+ 対応 | 直送(Opus 不可のブラウザ向けの AAC 変換は作らない。[D-52](../decisions/D-052.md)。スマホ版の試し聴きは例外で `?transcode=aac` を使う。[D-115](../decisions/D-115.md)) | +| AAC (m4a) | 全対応 | 直送 | +| WAV | 全対応 | 直送(サイズ大) | +| **ALAC** | **Safari のみ** | **既定で Opus へ変換** | +| ハイレゾ FLAC | 再生可だが帯域大 | 既定の Derived(Opus 48 kHz)再生で吸収。「原本」を選んだときはそのまま送る([D-52](../decisions/D-052.md)) | + +- `GET /api/stream/:id` は原本を Range で直送。`?transcode=opus` は `delivery` が Derived を指せば + Derived を Range で直送し、無いときだけ ffmpeg を `stdout` パイプで起動して chunked で返す([D-52](../decisions/D-052.md)) +- `?transcode=aac` は `aac` 系統の Derived(m4a)が現在値なら Range で直送し、無いときは ffmpeg で AAC を ADTS として + chunked で返す(`audio/aac`。Range なし。シークは `?start=`)。非可逆の原本は `opus` / `aac` のどちらでも変換せずそのまま + 返す(世代劣化させない)。スマホ版の試し聴きが、Ogg/Opus を再生できない端末で使う([§12.7](12-ui/07-mobile.md)、[D-115](../decisions/D-115.md)) +- 変換中のシークは該当位置から ffmpeg を再起動(`?start=` → `-ss`) +- 変換結果はキャッシュしない(Derived がそのキャッシュ) +- クライアント能力は起動時に `canPlayType()` で判定し、再生時に URL で選ぶ(サーバへは通知しない)。 + 可逆はブラウザが再生できても既定で Derived の Opus(設定「原本」で直送) diff --git a/docs/spec/12-ui/01-skeleton.md b/docs/spec/12-ui/01-skeleton.md new file mode 100644 index 00000000..ca3353dc --- /dev/null +++ b/docs/spec/12-ui/01-skeleton.md @@ -0,0 +1,63 @@ +# 12.1 骨格(上部バー + 3 ペイン) + +``` +┌──────────────────────────────────────────────────────────────────────────────────────┐ +│ spindle ライブラリ アルバム CD YouTube Inbox❷ 端末❸ │ ⏮ ▶ ⏭ ■ 0:38 ━━━━━━━━ 3:37 曲名 — アーティスト │ RG track ☐原本 🔊━ │ ☰ │ +├──────────────┬───────────────────────────────────────────────────────────────────────┤ +│ [検索 _____] │ プロパティ │ 一括編集 │ 操作 選択 1,204 件 ▴ │ ← 右パネル +│ ▾ All Music │ Metadata │ Location │ +│ ▾ J-Pop │ Artist Name … │ File path … │ +│ ▸ 藍井 │ … │ General … │ +│ ▸ Game ├───────────────────────────────────────────────────────────────────────┤ +│ │ 表示 9,099 件 列 │ +│ プレイリスト │ ☐ │ Artist/album │ # │ Title / track artist │ 長さ │ バッジ │ │ +│ ♪ 通勤 │ ☑ │ … │ 1 │ … │ │ ✔ RG │ │ +│ ⚙ 未検証 │ … (6 万行、仮想スクロール) │ +│ フィルタ │ │ +│ 未検証 重複 │ │ +│ ┌──────────┐ │ │ +│ │ cover │ │ │ +│ └──────────┘ │ │ +└──────────────┴───────────────────────────────────────────────────────────────────────┘ +``` + +- **上部バー**(ナビとプレイヤーを 1 本に。[D-58](../../decisions/D-058.md) 追記): 左に画面の切替、中央に ⏮ ▶ ⏭ ■ とシーク、 + 曲名(無ければ Not playing)、右に RG モード / 原本 / 音量、☰(ジョブ / 記録 / 設定 / ログアウト。 + SSE の接続状態の点もここ) +- **ナビの並び**(P4-20): `ライブラリ / アルバム / CD / YouTube / Inbox / 端末`。ジョブ・記録・設定は ☰ の中。 + **タブは取り込みの流れ順には並べない。** ものは 入力(CD / YouTube)→ Inbox → ライブラリ と流れるが、 + ライブラリが日常の入口なので先頭(ホーム)に置く。導線は「取り込みタブのすぐ右に Inbox があり、 + そこに赤い数字が増える」ことで示す(CD を取り込み終えたら右隣の Inbox を見る)。 + Inbox のバッジは承認待ちの件数(`GET /api/inbox/summary`)で、失敗があれば赤点も出す。 + 一覧(`GET /api/inbox`)は下書きまで読んで重いので、バッジのためには要約だけを取る。取り直しは inbox ジョブ + (走査・配置)の SSE(250ms で間引く)と 60 秒の周期(`hooks/useInboxSummary`)。503(`[paths].inbox` 未設定)なら + バッジを出さない。 + ジョブが ☰ に入るので、実行中 + 待ちの件数は ☰ のメニュー内の「ジョブ」に添え、失敗の赤点は + ☰ のボタン自体に出す(SSE の点と合わせて 2 つまで。3 つ並べると読めない)。 + **端末**(P5-2、[D-95](../../decisions/D-095.md))は取り込みの流れの終点として Inbox の右に置き、全端末の未反映(追加 + 更新 + 移動 + + 削除 + エラー。待ちは含めない。同じ曲が 2 端末で未反映なら 2)の件数を黄色で出す(`GET /api/devices` の + `counts` の合計。一覧は画面に関わらず取る) +- **左サイドバーを出す画面**(P4-20): ツリーの選択が表の絞り込みに効く画面(ライブラリ / アルバム)だけ。 + CD / YouTube / Inbox / 端末 / ジョブ / 記録 / 設定では左カラムごと畳んで全幅にする(ツリーが何にも効かないため。 + 左カラム下のアルバムアートも一緒に隠れるが、曲名は上部バーに残る) +- **検索ボックス**は左サイドバーの先頭(スクロールしない)。表の `filter.q`(入力から 250ms 後に + 反映。3 文字以上で FTS、未満は LIKE。[D-39](../../decisions/D-039.md) / [D-40](../../decisions/D-040.md)) +- **左サイドバー**: 3 区画(ツリー / プレイリスト / フィルタ)。どれを選んでも + **中心の表の集合を差し替えるだけ**で、列・ソート・選択の仕組みは共通 + - ツリー: Category → AlbumArtist → Album(`GET /api/albums` から構築。選ぶと scope を置き換える) + - プレイリスト: 手動 / スマート。表からドラッグで追加。**選曲の近道**(P5-2、[D-95](../../decisions/D-095.md)): 印の付いた端末を行に + 端末ごとの 📱 で出す。同期の状況(Device の `playlist_states`)を記号で添える: 同期済みは 📱 だけ、 + 同期待ち・同期中は 📱⏳、送れないものがあれば 📱⚠、選曲から外したが端末に残るものは薄い 📱🗑。 + 「同期中」は同期の実行中(Android は device_sync ジョブが running、iPhone は `sync_progress` が届いている間)の + 同期待ちのうち、送る曲かファイルがあるものだけ(送れないもの・準備待ちだけのものは実行中の同期で片付かない)。 + 端末名と状況はホバーで出す(名前が長くても印は切らず、名前のほうを省略する)。 + 右クリックと … のメニューの「端末へ送る」に端末ごとのチェックを並べる + (端末タブの選曲と同じ `PUT /api/devices/:id/playlists` を触る。端末が 1 台も無ければ追加を案内する。 + 同期が途中の端末は変えられない) + - フィルタ(固定): 未検証 / 重複 / missing / RG なし / 反映待ち / conflict / hardlink。 + トグルで AND、ツリーの絞り込みと組み合わせられる([D-40](../../decisions/D-040.md))。端末があれば「端末に未反映 ▾」で端末を選ぶと + `filter.device_pending` で絞る(追加・更新・移動・更新 + 移動。前回の失敗も含む。DSL の `device_pending` と + 同じ定義。選んでいた端末が消えたら外す) +- **右パネル**: 2 タブ(一括編集 / 選択の詳細)。折りたたみ可、幅は永続化 +- **下部バー**: 左が再生(再生・停止・シーク・音量・RG の off / track / album)、右がジョブ要約 + 「実行中 N · 反映待ち M · 失敗 K」。右側クリックでジョブ画面へ diff --git a/docs/spec/12-ui/02-track-list.md b/docs/spec/12-ui/02-track-list.md new file mode 100644 index 00000000..1632b10c --- /dev/null +++ b/docs/spec/12-ui/02-track-list.md @@ -0,0 +1,63 @@ +# 12.2 トラック一覧 + +**列**(既定。順・幅・表示は localStorage に永続化): +選択 / # / Title / Artist / Album / AlbumArtist / Date / Category / 長さ / Codec / バッジ / +rel_path(既定非表示) + +**バッジ列**(1 セルに複数アイコン、ホバーで文言): + +| バッジ | 条件 | 出典 | +|---|---|---| +| 検証 | `verification` を検証済み / 不一致 / 検証不能 / 未検証の 4 つの見え方で出す。`verified_ctdb` と `verified_ar` は同じ「検証済み」(どちらで一致したかはプロパティの General の CTDB / AccurateRip 行。[D-96](../../decisions/D-096.md))。`unverifiable` は「未検証」と別の見え方 | tracks | +| 可逆 / 非可逆 | `lossless` | tracks | +| RG | `rg_scanned_at` の有無。書き込み未反映(`rg_written_at < rg_scanned_at`)は半透明 | tracks | +| Derived | 系統ごとに 1 つ: **O** = `derived_files` の `opus` 系統あり(= 配布ビュー)、**A** = `aac` 系統あり。この順に並べ、`stale_tags` は点付き(O• / A•)。プロパティの Location 列にも系統ごとの行を出す([§7.6](../07-pipeline/06-derived.md)、[D-112](../../decisions/D-112.md)) | delivery | +| 反映待ち ⏳ | `edit_ops.result = 'pending'` がある | edit_ops | +| conflict ⚠ | このトラックの**最新の op**(`edit_ops` を `id DESC` で 1 件)が `skipped_conflict` | edit_ops | +| 重複 | `duplicate_groups` に属する | view | +| hardlink | `nlink > 1` | tracks | +| missing | `missing_since` あり。行全体をグレー | tracks | +| 端末 | 端末ごとに 1 つ(ホバーで端末名): 📱 反映済み / 📲 未反映(追加・更新・移動・更新 + 移動)/ ⏳ 待ち(理由)/ ⚠ エラー(理由)。対象外(removing)と、その端末に関係しない曲は出さない(P5-2、[D-95](../../decisions/D-095.md) の可視化 D) | `/api/tracks` の `devices` | + +**選択**: クリック / Shift 範囲 / Ctrl 追加 / Ctrl+A(フィルタ結果全件)/ キーボード(下記)。 +全件選択は ID 列挙ではなく**選択した時点のフィルタ式**をサーバに渡す(`selection.filter`)。 +選択は immutable で、その後にソートを変えても**選択集合は変わらない**。表の集合(サイドバーの +scope・固定フィルタ・検索語・ルール編集中の DSL)が変わったら**選択を解除する**。右パネル(プロパティ・ +一括編集・操作)の対象は表示中の表の行だけにし、前の表示で選んだ行を持ち越さない([D-40](../../decisions/D-040.md))。 +プレビューでサーバが集合をスナップショットし(`selection_token`)、適用はその集合だけに +効く。右パネル頭に「選択 N 件(うち反映待ち M 件)」、表の上に「表示 K 件」。 + +**反映待ちの行**は編集不可。右パネルの [適用] は「M 件を除外して適用 / 待つ」の 2 択に +なる(API の 409 をここで吸収する)。 + +**キーボード**(P4-17、[D-80](../../decisions/D-080.md)。表にフォーカスがあるとき。クリックした行がカーソルになる): + +| キー | 動き | +|---|---| +| ↓ / ↑ | カーソルを 1 行動かし、その行だけを選択する | +| PageDown / PageUp | 1 画面ぶん動かす(同上) | +| Home / End | 読み込み済みの先頭 / 末尾へ(同上) | +| Shift + 上記 | カーソルを動かし、選択を **anchor からカーソルまでの範囲そのもの**に置き換える(戻れば縮む。クリックの Shift が「足す」のと違う)。anchor が無ければ(Ctrl+A / Esc の直後)移動前のカーソル行が anchor | +| Ctrl + 上記 | カーソルだけ動かす(選択は変えない) | +| Space | カーソル行を選択にトグル(Ctrl+クリックと同じ) | +| Ctrl+A / Esc / Delete | 全件選択 / 解除 / プレイリスト scope で選択を除外(従来どおり) | + +カーソルは**読み込み済みの行の中**でだけ動く(未読込の骨組み行には id が無く選択に入れられない)。末尾に +着くと次のページが読まれるので、End を繰り返せば先へ進める。カーソルは選択とは別の見た目上の状態で、 +ソートを変えても選択集合は変わらない(表の集合が変われば上記のとおり解除する)。 + +**インライン編集**: セルをダブルクリック → 1 件のバッチとして同じ経路(プレビュー省略)。 + +**プロパティタブの Metadata**([D-58](../../decisions/D-058.md) / [D-72](../../decisions/D-072.md)、P4-3): 値のダブルクリックで選択全体への `set`(`;` で多値)。 +foobar2000 の Properties と同じく、行末の × か右クリックのメニューで**フィールドの削除**(選択全体への +`delete` op。値の無い行は消せない)、表の下の「フィールドを追加」で**新しいキー**に `set`(キーは大文字化、 +`=` と制御文字は不可、`PICTURE` 不可、表に既にあるキーはその行の編集を案内、空の値は追加しない)。キーの入力欄は Inbox の「タグを追加」・一括編集の +操作のキーと同じく、入力のたびに**前方一致の候補**(`GET /api/tags/keys` のキーを曲数の多い順に、ライブラリに +無いよく使うキーを後ろに。既にあるキーは除く)を出し、↑↓ と Enter / Tab かクリックで選ぶ(`TagKeyInput`、[D-113](../../decisions/D-113.md))。 +どれも 1 op のバッチ(preview → apply、履歴から巻き戻せる)。サーバの変更は無い。 + +**プロパティタブの「端末」節**(P5-2、[D-95](../../decisions/D-095.md) の可視化 A): Location の下に、端末が 1 台でもあれば端末ごとに +1 行: `✓ 反映済み(日時)` / `未反映(追加・更新・移動・更新 + 移動)`(前回の失敗があれば理由)/ +`待ち: <理由>` / `エラー: <理由>`(既存の写しがあれば「(古い版が端末にあり)」)/ `対象外` +(次の同期で端末から消えるものは「次の同期で端末から削除」)。複数選択は他の行と同じく集計し、 +全部反映済みなら `✓ 反映済み`(日時は畳む)。 diff --git a/docs/spec/12-ui/03-bulk-edit.md b/docs/spec/12-ui/03-bulk-edit.md new file mode 100644 index 00000000..3b55a6df --- /dev/null +++ b/docs/spec/12-ui/03-bulk-edit.md @@ -0,0 +1,29 @@ +# 12.3 右パネル: 一括編集 + +``` +操作リスト(上から順に適用) + 1. TITLE 正規表現置換 /\s*\(Official.*\)$// + 2. ALBUMARTIST フィールド参照 %artist% + 3. TRACKNUMBER 連番 開始 1、現在のソート順に + 4. COMMENT 削除 + [+ 操作を追加] +[プレビュー] → 表の該当セルに 旧値→新値 の差分表示。変更なしは薄く。 + 頭に「変更 1,180 / 変更なし 24 / 反映待ちで除外 3」 +[適用] → 説明文(任意)→ バッチ作成 → 下部バーの反映待ちが動く +``` + +操作: 固定値代入 / フィールド参照(`%albumartist%`)/ 正規表現置換 / トラック番号連番 / +タグ削除。プレビューは `POST /api/tracks/batch/preview` の結果を表に重ねる。別表は開かない。 +操作リストは選択を変えても残る(同じ操作を別の集合へ繰り返し適用できる)。 + +**導線**([D-87](../../decisions/D-087.md)。ユーザ要望・モックで合意): 一括編集タブと操作タブは CD / Inbox と同じ番号付きの段を +**横に 4 列**並べる(パネルは表の上で低く横長なので。コンテナ幅が狭ければ 2 列 → 1 列)。 + +- 一括編集: **① 対象**(選択件数・アルバム・反映待ち。`PanelTargetStep`)→ **② 操作**(上の操作リスト)→ + **③ プレビュー**(変更 / 変更なし / 反映待ち除外の件数。選択・操作・ソートを変えると「古い」になり枠が + 警告色)→ **④ 適用**(説明と「N 曲に適用」。③ が済むまで押せない。済んだらバッチ #、巻き戻しは履歴) +- 操作: **① 対象** → **② 操作を選ぶ**(ファイル / 音量 / 検査 / 画像・プレイリストの群から 1 つ。選んだものは + localStorage に残す)→ **③ 確かめる / プレビュー**(選んだ操作の説明と「巻き戻せる変更 / ジョブ / 読むだけ / + すぐ反映」の印。リネーム・正規化はパスのプレビュー、アートワークは画像の選択、album gain はチェックボックス、 + プレイリストは追加先)→ **④ 実行**(見出しは適用 / 投入 / 反映。押せない理由を添える。結果の 1 行と + 反映待ちの「除外して適用」もここ) diff --git a/docs/spec/12-ui/04-records.md b/docs/spec/12-ui/04-records.md new file mode 100644 index 00000000..ca4b49aa --- /dev/null +++ b/docs/spec/12-ui/04-records.md @@ -0,0 +1,41 @@ +# 12.4 記録(編集履歴 / 退避ファイル / ログ) + +☰ の「記録」(旧「履歴」)は 3 つのタブを持つ: **編集履歴 / 退避ファイル / ログ**。設定にあった退避ファイルと、 +操作タブが実ファイルのパスで案内していた照合ログをここに集める([D-106](../../decisions/D-106.md))。 + +- **退避ファイル**: `archived_files` の一覧と復元。既定は保持中(`held`)で 100 件ずつ(「もっと見る」で + `cursor`)。状態ごとの件数は `counts`。GC で実体を消した記録(`deleted`)は既定では出さない。 + 上部の切り替えは 保持中 n / 復元済み n / 削除済み n / すべて。保持中が 0 件なら「保持中の退避ファイルは無い + (削除済み n 件は GC で実体を消した記録)」。列と「バッチ #n を巻き戻す」は設定画面にあったときと同じ +- **ログ**: 照合(`retro`)と吸い出し(`rip`)の実行ごとの一覧(50 件ずつ。`kind` で絞る)。照合ログは + album ごとに最新 1 回分しか残らないので、古い実行は `superseded`(「上書きされた」)と出す。吸い出しログは + DB の `log_path` を使わず、その disc の active な曲がいまあるディレクトリから `rip.log` / `rip<N>.log` を引く + (リネームに追随する)。行を開くと本文を読み、コピーとダウンロードができる。Inbox の承認画面でも CD の + 取り込みの `rip*.log` / `cue` / `toc` を読める(`GET /api/inbox/:id/files/:name`) + - 一覧の行は日時・種別(照合 / 吸い出し)・album・結果の要約(手法 × ディスクを並べた 1 行。複数枚組は + `disc 1: CTDB 一致 / AR 一致 · disc 2: …`)。本文は等幅・折り返さず横スクロール。ページ送りは offset なので、 + 間に新しい実行が増えて重なった行は `key` で捨てる + - 本文が無いときの文言: `superseded` は「このログは後の照合で上書きされた」、404 は「ログが残っていない + (後の照合で上書きされたか、ファイルが無い)」(Inbox では「ファイルが見つからない(Inbox から移動・削除された)」)、 + 413 は「ログが大きすぎて表示できない(1 MiB 超)」 + - **入口**: 操作タブの照合結果(④)の各行の「ログ」、プロパティの「照合・吸い出しのログを見る」(選択が 1 つの + album のときだけ)、ジョブ画面の verify の行を開いたときの「ログを見る」(`payload.album_id`)。どれも + 記録 > ログをその album で絞って開く。rip のジョブ行には出さない(Inbox に置くまでで album を知らない。 + 承認前の吸い出しログは Inbox で読む)。操作タブはログを実ファイルのパスで案内しない +- **タブの切り替え**: 画面の状態で持ち、他画面からは `openRecords({ tab, … })` で開く(ジョブ画面と退避ファイルの + 「バッチ #n」は編集履歴タブでそのバッチを開く) +- **編集履歴タブ**: 以下 + +``` +#42 09-16 14:03 "Official を除去" tags 1,204 件 applied [巻き戻す] +#41 09-16 13:50 "アーティスト統一" tags 312 件 partial (conflict 3) [巻き戻す] [conflict を見る] +#40 09-16 13:20 "リネーム J-Pop/…" rename 980 件 applying 640/980 [キャンセル] +#39 09-15 … (#38 の巻き戻し) tags 50 件 applied ↩ #38 [巻き戻す(=やり直し)] +#38 09-15 … "…" tags 50 件 applied reverted — +``` + +- 行を開くと op 一覧(トラック / result / error)。conflict の op は「ファイルを再読込した」旨と + 現在値を表示 +- [巻き戻す] は終端状態のみ有効。`reverted_at` 済みは無効化して「#39 で戻し済み」と表示 +- 巻き戻しも新バッチなので同じ一覧に現れる(`reverts_batch_id` を ↩ で表示) +- [キャンセル] は `prepared` / `applying` のみ diff --git a/docs/spec/12-ui/05-jobs.md b/docs/spec/12-ui/05-jobs.md new file mode 100644 index 00000000..7baa8f5a --- /dev/null +++ b/docs/spec/12-ui/05-jobs.md @@ -0,0 +1,17 @@ +# 12.5 ジョブ + +種別ごとの並列度と待ち行列(待ち / 実行中 / 完了 / 失敗。件数は `by_type` の全件集計。一覧は上限付きなので +画面で数えない)、一覧の各行に**対象**(`subject`。どのファイル / アルバム / バッチかが id だけでは分からない)、 +実行中の進捗(`done / total`)、失敗の `last_error` と [再試行] / [キャンセル]、終端の行の [消す](確認済みの +片付け。失敗・取り消しタブには [失敗をすべて消す]。P4-18)。一覧のタブは +**実行中・待ち / 完了 / 失敗・取り消し / すべて**で、各タブに `summary` の件数を添える(待ち → 実行中 → 完了と +数が移っていくのが分かる)。一覧は状態ごとに切り出す(実行中・待ちはキューの順で 1,000 件、完了と失敗・取り消しは新しい順で +各 300 件。`limits`)。表示中のタブが上限に達していれば「表示は最新 N 件まで」と添える。種別表の下に CPU 系の実行中の合計と共通予算(`cpu_budget`。[D-73](../../decisions/D-073.md))。編集バッチ由来のジョブは `edit_batch_id` で記録画面(編集履歴)へリンク。 +SSE `/api/events` で更新し、リロードしても DB の値で復元する。 + +**結果**列は完了したジョブの `note` の 1 行目を出す(`transcode` は実行中から処理の名前を出す。[§7.6](../07-pipeline/06-derived.md))。`note` かエラーのある行は、クリックで開いて全文を読む +(verify の行には「ログを見る」で、記録 > ログをその album で絞って開き、最新の実行の本文を開く)。GC・スキャン・バックアップは複数行の `note` を残す: +GC は削除件数・バイト数と区分ごとの内訳・見送りと失敗・掃除したジョブ行と計画の数、スキャンは見たファイル数・新規・更新・ +移動・missing・エラーの件数と、読めなかったファイルと対象外のパス(対象外は 20 件まで)、バックアップは作った +ファイルと大きさ・保持する世代数・消した件数。ジョブ行と同じ保持期間 +(done 7 日)で消える([D-106](../../decisions/D-106.md))。 diff --git a/docs/spec/12-ui/06-other-screens.md b/docs/spec/12-ui/06-other-screens.md new file mode 100644 index 00000000..2ce57354 --- /dev/null +++ b/docs/spec/12-ui/06-other-screens.md @@ -0,0 +1,255 @@ +# 12.6 その他の画面(骨格のみ) + +- **アルバム**(P1-3): サムネイルグリッド(`/api/artwork/:hash?size=256`。missing は出さない)→ + クリックで表を `album_id` に絞る(一覧へ戻る)。**表と同じ絞り込み**(ツリーの選択・プレイリスト・検索語・ + 編集中のルール)が効き、`GET /api/albums?filter=` で「一致する active なトラックを持つ album」を出す + (P4-6、[D-58](../../decisions/D-058.md) 追記)。件数は絞り込み中「全 M 件中 N 件」。ツリーは全件の一覧から組む(絞り込みとは別に取る) +- **操作タブの「アートワーク」**(P1-3 書き側、[D-60](../../decisions/D-060.md)): ファイル選択 → `POST /api/artwork/upload` → + 256px のプレビュー(形式・寸法)→ 「選択 N 件の埋め込み画像を差し替え」(`POST /api/artwork/embed`。 + 反映待ちの 409 は他の操作と同じ「除外して適用」)。履歴画面の `PICTURE` 値はサムネイルで出す +- **CD**(P2、P4-20): **ドライブに CD が入っている前提の画面**。左カラム(ツリー)は出さない([§12.1](01-skeleton.md))。 + - **導線**([D-85](../../decisions/D-085.md)。ユーザ要望): 上から番号付きの枠で **① 挿入中の CD → ② 認識したトラック → ③ 候補を + 選ぶ → ④ 取り込む**。各枠の見出しの右に要約(① は「表示のみ」のバッジ、② は曲数、③ は照会の要約 + `lookupHeadline`)と ⓘ。**長い説明は本文に並べず ⓘ のツールチップ**(`components/Hint.tsx`。ホバーと + キーボードのフォーカスで出る)に畳む(照会の段の順序、写す範囲、CD 以外の媒体、「さらに広げて探す」、 + 「どれも違う」、ドライブの型番とオフセット)。③ の候補の一覧は枠の中でスクロールし(高さの上限)、 + ④ を画面の遠くへ押し出さない。④ の横の一行は「『アルバム』として取り込む」/「候補を選ばずに取り込む」 + - **ライブラリにあるかの帯**([D-85](../../decisions/D-085.md)): ツールバーの下。`POST /api/cd/library`(TOC と選択中の候補の + リリース)で、その盤そのもの(DiscID)が当たれば緑の「✓ この盤はライブラリにある: 『…』(…)」、 + リリースだけ当たれば黄の「同じリリースの album がライブラリにある(この盤は未取り込み)」と + 「ライブラリで開く」(表を album に絞る)。どちらも無ければ出さない。失敗しても画面は止めない + - **ドライブ**(P2-1): 画面を開いている間 `GET /api/cd/status` を 2 秒間隔で取り(`useCdDrive`)、 + 状態の一行(ディスクなし / トレイが開いている / ディスクあり(N トラック・総時間)/ ドライブが無い + (理由))と「照会し直す」「取り出す」。新しいディスクの TOC が出たときだけ(同じディスクの間は 1 回。 + `lib/cdDrive.ts` の `newDiscToc`)照会を自動で始める。自動の照会はサーバが覚えている結果を使い、 + ボタンからの照会は `refresh` で引き直す + - **この画面では編集させない**(P4-20 追記。ユーザ要望)。吸い出したものは Inbox を通すので + ([D-67](../../decisions/D-067.md) 追記)、値を直すのは Inbox の承認画面に一本化する。入力欄が 2 か所にあると、どちらで直すのか + 分からなくなる。CD 画面は「何が入っていて、どの盤として取り込むか」を確かめる場所 + - **トラック表**: TOC の音声トラックと 1:1 で、**照会の前から出る**(`GET /api/cd/status` の `tracks`。 + 番号と長さはサーバが TOC から出す)。列は `# / タイトル / アーティスト / 長さ`、取り込み中だけ右端に + `進捗`。**読み取り専用**で、見た目は一覧の表に寄せる(sticky なヘッダ、`--row-h` の行高、行のホバー、 + 番号と長さは右寄せ)。名前の入っていない行は**取り込んだときに付く名前**(`Track NN` / + アルバムアーティスト)を薄字で見せ、候補を選ぶと実名に置き換わる。ISRC は列にせず `#` の + ツールチップ。コンポーネントは一覧の `TrackTable` とは別(あちらは仮想スクロールと行選択を持つ) + - **アルバムの要約**(① 挿入中の CD。`CdAlbumSummary`): 読み取り専用。**すべてラベル付き**の 2 列の + 定義リストで、アルバム(強調)/ アルバムアーティスト / 日付は常に(空は「—」、アルバムは「(候補を + 選んでいない)」)、ディスク番号(複数枚組のときだけ)/ レーベル / カタログ番号 / JAN/UPC は値のある + ときだけ。名前は Inbox で入れる旨は ⓘ に。その下に**品番と JAN/UPC の入力欄**と「品番で照会」 + ([D-94](../../decisions/D-094.md)。盤の帯・背から読む版の識別子。入れた値はすぐ下書きに重なり、盤が替わったら消える。 + 照会のボタンすべてに添える)。 + **`category` は CD 画面に無い**(Inbox の承認時に選ぶ)。トラックリスト貼り付け(P2-4、[D-65](../../decisions/D-065.md))も + CD 画面からは外し、Inbox の承認画面へ移した(§12.6 の Inbox、P2-10)。TOC の貼り付け + (CTDB 形式 / MusicBrainz 形式 / `cdrecord -toc` の出力)・各種 ID・用語の凡例は「詳細」に残す + (編集ではなく照会の入力。ドライブの無い環境とデバッグ用。P2-3、[D-64](../../decisions/D-064.md)) + - **候補**: 左にジャケット(`GET /api/cd/cover/{release_id}`。無ければ空の枠。[D-82](../../decisions/D-082.md))、右に候補の + ラジオ一覧。MusicBrainz のリリースへのリンク、収録構成(`mediaSummary`。`CD + Blu-ray の 1 枚目` / + `CD 2 枚組の 1 枚目`)、ディスクとの長さ差(`lengthDiffMs`)で見分ける。CD 以外の medium + (デジタル配信・DVD・Blu-ray)は既定で畳み、「CD 以外の媒体に当たった候補も表示」で出す + (`isCdMedium` / `splitByMedium`)。バッジは経路(品番一致 / DiscID 一致 / 指定 / ISRC / バーコード / TOC 近似。 + 複数可)、見出しは段と経路(`stage`。DiscID が登録済みのまま下の段に落ちたときは「未登録」と言わない)、 + `notes` は赤字。入力した品番が選んだ候補と食い違えば注記(`catnoMismatch`。別の版として取り込む)。 + 品番で当たった候補が 1 件なら既定でそれを選ぶ(`initialSelection`)。DiscID 未登録なら「MusicBrainz に DiscID を登録」リンク(`discidSubmissionUrl`)。 + 応答の `can_widen` が真なら**「さらに広げて探す」**(`widen`。TOC 近似まで引き直す。[D-64](../../decisions/D-064.md) 追記 4) + - **写す範囲**([D-72](../../decisions/D-072.md)、P4-2)は**既定で「全部写す」**([D-72](../../decisions/D-072.md) 追記 2、P4-20。レーベル・カタログ番号・ + JAN/UPC・`MUSICBRAINZ_RELEASEGROUPID`・各トラックのタイトル / アーティスト・MB id・ISRC まで写す)。 + 「識別用の最小限」に切り替えると盤を見分けるのに要るものだけ(アルバム・アルバムアーティスト・ + 日付・ディスク番号 / 枚数・`MUSICBRAINZ_ALBUMID`。`MUSICBRAINZ_DISCID` / `TRACKTOTAL` は吸い出し時に + TOC から付く。トラック行は番号と長さだけの空行)になる。範囲を切り替えると選択中の候補を写し直す。 + 候補ゼロ件でも、また「どれも違う(候補を使わない)」を選んでも取り込める(P2-4、[D-21](../../decisions/D-021.md) / [D-65](../../decisions/D-065.md)。 + 名前は Inbox で入れる) + - **「確定」の段は無い**(P4-20)。「取り込む」で吸い出しが始まる(`POST /api/cd/rip`、P2-5)。押せるのは + 下書きがあり、ドライブの TOC が照会した TOC と同じで、`validateDraft` と品番・JAN の形に問題が無く、吸い出しが + 走っていないときだけ。走行中はボタンの横に状態の一行(`ripStatusLabel`。[§7.2](../07-pipeline/02-cd-rip.md)「進捗」)、 + 終わったら結果の一行。押せない理由(`validateDraft` と品番・JAN の問題)は押す前から同じ一行に出す(空の + タイトルは `Track NN` で埋まるので問題にしない)。遷移は `lib/cdState.ts` の reducer: フォームは TOC が読めた時点ででき、 + 消えるのは別のディスクに替わったとき(`set_disc` / `set_toc` / `reset`)だけ。**照会の開始と + 失敗ではフォームを消さない**(表は照会の前から出ているため) +- **Inbox**(P2-10、[D-68](../../decisions/D-068.md)): 左に取り込み(= `[paths].inbox` の音声ファイルのあるディレクトリ)の一覧 + (状態バッジ・ファイル数・コーデック・検出時刻・失敗理由)と「今すぐ確認」(`POST /api/inbox/scan`)、 + 右に選んだ取り込みの補正フォーム: アルバムアーティスト / アルバム / 日付 / category + (`components/CategoryField.tsx`)。**CD で吸い出したものもここへ来る**([D-67](../../decisions/D-067.md) 追記)ので、 + 盤の名前を直すのもこの画面と、トラックごとの disc / # / タイトル / アーティスト(ファイル名・コーデック・長さは + 表示のみ)。初期値はタグからの提案(`proposal`)、承認済み・失敗の取り込みは保存した下書き。検証はサーバと + 同じ規則(`lib/inbox.ts` の `validateDraft`)で、問題が無いときだけ「承認して配置」が押せる。 + 「却下」はファイルを残したまま取り込みを却下にし(一覧には「却下」のバッジで残る)、「下書きに戻す」で pending に戻る。 + 却下した取り込みには「削除」(確認ダイアログ → 破棄待ち。一覧に「削除待ち」のバッジ、承認画面に「削除待ち: <期限> 以降の + GC でファイルを消す」と「削除を取り消す」。期限は `GET /api/inbox` の `discard_retention_days` から。[D-90](../../decisions/D-090.md))。placed の取り込みは 24 時間 + 残り、「アルバムを開く」で表を `album_id` に絞る。inbox ジョブの完了で一覧を取り直す。 + `destination` があれば「宛先: 既存の『…』(N 曲)に追加。番号は <max+1> から」と出す。**購読から落とした曲** + (`source.subscription_id` あり)を含む取り込みは番号が再生リストの位置(同期が空けた番号)に入るので、「番号 165 に入る + (再生リストの位置。空けてある番号)」と出し、その下に購読の直近の同期の要約(「同期(日時)で既存の 14 曲の番号を + 揃え … 165 を空けた(バッチ #14 / #15)。承認しても既存の曲は動かない」。直近の同期で揃え直しが無ければその旨)を + 出す。下書きの番号が `destination.numbers` と重なれば赤で警告する(承認はサーバが 400 で止める。P4-22) + 追記先の曲がどれもディスク番号を持たない(`destination.uses_disc = false`)取り込みで、下書きの `disc_no` の最大が 1(人が 2 以上にしたら書く。 + CD の取り込みを除く)なら、配置で + `DISCNUMBER` を書かず取り込みのファイルにあれば消し(宛先に合わせる。DB の `disc_no` も NULL になる)、宛先の文言に + 「ディスク番号は付けない(宛先の曲に無い)」を足し、③ の disc 列を空で見せる(下書きの値は 1 のまま。[D-70](../../decisions/D-070.md) 追記) + (配置済みの取り込みは `destination` を引かないので、宛先も同名の警告も出ない。引くと自分が置いた album に当たる)。 + **同名の警告**(P4-19)はタイトル欄の下に「⚠ Library に同名: <ファイル名>(長さ)/この曲 <長さ>」(取り込みの曲の長さも + 並べる。P4-22)、取り込みの一覧に「同名 N」のバッジ(承認は止めない。長さで別テイクと見分ける)。ツールバーに周期監視の「最後に確認: HH:MM:SS(N 秒ごと)」(P4-18)。`source` のあるトラック行は判定バッジ + (ok / 未判定)を出し、行を開くと `message`(参照実装ならルールの足し方)と URL が読める([D-70](../../decisions/D-070.md))。 + 「album gain を計算する」のチェックボックス(既定 off。`destination` があればその現在値が初期値。[D-74](../../decisions/D-074.md))。 + **忠実表示**(P4-4、[D-70](../../decisions/D-070.md)): ARTIST が多値のファイルの行は元の値をチップで見せ、「ファイルの多値をそのまま + 保つ」のチェック(提案は on。on の間は欄が `"; "` 結合の表示で編集不可、外すと欄が編集できて 1 値で書く + 旨を示す)。見出しの横と**取り込みの一覧の各取り込みの左**(48px。無い取り込みは同じ寸法の空枠。[D-85](../../decisions/D-085.md))に取り込みの代表画像 + (各ファイルの代表 = `PICTURE` の先頭、の最頻)、トラック表に小さなサムネイル列 + (`GET /api/inbox/:id/artwork/:hash`。無ければ空)。 + **導線**([D-86](../../decisions/D-086.md)。ユーザ要望・モックで合意): 承認画面は CD 画面と同じ番号付きの段(`components/Step.tsx`)で + **① 取り込むファイル**(出どころ CD / YouTube / 手置き・ディレクトリ・検出時刻・追記先・ファイルのタグで足りないもの + = `warnings`)→ **② アルバム情報** → **③ トラック** → **④ 確認して配置**。見出しに取り込みの代表画像 + (下書きを当てた後の各曲の画像の最頻)・出どころ・状態・「変更 N 件」(`draftChangeCount`)。 + **② アルバム情報**: 先頭に画像の欄(`InboxCover`)、その下にライブラリのプロパティと同じ操作の 2 列の表 + (`InboxAlbumProps`)。Metadata(アルバムアーティスト / アルバム / 日付 / category / album gain)は最初から + 入力欄にせず、行をクリックで選び、ダブルクリック(Enter / F2)で入力欄、Enter で確定、Esc で取り消し、↑↓ で + 行を移る。category は統制語彙の選択欄(`CategoryField` の inline)、album gain はダブルクリックで切り替わる。 + 提案(album gain は追記先の現在値)と違う行に「変更」、行のツールチップに元の値。Info(MusicBrainz リリース / + ディスク・トラック数 / 追記先)は表示のみ。 + **画像の欄**: 全曲が同じ画像なら 1 枚(クリックかドロップで差し替え=全曲)、画像なしは点線の枠、**曲ごとに + 違う(YouTube)なら既定で曲ごとの画像を保ち**、サムネイルを並べて「画像の無い N 曲に入れる」「全曲を 1 枚に + そろえる」を明示的な操作にする。CD の取り込みでリリースが決まっていれば、表の画像を Cover Art Archive から + 自動で取って初期値に入れてある([D-91](../../decisions/D-091.md)。そのリリースに無ければリリースグループの代表画像。[D-93](../../decisions/D-093.md)。欄に + 「Cover Art Archive の表の画像(…自動で取った)」、「画像を外す」で + 外せる。`lib/inbox.ts` の `usesCaaPicture`)。手動の「Cover Art Archive から取る」 + (`POST /api/artwork/from-caa`)も残す(リリースを引き直した後など)。**「別のリリースから取る…」**([D-93](../../decisions/D-093.md)。 + 曲ごとに違う画像のときは出さない)は、メタデータを取り込むリリースのまま表の画像だけを別の版から取る + (`components/CaaReleasePicker.tsx`): 下書きの `release_group_id` があれば同じグループの版 + (`GET /api/cd/release-group/{id}/releases`)のうち表の画像のある版をジャケット(`/api/cd/cover/{id}`)・ + 日付・国・形式・レーベル・カタログ番号付きで並べ(取り込むリリースに印。画像の無い版は件数だけ)、クリックで + その画像を全曲に入れる。「リリースグループの代表画像を取る」と、別のグループに登録された版のための + MusicBrainz のリリースの URL / MBID の貼り付けもある(この 3 つと「Cover Art Archive から取る」は `POST /api/artwork/from-caa` + で置く)。選んだ・ドロップしたファイルは `POST /api/artwork/upload` で置く。どちらも置いた画像を下書きの `picture` に入れる + (`hooks/useArtworkUpload`、`lib/inbox.ts` の `pictureState` / `applyPicture` / `resetPictures`)。 + **③ トラック表は全項目**([D-85](../../decisions/D-085.md) / [D-86](../../decisions/D-086.md)): ライブラリの表のように列で並べ、枠の中で横スクロールする + (`InboxTrackGrid`)。セルをクリックで選び、ダブルクリック(Enter / F2)で入力欄、↑↓←→ で直せるセルを移る。 + disc / # / タイトル / アーティストはその行だけ、**画像**はその曲だけ差し替える(ファイルを選ぶかドロップ)、 + アルバム / アルバムアーティスト / 日付は**アルバム単位**(どの行で直しても全行と ② に反映。列を薄く塗る)、 + **ファイルの全タグ**(取り込みのファイルと下書きで足したキーのうち、固定列が出している TITLE / ARTIST / ALBUM / + ALBUMARTIST / DATE / TRACKNUMBER / DISCNUMBER と PICTURE、種類・除外の列が出す TRACK_TYPE / PLAYLIST_EXCLUDE を + 除いたもの。ABC 順、多値は `"; "` で結合)は + その行だけ直し、空にするとそのタグを消す(`setTrackTag`)。表の下の「タグを追加」で新しいキーの列を足す + (`newTagKeyProblem`)。長さ / codec / ファイル / 判定 / category と、**🔒 の付いた同一性のタグ** + (`SOURCE_URL` / `MUSICBRAINZ_*`)と DISCTOTAL は直せない。変更したセルは色で示し、ツールチップに元の値。 + ARTIST が多値の行はセルに「多値を保つ」のチェック([D-70](../../decisions/D-070.md))。disc / # / 画像 / タイトルは横スクロールしても + 左端に残す(`stickyColumns`)。アーティストの右に**種類**(`TRACK_TYPE`。曲(空)/ カラオケ / BGM / ドラマ / トークの + 選択欄)と**除外**(`PLAYLIST_EXCLUDE=1` のチェック)の列を置き、どちらもその行の下書きのタグとして配置のときに + 書く。種類が空の行には、タイトルとアルバム名からの候補(`suggestTrackType`: instrumental / off vocal / カラオケ / + 歌無し → カラオケ、ドラマ / ボーナス・トラック → ドラマ、MC / ラジオ / トーク / コメント → トーク、サントラの + アルバムで一番多いアーティスト(作曲者)の曲 → BGM)を「候補: …」のボタンで出し、表の上の「種類の候補をすべて + 適用」でまとめて入れる。候補は自動では入れない([D-113](../../decisions/D-113.md))。列の組み立ては `inboxColumns` / `extraTagKeys`。「列」メニューで + disc / # / タイトル以外を隠せ、隠した列は localStorage(`inbox.columns.hidden`)に覚える。 + **④ 確認して配置**: 赤(`validateDraft` の問題。あると承認できない)/ 黄(判定できなかった曲・Library に + 同名・画像の無い曲)/ 緑(タグを直す曲数・画像を差し替える曲数)の一覧、配置先の見込み + (`POST /api/inbox/:id/preview` を下書きの変更から 400ms 後に引く。`hooks/useInboxPreview`)、 + 「承認して配置」「却下」「下書きに戻す」。 + **MusicBrainz の引き直し**(P4-21、[D-84](../../decisions/D-084.md)): CD の取り込み(`GET /api/inbox` の `rip` がある取り込み)だけ、アルバムの欄の + 下に節を出す。ボタンで `POST /api/cd/lookup { toc, isrcs, mcn, release?, refresh?, widen? }`(CD 画面と同じ)を + 引いて候補を並べ、選ぶと下書きの `release_id` / `release_group_id` を写し、ディスク番号を候補の medium の位置に + し、空欄の名前(と `Track NN`)だけ埋める(`lib/inbox.ts` の `applyCandidate`)。結果は保存しない。 + **トラックリスト貼り付け**(P2-10、[D-65](../../decisions/D-065.md) / [D-65](../../decisions/D-065.md) 追記 2): トラック表の下の畳んだ節。テキストを + `lib/tracklist.ts` で行解析し、`lib/inbox.ts` の `applyTracklist` で**選んだディスクの行へトラック番号で** + 写す(複数枚組のときだけ「写す先」のディスクを選ぶ)。アーティストの無い行は既存の値を保ち、 + アーティストを貼った行は「そのまま保つ」を外す。取り込みに無い番号・同じ番号の行が複数あるもの・行数の違い・ + 未設定の行と、解析の警告(飛ばした見出し・番号の重複 / 飛び)を節の中に出す。写した後もフォームで直せる +- **Inbox の段**(P5-2、[D-95](../../decisions/D-095.md) の可視化 B): 承認した件(approved / placing / placed)の見出しの下に、その後の段を + 横並びで出す: `配置 → RG → opus → aac → <端末…>`(`GET /api/inbox` の `stages`)。段ごとに 完了 / 進行中 + (n/m)/ 未 / 対象外。RG は書き込みが要る設定なら書き込みまで、系統は無効なら「(無効)」で対象外、端末は + その端末の選曲に入る曲の数を母数に反映済みを数える(対象外へ向かう曲は数えない) +- **Inbox の再判定**([D-114](../../decisions/D-114.md)、[§7.8](../07-pipeline/08-inbox.md)): 件の見出しの操作群に「再判定」。出すのは 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](../../decisions/D-074.md)): 「ReplayGain / FLAC」節に、選択行が属する album ごとの + チェックボックス(`PATCH /api/albums/:id`。20 album を超えたら絞るよう促す)。アルバム画面は無く + アルバム一覧は表を絞るだけなので、切り替えはここに置く +- **YouTube**(P3-3 → P4-13、[D-70](../../decisions/D-070.md) 追記): 上部バーの独立した画面(取り込み元は Inbox / CD / YouTube で + 横並び、結果は Inbox に集まる)。 + **導線**([D-87](../../decisions/D-087.md)。ユーザ要望・モックで合意): 見出しの切り替えで「ダウンロード」と「購読」。ダウンロードは + 番号付きの段 **① URL を貼る**(入力が止まって 400ms で `POST /api/ytmusic/lookup` を引き、行ごとに種類と + 状態(新規 / ライブラリにある / Inbox で承認待ち / YouTube 以外 / 取れない)を表で出す。再生リストは URL + ごとに 1 回 `POST /api/ytmusic/playlist` で本数と新規の数を出す)→ **② 取り込み方**(行き先・判定・展開・ + 画像の説明。未購読の再生リストがあれば「購読にする →」で購読の ① へ URL を渡す)→ **③ ダウンロード** + (飛ばす行を除いた「N 件をダウンロード」。投入後は今回のジョブ = 返ったジョブと、その再生リストの展開で増えた子(payload の + `parent_job_id`)を追う)→ + **④ Inbox で承認**(今回のジョブの note から置いた取り込みを並べ、「Inbox で開く」でその取り込みを選んで開く。取り込みが + まだ一覧に無ければ現れるまで待ち、人が別の取り込みを選んだらやめる)。 + これまでのジョブは折りたたみ。購読は **① 再生リスト**(list_id・題名・本数。購読済みなら止める)→ + **② アルバムとして登録**(題名が取れたら空のアルバム名に入れる)→ **③ 登録して同期**(「登録して今すぐ同期」と + 「登録だけ」。定期同期は既定で無いので、登録だけなら後から一覧の「同期」)、その下に登録済みの一覧。以下は各部の中身。(1) 1 行 1 URL のテキストエリアと「ダウンロード」(`POST /api/ytmusic/ + download`。再生リストは動画ごとに展開し、Library / Inbox に `SOURCE_URL` のある動画は投入しない)、 + (2) ytdl ジョブの一覧(`GET /api/jobs?type=ytdl` を新しい順。URL・結果(待ち / ダウンロード中 / 完了は + `note` = Inbox に置いた・プラグインが skip・再生リストを展開した N 件 / 失敗の理由)・時刻・取り消し / + 再試行・Inbox に置いた行だけ「Inbox で確認」)、 + (3) 購読(P4-16、[D-78](../../decisions/D-078.md)。ジョブの表は長くなるので購読の節はその上に置く): 一覧表(アルバムアーティスト / アルバム(category、束ねた album #)・再生リスト + (list_id、リンク)・有効・揃える(その場で PATCH)・最終同期・結果 1 行(走行中なら「同期中 / 同期待ち / + 再試行待ち」を `GET /api/jobs?type=playlist_sync` から、無ければ `last_result` の要約)・「同期」「編集」 + 「削除」)。結果に詳細があれば「詳細」で行を開く(取れない一覧(非公開 / 削除 / 取れない)・別の album に + ある・走行中・持ち越し・揃えられない(理由付き)・番号 / 改名のバッチ id と適用 / 衝突 / 失敗)。追加 + フォーム(URL(`list=` が無ければその場で注意)/ アルバムアーティスト / アルバム / category(語彙から + 選択、空 = 未分類)/ 揃える / 上限)。編集はアルバムアーティスト / アルバム / category をその行で。 + 削除は確認ダイアログ。SSE job のたびに取り直す。 + `/youtube?url=<URL>` で開くと欄に入れた状態で開く(ブックマークレットの + 受け口。同一 origin の GET なので CORS / CSRF を触らない。http / https 以外は受けない。[操作手順](../../user-guide/README.md) §11.3)。 + 操作タブの YouTube 節は廃止 +- **端末**(P5-2〜P5-4、[D-95](../../decisions/D-095.md) / [D-98](../../decisions/D-098.md) / [D-99](../../decisions/D-099.md)。[§7.11](../07-pipeline/11-device-delivery.md)): 左カラム(ツリー)は出さず、代わりに左に端末の一覧 + (名前・接続状態の点(adb のみ)・Android / iPhone・未反映 / 待ちの件数、未接続や USB デバッグの許可待ちの + 一言)と「端末を追加 ▸ iPhone(Mac 経由)/ Android(USB)」、右に選んだ端末のサブタブ **差分 / 選曲 / 設定** + (選んだタブは localStorage)。一覧は 60 秒ごとに、一覧と差分はジョブの状態が変わるたびに(間引いて) + 取り直す(ハッシュ計算・変換・評価の完了で差分が変わり、SSE の job イベントには種別が無いため) + - **追加**: iPhone は名前・系統(既定 AAC)・選曲で行を作るだけ(Mac は設定タブの pair で結ぶ)。Android は + `GET /api/devices/adb/unregistered` を前の要求の 3 秒後ごとに取り直し、許可待ちの端末には「端末で許可して + ください」、`device` の端末には名前(機種名)・系統(既定 Opus)・選曲・保存先(内部共有ストレージ / + SD カード。空き容量付き。空でないものは選べない)のフォームを出して登録する。Poweramp の音楽フォルダの + 下に置くよう添える。前の登録の保存先(`adoptable`)は「前の登録の保存先(引き継げる)」として選べ、選ぶと + `GET /api/devices/adb/adopt-preview` の件数(引き継ぐ曲・ライブラリに見つからず管理外になる曲・プレイリスト・ + 既にある管理外のファイル)を出し、ボタンは「引き継いで登録」(`POST /api/devices` の `adopt: true`)になる。 + プレイリスト選曲なら「引き継いだプレイリストには印を付け直します」を添える。スキャン中(`library_busy`)か + プレビューの失敗の間は押せず、5 秒ごとに取り直す([§7.11](../07-pipeline/11-device-delivery.md)「引き継ぎ」、[D-103](../../decisions/D-103.md)) + - **差分**: 上に送る量・一時的に増える量・端末の空き、評価待ちの警告と印の付いたスマートの評価時刻。表は + 操作(追加・更新・移動・更新 + 移動・削除・待ち・エラー)/ 曲 / 理由で、待ちとエラーに既存の写しがあれば + 「保留: 既存の写しは残す」を添える(先頭 1,000 行まで)。下にプレイリストの差分。Android は未接続・許可待ちの + 一言と「同期(N 件・M バイト)」ボタン(`plan_token` を添えて `POST /api/devices/:id/sync`。差分が無い・ + 同期が待ち / 実行中・途中の計画があれば押せない)、途中の計画があれば「前回の同期が途中です」に + 「続きを実行」「破棄」、未接続か通常の破棄が `not_connected` なら「強制破棄」。iPhone はボタンを出さず + 「Mac で `spindle-agent sync` を実行する」の案内とエージェントの最終報告時刻、途中の計画があれば Mac で + 続けるかの案内と「強制破棄」(進捗が 30 秒以内に届いている間は Mac が同期中なので、どちらも出さない。[D-108](../../decisions/D-108.md)) + - **同期の進捗**(agent。[D-108](../../decisions/D-108.md)): 「差分」の同期中の表示の下に、`sync_progress` があれば(`plan_open` は見ない。 + サーバは open な計画の進捗だけを返し・流し、計画の確定はイベントを流さないので `plan_open` は一覧の取り直しまで + 古い。非 null の `device_progress` を受けたら `plan_open` も真にする)進捗バー(`bytes_total > 0` + ならバイト、無ければ件数)、`12 / 92 件 · 85.3 MB / 634.8 MB · 12.4 MB/s · 残り 0:42`、段の動詞と今の項目を出す。 + `device_progress` で差し替え、一覧の応答より新しい SSE の値は古い応答で上書きしない(前後は時刻ではなく + イベントの到着順の番号で比べる。要求と同じ 1 ms に届いたイベントも負けない)。null の `device_progress` + (計画が閉じて進捗を消した)を受けたら一覧を取り直し、`plan_open` を合わせる。`age`(応答時点)と受信後の + 経過から 30 秒を超えたら速度と残り時間を消して「応答なし(最後の進捗: 1 分前)」と出す(1 秒ごとに描き直す)。 + 無ければ従来どおり(古いエージェント) + - **同期の進捗**(Android。[D-109](../../decisions/D-109.md)): `sync_job` の job の進捗を `job` イベントで受けているとき(一覧の state が queued のままでも。 + 進捗の表は running の間だけ項目を持つ)、 + 上と同じ進捗の行(件数・バイト・速度・残り時間・段の動詞と今の項目)を「差分」の同期中の表示に出す。 + 段は `delete`(削除)/ `move`(パスの変更)/ `send`(送信中)/ `playlist`。Mac の「受信中」は使わない。進捗が + 無ければ従来どおり「同期中…」。ページを開き直した直後は、次の `job` イベントが届くまで「同期中…」のまま + (DB の件数は足さない)。「応答なし」(30 秒)は出さない(ジョブの生死はワーカーが管理し、パスの変更のバッチは + 間が空く) + - **選曲**: 全曲 / プレイリストの切り替えと、プレイリストのチェック一覧(スマートは ⚙、曲数)。選び方を + 変えるたびに `GET /api/devices/:id/estimate` で合計曲数と容量(推定で数えた曲数と、容量の分からない + 曲数を添える。[D-107](../../decisions/D-107.md))を出す。保存は印を先に、選び方を後に(プレイリスト選曲へ切り替えるとき空の選曲の差分を作らない)。 + 途中の計画があれば保存できない + - **設定**: 名前・系統(途中の計画があれば系統は変えられない)、Android は保存先の表示(変えるには削除して + 登録し直す)と「内容を検証」、iPhone は「pair コードを発行」(確認のうえ発行し、コードと期限と + `spindle-agent pair <この画面の origin> <コード>` をコピーできる形で出す。「この Mac に前の登録の保存先が + 残っていれば、代わりに次を実行すると引き継げます」と `spindle-agent pair --adopt <origin> <コード>` も + コピーできる形で添える([§7.11](../07-pipeline/11-device-delivery.md)「引き継ぎ」、[D-104](../../decisions/D-104.md))。コードは画面の state にだけ持つ)。最後に「端末を削除」(端末上のファイルは消さない。途中の計画があればそれも破棄する旨を確認に出す) +- **設定**: 「表示」(配色: OS に従う / ライト / ダーク。localStorage に保存。P4-10、[D-58](../../decisions/D-058.md) 追記)、 + `config.toml` の閲覧、再スキャン / deep scan / GC dry-run のボタン、 + category(統制語彙)の表(名前 / album 数 / その他の使用(GENRE の写像・購読・Inbox の下書き)/ 削除。使用数は + `GET /api/categories` の `albums` / `genres` / `subscriptions` / `inbox_drafts` で、削除の可否の判定と同じ数え方。 + 使用中は削除ボタンを無効にし、横に理由(「528 album・購読 1 で使用中」)を出す。未使用だけ確認ダイアログを経て消せる)。 + 退避 WAV(`archived_files`)の一覧と復元は記録画面([§12.4](04-records.md))へ移した([D-106](../../decisions/D-106.md)) diff --git a/docs/spec/12-ui/07-mobile.md b/docs/spec/12-ui/07-mobile.md new file mode 100644 index 00000000..924dea3f --- /dev/null +++ b/docs/spec/12-ui/07-mobile.md @@ -0,0 +1,49 @@ +# 12.7 スマホ版(`/m/`、D-115) + +外出先で眺める・探す・試し聴きする・誤字を直す・プレイリストを手直しする・購読を同期する、ための別ページ。 +一括編集・巻き戻し・CD・Inbox の承認・端末同期・ジョブ・設定は PC で行う(スマホ版には置かない)。 + +- **PC 版に手を入れない**: コードは `web/src/mobile/`、ビルドは PC と別の vite 設定(`web/vite.mobile.config.ts`)で + `web/dist/m/` に出す(`npm run build` が PC のビルドの後に実行)。入口を足すと共通チャンクが変わり PC の JS が + 変わるため。`web/src/components/` `hooks/` `App.tsx` `main.tsx` `index.css` は import も編集もしない。`api/` と `lib/` は + 編集せず import だけする。`dist/m/` 以外の出力が変更前と同一であることを `scripts/pc-dist-hash.sh` で確かめる。 + 配色はライト / ダークとも PC の `index.css` と同じ値を持ち、テーマの保存値は PC と共有する。指で押す要素は 44px 以上 +- **入口と振り分け**(`src/api/spa.rs`): `/` を `User-Agent` に `Mobi` / `Android` / `iPhone` を含むときだけ `/m/` へ 302 で + 飛ばす(iPad は Mac の UA を名乗るので PC 版)。スマホ版のメニューの「PC 版を開く」は Cookie `spindle_view=pc` を立てて + `/` を開き、以後は振り分けない。PC 版からの戻り導線は PC を触らないと作れないので `/m/` を直接開く。`/m/` を開くと + Cookie を消す。`/m/` 以下の未知のパスは mobile の HTML を返す。認証と CSRF は PC と同じ(未ログインはスマホ版のログイン画面)。 + 302 は UA と Cookie で応答が変わるので `Cache-Control: no-store` と `Vary: User-Agent, Cookie` を付ける(間の + キャッシュが PC のブラウザへ 302 を返さないように。PC 向けの 200 には付けない)。`/m` は `/m/` へ 301(相対の assets を + 解決させるため)。拡張子のあるパスは静的ファイルの要求なので、無ければ HTML に倒さず 404。Cookie は + `spindle_view=pc; path=/; max-age=31536000; samesite=lax`(1 年)、消すときは同じ属性で `max-age=0` +- **画面の位置は URL で持つ**(`mobile/route.ts`。`history.pushState` と `popstate`): `/m/library/{artists|albums|tracks}?albumartist=&album=&q=`、 + `/m/folders/{category}/{albumartist}/{album}`(各段は `encodeURIComponent`)、`/m/playlists/{id}?edit={rule|text}`、 + `/m/subscriptions/{id}`。知らないパスはライブラリ。曲のシートは URL ではなく `history.state` に持ち、ブラウザの戻るで閉じる +- **4 タブ**(下部。再生中だけタブの上に小さな再生バー): + - **ライブラリ**: 検索欄(`GET /api/tracks` の検索)と「アーティスト(albumartist)/ アルバム / 曲」の切り替え(最後の見方を + localStorage に覚える)。1 段ずつ降り、一覧は仮想スクロール。行は曲名・アーティスト・アルバムと形式のバッジ + - **フォルダ**: パンくずと、カテゴリ → アルバムアーティスト → アルバム → 曲の木(`GET /api/albums` からクライアントで組む) + - **プレイリスト**: 普通とスマートの一覧(曲数・長さ付き)。普通は中身の外す・並べ替え。スマートは条件を + 「フィールド / 演算子 / 値」で組み立てる(AND / OR、条件ごとの NOT、ORDER BY、LIMIT。入力のたびに + `POST /api/playlists/preview` で検証と件数)。組み立て画面で表せない規則(入れ子、AND と OR の混在)は開いた時点から、 + それ以外も「文で編集する」でルール文の編集へ移る(組み立てから表せなければ文のまま)。値の `"` と `\\` はエスケープして + 書く([DSL](../../user-guide/dsl.md))。新規作成・名前の変更・削除(確認あり。409 `open_plan` はその旨を出す)ができる + - **購読**: 先頭に Inbox の未確認の件数(0 件なら出さない。確認は PC)。購読ごとのカード(最後の同期、「同期」)。 + 同期は**手動のまま**(自動にしない)。「完了」は `playlist_sync` の終了ではなく**投入した ytdl のダウンロードがすべて + 終わった時点**で、それまで進捗バー(`GET /api/ytmusic/subscriptions/:id/progress`、SSE のジョブイベントで取り直す)。 + 結果は 1 行(新しい曲の数・非公開・失敗と「再試行」)、カードの詳細に曲ごとの状態。購読の追加・変更・削除は PC。 + 相は `mobile/subscriptions.ts` の `syncPhase`: 同期のジョブが無ければ未同期、同期か ytdl のどれかが queued / running なら + 同期中、すべて終端で同期の失敗か ytdl の失敗・**取り消し**があれば失敗、それ以外は完了。「同期」を押して 409 `duplicate` + (既に待ち・走行中)が返ってもエラーにせず同期中として進み具合を出す。画面を閉じても同期はサーバで続き、戻れば + 最新を出す。Inbox の件数は `GET /api/inbox/summary` の `pending` +- **曲のシート**: 曲をタップで下から出る。半分は ジャケット・曲名・再生・バッジ・主要タグ、引き上げると全タグ・ファイル情報・RG。 + 「プレイリストに追加」(`POST /api/playlists/:id/items`)。戻るボタンで シート → 一覧 → 上の階層 +- **タグの修正**: 1 曲だけ。値の修正のみで、フィールドの追加・削除と空にすることはしない。PC と同じ + `batch/preview` → `apply`(`ids: [id]`)を通り、`edit_batches` に載るので PC の「履歴」から巻き戻せる。pending / conflict + のときは保存させない(`editBlockReason`: 「反映待ちの間は編集できません」/「競合を PC で解決してから編集してください」/ + missing は「ファイルが見つかりません」)。多値は PC のプロパティ欄と同じ `;` 区切り。空にすると「空にはできません。 + フィールドの削除は PC で行ってください」で止める +- **試し聴き**: 開いている間だけ(バックグラウンド再生・ロック画面・キューは無い)。LTE + Cloudflare 経由のため原本は流さず + `?transcode=opus`(Derived があればそれ、無ければその場で変換。非可逆の原本はそのまま)。Opus が鳴らない端末(iOS Safari の + 判定は当てにならない)では、再生前にエラーになったとき**1 回だけ** `?transcode=aac` で同じ曲を引き直す。シートに使った + コーデックを出す。ReplayGain は掛けない diff --git a/docs/spec/12-ui/README.md b/docs/spec/12-ui/README.md new file mode 100644 index 00000000..b8614088 --- /dev/null +++ b/docs/spec/12-ui/README.md @@ -0,0 +1,13 @@ +# 12. UI + +デスクトップブラウザ専用(スマホ用の別ページは [§12.7](07-mobile.md))。レスポンシブ・アニメーションは対象外。配色はライト / ダーク +(設定「表示」。既定は OS に従う。P4-10、[D-58](../../decisions/D-058.md) 追記)。キーボードは Ctrl+A / Delete / Enter / Esc と +一覧のカーソル移動([§12.2](02-track-list.md)「キーボード」)。 + +- [§12.1 骨格(上部バー + 3 ペイン)](01-skeleton.md) +- [§12.2 トラック一覧](02-track-list.md) +- [§12.3 右パネル: 一括編集](03-bulk-edit.md) +- [§12.4 記録(編集履歴 / 退避ファイル / ログ)](04-records.md) +- [§12.5 ジョブ](05-jobs.md) +- [§12.6 その他の画面(骨格のみ)](06-other-screens.md) +- [§12.7 スマホ版(`/m/`、D-115)](07-mobile.md) diff --git a/docs/spec/13-config.md b/docs/spec/13-config.md new file mode 100644 index 00000000..0de819c2 --- /dev/null +++ b/docs/spec/13-config.md @@ -0,0 +1,140 @@ +# 13. 設定 + +```toml +[server] +listen = "0.0.0.0:8080" # 待ち受けアドレス。省略時はこの値。ポート公開は compose 側で行う + +[paths] +library = "/library" +derived = "/derived" +archive = "/archive" +inbox = "/inbox" +playlists = "/playlists" +data = "/data" + +[layout] +multi_disc = "{category}/{albumartist}/{album}/{disc}-{track:02}. {title}" +single_disc = "{category}/{albumartist}/{album}/{track:02}. {title}" +unsorted = "_Unsorted/{albumartist}/{album}/{track:02}. {title}" + +[rip] +device = "/dev/sr0" +drive_offset = "auto" # auto | 整数 +retry_on_mismatch = 2 +prefer_ctdb = true + +[encode] +flac_compression = 8 + +[encode.derived.opus] # Derived の opus 系統(§7.6、D-75)。Android の同期・Web 再生・配布ビュー +enabled = true +bitrate = 256 # opusenc --vbr --music --bitrate(D-9 追記) + +[encode.derived.aac] # Derived の aac 系統(§7.6、D-75)。Mac のミュージック.app へ取り込む Apple 向け +enabled = true # 節を省略すると false(RG 全件解析 → 有効化の順序のため) +bitrate = 256 # ffmpeg -c:a aac -b:a +lossy_sources = true # 非可逆原本も AAC へ(D-8 の例外。aac 原本も再エンコード) +multi_value_separator = " & " # 多値フィールドの結合 + +[replaygain] +reference_lufs = -18.0 # 内部表現。Opus のみ書き出し時に -23 へ変換 +write_tags = true + +[normalize] +wav_to_flac = true +flac_verify_on_import = true +flac_fix_missing_md5 = true +flac_recompress_all = false # 圧縮レベル統一のための一括再エンコードはしない + +[scan] +deep_interval_days = 30 # tag_hash / audio_md5 を全件再計算する deep scan の間隔。0 で自動実行なし + +[inbox] +poll_interval_secs = 60 # Inbox の確認間隔(秒。変化があったときだけ走査を投入)。0 で自動なし(UI の「今すぐ確認」だけ) + +[gc] +retention_days = 7 # missing_since / 退避 WAV / Derived 孤児 / 却下して削除した Inbox の取り込みを物理削除するまでの日数(終端した同期の計画の回収にも使う) +jobs_done_days = 7 # 終端のジョブ行(done / cancelled)を消すまでの日数。0 で消さない +jobs_failed_days = 30 # failed のジョブ行を消すまでの日数。0 で消さない + +[auth] # 認証は常に有効(無効化する設定は無い) + # 初期パスワードは環境変数 SPINDLE_INITIAL_PASSWORD(初回起動時のみ) +session_days = 30 +trusted_cidrs = [] # 例: ["192.168.1.0/24"]。stream / artwork / tracks/:id / playlist export だけ認証スキップ +trusted_proxies = [] # ここからの X-Forwarded-* だけを信用する + +[backup] +interval_hours = 24 +retention_generations = 14 + +[export] +autoexport_debounce_sec = 30 +fb2k_prefix = "\\\\TRUENAS\\music\\" + +[musicbrainz] +user_agent = "spindle/0.1 (contact@example.com)" +rate_limit_per_sec = 1 +url = "https://musicbrainz.org/ws/2/" # 省略可。テストと自前ミラー用 +address_family = "auto" # auto | ipv6 | ipv4。片方の経路が塞がれている環境で選ぶ +cover_art_url = "https://coverartarchive.org/" # 省略可。候補のジャケット(D-82) + +[verify] # 遡及照合 / リップ検証の照会先。UA は musicbrainz.user_agent を共用 +accuraterip_url = "http://www.accuraterip.com/accuraterip/" +ctdb_url = "http://db.cuetools.net/lookup2.php" + +[ytmusic] +enabled = true +metadata_command = ["/usr/local/bin/spindle-ytmusic-meta", "metadata"] # メタデータプラグイン(D-69)。引数配列 +metadata_timeout_secs = 30 +download_timeout_secs = 900 # yt-dlp のダウンロード 1 件の上限(D-70) +# yt-dlp に毎回付ける追加引数(省略時は無し)。UA / Referer は付けない。 +# youtube:lang=ja: 言語を指定しないと、投稿者が翻訳タイトルを付けた動画は英語のタイトルが返り、日本語の曲名が +# 取れない(メタデータプラグインのルールにも合わない)。チャンネル名も言語で変わる +ytdlp_args = ["--extractor-args", "youtube:lang=ja"] +# YouTube に弾かれるときは extractor-args を足す(同じキーは ; で並べる)か cookies を渡す +# ytdlp_args = ["--extractor-args", "youtube:lang=ja;player_client=web_safari"] +# ytdlp_args = ["--extractor-args", "youtube:lang=ja", "--cookies", "/data/cookies.txt"] +sync_interval_hours = 0 # 再生リストの購読を定期同期する間隔(時間)。0 = 手動と承認の後続だけ(D-78) + +[hires] # 偽ハイレゾ検出(D-71) +check_on_import = true # スキャン完了時に未検査の対象(可逆かつ >48 kHz または >16 bit)を自動投入 +cutoff_hz = 25000 # カットオフがこれ以下なら「上げただけ」の疑い(44.1k の 22.05 kHz と 48k の 24 kHz を余裕込みで拾う) +cliff_db = 10.0 # カットオフ前後 1 kHz の落差がこれ以上なら SRC の崖とみなす(実 SRC は 12〜21 dB) +hard_cutoff_hz = 22500 # カットオフがこれ以下なら崖に関わらず「上げただけ」(44.1k の Nyquist + 余裕) + +[bin] +ffmpeg = "ffmpeg" +flac = "flac" +opusenc = "opusenc" +cdparanoia = "cd-paranoia" +cdrdao = "cdrdao" +ytdlp = "yt-dlp" +adb = "adb" + +[devices] +# adb サイドカー(deploy/compose.yaml の adb サービス)のソケット。空なら Android の同期を無効にする(D-98) +adb_server = "localfilesystem:/run/adb/adb.sock" +adb_timeout_secs = 60 +adb_transfer_timeout_secs = 3600 +``` + +実体は `deploy/config.example.toml`。両者は一致させる。 + +端末への配信([§7.11](07-pipeline/11-device-delivery.md))の設定(P5-3b、[D-98](../decisions/D-098.md)): + +- `[bin].adb`: adb クライアント。イメージに platform-tools の adb(版と sha256 を固定)を同梱し、compose の adb + サイドカーも同じイメージの adb をサーバとして使う(クライアントとサーバの版を揃える)。省略時は `adb`、空と前後の + 空白は起動時に拒否する。子プロセスには `HOME=<data>/adb` を渡す(`$HOME/.android` を作れないと adb が止まる) +- `[devices].adb_server`: adb サーバの場所(`ADB_SERVER_SOCKET`)。`localfilesystem:/絶対パス`(サイドカーの + Unix ソケット。既定の構成)か `tcp:ホスト:ポート`。**空(節ごと省略を含む)なら Android の同期を無効**にし、 + ADB を使う操作(作成・未登録の一覧・同期・再開・通常の破棄・検証)は 503 `adb_disabled`(強制破棄と端末の削除、 + iPhone、PATCH / PUT playlists / 差分 / 見積りは動く)。前後の空白は起動時に拒否する +- `[devices].adb_timeout_secs`: 1 回で終わる adb のコマンド(状態の確認・manifest の読み書き・1 曲の + `sha256sum` など)の上限(秒。既定 60、1 以上) +- `[devices].adb_transfer_timeout_secs`: 1 曲の転送と、端末上の全ファイルの一覧(数千件の `stat` で長く + かかる)の上限(秒。既定 3600、1 以上) + +`[bin].cdparanoia` の `cd-paranoia` は libcdio 版(Debian パッケージ cd-paranoia)で、xiph の `cdparanoia` ではない。 + +iPhone(Mac の `spindle-agent`)はサーバ側の設定を持たない(エージェントは pair のときに spindle の URL を +受け取る。[§7.11](07-pipeline/11-device-delivery.md)、`agent/README.md`)。 diff --git a/docs/spec/14-deploy.md b/docs/spec/14-deploy.md new file mode 100644 index 00000000..8aa71ca2 --- /dev/null +++ b/docs/spec/14-deploy.md @@ -0,0 +1,193 @@ +# 14. デプロイ + +## イメージの配布(P4-12、D-79) + +- 置き場は GHCR `ghcr.io/akashisn/spindle`(public。pull にトークン不要)。`linux/amd64` のみ +- CI(`.github/workflows/ci.yml`)は web → rust → docker → publish の順(Mac の `spindle-agent` の + `agent-macos` も publish の前提。下記)。docker ジョブ(PR でも走る。 + `contents: read` だけ)が**イメージを 1 回だけビルド**し(`docker/build-push-action` の `load`)、起動確認 + (`--version`、`/health` の `version` / `ytdlp`、ロックモード、ログイン、CSRF、ジョブ API、同梱 SPA)に + 通ったものを `docker save` して成果物に渡す。publish ジョブ(push イベントだけ。書き込み権限 = GHCR / + Release はここだけ)がそれを skopeo で GHCR へ複製する(二度ビルドしない = 確認した digest と配布する + digest が同じ。`docker push` は load したイメージで「unknown blob」になる)。タグは `docker/metadata-action`: + `main` への push → `edge` と `sha-<7 桁>`、`vX.Y.Z` タグ(この形だけ。他の `v*` は CI が拒む)→ `X.Y.Z` / + `X.Y` / `latest`(+ GitHub Release。自動生成のノートにイメージのタグと digest を添える)。PR はビルドと + 起動確認だけで push しない +- 版は build context に `.git` を入れないので、CI が `--build-arg SPINDLE_VERSION=$(git describe --tags + --always)` で渡し、`build.rs` が `cargo:rustc-env` で焼く(環境変数 > 作業ツリーの `git describe`(HEAD / + その ref / packed-refs / index の変化で再計算。`--dirty` は付けない)> `dev`)。OCI ラベル(`org.opencontainers.image.{source,revision,version,created,…}`)は metadata-action +- メタデータプラグイン(`spindle-ytmusic-meta`)はイメージに焼かず実行時マウントのまま([D-70](../decisions/D-070.md)) +- adb(Android の platform-tools。版と sha256 を Dockerfile で固定)はイメージに同梱し、spindle のクライアントと + compose の adb サイドカーのサーバが同じものを使う([D-98](../decisions/D-098.md)) +- **Mac の `spindle-agent`**(P5-4c、[D-101](../decisions/D-101.md)): CI の `agent-macos` ジョブ(`macos-latest`)が clippy・test + (偽のミュージック.app の `fake` 機能)・release ビルドを行い、`spindle-agent-<git describe>-aarch64-apple-darwin.tar.gz` + (バイナリと `agent/README.md`)と `.sha256` を成果物にする。publish ジョブはこれにも依存し(edge のイメージも + macOS ジョブの成功が要る)、`vX.Y.Z` タグでは Release に添付する。macOS(arm64)だけで、 + **署名・公証はしない**(Developer ID が無い。必要になったら入れる)。導入は README の + `xattr -d com.apple.quarantine` と、初回の Automation(ミュージック.app の操作)の許可 +- **`edge` は開発用で DB の互換(マイグレーションの前進)以外は約束しない。** 本番は `latest` + (= 最新の `vX.Y.Z`)か `X.Y` を指す。マイグレーションは起動時に自動で前進のみ = 戻すときは + バックアップから(OPERATIONS) +- 依存の更新は Renovate(`renovate.json`。GitHub App): Dockerfile の `FROM`(node / rust / debian / + deno)、GitHub Actions、Cargo / npm(minor / patch は週 1 のまとめ)、**yt-dlp**(`ARG YTDLP_VERSION` + を `# renovate:` 注釈の custom manager で github-releases から追い、schedule の例外で 1 件ずつ即時)。 + マージは手動。Dependency Dashboard の issue に保留中の更新と手動実行のチェックボックスが出る。 + マージすれば `edge` が作り直され、本番へは次の `vX.Y.Z` で届く(yt-dlp だけの更新でもパッチ版を切る) +- スカッシュ(`db/migrations` を `0001` に畳む)は最初の `vX.Y.Z` より前に 1 回だけ行った(2026-09-24、[D-88](../decisions/D-088.md))。 + 公開イメージで DB を作った後は既存ファイルを書き換えられないので、以後は連番を足すだけ + +## TrueNAS Custom App (compose) + +```yaml +services: + spindle: + image: ghcr.io/akashisn/spindle:latest + init: true # PID 1 を tini にする(取り消しで kill した孫プロセスをゾンビにしない) + devices: + - /dev/sr0:/dev/sr0 # CD ドライブ。ioctl / SG_IO とも sr0 に直接通る(/dev/sg* は不要) + group_add: + - "24" # host の cdrom グループ GID + device_cgroup_rules: + - 'b 11:* rmw' # sr (block) + user: "1000:1000" # 既存ライブラリの所有者に合わせる + volumes: + - /mnt/ssd/media/Library:/library + - /mnt/ssd/media/Derived:/derived + - /mnt/hdd/media/Archive:/archive + - /mnt/ssd/media/Inbox:/inbox + - /mnt/ssd/media/Playlists:/playlists + - /mnt/ssd/apps/spindle:/data + - adb-run:/run/adb:ro # adb サイドカーの Unix ソケット(D-98)。:ro は外さない(下記) + - /mnt/ssd/apps/spindle/bin/spindle-ytmusic-meta:/usr/local/bin/spindle-ytmusic-meta:ro # メタデータプラグイン(D-70) + ports: + - "8080:8080" + restart: unless-stopped + + adb: # adb サーバだけを動かすサイドカー(D-98)。Android の同期を使わないなら不要 + image: ghcr.io/akashisn/spindle:latest + network_mode: host + init: true + entrypoint: ["/bin/sh", "-c", "umask 000 && exec /usr/local/bin/adb -L localfilesystem:/run/adb/adb.sock nodaemon server"] + volumes: + - /dev/bus/usb:/dev/bus/usb + - adb-run:/run/adb + - /mnt/ssd/apps/spindle/adb-keys:/root/.android # USB デバッグの許可の鍵。消すと端末で許可し直す + device_cgroup_rules: + - 'c 189:* rmw' # USB(/dev/bus/usb) + restart: unless-stopped + +volumes: + adb-run: {} +``` + +実体は `deploy/compose.yaml`(PID 1 を tini にする `init: true` などのコメントもそこ)。 + +**注意点:** + +- USB 接続だとデバイス再列挙でノード番号が変わり得る(sr0 → sr1)。 + `/dev/disk/by-id/...` を指すか、SATA 接続を推奨 +- UID/GID が既存ライブラリの所有者と一致しないとタグ書き込みが全滅する +- udev はコンテナに届かないため、ディスク挿入検知はポーリング +- ホストにドライブが無いと `devices` の行で compose が起動に失敗する。ドライブの無い機体では + `devices` / `group_add` / `device_cgroup_rules` を消す。アプリ側は `[rip].device` を開けなくても + 起動し、CD 画面に「ドライブが無い」と出す + +**adb サイドカー**(Android の同期。[§7.11](07-pipeline/11-device-delivery.md)、[D-98](../decisions/D-098.md)): + +- **`network_mode: host`**: 独自のネットワーク名前空間では、挿し直した端末を拾わない(USB のホットプラグの + 通知 = netlink の uevent が届かない。実機で確認)。代わりに **TCP は開かず**、名前付きボリューム `adb-run` 上の + Unix ソケット(`-L localfilesystem:/run/adb/adb.sock`)だけで待ち受ける(`-a` で 5037 を開くと LAN から端末を + 操作できてしまう)。`umask 000` はサイドカー(root)が作るソケットに spindle(`user: 1000:1000`)が繋げるようにするため +- **spindle 側はソケットのボリュームを `:ro` でマウントする**: `localfilesystem:` を指定しても adb クライアントは + 「サーバに繋がらなければ同じパスで自前のサーバを起こす」。サイドカーが落ちている間にそれが起きると、戻った + サイドカーとサーバが 2 つになり、端末の監視が USB を持たない方に繋がったままになる。読み取り専用なら connect() + は通り、自前のサーバの待ち受けの作成だけが `Read-only file system` で失敗する(未接続として扱われる) +- **鍵の置き場**: USB デバッグの許可の鍵(`/root/.android`)を `/mnt/ssd/apps/spindle/adb-keys` に残す。消すと + 端末で許可し直しになる。USB は `/dev/bus/usb` の bind と `c 189:* rmw` +- spindle 側は `[devices].adb_server = "localfilesystem:/run/adb/adb.sock"`([§13](13-config.md))。サイドカーを置かない構成では + 空にする(Android の同期だけが無効になる)。spindle 本体の権限は変えない + +## ZFS データセット + +```bash +COMMON="-o casesensitivity=insensitive -o normalization=formD" # 作成時のみ指定可能。utf8only=on が強制される +zfs create ssd/media +zfs create $COMMON -o recordsize=1M -o compression=lz4 -o atime=off ssd/media/Library +zfs create $COMMON -o recordsize=1M -o compression=lz4 -o atime=off ssd/media/Derived +zfs create $COMMON -o recordsize=1M -o compression=lz4 -o atime=off ssd/media/Inbox +zfs create hdd/media +zfs create $COMMON -o recordsize=1M -o compression=lz4 -o atime=off hdd/media/Archive +zfs create -o recordsize=16K -o compression=lz4 -o atime=off ssd/apps/spindle +``` + +| dataset | recordsize | snapshot | 備考 | +|---|---|---|---| +| ssd/media/Library | 1M | 毎日 + 一括編集前 | 唯一の正 | +| ssd/media/Derived | 1M | なし | 再生成可能。レプリケーション対象外 | +| hdd/media/Archive | 1M | 週次 | 追記のみ。FLAC 正規化で退避するロスレスの受け皿([D-45](../decisions/D-045.md)) | +| ssd/media/Inbox | 1M | なし | 承認前の一時領域 | +| ssd/apps/spindle | 16K | 毎日 | SQLite。ページサイズに合わせる | + +**`casesensitivity` と `normalization` はデータセット作成時のみ指定可能で、 +後から変更できない。** このためライブラリは既存データセットの rename ではなく、 +新規作成 + ファイルコピーで移行する([D-19](../decisions/D-019.md))。 + +- `casesensitivity=insensitive`: Windows の foobar2000 が `Cover.jpg` を、 + spindle が `cover.jpg` を作る事故を ZFS 層で潰す。spindle は自身の生成パスの + 一意性は保証できるが、他クライアントが作るファイルまでは制御できない +- `normalization=formD`: 日本語の濁点・半濁点には NFC(`が` 1 文字)と + NFD(`か` + 結合濁点)の 2 表現がある。macOS 由来のパスは NFD になりがちで、 + 正規化なしだと「見た目が同じで別ファイル」が発生する。目視では気づけない + +Inbox は Library と別データセットなので move は実コピーになるが、 +1 回あたりアルバム 1 枚(数百 MB〜数 GB)なので実用上の問題はない。 +Archive は別プールなので退避も実コピー(追記のみで速度は要らない)。 + +## 移行手順 + +手順は [移行手順](../admin-guide/migration.md)、振り分けの判断は [D-45](../decisions/D-045.md)。要点: + +- 事前チェックは `scripts/preflight.py`(衝突 / 不正 UTF-8 / 255 バイト超 / symlink / hardlink / + SMB 禁止名 / 容量)、振り分けは `scripts/migrate_plan.py`(`Opus/` と `Original/` の 1:1 対応を + 原本の形式で Library / Archive に割り、`rsync --files-from` の一覧を出す)。手書きの find / awk は + 置かない +- 旧 `Original/` の ALAC が Library の master(後で FLAC 正規化)、webm 由来は `.opus` が master で + webm は Archive、旧 `Opus/` のロスレス由来分は移さず Derived を再生成する +- `zfs send/recv` は不可(作成時プロパティが継承される)。**ACL は rsync で引き継げない** + (NFSv4 ACL と `rsync -A` の POSIX ACL は互換がない)ので TrueNAS の ACL エディタで適用し直す +- 旧データセットは `readonly=on` にして残し、初回スキャンが完走してトラック数が一致し、 + 数日間の運用で問題が出ないことを確認してから破棄する + +## 環境変数 + +| 変数 | 用途 | +|---|---| +| `SPINDLE_CONFIG` | `config.toml` のパス(既定 `/data/config.toml`) | +| `SPINDLE_INITIAL_PASSWORD` | 初回起動時の管理パスワード。DB にパスワードが無いときだけ読み、argon2id で保存して以後は無視する。未設定かつ DB にも無ければロックモード(`/health` 以外 503) | + +## バックアップ + +- SQLite は `backup` ジョブが `VACUUM INTO` でバックアップ(WAL 中の安全なコピー手段。 + 読み取りコネクションで走らせ、書き手を止めない)。tmp に書いて `quick_check` → fsync → + rename(`RENAME_NOREPLACE`。同名の確定済みを上書きしない)→ **`backup/` とその親も fsync** + してから世代 GC。空き容量が DB サイズ + 64 MiB を + 下回るなら書かずに失敗する(ジョブのバックオフで再試行) +- 周期は `[backup].interval_hours`(既定 24)。スケジューラが起動時と 10 分ごとに + 「`jobs` の最後の終端 `backup` からの経過」で due を判定して投入する(dedup key `backup`)。 + ファイルの mtime は見ない。復元直後は古い記録しか無いので、すぐ 1 世代取れる +- ファイル名は `spindle-<YYYYMMDDTHHMMSSZ>.db`(UTC、固定幅。名前順 = 時刻順)。 + 保持世代は `[backup].retention_generations`(既定 14)で、この名前形式のファイルだけを + 新しい順に残す。`apps/spindle` データセットのスナップショットと二重化 +- 復元: コンテナ停止 → `spindle.db`(と `-wal` / `-shm`)を差し替え → 起動。起動時スキャンが + ファイルとの差分を吸収する。未反映だった編集意図(pending)は失われるが、ファイルは + 旧値のままなので壊れない。手順は [運用手順](../admin-guide/operations.md)、復元ドリルは `tests/backup.rs` +- DB は「キャッシュ」だが、プレイリスト / 編集履歴 / 検証結果 / ジョブ履歴 / 端末の設定・印・計画・ + エージェントのトークンは DB にしかない([§3](03-principles.md))。バックアップは任意ではない +- 古いバックアップへ戻すと、それより後に発行したエージェントのトークン(`devices.agent_selector` と `agent_secret_hash`)は + 失われ、その端末は pair し直しになる(`devices.uuid` も同様に、後から足した端末は消える) +- 古いバックアップへ戻すと `tracks.id` の採番も巻き戻る。バックアップより後に足した曲には起動時スキャンが + id を振り直し、別の曲に当たり得るので、バックアップより後に同期した端末は**同期する前に**引き継ぎし直す + (端末の名前・形式・送る範囲を控えて行を消し、同じ設定で Android は「前の登録の保存先」、Mac は新しい行で + `pair --adopt`。範囲が分からなければ「全曲」。最初の差分に予期しない削除があれば確定しない。 + [運用手順](../admin-guide/operations.md)「端末は引き継ぎし直す」、[D-105](../decisions/D-105.md))。 + GC が最大 id の行を消さないこと([§6](06-data-model.md)「論理削除」)は DB の中の再利用だけを防ぐ diff --git a/docs/spec/15-rust-modules.md b/docs/spec/15-rust-modules.md new file mode 100644 index 00000000..3b218b22 --- /dev/null +++ b/docs/spec/15-rust-modules.md @@ -0,0 +1,123 @@ +# 15. Rust モジュール構成 + +``` +src/ +├── main.rs +├── config.rs config.toml の読み込みと検証(D-34) +├── logging.rs tracing の初期化 +├── db/ +│ ├── mod.rs コネクション管理(write 単一 / read プール) +│ ├── migrations.rs リポジトリ直下 db/migrations/*.sql の埋め込みと適用 +│ ├── tracks.rs 一覧(キーセット)・検索・selection 解決・アルバム一覧(D-39) +│ ├── archive.rs archived_files 台帳(退避・復元・GC の根拠) +│ ├── categories.rs 統制語彙(canonical key で一意) +│ ├── inbox.rs inbox_items / inbox_files(承認キューの状態機械。D-68) +│ ├── subscriptions.rs playlist_subscriptions(再生リストの購読。追記先の CAS 束ねと同期の latch。D-78) +│ ├── devices.rs 端末の表(§6): マニフェストの入力の読み取りと全端末のスナップショット、計画の確定・終端、 +│ │ キャッシュの全置換、pair コードとトークン、source_hash の投入(§7.11、D-95 / D-99) +│ ├── stages.rs Inbox の承認済みの件の段(配置 → RG → 系統 → 端末。§12.6) +│ ├── playlists.rs jobs.rs history.rs +├── domain/ +│ ├── identity.rs inode / audio_md5 による同一性解決 +│ ├── filter.rs 一覧のフィルタ(JSON)/ ソート / カーソルの検証 +│ ├── selection.rs selection の 2 形、preview のスナップショット store(D-33) +│ ├── pathgen.rs テンプレート展開・正規化・衝突回避 +│ ├── tags.rs lofty ラッパ、正規化、多値処理、Derived への書き出し(Opus の VorbisComments / MP4 の ilst + フリーフォーム) +│ ├── replaygain.rs ebur128、フォーマット別変換 +│ ├── device.rs 端末への配信の純粋関数(選曲・送る元・パスの検査と衝突・desired / hold / remove・トークン・ +│ │ 差分・plan_token・曲ごとの状態・見積もり。§7.11、D-95) +│ └── category.rs 統制語彙、GENRE 写像 +├── edit/ +│ ├── mod.rs 編集バッチの coordinator(記録・DB 先行更新・反映・overlay 解消・ +│ │ キャンセル・起動時リカバリ。D-24 / D-41) +│ ├── rename.rs 一括リネームの計画・記録・2 phase 反映・album の追随(D-43) +│ ├── normalize.rs ロスレス → FLAC 正規化の計画・記録・反映・Archive 退避(D-46) +│ ├── md5fill.rs FLAC の MD5 補填(md5 op。D-59) +│ ├── picture.rs 埋め込み画像の差し替え(`PICTURE` の tags op。画像の読み出し・退避。D-60) +│ └── revert.rs 巻き戻し(対象集合・現在値の比較・逆バッチの記録。D-44) +├── media/ +│ ├── fingerprint.rs STREAMINFO MD5 / デコード PCM MD5 / パケット列ハッシュ +│ ├── decode.rs symphonia / ffmpeg フォールバック +│ ├── hires.rs 偽ハイレゾ検出の解析(PcmSink: FFT の累積とサンプル OR → 計測値(cutoff / cliff / 実効ビット)→ 判定。§7.10、D-71) +│ ├── encode.rs flac(ffmpeg デコード → flac -8)/ opus(ffmpeg デコード → opusenc)/ aac(ffmpeg 1 パス。RG 焼き込み・48 kHz 上限) +│ └── artwork.rs 同梱 / 埋め込み画像の選択、判別、ハッシュアドレスのキャッシュ(P1-3) +├── cd/ +│ ├── mod.rs TrackLayout(サンプル単位のトラック列)、照会用 HTTP クライアント +│ ├── device.rs ioctl(CDROM_DRIVE_STATUS / READ TOC / LOCKDOOR + EJECT)、SG_IO READ SUB-CHANNEL(ISRC / MCN)、 +│ │ Drive トレイト、DriveMonitor とポーラ +│ ├── toc.rs TOC の検証、各種 DiscID 算出、サンプル数からの再構成(§7.3) +│ ├── rip.rs cd-paranoia、オフセット、分割 +│ ├── accuraterip.rs ARv1/v2 CRC、DB の照会(dBAR-*.bin)、オフセット表 +│ ├── ctdb.rs CRC32、照会(lookup2.php)、パリティファイルの取得 +│ ├── repair.rs CTDB の修復(GF(2^16) RS: シンドローム表、オフセット探索、復号、適用。D-66) +│ ├── crctable.rs 1 回流して任意オフセットのトラック CRC を出す表(累積和と CRC32 combine) +│ ├── verify.rs DB の応答との照合(オフセット探索、トラックごとの一致、`tracks.verification` の写像) +│ ├── musicbrainz.rs ws/2/discid の照会(UA、1 req/s、503 の再試行)と候補(リリース × medium) +│ ├── metadata.rs 確定フォームの DiscMetadata(検証、タグ写像 + TRACKTOTAL / MUSICBRAINZ_DISCID。D-65 / D-67) +│ ├── riplog.rs RipReport と rip.log / disc.cue / disc.toc の描画、同梱ファイルの名前(D-67) +│ └── place.rs 配置(分割 MD5 → flac → pathgen::plan → library 排他 → tmp + RENAME_NOREPLACE → +│ 1 トランザクション登録 → rg / transcode。MD5 で冪等。D-67) +├── import/ +│ ├── scanner.rs +│ ├── placement.rs CD / Inbox 共通の配置(tmp + RENAME_NOREPLACE、album の解決と登録トランザクションでの +│ │ リリースキー再検証、行の登録 / 採用。D-67 / D-68) +│ ├── inbox.rs Inbox の走査(取り込み = ディレクトリ)、タグからの下書き、承認の検証、承認済みの配置 +│ │ (補正をタグに書いて pathgen::plan の宛先へ。source_type = download。D-68) +│ └── ytmusic/ metadata.rs(メタデータプラグインのプロトコル v1 と呼び出し。D-69)、 +│ downloader.rs(yt-dlp の dump / download、remux、タグ、Archive、Inbox への配置と +│ spindle-inbox.json。D-70)、sidecar.rs(spindle-inbox.json の読み書き。Inbox と共有)、 +│ playlist.rs(再生リストの列挙の解釈と番号揃えの計画。純粋。D-78) +├── device/ 端末への配信の実行側(§7.11。判定は domain::device、キャッシュは db::devices) +│ ├── remote.rs DeviceFs(端末の root 配下の操作。試験は偽の端末 FS) +│ ├── adb.rs adb による DeviceFs(shell v2 だけ、引数配列。ボリュームの探索) +│ ├── quote.rs 端末側のシェルへ渡すパスの検査と単引用符クォート(1 か所だけ) +│ ├── ondevice.rs journal.rs store.rs +│ │ 端末側の正本(manifest.json)・ジャーナル(NDJSON)・耐久化の順序 +│ ├── recover.rs 接続のたびの回復(DB は見ない) +│ ├── plan.rs 確定した計画と、開始済みの計画の再開(実行する部分集合) +│ ├── sync.rs 計画の実行(削除 → パス変更のバッチ → 更新 → 追加 → プレイリスト) +│ ├── verify.rs 内容の検証(全曲の sha256sum) +│ ├── track.rs runtime.rs +│ │ `adb track-devices` の解析と常駐監視、端末ごとのロック・空き容量(D-98) +│ ├── credential.rs エージェントのワンタイムコードとトークン(`<selector>.<secret>`。D-99) +│ └── report.rs エージェントの報告の検証と正準ダイジェスト(D-99) +├── jobs/ +│ ├── queue.rs worker.rs recovery.rs scheduler.rs(backup / gc の周期投入。inbox は handlers/inbox.rs、 +│ │ 購読の dispatcher は handlers/playlist_sync.rs) +│ └── handlers/ 種別ごと(playlist_sync.rs = 購読の同期: 列挙 → 追記先 → 揃え → 投入。D-78。 +│ device.rs = device_scan / device_sync / device_verify、source_hash.rs。§7.11) +├── gc/ +│ └── mod.rs 物理削除の唯一の経路。plan(判定・dry-run)と execute_*(6 区分。D-56 / D-90) +├── playlist/ +│ ├── dsl.rs pest 文法 → AST +│ ├── compile.rs AST → パラメータ化 SQL +│ ├── fb2k.rs AST → foobar クエリ + ソートパターン +│ └── export.rs m3u8 / pls / パスマッピング +├── api/ +│ ├── mod.rs tracks.rs albums.rs categories.rs selection.rs batch.rs rename.rs normalize.rs +│ │ history.rs stream.rs cd.rs inbox.rs events.rs ytmusic.rs subscriptions.rs +│ ├── auth.rs argon2id / セッション Cookie / CSRF / trusted_cidrs のミドルウェア +│ ├── devices.rs 端末 API(UI 向け。`/api/devices*`) +│ ├── agent.rs エージェント API(`/api/agent/*`)と Bearer のミドルウェア(auth::guard の外。D-99) +│ ├── state.rs error.rs AppState、`{ "error": code }` 応答 +└── web/ SPA を rust-embed で同梱 + +agent-proto/ ワークスペースのクレート。本体とエージェントが共有する `/api/agent/*` のワイヤ型 + (serde だけに依存する。D-99) +agent/ ワークスペースのクレート `spindle-agent`(Mac の CLI。§7.11、D-100 / D-101)。本体には依存しない +├── src/main.rs CLI(pair / sync / status / resolve / abandon) +├── src/sync.rs 各コマンドの段取り(state のディレクトリの lock を取り、state.json を読んでエンジンを呼ぶ) +├── src/pair.rs pair と冪等な初期化(`.spindle-device`、「spindle」フォルダ) +├── src/adopt.rs `pair --adopt`(DB 全損後の引き継ぎ)と、途中で落ちたときの続き(D-104) +├── src/state.rs ctx.rs state.json(エージェントの実状態の正本)と耐久化 +├── src/recover.rs rediscover.rs plan.rs exec.rs batch.rs playlist.rs +│ 回復・再発見・再開の照合・実行・パス変更のバッチ・プレイリストの入れ替え +├── src/local.rs pathkey.rs ローカルの root(`~/Music/spindle`)と canonical_key +├── src/failpoint.rs 試験の中断点(state の保存・ミュージック.app の操作・配置の前後で落とす。本番は何もしない) +├── src/music.rs + music/ ミュージック.app の trait と JXA 実装(`osascript -l JavaScript`)・偽実装 +├── src/music/music.js バイナリに埋め込む JXA(要求は argv の JSON、応答は 1 行の JSON) +├── src/server.rs + server/ `/api/agent/*` のクライアント(reqwest blocking)・偽実装 +├── src/secrets.rs + secrets/ トークンの保管(Keychain / ファイル) +├── tests/ エンジンの試験(偽のミュージック.app・サーバ、中断点からの回復。実機の JXA・Keychain は #[ignore]) +└── README.md 導入・前提・困ったとき +``` diff --git a/docs/spec/16-implementation-order.md b/docs/spec/16-implementation-order.md new file mode 100644 index 00000000..2bb9d6cd --- /dev/null +++ b/docs/spec/16-implementation-order.md @@ -0,0 +1,12 @@ +# 16. 実装順 + +| フェーズ | 内容 | 完了条件 | +|---|---|---| +| **P0** | データセット構築、移行、スキャナ、DB、表 UI、タグ一括編集、リネーム、編集履歴 | 既存ライブラリ全曲が表に出て、一括編集と巻き戻しができる | +| **P1** | ReplayGain、アートワーク、WAV 正規化、プレイリスト(m3u8 出力)、再生、Derived 生成・追随、GC | foobar2000 を開かずに日常運用が回る | +| **P2** | CD 取り込み(TOC → MB → rip → AR/CTDB → エンコード)、遡及照合 | 新規 CD が検証付きで取り込め、既存 FLAC が格付けされる | +| **P3** | ytmusic 移植(メタデータプラグイン + yt-dlp → Inbox)、配置直後のアートワーク解決、偽ハイレゾ検出 | ytmusic CLI を廃止できる | +| **P5** | 端末への配信([§7.11](07-pipeline/11-device-delivery.md)、[D-95](../decisions/D-095.md)): 端末の表とマニフェストの計算、端末タブ・選曲の近道・可視化、Android の ADB 同期(ジョブ・常駐監視・adb サイドカー)、エージェント API と Mac の `spindle-agent` | Xperia(Poweramp)を USB でつなげば差分を見てからボタンで同期でき、Mac で `spindle-agent sync` を実行すれば iPhone に入れる曲とプレイリストがミュージック.app に反映される。取り込みから端末までの状態が画面で追える | + +P0 を先に置くのは、リップの出口(タグ付け・配置・RG)がすべて P0 の成果物であり、 +先に作った方が結果的に早いため。 diff --git a/docs/spec/17-open-issues.md b/docs/spec/17-open-issues.md new file mode 100644 index 00000000..8b7dafd3 --- /dev/null +++ b/docs/spec/17-open-issues.md @@ -0,0 +1,72 @@ +# 17. 決定済み事項と残課題 + +## 決定済み + +| 項目 | 決定 | 根拠 | +|---|---|---| +| 名称 | **spindle** | CD を積むスピンドル。バイナリ名として扱いやすい | +| ライブラリ構造 | 役割別 3 層(Library / Derived / Archive) | フォーマット別だと CD の FLAC が保管物かつ再生対象で破綻する | +| Category 軸 | ジャンル別・統制語彙 | GENRE タグとは別フィールド | +| アルバム名 | `{album}` のみ、衝突時のみ年を付与 | | +| Derived | 可逆のみ変換 + 配布ビュー解決。Apple 向け `aac` 系統だけ非可逆も変換([D-75](../decisions/D-075.md)) | 非可逆の多重劣化を回避。ミュージック.app は Opus を読めない | +| ビットレート | Opus 256k VBR(当初 128k。[D-9](../decisions/D-009.md) 追記)、AAC 256k | 再生成可能なので低リスクな決定 | +| WAV | FLAC へ正規化(MD5 照合付き)。元 WAV は Archive へ退避し GC 待ち | タグ・RG の互換性が低いため。即時削除は禁止事項と矛盾 | +| ジョブ dedup | `queued`/`running` の間だけ一意 | 列 UNIQUE だと完了後に同キーを再投入できない | +| 一括編集 | DB 先行更新 + `edit_ops.pending`(トラック単位)、pending 中の再編集は 409、スキャナは pending の論理値を巻き戻さない | ファイル反映待ちの窓で「ファイルが正」と衝突する | +| 配布ビュー | 音声版一致の Derived のみ。音声が古ければ原本へ | 再エンコード待ちの古い音声を配らない | +| スキャン | 4 相(inventory → 候補 → claim → commit)、`scan_runs` で claim、missing は completed でのみ | 走査途中では「移動元の消失」を判定できない | +| 一括編集の対象 | preview 時のスナップショット(`selection_token`) | 見ていない行を破壊的操作の対象にしない | +| 認証スキップ | `trusted_cidrs` は route allowlist のみ、CSRF は Origin 完全一致 | 全 GET 開放は履歴・パスが漏れる。Host は防御にならない | +| 記法 | foobar 風 DSL → AST → SQL | 生 SQL 保存はスキーマ変更で壊れる | +| 認証 | 単一パスワード + セッション Cookie | 事故対策。破壊的 API を裸で置かない | +| Subsonic | 非対応 | ファイル同期を継続。認証も argon2 単独で閉じる | +| メタデータ | MusicBrainz + 手入力の一級市民化 | 同人・VTuber 盤は未登録が常態 | +| マルチch | Derived 対象外・album RG 除外、個別オプトイン | | +| FLAC 再圧縮 | 一括再エンコードはしない。MD5 未設定のみ補填 | 削減 1% 未満に対し 200GB の書き直しとスナップショット肥大 | +| インポート | Inbox + 承認キュー方式 | 外部ツリーを直接スキャンすると規約とタグ品質が混入する | +| SMB | `casesensitivity=insensitive` / `normalization=formD` | 他クライアントが作るファイルと NFC/NFD 混在の事故を ZFS 層で潰す | +| 移行方式 | 新規データセット作成 + rsync コピー | 上記 2 プロパティは作成時のみ指定可能 | +| 外部メタデータ | MusicBrainz だけ。候補から写す範囲は選べる(CD 画面の既定は「全部写す」。[D-72](../decisions/D-072.md) 追記 2)。Discogs / VGMdb は作らない | 表記揺れが激しく値は結局手で直す。外部の価値は盤の識別([D-72](../decisions/D-072.md)) | +| `.fpl` | 非対応(確定) | .m3u8 とクエリ文字列で足りる。非公開バイナリで 1.x / 2.x が違う([D-72](../decisions/D-072.md)) | +| album gain | album ごとの属性(既定 off、CD 取り込みは on、承認画面で選ぶ)。RG の一致判定は完全一致のまま | 育つコレクションに曲を足すたびに全曲の再解析と書き換えになる。運用はシャッフルが主([D-74](../decisions/D-074.md)) | +| ACL の再適用 | 移行後に TrueNAS の ACL エディタで手動([移行手順](../admin-guide/migration.md) §2 のチェックリスト) | rsync は NFSv4 ACL を引き継げない。1 回しか使わないのでスクリプト化しない | +| 端末への配信 | spindle の管轄に入れる。Android は ADB 直結(host ネットワークの adb サイドカーが Unix ソケットで待ち受ける)、iPhone は Mac の `spindle-agent` がミュージック.app を中継し Finder 同期に任せる。端末ごとのマニフェストを毎回計算し、差分を確かめて確定した計画だけを実行する(自動では送らない)。端末の写しは Library から再生成できる複製なので物理削除と `edit_batches` の対象外を明示の例外にする([§7.11](07-pipeline/11-device-delivery.md)、[D-95](../decisions/D-095.md) / [D-98](../decisions/D-098.md)〜[D-101](../decisions/D-101.md)) | 端末に何が入っていて何が古いかが spindle から見えなかった。Linux から純正ミュージックへ直接書く手段が無い。配信ツリーの実体化は Derived と同量をもう一度持つ。差分の確認はユーザが選んだ | + +## 残課題 + +- [x] 印のついたプレイリストの削除(2026-10-09。印の変更と同じ規則: open な作業中は 409 `open_plan`、 + 通れば `generation` を進める。[D-102](../decisions/D-102.md)) +- [x] 終端した計画の回収(2026-10-09。GC が `[gc].retention_days` 後に消す。端末ごとに最後の終端 1 件は残す。 + [D-102](../decisions/D-102.md)) +- [x] Discogs / VGMdb 連携(2026-09-20。作らない。[D-72](../decisions/D-072.md)) +- [x] `.fpl` 書き出し(2026-09-20。作らない。[D-72](../decisions/D-072.md)) +- [x] `HAS` 等の演算子の foobar 実機との挙動突き合わせ(2026-09-19。部分一致で一致。[D-55](../decisions/D-055.md)) +- [x] 偽ハイレゾ検出のしきい値設計(2026-09-20。カットオフ ≤ 25 kHz かつ(崖 ≥ 10 dB または ≤ 22.5 kHz)/ 実効 ≤ 16 bit。 + 実機 124 本で較正。計測値も保存。[§7.10](07-pipeline/10-fake-hires.md)、[D-71](../decisions/D-071.md)) +- [x] 移行後の NFSv4 ACL 再適用(2026-09-20。UI で手動。[移行手順](../admin-guide/migration.md) §2 のチェックリスト) +- [x] CPU 系ジョブ(rg / transcode / flaccheck / hirescheck)に共通の並列予算(2026-09-20。共通 Semaphore = + コア数を P4 で実装。[D-73](../decisions/D-073.md)) +- [x] Inbox のポーリング間隔(2026-09-19。`[inbox].poll_interval_secs` 既定 60 秒 + 手動。[D-68](../decisions/D-068.md)) +- [x] 一括リネームで album 全体を動かした後、旧ディレクトリに残る同梱ファイル(cover.jpg / + disc.cue / rip.log 等)の追随と空ディレクトリの扱い(2026-09-19。rename ジョブが commit 後に + 既知の名前を追随させ、空なら rmdir。[D-67](../decisions/D-067.md)) +- [x] 端末への配信の実現性の確認(2026-09-30 / 10-01。Poweramp の再スキャンは `ACTION_SCAN_DIRS` を API レシーバへ + 明示で送り extras を付けない([D-98](../decisions/D-098.md))。ミュージック.app の track の `location` は文字列の代入で付け替えられ、 + 移動でも track と再生回数を保てる([D-101](../decisions/D-101.md))) +- [ ] ADB の転送の速さ: shell v2 の stdin 自体は約 38MB/s だが、1 曲ごとの意図・`sync`・sha256 の往復込みの実測は + 約 6.5MB/s で、全曲(約 9,000 曲)の初回はおよそ 2 時間。意図の追記や `sync` をまとめる高速化は持ち越し([D-98](../decisions/D-098.md)) +- [x] DB を失ったときの既存の保存先の引き継ぎ(Android。2026-10-13。P5-7): 端末タブの「前の登録の保存先(引き継げる)」。 + manifest の uuid の行を作り、曲の `rel_path` で `track_id` を付け替える。manifest に `rel_path` / `source` を + 足し、`ADOPTED_TOKEN` の近道で送り直さずにトークンだけ書き換える([§6](06-data-model.md)、[§7.11](07-pipeline/11-device-delivery.md)「引き継ぎ」。[D-103](../decisions/D-103.md)) +- [x] 引き継ぎ(Mac の `pair --adopt`。2026-10-13。P5-8): 新しい行の pair コードで `pair --adopt` を実行すると、 + pair が行の uuid を state の uuid へ差し替え、`POST /api/agent/adopt` が state の曲を `rel_path` で付け替えて + キャッシュへ直接書く。state と manifest に `rel_path` / `source` を足し、`ADOPTED_TOKEN` の近道で送り直さずに + トークンだけ書き換える。ミュージック.app の track(再生回数)は保たれる。pending が残っていれば止める + ([§6](06-data-model.md)、[§7.11](07-pipeline/11-device-delivery.md)「iPhone」の「引き継ぎ」。[D-104](../decisions/D-104.md)) +- [x] id の再利用(2026-10-14。[D-105](../decisions/D-105.md)): `tracks.id` / `playlists.id` は `INTEGER PRIMARY KEY` で `AUTOINCREMENT` ではないので、 + 最大 id の行を GC が消すと次の挿入が同じ id を得る。端末に写しのある曲の id が別の曲に再利用されると、 + 差分は `UpdateMove` になり、Android は収束するが、Mac ではミュージック.app の track(再生回数)が + 別の曲に引き継がれる。7 日以上同期しない場合に限る([D-102](../decisions/D-102.md))。方針は **GC が `tracks` の最大 id の行を消さない** + (SQLite は `max(rowid)+1` を振るので再利用が起きない)。「token・sha256・パスがすべて違う写しを Delete + Add + にする」は却下(Library の一括のタグ修正と改名は日常操作で、そのたびに Mac の再生回数が消える)。 + プレイリストの id の再利用は受け入れる(Update = 改名として収束し、失う情報が無い。[D-103](../decisions/D-103.md)) diff --git a/docs/spec/README.md b/docs/spec/README.md new file mode 100644 index 00000000..acdfad1d --- /dev/null +++ b/docs/spec/README.md @@ -0,0 +1,32 @@ +# spindle — 設計仕様 + +> ステータス: v0.6.0 時点の実装に合わせてある(P0〜P5 実装済み)。主要な設計判断は確定済み +> +> CD を積むスピンドルから。バイナリ名・コマンド名も `spindle`。 + +仕様は章ごとのファイルに分かれている。§7(パイプライン)と §12(UI)は節ごとに分けてある。 +コードやほかの文書の「SPEC §7.5」は、この表の §7.5 の行を指す。設計判断の理由は +[設計判断の記録](../decisions/README.md) を見る。 + +仕様と実装が食い違ったら、どちらが意図どおりかを確かめる。実装が正しいと確かめられたときだけ仕様を直し、そうでなければ実装を直す。新しい判断を伴うなら +[設計判断の記録](../decisions/README.md) にも足す。 + +## 目次 + +- [§1. 目的とスコープ](01-scope.md) +- [§2. 用語](02-glossary.md) +- [§3. 基本原則](03-principles.md) +- [§4. アーキテクチャ](04-architecture.md) +- [§5. ライブラリレイアウト](05-library-layout.md) +- [§6. データモデル](06-data-model.md) +- [§7. パイプライン](07-pipeline/README.md) +- [§8. ジョブシステム](08-jobs.md) +- [§9. HTTP API](09-http-api.md) +- [§10. プレイリストとエクスポート](10-playlists-export.md) +- [§11. 再生とトランスコード](11-playback.md) +- [§12. UI](12-ui/README.md) +- [§13. 設定](13-config.md) +- [§14. デプロイ](14-deploy.md) +- [§15. Rust モジュール構成](15-rust-modules.md) +- [§16. 実装順](16-implementation-order.md) +- [§17. 決定済み事項と残課題](17-open-issues.md) diff --git a/docs/user-guide/README.md b/docs/user-guide/README.md new file mode 100644 index 00000000..08655d01 --- /dev/null +++ b/docs/user-guide/README.md @@ -0,0 +1,533 @@ +# 操作手順 + +spindle の画面の使い方。spindle を動かすまで(カスタムアプリの作り方・設定・初回起動)は [導入](../admin-guide/install.md)、 +更新・バックアップ・復元は [運用手順](../admin-guide/operations.md)、旧ライブラリからの移行は [移行手順](../admin-guide/migration.md)、 +スマートプレイリストの文法は [DSL](dsl.md)、iPhone へ送る Mac 側のコマンドは [spindle-agent](../../agent/README.md) を見る。 +画面の仕様そのものは [仕様 §12](../spec/12-ui/README.md)。 + +## 1. 初回起動とログイン + +1. spindle を TrueNAS のカスタムアプリとして起動する(作り方は [導入](../admin-guide/install.md)「カスタムアプリの作り方」)。 + 初回だけ環境変数 `SPINDLE_INITIAL_PASSWORD` を渡す。パスワードは argon2id で DB に保存され、以後この環境変数は読まれない +2. ブラウザで `http://<ホスト>:8080/` を開き、パスワードを入れてログインする(ユーザ名は無い)。 + セッションは Cookie で保持される +3. ログイン後は「ライブラリ」画面が開く。起動時にライブラリのスキャンが自動で走るので、 + ☰ →「ジョブ」の `scan` が完了すると全曲が表に出る + +初期化が済んだら compose から `SPINDLE_INITIAL_PASSWORD` の行を消してよい(平文を残さないため。 +消しても DB のパスワードで起動する。手順は [導入](../admin-guide/install.md)「初回起動」)。環境変数も DB もパスワードが無いと +**ロックモード**で起動し、`/health` 以外は(画面も含めて)503 を返す。ブラウザで開けないときは `GET /health` が +`{"status":"locked"}` になっていないかと、起動ログを見る。 + +ログインに何度も失敗すると IP 単位でしばらく拒否される(「試行回数が多すぎます」)。 + +## 2. 画面の骨格 + +``` +┌──────────────────────────────────────────────────────────────────────────────────┐ +│ spindle ライブラリ アルバム CD YouTube Inbox❷ 端末❸ │ ⏮ ▶ ⏭ ■ シーク 曲名 │ RG 原本 🔊 │ ☰ │ ← 上部バー +├──────────────┬───────────────────────────────────────────────────────────────────┤ +│ [検索] │ プロパティ │ 一括編集 │ 操作 選択 N 件 ▴ │ ← 右パネル +│ ▾ ツリー ├───────────────────────────────────────────────────────────────────┤ +│ プレイリスト │ 表示 K 件 凡例 列 │ +│ フィルタ │ ☐ │ # │ Title │ Artist │ Album │ … │ 長さ │ バッジ │ │ ← 表 +│ [cover] │ (仮想スクロール) │ +└──────────────┴───────────────────────────────────────────────────────────────────┘ +``` + +- **上部バー**: 左が画面の切替。「ライブラリ」がホームで、その右に取り込みの入口(CD / YouTube)、 + さらに右が「Inbox」、最後が「端末」。**取り込むと Inbox に赤い数字(承認待ちの件数)が増える**ので、次にどこを + 見ればよいか分かる。端末の黄色い数字は、全端末の未反映(追加・更新・移動・削除・エラー。待ちは含めない)の件数。 + 中央がプレイヤー(§9)。右端の ☰ に「ジョブ」「記録」「設定」「ログアウト」 + (ジョブには実行中 + 待ちの件数が付き、失敗があれば ☰ 自体に赤点が付く)。☰ の横の点は + サーバとの SSE 接続状態(緑なら接続中。切れても自動で再接続する) +- **左サイドバー**: 検索ボックス、ツリー、プレイリスト、固定フィルタ、選択中の曲のアートワーク。 + どれを選んでも**中央の表の集合を差し替えるだけ**で、列・ソート・選択の仕組みは共通。 + ツリーが効かない画面(CD / YouTube / Inbox / 端末 / ジョブ / 記録 / 設定)では出ない +- **表**: 全曲(最大数万行)を仮想スクロールで出す。列は右上の「列」で表示 / 非表示と順序を変え、 + ヘッダのクリックでソート、ドラッグで並べ替え・幅変更。設定はこのブラウザに保存される +- **右パネル**: 「プロパティ」「一括編集」「操作」の 3 タブ。頭に「選択 N 件(うち反映待ち M 件)」。 + ▴ で折りたたみ、境界のドラッグで幅を変えられる(保存される)。一括編集と操作は + **① 対象 → ② 操作 → ③ プレビュー(確かめる)→ ④ 適用** の番号付きの段で進む + +## 3. 一覧の使い方 + +### 3.1 絞り込み + +- **検索**: サイドバー先頭の検索ボックス。入力から 250ms 後に表へ反映する。3 文字以上は部分一致 + (全文検索)、2 文字以下は LIKE の部分一致(両端に `%` を付ける。全文検索の trigram は 3 文字未満を引けないため) +- **ツリー**: 既定は Category → AlbumArtist → Album。表示形式は組み込み 5 種(by folder structure / + by category / by artist / by album / by year)と、`%category%|%albumartist%|%album%` のような + パターンで自作できる(「新しい表示形式…」。`|` が階層の区切り)。節を選ぶとその範囲に絞る +- **プレイリスト**: 選ぶとその内容だけになる(§8) +- **固定フィルタ**: 未検証 / 重複 / missing / RG なし / RG 未書込 / 反映待ち / conflict / hardlink / + FLAC 未検査 / FLAC エラー / Hi-Res 未検査 / 偽ハイレゾ疑い。複数を押すと AND。ツリーや検索と + 組み合わせられる。端末を登録していれば「端末に未反映 ▾」で端末を選ぶと、その端末へまだ送っていない曲 + (追加・更新・移動。前回失敗したものを含む)に絞る + +絞り込みを変えても**選択集合は変わらない**(§3.2)。 + +### 3.2 選択 + +一括編集も操作もすべて「選択」に対して行う。 + +| 操作 | 動き | +|---|---| +| クリック | その行だけを選択(カーソルも置く) | +| Shift + クリック | 直前の行からの範囲を**足す** | +| Ctrl + クリック / Space | 1 行をトグル | +| Ctrl+A | 表示中の絞り込み結果を**全件**選択(何万件でも即時。ID ではなくフィルタ式を持つ) | +| Esc | 選択解除 | +| ↓ ↑ / PageDown PageUp / Home End | カーソルを動かし、その行だけを選択 | +| Shift + 上記 | anchor からカーソルまでの範囲そのものに置き換える(戻せば縮む) | +| Ctrl + 上記 | カーソルだけ動かす(選択は変えない) | +| Delete | プレイリストを開いているとき、選択をそのプレイリストから除外 | + +選択は作った時点で固定される。その後に検索語やツリー、ソートを変えても選択集合は変わらないので、 +表に見えている行と選択が食い違うことがある。右パネルの「選択 N 件」が正。 + +### 3.3 バッジ列 + +1 セルに複数のアイコンが並ぶ。カーソルを合わせると文言が出る。凡例は表の上の「凡例」から。 + +| バッジ | 意味 | +|---|---| +| ✔ / ✘ / ○ / · | CD 由来の照合。検証済み(CTDB か AccurateRip のどちらかで一致。どちらかはプロパティ)/ 不一致 / 検証不能(配信・ダウンロード音源)/ 未検証 | +| L / l | 可逆 / 非可逆(コーデック名はツールチップ) | +| RG | ReplayGain 書き込み済み。薄い表示は計測済みだがタグ未書き込み | +| O / A | Derived あり。O は Opus(Android・ブラウザ再生用)、A は AAC(iPhone 用)。O• / A• はタグが古く追随待ち | +| F / ✘F | FLAC の STREAMINFO に MD5 が無い / デコードエラー | +| H | 偽ハイレゾ検出の疑い・判定できず・エラー(色で区別。• は結果が古い) | +| ⏳ | 編集の反映待ち(バッチが処理中)。この行は編集できない | +| ⚠ | conflict。外部の変更と衝突して最後の編集が反映されなかった(記録画面の編集履歴で現在値を確認) | +| ⧉ | 同一音声の重複がある | +| ⛓ | hardlink(同じファイルに複数の名前がある) | +| ∅ | missing。ファイルが見つからない(行はグレー。7 日後に GC が消す) | +| 📱 / 📲 / ⏳ / ⚠ | 端末ごとの状態(§15)。反映済み / 未反映(次の同期で送る)/ 待ち(RG 未解析など条件が整うまで送らない)/ エラー。端末名と理由はツールチップ | + +### 3.4 その場で直す + +- セルをダブルクリックすると 1 件だけの編集になる(Enter で適用、Esc で取り消し)。裏では 1 件の + バッチとして同じ経路を通るので、編集履歴に載り巻き戻せる +- 行の先頭に出る ▶ で、その行から表示順に連続再生する(§9) + +## 4. プロパティタブ + +選択した行のタグ(Metadata)、ファイル情報(Location)、技術情報(General)を出す。複数選択なら共通の +値だけを出し、違う値は「<複数の値>」になる。 + +- 値をダブルクリックすると編集欄になり、Enter で**選択全体**にその値を書く(`;` で多値) +- 行末の × か右クリックで、そのフィールドを選択全体から削除する(値の無い行は消せない) +- 表の下の「フィールドを追加」で新しいキーに値を書く(キーは大文字化。`=` と制御文字、`PICTURE` は不可。 + 既にあるキーはその行を編集する)。キーの欄はライブラリで使われているキーを前方一致で候補に出す + (↑↓ で選んで Enter / Tab) + +どれも 1 操作のバッチになり、プレビュー → 適用 → 編集履歴と同じ流れで反映される。 + +General の「CTDB」「AccurateRip」の行には、手法ごとの最新の照合結果が出る(一致なら信頼度とオフセット、 +ほかに不一致 / 登録なし / 検証不能)。バッジの ✔ はどちらかで一致すれば付くので、どちらで一致したかはここで見る。 +Location には Derived の系統(Opus / AAC)ごとの行が出る。 + +## 5. 一括編集タブ + +選択した全曲に同じ操作の列を適用する。foobar2000 の Masstagger に相当。 + +1. 「② 操作」で種類を選んで足し、キーと値を入れる。上から順に適用される(上へ / 下へで並べ替え)。 + キーの欄はプロパティタブと同じく前方一致の候補を出す + - **固定値**: キーに値を書く(`;` で多値。空なら削除) + - **フィールド参照**: `%artist%` のように他のフィールドの値を写す(テンプレート) + - **正規表現置換**: パターンと置換(`$1` で参照) + - **連番**: TRACKNUMBER などを現在のソート順に振り直す(開始番号と桁数を指定) + - **削除**: キーを消す +2. 「③ プレビュー」を押すと、表の該当セルに 旧値 → 新値 の差分が重なる。頭に「変更 N / 変更なし M / + 反映待ち除外 K」 +3. 「④ 適用」で説明(任意。編集履歴に残る)を入れて適用。バッチが作られ、ジョブがファイルへ書く。表のバッジが + ⏳ → 消える、で完了。件数が多ければ「ジョブ」画面で進捗が見える + +操作リストは選択を変えても残るので、同じ操作を別の集合へ繰り返し適用できる。選択・操作・ソートを +変えたら**プレビューし直す**(③ に「古い」と出る。プレビュー時の集合に対してだけ適用される)。 + +対象に反映待ち(⏳)の行があると適用できない。「M 件を除外して適用」か、待ってから再度プレビューする。 + +画像はタグ編集の対象外(アートワークは操作タブの「埋め込み画像の差し替え」で差し替える)。 + +## 6. 記録画面(編集履歴と巻き戻し) + +☰ →「記録」。**編集履歴 / 退避ファイル / ログ** の 3 タブがある。 + +### 編集履歴 + +タグ編集・リネーム・正規化・MD5 補填・アートワーク差し替え・ReplayGain の書き込み・購読の番号揃えなど、 +ファイルを書き換えた操作はすべてバッチとしてここに並ぶ。 + +- 行を開くと、曲ごとの結果(applied / skipped_conflict / failed)と変更内容が見える。conflict の行は + 「ファイルを再読込した現在値」を並べて出す +- 「巻き戻す」は終端状態(applied / partial / failed)のバッチにだけ押せる。巻き戻しも新しいバッチとして + 同じ一覧に現れる(↩ で元バッチを示す)。戻したものを「巻き戻す (=やり直し)」でもう一度戻せる +- 戻し済みのバッチは「#N で戻し済み」と表示され、再度は戻せない +- 巻き戻しの時点でファイルが元バッチの結果と違っていれば(外部で書き換えた等)、その曲だけ conflict と + なり他は戻る。直してから再度巻き戻すと残りだけが対象になる +- 処理中(prepared / applying)のバッチは「キャンセル」できる + +外部の変更(foobar2000 で書いた等)は次のスキャンで取り込まれ、DB ではなく**ファイルの内容が採用**される。 +編集と外部変更がぶつかったときは、編集側が conflict になる。 + +### 退避ファイル + +正規化(§7)で置き換えた元ファイルは `Archive/` に退避され、その台帳がここに出る。既定は「保持中」で、 +「復元済み」「削除済み」「すべて」に切り替えられる(100 件ずつ。「もっと見る」で続き)。保持中の行は +「復元」で元に戻せる(GC 期限の列の日時まで。過ぎると GC が実体を消し、「削除済み」の記録だけが残る)。 + +### ログ + +遡及照合(§7)と CD の吸い出し(§11.2)の実行ごとの記録。「すべて / 照合 / 吸い出し」で絞り、行を開くと +ログの本文を読める(コピーとダウンロードもできる)。照合のログは album ごとに最新 1 回分しか残らないので、 +古い実行は「上書きされた」と出る。吸い出しのログはリネームしても追随する。ジョブ画面の verify の行の +「ログを見る」と、操作タブの遡及照合の結果からもここへ飛べる。 + +## 7. 操作タブ + +選択した曲に対する、タグ編集以外の操作。「② 操作を選ぶ」でボタンを選び、「③ 確かめる / プレビュー」で +説明と対象を確かめ、「④」で適用(巻き戻せる変更)・投入(ジョブ / 読むだけ)・反映(すぐ反映)する。 +③ の見出しの印(巻き戻せる変更 / ジョブ / 読むだけ / すぐ反映)がその操作の性質。 + +### ファイル + +- **リネーム**: `config.toml` の `[layout]` の規則でパスを組み直し、プレビューで旧 → 新を一覧に出す。 + 生成できない行(必要なタグが無い、宛先が衝突)は理由付きで赤くなる。適用するとファイルを移動する + (編集履歴に載り巻き戻せる。Derived は自動で追随する)。同じ名前・同じ年の別の版(通常版と + Hi-Res 版など)が同じフォルダに当たるときは、区別したい側の曲に `EDITION` タグ(例: `Hi-Res`)を付けると + `アルバム名 (Hi-Res)` に分かれる(付けていない方は元の名前のまま) +- **正規化(→ FLAC)**: WAV / ALAC / AIFF を FLAC にする。適用すると元ファイルは `Archive/` へ退避 + され(すぐには消えない。記録画面の「退避ファイル」から復元できる)、変換前後の PCM が一致するときだけ + 置き換わる。既に FLAC の行や非可逆は「変更なし」 + +### 音量 + +- **ReplayGain を解析**: 曲ごと(album gain が on の album は album 単位)にラウドネスを計測する。 + `config.toml` の `[replaygain].write_tags` が有効(既定)なら、計測が済むと自動でタグに書く(編集バッチとして + 記録され巻き戻せる)。無効なら書かず、バッジが薄い RG になる +- **解析値をタグに書き直す**: 計測済みなのにタグに書かれていない曲(外部で書き換えた・巻き戻した等)に、 + 測った値を書く編集バッチ(巻き戻せる)。Opus は R128 形式で書く +- **album gain**: 選択行が属する album ごとのチェック。on にすると album 単位で解析し直し、通し再生の + ゲインが付く。off にすると album の値を消す。既定は off(CD 取り込みは on) + +### 検査 + +- **FLAC を検査**: `flac -t` と MD5 の有無を確認する(読むだけ)。結果はバッジ F / ✘F と固定フィルタ +- **MD5 を補填**: 検査で MD5 無しだった FLAC の STREAMINFO に MD5 を書く(巻き戻せる) +- **偽ハイレゾを検出**: 可逆かつ 48 kHz 超または 16 bit 超の曲を解析し、アップサンプリングやビット深度の + 水増しを判定する(読むだけ。結果はバッジ H とプロパティの「Hi-Res check」) +- **遡及照合**: 44.1 kHz / 16 bit / 2ch の FLAC だけが対象。アルバム単位で CTDB / AccurateRip に照会し、 + CD 由来の音源かどうかを格付けする。終わったアルバムから結果(手法ごとの一致数と信頼度)が ④ に出る。 + 不一致は「要確認」で不良とは限らない(ログは記録画面の「ログ」で読める) + +### 画像・プレイリスト + +- **埋め込み画像の差し替え**: 画像ファイル(JPEG / PNG / WebP)を選ぶとプレビューが出る。適用すると選択した + 全曲の埋め込み画像をこの 1 枚に置き換える(編集バッチ。巻き戻すと元の画像に戻る)。同梱の + `cover.jpg` 等は書かない(埋め込みに統一) +- **プレイリストへ追加**: 選択を手動プレイリストの末尾に足す(表からのドラッグでも同じ) + +## 8. プレイリスト + +サイドバーの「プレイリスト」区画。 + +- **手動プレイリスト**: 「新しいプレイリスト」で作る。表の行をプレイリスト名へドラッグして追加、 + プレイリストを開いた状態で行をドラッグして並べ替え、「…から除外」ボタンか Delete キーで外す +- **スマートプレイリスト**(⚙): 「新しいスマートプレイリスト」でルールを書く。中央にエディタが出て、 + 入力しながら件数と表の結果が追随する(表は条件だけの結果で、ORDER BY / LIMIT は保存したときの評価に効く。 + Ctrl+Enter で保存、Esc で閉じる)。例: + `%albumartist% IS ヰ世界情緒 AND NOT %category% IS _Unsorted ORDER BY %date% DESC LIMIT 100`。 + 文法は [DSL](dsl.md)。ライブラリが変わると自動で再評価される(「再評価」で手動も可)。 + 項目の追加・並べ替えはできない +- **メニュー**(行末の「…」か右クリック): 改名 / ルールを編集 / 再評価 / foobar クエリ / 書き出し / 削除 / 端末へ送る +- **端末の印**: 端末の選曲に入っているプレイリストには、端末ごとに 📱 が付く。📱⏳ は同期待ち・同期中、 + 📱⚠ は送れない曲がある、薄い 📱🗑 は選曲から外したが端末に残っている。メニューの「端末へ送る」で端末ごとに + チェックを付け外しできる(端末タブの「選曲」と同じ。§15)。同期が途中の端末は変えられない +- **書き出し**: プロファイル(foobar / android / internal)を選ぶと `Playlists/<profile>/<名前>.m3u8` + に書く。パスの形はプロファイルごと(foobar は Windows のドライブレター、android は Derived への + 相対パス)。Derived のタグが古い曲があれば件数が通知に出る。一度書き出したプレイリストは、 + 内容が変わると自動で書き直される +- **foobar クエリ**: スマートプレイリストのルールを foobar2000 の Autoplaylist 構文に変換して表示する + (コピーして foobar 側に貼る。変換できない条件は脚注に出る) +- **取り込み**: 「Playlists/ の m3u8」から、Playlists ディレクトリにある m3u8 を手動プレイリストとして + 取り込む。行はパスとファイル名で照合し、見つからない行は未解決として件数に出る + +## 9. 再生 + +上部バー中央のプレイヤー。表の行の ▶ で、その行から表示順に連続再生する。 + +- ⏮ ▶ ⏭ ■ とシークバー、音量 +- **RG**: off / track / album。album は album gain が無ければ track にフォールバック +- **原本**: 既定では可逆の曲は Derived の Opus(48 kHz)を再生する。チェックすると原本を流す + (ハイレゾをそのまま聴きたいとき。ブラウザが再生できない形式は ffmpeg で変換される) +- 非可逆(Opus / MP3 / AAC)は常に原本 + +### 9.1 スマホで使う + +外出先のスマホからは、専用の簡易ページ `/m/` を使う。スマホのブラウザで spindle の URL(`/`)を開くと自動で `/m/` に移る +(iPad は PC 版が開く)。`/m/` を直接開いてもよい。ログインは PC と同じ。仕様は [§12.7](../spec/12-ui/07-mobile.md)。 + +- 下のタブは ライブラリ / フォルダ / プレイリスト / 購読。曲をタップすると下からシートが出て、タグやファイル情報を確かめ、 + 試し聴き(▶)とプレイリストへの追加ができる。タグの誤字は、シートのタグの行をタップして 1 曲だけ直せる(値の修正だけで、 + フィールドの追加・削除はできない)。直した内容は PC の記録画面(編集履歴)から巻き戻せる +- 試し聴きは通信量を抑えるため、原本ではなく軽い形式(Opus、鳴らなければ AAC)で流れる。音量の補正(RG)は掛からず、 + 画面を閉じると止まる +- プレイリストタブでは、プレイリストの作成・改名・削除と、手動プレイリストの曲の並べ替え(≡ を長押ししてドラッグ)・ + 除外(⊖)ができる。スマートプレイリストは「フィールド / 演算子 / 値」を並べて条件を組み立てる(AND / OR、条件ごとの + NOT、並び順、件数の上限)。組み立てで表せない規則(入れ子、AND と OR の混在)は「文で編集する」でルール文を直接書く +- 購読タブの「同期」は手動。ダウンロードがすべて終わると「完了」になる。購読の追加・変更・削除と Inbox の確認は PC で行う +- 一括編集・巻き戻し・CD・Inbox の承認・端末同期・ジョブ・設定は PC 版だけ + +**PC 版に切り替える**: スマホ版のメニューの「PC 版を開く」を押す。以後そのブラウザでは `/` が PC 版になる。スマホ版に +戻るには `/m/` を開く(開くと PC 版の指定は解ける)。 + +## 10. アルバム画面 + +上部バーの「アルバム」。サムネイルのグリッドで、表と同じ絞り込み(ツリー・プレイリスト・検索語)が +効く。クリックすると一覧をその album に絞って戻る。missing だけの album は出ない。 + +アートワークは埋め込み画像か同梱画像(`cover.jpg` 等)から自動で拾う。曲ごとに画像が違う album は +曲ごとの画像を使う。 + +## 11. 取り込み + +取り込み元は Inbox / CD / YouTube の 3 つで、結果はすべて **Inbox** に集まり、人が承認して配置する。 + +### 11.1 Inbox + +**category**(配置先の最上位のフォルダ)の選択肢は、Library 直下のフォルダ名(Anime / J-POP など)から +スキャンで自動で作られる。新しい category を増やすときは、選択欄で名前を足すか、Library の直下に +フォルダを作ってアルバムを置けば次のスキャンで選択肢に出る(`_Unsorted` は category にならない)。 +自動で作られるのは追加だけなので、SMB で一時的に作ったフォルダの名前などが残ったら、設定画面の +「category」で消す(どのアルバム・購読・Inbox の下書きにも使われていない語彙だけ消せる)。 + +`config.toml` の `[paths].inbox` に音声ファイルの入ったディレクトリを置くと、1 つのディレクトリが 1 つの**取り込み**として +一覧に出る(定期の確認は変化があったときだけ走査する。「今すぐ確認」で即時)。CD と YouTube の取り込みも同じ一覧に並ぶ。 + +1. 左の一覧から取り込みを選ぶ。右に番号付きの段で **① 取り込むファイル → ② アルバム情報 → ③ トラック → + ④ 確認して配置** が出る。初期値はファイルのタグからの提案 + - ① は出どころ(CD / YouTube / 手置き)・ディレクトリ・追記先と、ファイルのタグで足りないもの + - ② はアルバムアーティスト / アルバム / 日付 / category / album gain と画像。行をダブルクリック(Enter / F2)で + 編集欄になり、Enter で確定、Esc で取り消し。CD の取り込みなら「吸い出しの記録」から `rip.log` / cue / toc を読める + - ③ はトラックの表。disc / # / タイトル / アーティストのほか、ファイルの全タグが列で並ぶ(横スクロール)。 + セルをダブルクリックで直し、空にするとそのタグを消す。表の下の「タグを追加」で列を足す(キーは前方一致の候補が出る)。 + 🔒 の付いた列(`SOURCE_URL` / `MUSICBRAINZ_*`)と灰色の列は直せない。「列」で列を隠せる +2. 直す。問題(必須項目が空、番号の重複など)が残っている間は「承認して配置」が押せない(④ に赤で出る) + - タイトル欄の下に「⚠ Library に同名」が出たら、同じ曲を二重に取り込もうとしている可能性がある + (Library 側とこの曲の長さが並ぶので、長さで別テイクか見分ける。Cover / Live ver. は別曲。承認は止めない) + - ARTIST が多値のファイルは元の値をチップで示す。「ファイルの多値をそのまま保つ」を外すと 1 値で書く + - **種類**と**除外**の列: 曲でないもの(カラオケ / BGM / ドラマ / トーク)は「種類」を選ぶと `TRACK_TYPE` タグに、 + 曲だがプレイリストから外したいものは「除外」をチェックすると `PLAYLIST_EXCLUDE=1` に書かれる(スマートプレイリストで + 除くのに使う)。種類が空の行にはタイトルやアルバム名からの「候補: …」が出る。押すか、表の上の「種類の候補を + すべて適用」で入る(自動では入らない。[D-113](../decisions/D-113.md)) + - 「album gain を計算する」: アルバム通し再生用。既定 off + - 「宛先: …(N 曲)に追加。番号は M から」が出ていれば、既存の album に追記される。再生リストの購読から + 落とした曲は「番号 165 に入る(再生リストの位置。空けてある番号)」と出る。後ろの曲をずらす処理は承認では + なく同期が先に済ませていて、その経緯(何曲をずらしたか・バッチ番号)が下に出る。承認しても既存の曲は動かない + - 追記先の album の曲にディスク番号が無ければ「ディスク番号は付けない」と出て、disc 列が空になる(追記した曲 + だけが並びの末尾に回らないよう、宛先に合わせてディスク番号を書かない) + - CD の取り込みは、② の「MusicBrainz の引き直し」で候補を選び直せる(空欄の名前だけ埋まる)。 + 曲名の一覧があれば ③ の下の「トラックリスト貼り付け」で番号ごとに写せる +3. 「承認して配置」。inbox ジョブが Library へ移し、DB に登録し、ReplayGain / Derived を投入する。 + 見出しの下に配置後の段(`配置 → RG → opus → aac → <端末…>`)が並び、段ごとに 完了 / 進行中(n/m)/ 未 / 対象外 が出る。 + 配置済みの取り込みは 24 時間残り、「配置済み → アルバムを開く」で一覧に飛べる(画像は Library に登録された同じ画像を出す) +4. 「却下」はファイルを残したまま取り込みを却下にする(一覧に「却下」で残る)。「下書きに戻す」で承認前に戻せる +5. 要らない取り込みは、却下したうえで「削除」する。ファイルはすぐには消えず、`[gc].retention_days`(既定 7 日) + 経ったあとの GC が取り込みのディレクトリの音声・カバー画像などを消し、取り込みも一覧から消える(一覧に「削除待ち」、 + 承認画面に消す日時が出る)。それまでは「削除を取り消す」で却下に戻せる。削除待ちの間にそのディレクトリへ + ファイルが足されると、見ていないものを消さないよう削除は自動で取り消される + +承認後にファイルが変わると自動で pending に戻る。 + +**YouTube 由来の件の再判定**: YouTube の曲名・アーティストはメタデータプラグインが動画タイトルから決める。プラグインを +新しくした後など、判定をやり直したい件は見出しの「再判定」を押す(承認前の pending で、YouTube 由来の曲がある件だけに出る)。 +保存済みの下書きや未保存の補正は判定結果で置き換わるので、その旨の確認が出る。実行中は「再判定中 done/total」と出て、 +承認・却下・下書きに戻すは押せない。判定が変わって曲が別の件へ移ったときは移動先が知らされる。見出しのバッジ +「判定: spindle-ytmusic-meta <版>」でどの版で判定したかが分かる([D-114](../decisions/D-114.md))。 + +### 11.2 CD + +上部バーの「CD」。ディスクを読み取って盤を特定し、「取り込む」で吸い出して照合し、**Inbox に置く** +(入力 → Inbox → ライブラリ)。名前や配置先はこの画面では直さず、Inbox の承認画面で直す。 +画面は番号付きの段で **① 挿入中の CD → ② 認識したトラック → ③ 候補を選ぶ → ④ 取り込む**。 +ⓘ にカーソルを合わせると各段の詳しい説明が出る。 + +1. CD を入れる。② に**トラックの表**が出る(曲数と長さはディスクから。照会を待たない)。同時に + MusicBrainz へ自動で照会する。ドライブの無い環境では「詳細」の中に TOC の貼り付け欄がある +2. ③ に候補が並ぶ。選ぶと表のタイトルが**実名に置き換わり**、左にジャケットが出る。まだ名前が + 入っていない行は `Track 01`… が薄く見えていて、そのまま取り込めばその名前になる。この盤や同じリリースが + 既にライブラリにあれば、上に帯が出る(「ライブラリで開く」で表をその album に絞る) +3. 照会は DiscID → ISRC / JAN・UPC → TOC 近似の順に広げ、**当たった段で止まる**。どれも違うときは + 「さらに広げて探す」で TOC の近い盤まで見る。候補に出ない盤は「詳細」のリリース URL 指定で当てる。 + 同じ作品の版(通常盤・初回盤など)を見分けたいときは、① の欄に盤の帯や背の**品番**(と JAN)を入れて + 「品番で照会」する +4. 「候補から写す範囲」は既定が「全部写す」で、レーベル・カタログ番号・各トラックのタイトル / + アーティストまで入る。表記を自分で決めたいときは「識別用の最小限」(アルバム・アルバムアーティスト・ + 日付・ディスク番号 / 枚数・MB id だけ)に切り替える(トラック名は空のまま取り込まれ、Inbox で入れる)。 + 候補が無ければそのまま取り込んで Inbox で入れる +5. ④「取り込む」。rip ジョブがディスク全体を読み取り、AccurateRip / CTDB で照合し(通らなければ CTDB のパリティで + 修復するか吸い直す)、FLAC にして Inbox に置く。ボタンの横に段(読み取り / 照合 / CTDB で修復 / エンコード / + Inbox に配置)と進み具合、表の右端にトラックごとの進捗が出る。終わったら「Inbox を開く」で承認へ進む。 + どの候補も違うときは「どれも違う(候補を使わない)」で、名前の無いまま取り込んで Inbox で入れればよい +6. 照合が通らなかった盤も、不一致(mismatch)の印を付けて Inbox に置かれる(承認画面に警告が出る)。 + 吸い出しのログは Inbox の「吸い出しの記録」と、配置後は記録画面の「ログ」で読める +7. 候補を選んで取り込んだ盤は、CD 画面に出ていたジャケット(Cover Art Archive の表の画像)が Inbox の承認画面の + 画像の欄に**最初から入っている**。そのまま承認すれば全曲に埋め込む。要らなければ「画像を外す」、別の画像にするなら + 「差し替える…」か「別のリリースから取る…」。Cover Art Archive に画像の無い盤と、候補を選ばずに取り込んだ盤は + 画像なしのまま + +CD ドライブはコンテナにデバイスを渡しておく必要がある(渡っていなければ画面に「CD ドライブが使えない」と出る)。 +照合がどの盤でも通らないときは、ドライブのつなぎ先を疑う([導入](../admin-guide/install.md)「カスタムアプリの作り方」の +ドライブの注意)。 + +### 11.3 YouTube + +上部バーの「YouTube」。見出しの切り替えで「ダウンロード」と「購読」がある。ダウンロードは動画か再生リストの URL を +1 行 1 つ貼り(① URL を貼る)、行ごとの状態(新規 / ライブラリにある / Inbox で承認待ちなど)を確かめて +「N 件をダウンロード」。音声を Inbox に置く(承認は Inbox)。再生リストは動画ごとに展開し、取り込み済み +(`SOURCE_URL` が一致)の動画は飛ばすので、同じ再生リストを何度貼っても新しいものだけが落ちる。 +④ に今回置いた取り込みが並び、「Inbox で開く」で飛べる。これまでのジョブは下の「これまでのダウンロード」で +進み具合と結果(Inbox に置いた / プラグインが skip / 失敗の理由)が見える。 + +URL を貼る手間を減らすブックマークレット(ブックマークの URL 欄に貼る。`<spindle>` は +`<ホスト>:8080` のような spindle の URL): + +- **この動画を spindle へ**: いま見ているページの URL を spindle の YouTube 画面に入れて開く + ``` + javascript:void(open('http://<spindle>/youtube?url='+encodeURIComponent(location.href))) + ``` +- **このページの動画リンクを全部集める**: チャンネルの動画一覧・検索結果・再生リストのページで + 動画の URL を集め、改行区切りでクリップボードへ(spindle の欄に貼る) + ``` + javascript:(()=>{const s=new Set([...document.querySelectorAll('a[href*="/watch?v="]')].map(a=>new URL(a.href).searchParams.get('v')).filter(Boolean));navigator.clipboard.writeText([...s].map(v=>'https://www.youtube.com/watch?v='+v).join('\n')).then(()=>alert(s.size+' 件をコピーした'))})() + ``` + +#### 再生リストの購読 + +アーティストごとに再生リストを 1 本ずつ持っているなら、URL を貼るかわりに「購読」に登録する +(① 再生リストの URL → ② 追記先のアルバムアーティスト / アルバム / category → ③「登録して今すぐ同期」か「登録だけ」)。 +一覧の「同期」を押すと(`config.toml` の `[ytmusic].sync_interval_hours` を 0 以外にすれば定期的にも)、再生リストを列挙して + +1. 既に Library にある曲の `TRACKNUMBER` とファイル名を再生リストの位置に揃え(編集履歴に載り巻き戻せる)、 +2. Library にも Inbox にも無い動画だけを位置付きでダウンロードして Inbox に置く(1 回の上限は既定 50 本)。 + +承認は今までどおり Inbox で行う(初期値の番号がそのまま位置)。配置されると自動でもう一度同期が走り、 +番号を揃え直す。非公開・削除になった動画は位置を占め続けるので、その番号は飛ぶ(購読の結果に「取れない」 +として出る。公開に戻れば次の同期で拾う)。`SOURCE_URL` の無い曲や再生リストに無い曲は触らない(その番号に +入るべき曲は「揃えられない」として出るので、手で直す)。「揃える」を外した購読は番号に触らない。 + +結果に詳細があれば「詳細」で行を開く。同期中は編集・削除できない。 + +`config.toml` の `[ytmusic].ytdlp_args` には `["--extractor-args", "youtube:lang=ja"]` を入れておく(サンプルの +既定)。言語を指定しないと、投稿者が翻訳タイトルを付けた動画は英語のタイトル(とチャンネル名)が返り、日本語の +曲名が取れない。 + +YouTube に弾かれるようになったら、まず yt-dlp を新しくする(イメージの更新。[運用手順](../admin-guide/operations.md))。それでも +駄目なら `[ytmusic].ytdlp_args` に `--extractor-args`(`youtube:lang=ja;player_client=…` のように `;` で並べる)や +`--cookies` を足す。曲名の判定が間違っているだけなら、プラグインを直してから Inbox の「再判定」(§11.1)を使う。 + +## 12. ジョブ画面 + +☰ →「ジョブ」。スキャン、タグ書き込み、ReplayGain、Derived 生成、吸い出し、ダウンロード、端末の同期など裏で走る処理の +一覧。実行中 + 待ちの件数は ☰ のメニュー内の「ジョブ」に付き、失敗があれば ☰ 自体に赤点が付く。 + +- タブ: **実行中・待ち / 完了 / 失敗・取り消し / すべて**。各タブに件数 +- 種別表: 種別ごとの並列度と待ち / 実行中 / 完了 / 失敗の数。クリックで一覧をその種別に絞る。表の下に + CPU 系ジョブの共通予算(コア数) +- 各行: 対象(どのファイル / アルバム / バッチか)、進捗(done / total)、**結果**(完了したジョブが行ったこと。 + Derived 生成は実行中から処理の名前が出る)、失敗理由。結果やエラーのある行はクリックで全文を開く + (スキャン・GC・バックアップは件数の内訳が読める)。「再試行」「取り消し」、終端の行は「消す」で一覧から片付ける。 + 「失敗をすべて消す」で赤点も消える +- 編集バッチ由来のジョブは「バッチ #N」で記録画面の編集履歴に飛ぶ。遡及照合(verify)の行は「ログを見る」で + 記録画面のログに飛ぶ + +ジョブは冪等で、コンテナの再起動や電源断のあとは中断したものが自動で再開される。 + +## 13. 設定画面 + +☰ → 「設定」。 + +- **表示**: 配色(OS に従う / ライト / ダーク)。このブラウザにだけ保存される +- **ライブラリ**: 「再スキャン」は stat の差分だけを見る(速い)。「deep scan」は全件のタグと音声 + ハッシュを読み直す(時間がかかる。外部で大量に書き換えた後や、DB を復元した後に) +- **GC**: 「消すものを確認(dry-run)」で、missing が保持期間(既定 7 日)を超えた行、退避期限を過ぎた元ファイル、 + Derived の孤児、参照の無いアートワーク、古いジョブ行の件数と容量を出す。確認してから「GC を実行(N 件)」。 + 定期実行は 1 日 1 回(物理削除はここだけ) +- **category**: 配置先の最上位のフォルダの一覧(Library 直下のフォルダ名からスキャンで自動で作られる)。 + 使われていないものは「削除」で消せる(使われていれば理由が出て消えない)。Library の直下に同じ名前の + フォルダがあってアルバムが入っていれば、次のスキャンでまた作られる +- **config.toml**: 現在の設定の閲覧。変更はファイルを編集してコンテナを再起動する(画面からは書けない) + +退避ファイルの一覧と復元は記録画面(§6)にある。 + +## 14. 困ったとき + +| 症状 | 見るところ | +|---|---| +| 曲が表に出ない | ジョブ画面の `scan` が完了しているか。`[paths].library` が正しいか(設定画面の config.toml) | +| ⚠ conflict が付いた | 記録画面の編集履歴でそのバッチを開き「現在値」を確認。外部の変更を取り込んだ後に再適用する | +| ⏳ が消えない | ジョブ画面で `tagwrite` / `rename` が失敗していないか。失敗理由を見て「再試行」 | +| ∅ missing が付いた | ファイルを移動・削除した。戻せば次のスキャンで復帰する。7 日(`[gc].retention_days`)で GC が行を消す | +| YouTube の取り込みが失敗する | まずイメージを更新して yt-dlp を新しくする([運用手順](../admin-guide/operations.md)) | +| YouTube の曲名がおかしい | プラグインを直してから Inbox の「再判定」(§11.1) | +| CD の照合がどの盤でも通らない | ドライブのつなぎ先([導入](../admin-guide/install.md))。ログは記録画面の「ログ」 | +| 端末の曲が「待ち」のまま | ツールチップの理由を見る(RG 未解析・Derived 未生成・ハッシュ計算中なら、ジョブが終わるのを待つ) | +| Android が「未接続」のまま | USB でつなぎ、端末で USB デバッグを許可する。adb サイドカーが動いているか([導入](../admin-guide/install.md)) | +| 再生が始まらない | 「原本」を外して Derived を使う。Derived が無ければ ジョブ画面の `transcode` を待つ | +| DB が壊れた | [運用手順](../admin-guide/operations.md)「復元」。バックアップから戻して deep scan。端末は引き継ぎし直す | + +## 15. 端末(Android / iPhone へ送る) + +上部バーの「端末」。Library の曲とプレイリストを端末へ写す。Android(Poweramp)へは spindle が USB の ADB で直接書き、 +iPhone へは Mac の `spindle-agent` がミュージック.app へ反映し、その先は Finder の同期に任せる。**自動では送らない**: +端末ごとに差分を出し、確かめてから同期する。送るのは Derived(Android は既定で Opus、iPhone は既定で AAC)で、 +RG の解析や Derived の生成が済んでいない曲は「待ち」になる。仕様は [§7.11](../spec/07-pipeline/11-device-delivery.md)。 + +左に端末の一覧(名前・接続状態の点(Android のみ)・未反映 / 待ちの件数)と「端末を追加」、右に選んだ端末の +**差分 / 選曲 / 設定** のタブが出る。 + +### 15.1 端末を登録する + +- **Android(USB)**: 端末を USB でつなぎ、USB デバッグを許可する(「端末で許可してください」と出ている間は端末側の + ダイアログを待っている)。見つかった端末に名前(既定は機種名)・系統・選曲・保存先(内部共有ストレージ / SD カード。 + 空き容量付き。空でないものは選べない)を入れて「登録」。保存先は Poweramp の音楽フォルダの下にする。DB を + 作り直した後などで前の登録の保存先が残っていれば「前の登録の保存先(引き継げる)」と出るので、それを選んで + 「引き継いで登録」する +- **iPhone(Mac 経由)**: 名前・系統・選曲を入れて端末を作る。次に「設定」タブの「pair コードを発行」を押し、 + 出てきた `spindle-agent pair <URL> <コード>` を Mac で実行する(コピーボタンあり。Mac に前の登録の保存先が + 残っていれば、代わりに添えてある `--adopt` 付きのコマンドで引き継げる)。発行すると、その Mac が持っていた + トークンは使えなくなる。spindle-agent の導入と前提(ミュージック.app の設定)は [spindle-agent](../../agent/README.md) + +### 15.2 選曲 + +「選曲」タブで **全曲** か **プレイリスト** を選ぶ。プレイリストなら送るプレイリストにチェックを付ける(スマートは ⚙)。 +選ぶたびに合計の曲数と容量の見積もりが出る。保存すると「差分」が変わる。サイドバーのプレイリストのメニュー +「端末へ送る」でも同じ印を付け外しできる(§8)。同期が途中の間は変えられない。 + +印の付いたプレイリストは端末にもプレイリストとして書かれる(Android は `Playlists/<名前>.m3u8`、iPhone は +ミュージック.app の「spindle」フォルダ)。`%on_device%` / `%device_pending%`([DSL](dsl.md))を使うスマートプレイリストは +選曲に入れられない。 + +### 15.3 差分と同期 + +「差分」タブに、送る量・一時的に増える量・端末の空きと、曲ごとの操作(追加 / 更新 / 移動 / 更新 + 移動 / 削除 / +待ち / エラー)と理由、プレイリストの差分が出る。待ちとエラーの曲は、端末に古い写しがあればそれを残す。 +スマートプレイリストの評価待ちがあると、評価が終わるまで同期できない。 + +- **Android**: 端末をつなぐと spindle が自動で端末の状態を読み直して差分を出す。「同期(N 件・M バイト)」を押すと + ジョブが送る。同期中は件数・バイト数・速度・残り時間と、いま送っている曲が出る。途中で止まった同期は + 「前回の同期が途中です」と出るので、「続きを実行」か「破棄」(送り終えた分は端末に残る)。端末がつながらず + 通常の破棄ができないときは「強制破棄」。「設定」タブの「内容を検証」は端末上の全曲のハッシュを確かめ、 + 食い違った曲を次の同期で送り直す +- **iPhone**: 画面に同期のボタンは無い。Mac で `spindle-agent sync` を実行すると、Mac 側で差分を確かめて反映する。 + 反映中は Mac からの進捗がこの画面にも出る(30 秒届かなければ「応答なし」)。反映が済んだら Finder で iPhone を同期する。 + 「反映済み」は **Mac に反映済み**の意味(Finder の同期の結果は spindle から見えない)。途中の計画が残っていれば + Mac で続けるか、「強制破棄」で閉じる + +### 15.4 設定と削除 + +「設定」タブで名前と系統(途中の計画があれば系統は変えられない)を変える。Android の保存先は変えられない +(変えるには削除して登録し直す)。「端末を削除」は spindle の登録だけを消し、端末上のファイルは消さない +(途中の計画があればそれも破棄する)。 diff --git a/docs/DSL.md b/docs/user-guide/dsl.md similarity index 69% rename from docs/DSL.md rename to docs/user-guide/dsl.md index 23b30b3a..0a70fc83 100644 --- a/docs/DSL.md +++ b/docs/user-guide/dsl.md @@ -11,6 +11,8 @@ ORDER BY %date% DESC LIMIT 100 ## 文法 +簡略形。正は `src/playlist/dsl.pest`(キーワードは `^"IS"` のように大小文字を無視し、語の途中では切らない)。 + ```pest query = { SOI ~ expr ~ order? ~ limit? ~ EOI } @@ -39,28 +41,37 @@ bare = @{ !"\"" ~ (!(WHITESPACE | ")") ~ ANY)+ } ident = { (ASCII_ALPHANUMERIC | "_" | " ")+ } ``` -- キーワード(`AND` `IS` など)は大文字小文字を区別しない -- 値に空白を含める場合は二重引用符で囲む。引用符の中では `\"` が `"`、`\\` が `\` を表す(それ以外の `\` はそのまま)。 +- キーワード(`AND` `IS` など)は大文字小文字を区別しない。`ISLAND` のような語を `IS` + `LAND` とは読まない +- フィールド名は空白を除いて小文字にしてから引く(`%Album Artist%` も `%ALBUMARTIST%` も `albumartist`) +- 値に空白か `)` を含める場合は二重引用符で囲む。引用符の中では `\"` が `"`、`\\` が `\` を表す(それ以外の `\` はそのまま)。 値に `"` や `\` を含めるときはこのエスケープで書く(スマホ版のルール組み立てもこれで書き出す) - 演算子の優先順位: `NOT` > `AND` > `OR`。括弧で明示できる +- `ORDER BY` は既定で昇順(`ASC`)。値の無い曲は向きにかかわらず末尾に回る。`ORDER BY random` は無作為順 +- `LIMIT` は 1 以上の整数 ## 演算子 | 演算子 | 意味 | 備考 | |---|---|---| -| `IS` | 完全一致 | 文字列は大小文字を区別しない | -| `HAS` | 部分一致 | foobar も部分一致(実機で確認。D-55) | -| `GREATER` / `LESS` | 数値・日付の比較 | 対象フィールドが数値型でなければエラー | -| `MATCHES` | 正規表現 | **独自拡張。** foobar には無い | -| `PRESENT` / `MISSING` | タグの有無 | 値を取らない | +| `IS` | 完全一致 | 文字列は大小文字を区別しない(ASCII の範囲)。数値は等値、`duration` は秒に丸めて比べる、`added` はその日(UTC)に含まれるか | +| `HAS` | 部分一致 | 大小文字を区別しない(ASCII の範囲)。foobar も部分一致(実機で確認。[D-55](../decisions/D-055.md))。整数のフィールドは数字の文字列として比べる | +| `GREATER` / `LESS` | 数値・日付の比較 | 数値のフィールドと `date`(`YYYY-MM-DD` の文字列順)、`added`(`YYYY-MM-DD` か epoch 秒。`GREATER` はその日の終わりより後、`LESS` はその日の始まりより前)。それ以外(任意タグを含む)はエラー | +| `MATCHES` | 正規表現 | **独自拡張。** foobar には無い。Rust の `fancy-regex` の構文で、大小文字を区別する(`(?i)` で無視) | +| `PRESENT` / `MISSING` | 値の有無 | 値を取らない。任意タグはキーの有無、標準タグの文字列は空文字も無いとみなす。真偽のフィールドは常に PRESENT、端末のフィールドには使えない | + +真偽のフィールドは `IS true` / `IS false` だけ(`1` / `0`、`yes` / `no`、`on` / `off` も可)。型に合わない演算子と値は +保存の前にエラーになる(例: `%duration% HAS 3`、`%samplerate% IS abc`)。 ## フィールド ### 標準タグ -`title` `artist` `albumartist` `album` `date` `genre` `tracknumber` -`discnumber` `composer` `comment` — およびファイルに存在する任意のタグ名。 -ホワイトリストにない名前は `track_tags` への `EXISTS` サブクエリに展開する。 +`title` `artist` `albumartist` `album` `date` `tracknumber`(整数)`discnumber`(整数)は +キャッシュ列を引く。`artist` は多値を `, ` で結合した表示用の値なので、多値のアーティストの 1 つを探すときは `HAS` を使う。 + +それ以外(`genre` `composer` `comment` `track_type` `playlist_exclude` など)は、ファイルに存在する任意のタグ名として +`track_tags` を引く(キーは大文字にして比べる。多値ならどれか 1 つが一致すればよい)。任意タグは文字列なので +`GREATER` / `LESS` は使えない。 ### 拡張フィールド(spindle 固有) @@ -68,28 +79,28 @@ ident = { (ASCII_ALPHANUMERIC | "_" | " ")+ } |---|---|---| | `category` | 文字列 | 統制語彙のカテゴリ名 | | `verification` | 文字列 | `verified_ar` / `verified_ctdb` / `mismatch` / `unverifiable` / `not_attempted` | -| `source_type` | 文字列 | `cd_rip` / `download` / `youtube` / `unknown` | +| `source_type`(`sourcetype`) | 文字列 | `cd_rip` / `download` / `youtube` / `unknown` | | `lossless` | 真偽 | | -| `codec` | 文字列 | `flac` / `opus` / `alac` / `aac` / `mp3` / `wav` | +| `codec` | 文字列 | `flac` / `opus` / `alac` / `aac` / `mp3` / `wav` / `aiff` / `ogg` / `wv` / `ape` | | `samplerate` | 数値 | Hz | | `bitdepth` | 数値 | | | `channels` | 数値 | | | `bitrate` | 数値 | kbps | -| `duration` | 数値 | 秒 | -| `added` | 日付 | 初回スキャン日時 | -| `has_derived` | 真偽 | Derived が生成済みか | +| `duration` | 数値 | 秒(小数可) | +| `added` | 日付 | 初回スキャン日時。値は `YYYY-MM-DD`(UTC)か epoch 秒 | +| `has_derived`(`hasderived`) | 真偽 | Opus の Derived が生成済みか(AAC だけの曲は偽) | | `missing` | 真偽 | 論理削除されているか | -| `hirescheck` | 文字列 | `ok` / `upsampled` / `padded` / `both` / `inconclusive` / `decode_error`(未検査は MISSING) | +| `hirescheck`(`hires_check`) | 文字列 | `ok` / `upsampled` / `padded` / `both` / `inconclusive` / `decode_error`(未検査は MISSING) | | `cutoff` | 数値 | 偽ハイレゾ検出のカットオフ周波数(Hz) | -| `cliff` | 数値 | 偽ハイレゾ検出のカットオフ前後 1 kHz の落差(dB) | -| `effectivebits` | 数値 | 偽ハイレゾ検出の実効ビット数 | +| `cliff` | 数値(小数) | 偽ハイレゾ検出のカットオフ前後 1 kHz の落差(dB)。`HAS` / `MATCHES` は不可 | +| `effectivebits`(`effective_bits`) | 数値 | 偽ハイレゾ検出の実効ビット数 | | `on_device` | 端末名 | その端末に反映済みの写しがある(`device_items` に行がある)。`IS` だけ | | `device_pending` | 端末名 | その端末のマニフェストに載るが未反映(追加・更新・移動・更新 + 移動)。前回の同期・報告で失敗したものも含む。削除・待ち・エラー(パス衝突などの保留と、反映済みの写しの失敗)は含まない。`IS` だけ | 端末名は大小文字・Unicode の正規化を無視して比べる(`canonical_key`)。知らない端末名は一致なし。 端末を改名すると、旧名を書いたルールは保存し直さなくても一致なし(空)になる(ルールは書き換えない)。 `ORDER BY` にも使えない(400)。 -これらを使うスマートプレイリストは端末の選曲に載せられない(循環の禁止。D-95)。 +これらを使うスマートプレイリストは端末の選曲に載せられない(循環の禁止。[D-95](../decisions/D-095.md))。 `device_pending` は差分の計算結果を使うので、ライブラリの変更の直後は古い結果になりうる。 ## AST @@ -190,4 +201,4 @@ ORDER BY %date% DESC LIMIT 100 } ``` -`HAS` は foobar でも部分一致(2026-09-19 に実機で確認。D-55)。 +`HAS` は foobar でも部分一致(2026-09-19 に実機で確認。[D-55](../decisions/D-055.md))。 diff --git a/scripts/backfill_source_url.py b/scripts/backfill_source_url.py index 1c110fe5..186a7422 100755 --- a/scripts/backfill_source_url.py +++ b/scripts/backfill_source_url.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -spindle — 既存の webm 由来トラックへの SOURCE_URL 補填(docs/TASKS.md P4-14、D-70) +spindle — 既存の webm 由来トラックへの SOURCE_URL 補填(docs/development/tasks-history.md P4-14、D-70) 旧パイプラインは YouTube の再生リストごとに「再生リスト名 = アルバム名、リスト内の位置 = TRACKNUMBER」でファイルを並べていた。その再生リストを yt-dlp で列挙し、位置と番号で Library の diff --git a/scripts/migrate_plan.py b/scripts/migrate_plan.py index 343ae4c3..1429e37f 100755 --- a/scripts/migrate_plan.py +++ b/scripts/migrate_plan.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -spindle — 移行の振り分け計画(docs/MIGRATION.md §1、docs/DECISIONS.md D-45) +spindle — 移行の振り分け計画(docs/admin-guide/migration.md §1、D-45) 旧ライブラリ(`Opus/` と `Original/` が 1:1 で対になっている構成)を、役割別の Library / Derived / Archive に振り分ける rsync 用のファイル一覧を作る。 diff --git a/src/api/devices.rs b/src/api/devices.rs index 3576ff5c..fc6d255f 100644 --- a/src/api/devices.rs +++ b/src/api/devices.rs @@ -502,7 +502,7 @@ pub async fn put_playlists( } } -/// 端末のフィールドを使うスマートプレイリストは端末に載せられない(③ 循環の禁止) +/// 端末のフィールドを使うスマートプレイリストは端末に載せられない(SPEC §7.11「循環の禁止」) pub fn cycle(playlist_id: i64) -> Response { error_response_with_message( StatusCode::BAD_REQUEST, @@ -931,7 +931,7 @@ async fn is_adoptable_store(state: &AppState, rt: &AdbRuntime, serial: &str, vol } } -/// 接続中で未登録の端末と、`device` のものは保存先の候補のボリューム(仕様 ⑤「登録」1〜2) +/// 接続中で未登録の端末と、`device` のものは保存先の候補のボリューム(SPEC §7.11 Android「登録」1〜2) pub async fn unregistered(State(state): State<AppState>) -> Result<Response, ApiError> { let Some(rt) = state.adb.clone() else { return Ok(adb_disabled()); @@ -1009,7 +1009,7 @@ fn not_empty(rt: &AdbRuntime, volume: &str) -> Response { ) } -/// adb の端末の登録(仕様 ⑤「登録」): 接続を確かめ、保存先が空か前の登録の失敗の残りだけなら +/// adb の端末の登録(SPEC §7.11 Android「登録」): 接続を確かめ、保存先が空か前の登録の失敗の残りだけなら /// 行を作って端末に manifest を作り、差分の計算(`device_scan`)を投入する。端末の操作に失敗したら /// 行を残さない async fn create_adb( @@ -1375,7 +1375,7 @@ enum SyncResult { NotFound, } -/// 計画を確定して同期を投入する(仕様 ③「計画トークン」)。確定と投入は 1 つのトランザクション +/// 計画を確定して同期を投入する(SPEC §7.11「計画トークン」)。確定と投入は 1 つのトランザクション pub async fn sync( State(state): State<AppState>, Path(id): Path<i64>, @@ -1498,7 +1498,7 @@ pub struct AbandonBody { pub force: bool, } -/// open な計画を破棄する(仕様 ③「計画の終端」)。端末で回復を済ませ(封印済みバッチの `vacating` 以降は +/// open な計画を破棄する(SPEC §7.11「計画の終端」)。端末で回復を済ませ(封印済みバッチの `vacating` 以降は /// 前進で完遂、`prepared` 以前は破棄)、キャッシュを置き換えてから計画を閉じる。同期のジョブと /// 直列化するため、端末のロックを持ったまま閉じる。`force` なら端末につながない([`force_abandon`]) pub async fn abandon( diff --git a/src/api/history.rs b/src/api/history.rs index 29b28b1a..c382b128 100644 --- a/src/api/history.rs +++ b/src/api/history.rs @@ -1,4 +1,4 @@ -//! 編集履歴の API(SPEC §9 / §7.5「巻き戻し」/ §12.4、docs/TASKS.md P0-12)。 +//! 編集履歴の API(SPEC §9 / §7.5「巻き戻し」/ §12.4、docs/development/tasks-history.md P0-12)。 //! //! - `GET /api/history`: バッチ一覧(新しい順)。state / affected / applied / conflict / failed / //! reverts_batch_id / reverted_by diff --git a/src/api/playlists.rs b/src/api/playlists.rs index e1101b32..1311774f 100644 --- a/src/api/playlists.rs +++ b/src/api/playlists.rs @@ -1,4 +1,4 @@ -//! `/api/playlists`(SPEC §9 / §10、docs/TASKS.md P1-6、D-53)。 +//! `/api/playlists`(SPEC §9 / §10、docs/development/tasks-history.md P1-6、D-53)。 //! //! - `GET /api/playlists`、`POST { name, rule? }`(rule があれば smart)、`GET /:id`、 //! `PATCH /:id { name?, auto_export?, rule? }`、`DELETE /:id` @@ -58,7 +58,7 @@ pub struct PlaylistList { #[derive(Deserialize)] pub struct CreateBody { pub name: String, - /// あればスマートプレイリスト(docs/DSL.md)。構文・型エラーは 400 + /// あればスマートプレイリスト(docs/user-guide/dsl.md)。構文・型エラーは 400 #[serde(default)] pub rule: Option<String>, } diff --git a/src/db/devices.rs b/src/db/devices.rs index c9752ffa..93fbdd35 100644 --- a/src/db/devices.rs +++ b/src/db/devices.rs @@ -1,4 +1,4 @@ -//! 端末への配信の DB 層(docs/superpowers/specs/2026-09-29-device-delivery-design.md ②③、D-95)。 +//! 端末への配信の DB 層(SPEC §6「端末への配信の表」・§7.11「マニフェスト(あるべき状態)」「計画」、D-95)。 //! 判定は `domain::device` の純粋関数に任せ、ここは入力を組み立てて呼ぶだけ use std::collections::{BTreeMap, HashMap}; @@ -200,7 +200,7 @@ pub fn list(conn: &Connection) -> Result<Vec<Device>> { Ok(rows.into_iter().flatten().collect()) } -/// 印の全置換。generation を進める(仕様 ③「端末の設定変更と同期の排他」。排他の検査は API 側)。 +/// 印の全置換。generation を進める(SPEC §7.11「端末の設定変更と同期の排他」。排他の検査は API 側)。 /// SAVEPOINT で囲むので、未知の playlist_id による FK 違反などで失敗しても、印も generation も /// 呼び出し前のまま(`INSERT OR IGNORE` は UNIQUE 違反は黙らせるが FK 違反は黙らせない) pub fn set_playlists( @@ -309,7 +309,7 @@ struct TrackIdentity { ctime_ns: i64, } -/// 送る元のハッシュが、その `tracks` 行の**現在の**物理同一性と一致するか(仕様 ③「送る元のハッシュ」)。 +/// 送る元のハッシュが、その `tracks` 行の**現在の**物理同一性と一致するか(SPEC §7.11「送る元のハッシュ」)。 /// `tracks.inode` が NULL(未解決)なら常に不一致扱い。dev は比較しない(D-62) fn identity_matches(h: &SourceHash, id: &TrackIdentity) -> bool { id.inode.is_some_and(|inode| inode as u64 == h.inode) @@ -908,7 +908,7 @@ pub fn delete(conn: &Connection, id: i64) -> Result<bool> { Ok(conn.execute("DELETE FROM devices WHERE id = ?1", [id])? > 0) } -/// open な計画か、queued / running の `device_sync` があるか(仕様 ③「端末の設定変更と同期の排他」) +/// open な計画か、queued / running の `device_sync` があるか(SPEC §7.11「端末の設定変更と同期の排他」) pub fn has_open_work(conn: &Connection, id: i64) -> Result<bool> { Ok(conn.query_row( "SELECT EXISTS (SELECT 1 FROM device_sync_plans WHERE device_id = ?1 AND state = 'open') @@ -1210,7 +1210,7 @@ pub fn agent_by_selector(conn: &Connection, selector: &str) -> Result<Option<Age .optional()?) } -/// 端末の open な計画(仕様 ③「計画の終端」。端末ごとに 1 つまで) +/// 端末の open な計画(SPEC §7.11「計画の終端」。端末ごとに 1 つまで) #[derive(Debug, Clone, PartialEq, Eq)] pub struct OpenPlan { pub id: i64, @@ -1277,7 +1277,7 @@ pub enum Confirm { NotFound, } -/// 計画を確定する(仕様 ③「計画トークン」)。計算し直した計画の `plan_token` が一致すれば +/// 計画を確定する(SPEC §7.11「計画トークン」)。計算し直した計画の `plan_token` が一致すれば /// `device_sync_plans` に不変のまま保存する。呼び出し側のトランザクションの中で呼べる /// (P5-3b はここで同じトランザクションにジョブを投入する) pub fn confirm_plan( @@ -1356,7 +1356,7 @@ pub fn close_plan( /// 同期・報告で出た項目ごとのエラー(種類、track_id / playlist_id、理由) pub type ReportedError = (EntryKind, i64, String); -/// 端末側の正本を読んだ結果でキャッシュを全置換する(仕様 ②)。`errors` が None なら +/// 端末側の正本を読んだ結果でキャッシュを全置換する(SPEC §6「端末への配信の表」)。`errors` が None なら /// `device_errors` は触らない(差分の計算だけの `device_scan` は前回の同期のエラーを残す)。 /// `synced` なら `last_synced_at` を進める pub fn apply_device_state( @@ -1401,7 +1401,7 @@ pub enum PlaylistCheck { Cycle(i64), } -/// 印を付けてよいか: 実在し、端末のフィールドを使うスマートプレイリストでないこと(③ 循環の禁止) +/// 印を付けてよいか: 実在し、端末のフィールドを使うスマートプレイリストでないこと(SPEC §7.11「循環の禁止」) pub fn check_playlists(conn: &Connection, ids: &[i64]) -> Result<PlaylistCheck> { for id in ids { if dbpl::kind(conn, *id)?.is_none() { @@ -1451,7 +1451,7 @@ pub fn playlist_names(conn: &Connection, ids: &[i64]) -> Result<HashMap<i64, Str Ok(rows) } -/// 端末に載っているスマートプレイリストの評価時刻(④ 差分画面) +/// 端末に載っているスマートプレイリストの評価時刻(SPEC §12.6「端末」の差分) pub fn smart_evaluations( conn: &Connection, device_id: i64, diff --git a/src/db/jobs.rs b/src/db/jobs.rs index 192ab12f..d3822492 100644 --- a/src/db/jobs.rs +++ b/src/db/jobs.rs @@ -1039,7 +1039,7 @@ pub fn mark_failed_fatally(conn: &Connection, id: i64, error: &str, now: i64) -> Ok(changed > 0) } -/// 端末が繋がったとき、待機中の同期と検証を前倒しする(仕様 ⑤「同期」: 未接続で 300 秒後に置いたものを +/// 端末が繋がったとき、待機中の同期と検証を前倒しする(SPEC §7.11 Android「同期」: 未接続で 300 秒後に置いたものを /// `track-devices` が接続を見たら `run_after = now` にする)。変えたジョブの id を返す pub fn wake_device_jobs(conn: &Connection, device_id: i64, now: i64) -> Result<Vec<i64>> { let mut st = conn.prepare( diff --git a/src/db/playlists.rs b/src/db/playlists.rs index be71b6ed..123bb7b4 100644 --- a/src/db/playlists.rs +++ b/src/db/playlists.rs @@ -1,5 +1,5 @@ //! プレイリスト(`playlists` / `playlist_items` / `playlist_exports` / `export_profiles`。 -//! SPEC §10、docs/TASKS.md P1-6、D-53)。 +//! SPEC §10、docs/development/tasks-history.md P1-6、D-53)。 //! //! `playlist_items.position` は 0 から連続。追加・除外・移動のたびに振り直す(1 プレイリストは //! 高々数千件なので全行の書き直しで足りる)。同じトラックは 1 プレイリストに 1 回だけ @@ -22,7 +22,7 @@ pub struct Playlist { pub name: String, /// `manual` / `smart` pub kind: String, - /// smart: DSL の原文(docs/DSL.md)。manual は None + /// smart: DSL の原文(docs/user-guide/dsl.md)。manual は None pub rule_source: Option<String>, pub auto_export: bool, pub created_at: i64, diff --git a/src/db/stages.rs b/src/db/stages.rs index 198c3361..7591e1f8 100644 --- a/src/db/stages.rs +++ b/src/db/stages.rs @@ -1,4 +1,4 @@ -//! Inbox の承認済みの件の段(④ 可視化 B): 配置 → RG → 系統 → 端末 +//! Inbox の承認済みの件の段(D-95 の可視化 B): 配置 → RG → 系統 → 端末 use rusqlite::{params, Connection, OptionalExtension as _}; use serde::Serialize; diff --git a/src/device/adb.rs b/src/device/adb.rs index a5f1fe14..17f0dd45 100644 --- a/src/device/adb.rs +++ b/src/device/adb.rs @@ -1,4 +1,4 @@ -//! adb による [`DeviceFs`](仕様 ⑤「接続の構成」)。`adb -s <serial> shell <script>` を引数配列で +//! adb による [`DeviceFs`](SPEC §7.11 Android「接続の構成」)。`adb -s <serial> shell <script>` を引数配列で //! 呼ぶ(ローカルでは `sh -c` を使わない)。端末側のスクリプトに埋め込むパスは `sh_quote` だけを通す。 //! サブコマンドは shell v2 の `shell` だけ: pty を割り当てない限り stdin / stdout がバイナリ安全で、 //! 終了コードも返る(`exec-in` / `exec-out` は終了コードを返さない。D-95 の P5-3a 追記)。 @@ -231,7 +231,7 @@ fn base_command(cfg: &AdbConfig, serial: &str, timeout: Duration) -> ExternalCom .timeout(timeout) } -/// 登録の候補になるボリューム(仕様 ⑤「登録」2) +/// 登録の候補になるボリューム(SPEC §7.11 Android「登録」2) #[derive(Debug, Clone, PartialEq, Eq)] pub struct VolumeInfo { /// `emulated` か SD の UUID diff --git a/src/device/adopt.rs b/src/device/adopt.rs index 9278ae79..e8c3738b 100644 --- a/src/device/adopt.rs +++ b/src/device/adopt.rs @@ -1,4 +1,4 @@ -//! DB 全損後の保存先の引き継ぎ(仕様 ⑤「引き継ぎ」、D-103)。manifest の `rel_path` で今の `tracks` を +//! DB 全損後の保存先の引き継ぎ(SPEC §7.11 Android「引き継ぎ」、D-103)。manifest の `rel_path` で今の `tracks` を //! 引いて `track_id` を付け替え(remap)、プレイリストは名前で付け替える。引けない・曖昧な項目は //! manifest から外して管理外にする(ファイルは消さない) diff --git a/src/device/journal.rs b/src/device/journal.rs index 5c11800b..25b0b7c4 100644 --- a/src/device/journal.rs +++ b/src/device/journal.rs @@ -1,4 +1,4 @@ -//! 端末側のジャーナル `<root>/.spindle/journal`(仕様 ⑤「ジャーナルと中断からの回復」)。 +//! 端末側のジャーナル `<root>/.spindle/journal`(SPEC §7.11 Android「ジャーナルと中断からの回復」)。 //! 1 行 1 レコードの NDJSON。各操作は意図を耐久化してから副作用を起こし、終わったら完了を書く。 //! パス変更(移動・更新 + 移動)はバッチとして記録し、相(prepared → vacating → vacated → placing → //! done)で進める。バッチ以外の意図は `put` と `rm` だけ(D-95 の P5-3a 追記) diff --git a/src/device/mod.rs b/src/device/mod.rs index e2049bbd..c6be59d1 100644 --- a/src/device/mod.rs +++ b/src/device/mod.rs @@ -1,4 +1,4 @@ -//! 端末への配信の実行側(docs/superpowers/specs/2026-09-29-device-delivery-design.md ⑤、D-95)。 +//! 端末への配信の実行側(SPEC §7.11「Android(ADB)」、D-95)。 //! 差分の判定は `domain::device`、キャッシュは `db::devices`。ここは端末側の正本(manifest・ジャーナル)を //! 読み書きし、確定した計画を端末へ反映する。端末の操作は [`remote::DeviceFs`] で差し替える diff --git a/src/device/ondevice.rs b/src/device/ondevice.rs index 36956c77..8f6d33b7 100644 --- a/src/device/ondevice.rs +++ b/src/device/ondevice.rs @@ -1,4 +1,4 @@ -//! 端末側の正本 `<root>/.spindle/manifest.json`(仕様 ⑤「端末側の正本」)。 +//! 端末側の正本 `<root>/.spindle/manifest.json`(SPEC §7.11 Android「端末側の正本」)。 //! 端末側が正で、`device_items` はキャッシュ。書き直すのは spindle だけなので、未知のフィールドは //! 読み捨てて保持しない diff --git a/src/device/plan.rs b/src/device/plan.rs index 4a2cfd5d..2edac331 100644 --- a/src/device/plan.rs +++ b/src/device/plan.rs @@ -1,4 +1,4 @@ -//! 確定した計画(`device_sync_plans.plan`)と、開始済みの計画の再開(仕様 ③)。 +//! 確定した計画(`device_sync_plans.plan`)と、開始済みの計画の再開(SPEC §7.11)。 //! 実行するのは計画の部分集合だけ: 今の状態で満たされている操作は飛ばし、今の差分にも同じ操作・ //! 同じトークン・同じパスで残っているものだけを実行する。計画に無い操作(とくに新しい削除)は //! 実行しない。パス変更は入れ替え・循環の成分ごとに全員残すか全員外す。 diff --git a/src/device/quote.rs b/src/device/quote.rs index d839dc11..290bc7ea 100644 --- a/src/device/quote.rs +++ b/src/device/quote.rs @@ -1,4 +1,4 @@ -//! 端末側のシェルへ渡す文字列の検査とクォート(仕様 ⑤「接続の構成」)。 +//! 端末側のシェルへ渡す文字列の検査とクォート(SPEC §7.11 Android「接続の構成」)。 //! 端末側に渡すパス・root は必ず [`sh_quote`] を通す(クォートはここ 1 か所だけで行う) use crate::domain::relpath::RelPath; @@ -40,7 +40,7 @@ pub fn valid_serial(s: &str) -> bool { .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '-')) } -/// 登録で使う root(ボリューム内の相対パス。仕様 ⑤「登録」2) +/// 登録で使う root(ボリューム内の相対パス。SPEC §7.11 Android「登録」2) pub const DEFAULT_ROOT: &str = "Music/spindle"; /// 内部共有ストレージを表すボリューム名 diff --git a/src/device/recover.rs b/src/device/recover.rs index 295620f3..59b7d90f 100644 --- a/src/device/recover.rs +++ b/src/device/recover.rs @@ -1,4 +1,4 @@ -//! 接続のたびに行う回復(仕様 ⑤「ジャーナルと中断からの回復」)。DB は見ない。 +//! 接続のたびに行う回復(SPEC §7.11 Android「ジャーナルと中断からの回復」)。DB は見ない。 //! manifest とジャーナルを読み、完了の無い意図を実ファイルの sha256 で判定して取り込み、 //! 封印済みで `vacating` 以降のバッチは前進して完遂する(`prepared` 以前は `batch_abort` を //! 耐久化してから一時ファイルを消す)。`.spindle-tmp` の残骸を消し、`.spindle/moving/` の残りは @@ -132,7 +132,7 @@ pub async fn recover<F: DeviceFs>(fs: &F, expect: &Expect) -> Result<Recovered, }) } -/// 完了の無い意図を判定して取り込む(仕様 ⑤ の表の「追加・更新」「削除」「プレイリスト」の行) +/// 完了の無い意図を判定して取り込む(SPEC §7.11 Android の表の「追加・更新」「削除」「プレイリスト」の行) async fn apply_intent<F: DeviceFs>( fs: &F, it: &Intent, @@ -363,7 +363,7 @@ pub(crate) async fn complete_batch<F: DeviceFs>( } /// 旧パスを空ける: 移動は `from → staging`、更新 + 移動は旧版を消す。 -/// 回復(verify)の位置判定は「在るかどうか」で行う(仕様 ⑤: from にあれば未着手、staging にあれば +/// 回復(verify)の位置判定は「在るかどうか」で行う(SPEC §7.11 Android: from にあれば未着手、staging にあれば /// 空け済み)。内容の食い違いはここでは見ない。最後のサイズ照合(STALE_TOKEN)と「内容を検証」に任せる async fn vacate<F: DeviceFs>(fs: &F, m: &BatchMember, verify: bool) -> Result<(), StoreError> { match m.op { diff --git a/src/device/remote.rs b/src/device/remote.rs index 0225323c..8e784a17 100644 --- a/src/device/remote.rs +++ b/src/device/remote.rs @@ -1,4 +1,4 @@ -//! 端末の root 配下の操作(仕様 ⑤)。本物は `AdbFs`(後続タスク)、テストは偽の端末 FS。 +//! 端末の root 配下の操作(SPEC §7.11 Android)。本物は `AdbFs`(後続タスク)、テストは偽の端末 FS。 //! パスはすべて root 相対で、呼び出し側が `RelPath` として検証したもの #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] diff --git a/src/device/runtime.rs b/src/device/runtime.rs index b9f9ba73..ffae8ccd 100644 --- a/src/device/runtime.rs +++ b/src/device/runtime.rs @@ -142,7 +142,7 @@ impl Default for WatchOptions { pub type OnConnect = Arc<dyn Fn(String) -> BoxFuture<'static, ()> + Send + Sync>; -/// `adb track-devices -l` を購読し続ける常駐タスク(仕様 ⑤「ホットプラグ」)。EOF・エラーで一覧を空にし、 +/// `adb track-devices -l` を購読し続ける常駐タスク(SPEC §7.11 Android「ホットプラグ」)。EOF・エラーで一覧を空にし、 /// 上限付きの backoff で張り直す(60 秒以上続いた接続の後は最短に戻す)。停止でプロセスグループごと kill pub fn spawn_watcher( rt: Arc<AdbRuntime>, diff --git a/src/device/store.rs b/src/device/store.rs index b562757a..3e72f70a 100644 --- a/src/device/store.rs +++ b/src/device/store.rs @@ -1,4 +1,4 @@ -//! 端末側の正本の読み書きと耐久化の順序(仕様 ⑤「耐久化の順序」)。 +//! 端末側の正本の読み書きと耐久化の順序(SPEC §7.11 Android「耐久化の順序」)。 //! - ジャーナルの追記: 追記 → `sync`。意図の `sync` が返ってから副作用を起こす //! - ファイルの置き換え: 同じディレクトリの tmp に書く → `sync` → `mv -f` → `sync` //! - 圧縮: manifest を書き直して確定させてから、ジャーナルを空にする(逆だと回復できない) @@ -103,7 +103,7 @@ pub async fn compact<F: DeviceFs>(fs: &F, m: &DeviceManifest) -> Result<(), Stor Ok(()) } -/// 登録(仕様 ⑤「登録」4): root が空か存在しないときだけ manifest を作る +/// 登録(SPEC §7.11 Android「登録」4): root が空か存在しないときだけ manifest を作る pub async fn initialize<F: DeviceFs>( fs: &F, device_uuid: &str, diff --git a/src/device/sync.rs b/src/device/sync.rs index 558fbf75..8f62761a 100644 --- a/src/device/sync.rs +++ b/src/device/sync.rs @@ -1,4 +1,4 @@ -//! 保存した計画の実行(`device_sync` ジョブの本体。仕様 ⑤「同期」)。 +//! 保存した計画の実行(`device_sync` ジョブの本体。SPEC §7.11 Android「同期」)。 //! 呼び出し側は先に回復(`recover`)し、その結果で差分を取り直して計画の部分集合(`plan::runnable`)を //! 作ってから渡す。順序は 削除 → パス変更のバッチ → 更新 → 追加 → プレイリスト(先に空きを作る)。 //! 各操作は意図を耐久化してから副作用を起こす。`vacating` より前の書き込みの直前にキャンセルと @@ -65,7 +65,7 @@ pub struct SourceEntry { pub hash: SourceHash, } -/// Library / Derived の root から `openat2` で開く送る元(仕様 ③「配信時の照合」) +/// Library / Derived の root から `openat2` で開く送る元(SPEC §7.11「配信時の照合」) pub struct RootSources { library: Arc<RootDir>, derived: Arc<RootDir>, @@ -768,7 +768,7 @@ impl<F: DeviceFs, S: Sources, C: Control> Exec<'_, F, S, C> { Ok(()) } - /// パス変更を 1 つのバッチで進める(仕様 ⑤「パス変更は大域的な 2 段」)。 + /// パス変更を 1 つのバッチで進める(SPEC §7.11 Android「パス変更は大域的な 2 段」)。 /// 記録 → prepared(更新 + 移動の新しい内容を `new` へ)→ vacating → vacated → placing → done。 /// 行き先を管理外のファイルが占めるメンバーは、封印の前にその成分ごと外す(上書きしない)。 /// 準備で失敗したメンバーがいれば、その曲を含む成分を全員外し、旧バッチを `batch_abort` diff --git a/src/device/verify.rs b/src/device/verify.rs index 8ff9b373..f74a7dd4 100644 --- a/src/device/verify.rs +++ b/src/device/verify.rs @@ -1,4 +1,4 @@ -//! 内容の検証(仕様 ⑤「内容の検証」。差分画面の「内容を検証」ジョブの本体)。 +//! 内容の検証(SPEC §7.11 Android「内容の検証」。差分画面の「内容を検証」ジョブの本体)。 //! 日常の差分はサイズまでしか見ないので、同じサイズの破損や端末側の書き換えはここで見つける。 //! 食い違った曲・欠損した曲は manifest のトークンを空にして書き直す(次の差分で更新に戻る。 //! ファイルは消さない。manifest から外すと管理外になり、同期が上書きできない)。 diff --git a/src/domain/device.rs b/src/domain/device.rs index 92f5bd84..8347c1f3 100644 --- a/src/domain/device.rs +++ b/src/domain/device.rs @@ -1,4 +1,4 @@ -//! 端末への配信の純粋関数(docs/superpowers/specs/2026-09-29-device-delivery-design.md ③、D-95)。 +//! 端末への配信の純粋関数(SPEC §7.11「マニフェスト(あるべき状態)」、D-95)。 //! DB も HTTP も知らない。入力は `db::devices` が組み立てる。 //! //! トークンはすべて不透明な値(解析しない)。構成要素を正準 JSON(serde_json の Map は BTreeMap なので @@ -145,7 +145,7 @@ impl Wait { } } -/// 送る元を決める(仕様 ③「送る元の決定」)。ハッシュの有無はここでは見ない(`build_manifest` が見る) +/// 送る元を決める(SPEC §7.11「送る元の決定」)。ハッシュの有無はここでは見ない(`build_manifest` が見る) pub fn decide_source(s: &VariantSettings, t: &TrackInput) -> Result<Source, Wait> { if s.variant == Variant::Opus && !t.lossless { return Ok(Source { @@ -271,7 +271,7 @@ pub struct DeviceItem { pub sha256: String, } -/// 選曲に入った曲からマニフェストを作る(仕様 ③)。`root_prefix_utf16` は端末の root を前置きする長さ +/// 選曲に入った曲からマニフェストを作る(SPEC §7.11)。`root_prefix_utf16` は端末の root を前置きする長さ /// (区切りの `/` を含む) pub fn build_manifest( s: &VariantSettings, @@ -463,7 +463,7 @@ pub fn build_playlists( (out, errors) } -/// 差分の操作。並び順は実行順(削除 → パス変更 → 更新 → 追加。仕様 ⑤) +/// 差分の操作。並び順は実行順(削除 → パス変更 → 更新 → 追加。SPEC §7.11 Android) #[derive( Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize, )] @@ -568,7 +568,7 @@ pub struct Diff { pub playlist_errors: Vec<(i64, &'static str)>, } -/// 行き先の衝突を不動点まで解く(仕様 ③「差分」)。行き先が「動かない管理下の曲」に占められていれば、 +/// 行き先の衝突を不動点まで解く(SPEC §7.11「差分」)。行き先が「動かない管理下の曲」に占められていれば、 /// その曲をパス衝突で保留にし、その曲自身も動かなくなるので不動点まで繰り返す(入れ替えの片側が保留に /// なれば、もう片側も保留になる)。返すマニフェストの desired と hold が最終的なもの(needs_hash は /// そのまま)。解いた結果をもう一度渡しても変わらない(冪等)。プレイリストは解いた後の desired と @@ -615,7 +615,7 @@ pub fn resolve_collisions(manifest: &Manifest, current: &[DeviceItem]) -> Manife } } -/// 差分(仕様 ③「3 つの集合」「差分」)。desired と現状を track_id で突き合わせ、hold は触らず、 +/// 差分(SPEC §7.11「3 つの集合」「差分」)。desired と現状を track_id で突き合わせ、hold は触らず、 /// マニフェストに無い(remove)ものを削除にする。行き先の衝突は内部で `resolve_collisions` を通して /// 解く(冪等なので、解いた後のマニフェストを渡してもよい。`db::devices::compute` はプレイリストを /// 組み立てる前に解いたものを渡す) @@ -747,7 +747,7 @@ pub fn plan_token(generation: i64, d: &Diff) -> String { })) } -/// 曲ごとの端末の状態(④ 可視化 A / D、`device_pending`)。対象外で端末にも無い曲は持たない +/// 曲ごとの端末の状態(D-95 の可視化 A / D、`device_pending`)。対象外で端末にも無い曲は持たない #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] #[serde(tag = "state", rename_all = "snake_case")] pub enum TrackState { @@ -947,14 +947,14 @@ pub fn playlist_sync_states( out } -/// 送る量と、実行順(削除 → パス変更のバッチ → 更新 → 追加。仕様 ⑤)に沿った「今より増える量」の最大値 +/// 送る量と、実行順(削除 → パス変更のバッチ → 更新 → 追加。SPEC §7.11 Android)に沿った「今より増える量」の最大値 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)] pub struct Estimate { pub transfer_bytes: u64, pub peak_bytes: u64, } -/// 実行順(削除 → パス変更のバッチ → 更新 → 追加。仕様 ⑤)に沿った送る量と「今より増える量」の最大値。 +/// 実行順(削除 → パス変更のバッチ → 更新 → 追加。SPEC §7.11 Android)に沿った送る量と「今より増える量」の最大値。 /// `ops` は (操作, track_id, 新しい内容の size) pub fn peak_bytes(ops: &[(OpKind, i64, u64)], current: &[DeviceItem]) -> Estimate { let old: HashMap<i64, i64> = current diff --git a/src/domain/filter.rs b/src/domain/filter.rs index 81929483..af56b951 100644 --- a/src/domain/filter.rs +++ b/src/domain/filter.rs @@ -44,14 +44,14 @@ pub struct Filter { /// 固定フィルタ(SPEC §12.1 サイドバー)。複数は AND #[serde(default, skip_serializing_if = "Vec::is_empty")] pub flags: Vec<Flag>, - /// スマートプレイリスト DSL(docs/DSL.md)の原文。WHERE 句だけを使い、ORDER BY / LIMIT は + /// スマートプレイリスト DSL(docs/user-guide/dsl.md)の原文。WHERE 句だけを使い、ORDER BY / LIMIT は /// 無視する(プレビュー用。D-39 / D-54)。`parse` で構文・型を検証済み #[serde(default, skip_serializing_if = "Option::is_none")] pub dsl: Option<String>, /// 検索語。`FTS_MIN_CHARS` 以上なら FTS5 trigram、未満なら LIKE #[serde(default, skip_serializing_if = "Option::is_none")] pub q: Option<String>, - /// 端末に未反映(端末 id。追加・更新・移動。④ C) + /// 端末に未反映(端末 id。追加・更新・移動。D-95 の可視化 C) #[serde(default, skip_serializing_if = "Option::is_none")] pub device_pending: Option<i64>, /// `device_pending` と DSL の端末フィールドを引く集合。JSON には出さず、API が diff --git a/src/domain/pathgen.rs b/src/domain/pathgen.rs index 6938f475..d4512f0a 100644 --- a/src/domain/pathgen.rs +++ b/src/domain/pathgen.rs @@ -1,5 +1,5 @@ //! パス生成: テンプレート展開・ファイル名正規化・切り詰め・衝突降格 -//! (SPEC §5「パステンプレート」「ファイル名正規化」、D-7、docs/TASKS.md P0-11)。 +//! (SPEC §5「パステンプレート」「ファイル名正規化」、D-7、docs/development/tasks-history.md P0-11)。 //! //! - タグ値は NFC のまま保持し、置換テーブルは**ファイル名にだけ**適用する //! - 置換テーブルは ytmusic の foo_fileops 互換表を継承し、SPEC の追加分と、表に無い diff --git a/src/edit/mod.rs b/src/edit/mod.rs index 57027f64..8f70e1bb 100644 --- a/src/edit/mod.rs +++ b/src/edit/mod.rs @@ -1,4 +1,4 @@ -//! 編集バッチの coordinator(SPEC §7.5 / §8、D-24、docs/TASKS.md P0-9)。 +//! 編集バッチの coordinator(SPEC §7.5 / §8、D-24、docs/development/tasks-history.md P0-9)。 //! //! ```text //! prepare_tags: 1 トランザクションで edit_batches(prepared) / edit_ops(pending) / edits を記録 diff --git a/src/edit/normalize.rs b/src/edit/normalize.rs index 0501f284..d08101b3 100644 --- a/src/edit/normalize.rs +++ b/src/edit/normalize.rs @@ -1,4 +1,4 @@ -//! ロスレス → FLAC 正規化の coordinator(SPEC §7.4、D-10 / D-45 / D-46、docs/TASKS.md P1-4)。 +//! ロスレス → FLAC 正規化の coordinator(SPEC §7.4、D-10 / D-45 / D-46、docs/development/tasks-history.md P1-4)。 //! //! ```text //! plan_normalize: 対象トラックの宛先(拡張子を .flac にした同じパス)と、対象外・衝突の理由を diff --git a/src/edit/rename.rs b/src/edit/rename.rs index a571156a..351b58cf 100644 --- a/src/edit/rename.rs +++ b/src/edit/rename.rs @@ -1,4 +1,4 @@ -//! 一括リネームの coordinator(SPEC §7.5「リネーム」、§5、D-7、docs/TASKS.md P0-11)。 +//! 一括リネームの coordinator(SPEC §7.5「リネーム」、§5、D-7、docs/development/tasks-history.md P0-11)。 //! //! ```text //! plan_rename: テンプレートと album のメタデータからパスを計画する(`domain::pathgen`)。 diff --git a/src/edit/revert.rs b/src/edit/revert.rs index d5181ea9..7b971ed7 100644 --- a/src/edit/revert.rs +++ b/src/edit/revert.rs @@ -1,4 +1,4 @@ -//! 巻き戻し(SPEC §7.5「巻き戻し」、§9 `/api/history/:batch/revert`、docs/TASKS.md P0-12、D-44)。 +//! 巻き戻し(SPEC §7.5「巻き戻し」、§9 `/api/history/:batch/revert`、docs/development/tasks-history.md P0-12、D-44)。 //! //! 巻き戻しは**通常のバッチ**として記録し、`reverts_batch_id` で元を指す。 //! diff --git a/src/jobs/handlers/device.rs b/src/jobs/handlers/device.rs index c3939ac3..cee4be87 100644 --- a/src/jobs/handlers/device.rs +++ b/src/jobs/handlers/device.rs @@ -1,4 +1,4 @@ -//! 端末ジョブ(P5-3b、仕様 ⑤、D-98): `device_scan`(回復してキャッシュを置き換える)、`device_sync`(確定した +//! 端末ジョブ(P5-3b、SPEC §7.11 Android、D-98): `device_scan`(回復してキャッシュを置き換える)、`device_sync`(確定した //! 計画を実行)、`device_verify`(全曲の sha256 を検証)。どれも端末ごとの排他(`job_mutexes` の //! `device:<id>` と `AdbRuntime::device_lock`)の下で、最初に回復を行う。未接続なら attempts を消費せずに //! `DISCONNECTED_REQUEUE_SECS` 待つ(scan は待たずに終わる。次の接続でまた投入される) diff --git a/src/jobs/handlers/source_hash.rs b/src/jobs/handlers/source_hash.rs index 0168328c..c544df96 100644 --- a/src/jobs/handlers/source_hash.rs +++ b/src/jobs/handlers/source_hash.rs @@ -1,4 +1,4 @@ -//! `source_hash` ジョブ(docs/superpowers/specs/2026-09-29-device-delivery-design.md ③、D-95)。payload は +//! `source_hash` ジョブ(SPEC §7.11「`source_hash` ジョブ」、D-95)。payload は //! `{"track_id", "source"}`(source は `master` / `opus` / `aac`)、dedup `source_hash:<track_id>:<source>`。 //! //! 送る元を root の dirfd から `openat2` で開き(`RootDir::open_file`)、開いた FD の identity(inode・size・ diff --git a/src/jobs/mod.rs b/src/jobs/mod.rs index 0ecb9036..09fea25a 100644 --- a/src/jobs/mod.rs +++ b/src/jobs/mod.rs @@ -33,10 +33,10 @@ pub use crate::db::jobs::{ pub use context::{JobContext, TempGuard}; pub use queue::{Jobs, EVENT_CAPACITY, LIST_LIMITS}; -/// 未接続の端末ジョブを待たせる秒数(仕様 ⑤「同期」。繋がったら `Jobs::wake_device` で前倒し) +/// 未接続の端末ジョブを待たせる秒数(SPEC §7.11 Android「同期」。繋がったら `Jobs::wake_device` で前倒し) pub const DISCONNECTED_REQUEUE_SECS: i64 = 300; -/// 端末ごとの名前付き排他(scan / sync / verify を同じ端末で並べない。仕様 ③) +/// 端末ごとの名前付き排他(scan / sync / verify を同じ端末で並べない。SPEC §7.11) pub fn device_mutex(device_id: i64) -> String { format!("device:{device_id}") } diff --git a/src/lib.rs b/src/lib.rs index e2409b6f..b3f96d3b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,5 @@ //! spindle: TrueNAS 上で動く単一コンテナの音楽ライブラリ管理アプリ。 -//! 仕様は docs/SPEC.md、判断の理由は docs/DECISIONS.md +//! 仕様は docs/spec/、判断の理由は docs/decisions/ pub mod api; pub mod cd; diff --git a/src/playlist/autoexport.rs b/src/playlist/autoexport.rs index 35af57ca..35a60cfb 100644 --- a/src/playlist/autoexport.rs +++ b/src/playlist/autoexport.rs @@ -162,7 +162,7 @@ fn triggers(ev: &Event) -> bool { /// 端末の差分に「評価待ち」を出す合図になるイベント([`triggers`] の部分集合)。 /// 端末の選曲に載るプレイリストは端末のフィールド(`on_device` / `device_pending`)を使えない -/// (③ 循環の禁止)ので、端末の状態だけを変えるジョブ(`source_hash`)や、ルールが引く値を変えない +/// (SPEC §7.11「循環の禁止」)ので、端末の状態だけを変えるジョブ(`source_hash`)や、ルールが引く値を変えない /// ジョブ(`thumbnail`)の完了では立てない。再評価そのもの([`triggers`])はこれまでどおり走る /// (`device_pending` を使うプレイリストは source_hash の完了で結果が変わる) fn marks_reevaluation(ev: &Event) -> bool { @@ -183,7 +183,7 @@ fn marks_reevaluation(ev: &Event) -> bool { } } -/// スマートプレイリストの再評価待ち(仕様 ③「評価待ち」)。D-54 の常駐タスクは全件をまとめて評価するので +/// スマートプレイリストの再評価待ち(SPEC §7.11「評価待ち」)。D-54 の常駐タスクは全件をまとめて評価するので /// 1 ビットで持つ。永続化しない(起動時は dirty から始める) #[derive(Debug, Default)] pub struct ReevalFlag(std::sync::atomic::AtomicBool); diff --git a/src/playlist/compile.rs b/src/playlist/compile.rs index 824f0c68..f4c352ff 100644 --- a/src/playlist/compile.rs +++ b/src/playlist/compile.rs @@ -1,4 +1,4 @@ -//! AST → パラメータ化 SQL(docs/DSL.md「SQL 生成」、D-54)。 +//! AST → パラメータ化 SQL(docs/user-guide/dsl.md「SQL 生成」、D-54)。 //! //! - 列名はここのホワイトリスト([`resolve`])でだけ解決する。AST のフィールド名を SQL に埋めない //! - 値はすべてバインドパラメータ diff --git a/src/playlist/dsl.pest b/src/playlist/dsl.pest index 88910a25..8f0f5772 100644 --- a/src/playlist/dsl.pest +++ b/src/playlist/dsl.pest @@ -1,4 +1,4 @@ -// スマートプレイリスト DSL(docs/DSL.md)。キーワードは大小文字を区別しない。 +// スマートプレイリスト DSL(docs/user-guide/dsl.md)。キーワードは大小文字を区別しない。 // 値・フィールド名・整数は atomic(内部の空白を読み飛ばさない) WHITESPACE = _{ " " | "\t" | "\r" | "\n" } diff --git a/src/playlist/dsl.rs b/src/playlist/dsl.rs index 2181c8dc..3f8148dc 100644 --- a/src/playlist/dsl.rs +++ b/src/playlist/dsl.rs @@ -1,6 +1,6 @@ -//! スマートプレイリスト DSL のパース(docs/DSL.md、D-16、D-54)。 +//! スマートプレイリスト DSL のパース(docs/user-guide/dsl.md、D-16、D-54)。 //! -//! `pest` で `dsl.pest` の文法を読み、[`Rule`](AST)にする。AST は DSL.md の JSON 形で +//! `pest` で `dsl.pest` の文法を読み、[`Rule`](AST)にする。AST は docs/user-guide/dsl.md の JSON 形で //! `playlists.rule_ast` に保存し、実行時に `playlist::compile` がパラメータ化 SQL へ変換する。 //! 原文は `rule_source` に別途残す(AST から逆生成すると整形が変わる) @@ -96,7 +96,7 @@ impl Rule { } } -/// 端末の状態を引くフィールド(仕様 ④ C)。これを使うルールは端末の選曲に載せられない(③ 循環の禁止) +/// 端末の状態を引くフィールド(D-95 の可視化 C)。これを使うルールは端末の選曲に載せられない(SPEC §7.11「循環の禁止」) pub const DEVICE_FIELDS: [&str; 2] = ["on_device", "device_pending"]; /// 構文エラー(1 始まりの行・桁) @@ -324,7 +324,7 @@ fn build_order(p: Pair<GrammarRule>) -> Order { Order { field, desc } } -// ---------------------------------------------------------------- JSON 形(DSL.md) +// ---------------------------------------------------------------- JSON 形(docs/user-guide/dsl.md) #[derive(Serialize, Deserialize)] #[serde(tag = "op", rename_all = "lowercase")] diff --git a/src/playlist/fb2k.rs b/src/playlist/fb2k.rs index 21d88890..2e875c7c 100644 --- a/src/playlist/fb2k.rs +++ b/src/playlist/fb2k.rs @@ -1,4 +1,4 @@ -//! AST → foobar2000 Autoplaylist のクエリとソートパターン(docs/DSL.md「foobar2000 へのエクスポート」、 +//! AST → foobar2000 Autoplaylist のクエリとソートパターン(docs/user-guide/dsl.md「foobar2000 へのエクスポート」、 //! SPEC §10、D-55)。 //! //! foobar の Autoplaylist はファイルとして保存できないので、クエリ文字列を出してユーザが貼る。 diff --git a/src/playlist/import.rs b/src/playlist/import.rs index ec0d14bf..329c7afb 100644 --- a/src/playlist/import.rs +++ b/src/playlist/import.rs @@ -1,4 +1,4 @@ -//! m3u8 の取り込み(docs/TASKS.md P1-6、D-53)。 +//! m3u8 の取り込み(docs/development/tasks-history.md P1-6、D-53)。 //! //! 行を Library のトラックへ解決する規則: //! 1. `\` を `/` に。相対なら先頭の `./` `../` を剥がし先頭の `Library/` / `Derived/` を落とす。 diff --git a/tests/aac_encode.rs b/tests/aac_encode.rs index 335975b3..2fe447d2 100644 --- a/tests/aac_encode.rs +++ b/tests/aac_encode.rs @@ -1,4 +1,4 @@ -//! Derived 用の AAC エンコード(SPEC §7.6「aac 系統」、D-75、docs/TASKS.md P4-8)。ffmpeg 1 パスで +//! Derived 用の AAC エンコード(SPEC §7.6「aac 系統」、D-75、docs/development/tasks-history.md P4-8)。ffmpeg 1 パスで //! gain の焼き込みと 48 kHz 上限。ffmpeg が無ければ skip #![cfg(target_os = "linux")] diff --git a/tests/aac_tags.rs b/tests/aac_tags.rs index 7d9ae6a4..2434b1c3 100644 --- a/tests/aac_tags.rs +++ b/tests/aac_tags.rs @@ -1,4 +1,4 @@ -//! aac 系統に書くタグ集合と MP4 への書き込み(SPEC §7.6「aac 系統」、D-75、docs/TASKS.md P4-8)。 +//! aac 系統に書くタグ集合と MP4 への書き込み(SPEC §7.6「aac 系統」、D-75、docs/development/tasks-history.md P4-8)。 //! 多値の結合、RG 系キーの除去、iTunNORM、標準 atom / フリーフォームの写像。ffmpeg / ffprobe が //! 無ければ書き込みのテストは skip diff --git a/tests/albums_api.rs b/tests/albums_api.rs index aff7176b..1e16d259 100644 --- a/tests/albums_api.rs +++ b/tests/albums_api.rs @@ -1,4 +1,4 @@ -//! `PATCH /api/albums/:id { album_gain }`(P4-5、D-74)。仕様: docs/SPEC.md §9 +//! `PATCH /api/albums/:id { album_gain }`(P4-5、D-74)。仕様: SPEC §9 use std::net::SocketAddr; use std::sync::Arc; diff --git a/tests/artwork.rs b/tests/artwork.rs index ab277a22..8411378c 100644 --- a/tests/artwork.rs +++ b/tests/artwork.rs @@ -1,4 +1,4 @@ -//! アートワーク(SPEC §5 / §7.1、docs/TASKS.md P1-3、D-49): 同梱カバー画像の名前、画像の判別、 +//! アートワーク(SPEC §5 / §7.1、docs/development/tasks-history.md P1-3、D-49): 同梱カバー画像の名前、画像の判別、 //! 埋め込み画像の選択、ハッシュアドレスのキャッシュ mod common; diff --git a/tests/artwork_api.rs b/tests/artwork_api.rs index 769f62ec..d1ebfd5c 100644 --- a/tests/artwork_api.rs +++ b/tests/artwork_api.rs @@ -1,5 +1,5 @@ //! `GET /api/artwork/:hash?size=` と `GET /api/albums` の `artwork_hash`(SPEC §9、 -//! docs/TASKS.md P1-3、D-49)。合成ファイルは ffmpeg で作る(無ければ skip) +//! docs/development/tasks-history.md P1-3、D-49)。合成ファイルは ffmpeg で作る(無ければ skip) #![cfg(target_os = "linux")] diff --git a/tests/artwork_scan.rs b/tests/artwork_scan.rs index e038ae27..2ce490c9 100644 --- a/tests/artwork_scan.rs +++ b/tests/artwork_scan.rs @@ -1,4 +1,4 @@ -//! スキャンの Phase 5(アートワークの解決。SPEC §7.1、docs/TASKS.md P1-3、D-49)。 +//! スキャンの Phase 5(アートワークの解決。SPEC §7.1、docs/development/tasks-history.md P1-3、D-49)。 //! 同梱カバー画像 → 最初のトラックの埋め込み画像の順で album のアートワークを決め、原画像を //! ハッシュアドレスのキャッシュへ置き、thumbnail ジョブを投入する。合成ファイルは ffmpeg で作る //! (無ければ skip)。画像はヘッダだけの最小 JPEG / PNG(寸法が読めればよい) diff --git a/tests/auth.rs b/tests/auth.rs index ce77ab73..44638363 100644 --- a/tests/auth.rs +++ b/tests/auth.rs @@ -1,5 +1,5 @@ //! 認証(P0-3): 初期パスワード、セッション Cookie、CSRF、trusted_cidrs、ロックモード。 -//! 仕様: docs/SPEC.md §9「認証」、docs/DECISIONS.md D-27 / D-28 +//! 仕様: SPEC §9「認証」、D-27 / D-28 use std::net::SocketAddr; use std::sync::Arc; diff --git a/tests/backup.rs b/tests/backup.rs index 50db2df7..077493ab 100644 --- a/tests/backup.rs +++ b/tests/backup.rs @@ -1,4 +1,4 @@ -//! `backup` ジョブと復元ドリル(SPEC §8 / §14「バックアップ」、docs/TASKS.md P0-13)。 +//! `backup` ジョブと復元ドリル(SPEC §8 / §14「バックアップ」、docs/development/tasks-history.md P0-13)。 //! `VACUUM INTO` → tmp fsync → rename → 親 fsync → 世代 GC、容量不足の中断、 //! スケジューラの due 判定、バックアップから復元した DB での再スキャン一致 diff --git a/tests/batch_api.rs b/tests/batch_api.rs index 1abfc750..75fa9255 100644 --- a/tests/batch_api.rs +++ b/tests/batch_api.rs @@ -1,5 +1,5 @@ //! `POST /api/tracks/batch/preview` / `PATCH /api/tracks/batch`(SPEC §9 / §7.5 / §12.3、 -//! docs/TASKS.md P0-10、D-33 / D-42)。合成ファイルは ffmpeg で 1 本作り、コピーして増やす +//! docs/development/tasks-history.md P0-10、D-33 / D-42)。合成ファイルは ffmpeg で 1 本作り、コピーして増やす #![cfg(target_os = "linux")] diff --git a/tests/cd_crc.rs b/tests/cd_crc.rs index 0a2d5aa9..ab00a966 100644 --- a/tests/cd_crc.rs +++ b/tests/cd_crc.rs @@ -1,5 +1,5 @@ //! AccurateRip v1/v2 CRC と CTDB CRC32(SPEC §7.2「CRC 計算」、D-13)。 -//! 受け入れ: docs/TASKS.md P2-6 +//! 受け入れ: docs/development/tasks-history.md P2-6 //! //! 除外規則(先頭トラックの頭 5×588−1、末尾トラックの尻 5×588、CTDB の 10 セクタ)は //! 壊れても画面上は「不一致」に見えるだけで発見できないので、 diff --git a/tests/cd_toc.rs b/tests/cd_toc.rs index 46414cff..00d11c2c 100644 --- a/tests/cd_toc.rs +++ b/tests/cd_toc.rs @@ -1,5 +1,5 @@ //! TOC と各種 DiscID(SPEC §7.2「ID 算出」、§7.3「TOC 再構成」)。 -//! 受け入れ: docs/TASKS.md P2-2(ID 算出部分) +//! 受け入れ: docs/development/tasks-history.md P2-2(ID 算出部分) //! //! 参照値は実在のディスクから取った: //! - MusicBrainz「Disc ID Calculation」の 6 トラック例と CD-Extra(8 トラック、末尾がデータ)例。 diff --git a/tests/config.rs b/tests/config.rs index d2f0078c..c253b8c2 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -1,5 +1,5 @@ //! `config.toml` の読み込みと検証。 -//! 仕様: docs/SPEC.md §13。実体は deploy/config.example.toml と一致させる。 +//! 仕様: SPEC §13。実体は deploy/config.example.toml と一致させる。 use std::net::SocketAddr; @@ -62,8 +62,8 @@ fn inbox_section_is_optional_and_accepts_zero() { fn spec_config_block_matches_example() { // SPEC §13 の toml ブロックと deploy/config.example.toml は「一致させる」規約。 // 構造(キー集合と値)が一致することを固定する。コメントと空白は無視。 - let spec = include_str!("../docs/SPEC.md"); - let start = spec.find("## 13. 設定").expect("SPEC §13 が存在すること"); + let spec = include_str!("../docs/spec/13-config.md"); + let start = spec.find("# 13. 設定").expect("SPEC §13 が存在すること"); let block = &spec[start..]; let begin = block.find("```toml").expect("§13 に toml ブロック") + "```toml".len(); let end = block[begin..].find("```").expect("toml ブロックの終端") + begin; diff --git a/tests/db.rs b/tests/db.rs index 2e5d12ef..0c39134e 100644 --- a/tests/db.rs +++ b/tests/db.rs @@ -1,5 +1,5 @@ //! DB 層: マイグレーション適用、PRAGMA、書き込み単一 / 読み取りプール、FTS5 の挙動。 -//! 仕様: docs/SPEC.md §6「全文検索」、docs/TASKS.md P0-2 +//! 仕様: SPEC §6「全文検索」、docs/development/tasks-history.md P0-2 use rusqlite::{params, Connection}; diff --git a/tests/derived.rs b/tests/derived.rs index 97f47959..70149240 100644 --- a/tests/derived.rs +++ b/tests/derived.rs @@ -1,4 +1,4 @@ -//! Derived の純粋な判定(docs/TASKS.md P1-10、D-51)。期待パス、必要な処理の判定、Opus に書く +//! Derived の純粋な判定(docs/development/tasks-history.md P1-10、D-51)。期待パス、必要な処理の判定、Opus に書く //! タグ集合 use lofty::picture::{MimeType, Picture, PictureType}; diff --git a/tests/derived_db.rs b/tests/derived_db.rs index b4fab81e..787ceeb0 100644 --- a/tests/derived_db.rs +++ b/tests/derived_db.rs @@ -1,4 +1,4 @@ -//! `derived_files` の読み書きと transcode ジョブの投入判定(docs/TASKS.md P1-10、D-51)。 +//! `derived_files` の読み書きと transcode ジョブの投入判定(docs/development/tasks-history.md P1-10、D-51)。 //! `:memory:` にマイグレーションを流し、tracks / albums / artwork を直接 INSERT して判定だけを見る use rusqlite::{params, Connection}; diff --git a/tests/derived_sync.rs b/tests/derived_sync.rs index d82a50de..c8b41b60 100644 --- a/tests/derived_sync.rs +++ b/tests/derived_sync.rs @@ -1,4 +1,4 @@ -//! Derived の追随ジョブが投入される契機(docs/TASKS.md P1-10、D-51): scan 完了、tagwrite applied、 +//! Derived の追随ジョブが投入される契機(docs/development/tasks-history.md P1-10、D-51): scan 完了、tagwrite applied、 //! rename applied、RG 解析の保存。transcode ハンドラを登録せずに queued の transcode を数える。 //! 最後に scan → transcode の自動連鎖で `delivery` ビューが版に追随することを通す。 //! ffmpeg / opusenc が無ければ skip diff --git a/tests/device_domain.rs b/tests/device_domain.rs index 708d1133..31e21b35 100644 --- a/tests/device_domain.rs +++ b/tests/device_domain.rs @@ -1,4 +1,4 @@ -//! 端末配信の純粋関数(仕様 ③)。DB を使わない +//! 端末配信の純粋関数(SPEC §7.11)。DB を使わない use serde_json::json; use spindle::domain::derived::{Current, Variant}; diff --git a/tests/device_journal.rs b/tests/device_journal.rs index f659e5b4..c9b1a1cf 100644 --- a/tests/device_journal.rs +++ b/tests/device_journal.rs @@ -1,4 +1,4 @@ -//! ジャーナル(仕様 ⑤「ジャーナルと中断からの回復」) +//! ジャーナル(SPEC §7.11 Android「ジャーナルと中断からの回復」) use spindle::device::journal::*; use spindle::domain::device::EntryKind; diff --git a/tests/device_ondevice.rs b/tests/device_ondevice.rs index f0056886..6e5e0915 100644 --- a/tests/device_ondevice.rs +++ b/tests/device_ondevice.rs @@ -1,4 +1,4 @@ -//! 端末側の正本 `.spindle/manifest.json` の形式(仕様 ⑤「端末側の正本」) +//! 端末側の正本 `.spindle/manifest.json` の形式(SPEC §7.11 Android「端末側の正本」) use spindle::device::ondevice::*; use spindle::domain::device::{EntryKind, OpKind, PlaylistOpKind}; diff --git a/tests/device_plan.rs b/tests/device_plan.rs index 38a32ef4..0666696a 100644 --- a/tests/device_plan.rs +++ b/tests/device_plan.rs @@ -1,4 +1,4 @@ -//! 確定した計画と再開(仕様 ③「開始済みの計画の再開」) +//! 確定した計画と再開(SPEC §7.11「開始済みの計画の再開」) use spindle::device::plan::*; use spindle::domain::device::*; diff --git a/tests/device_quote.rs b/tests/device_quote.rs index 1f6e48eb..5f181473 100644 --- a/tests/device_quote.rs +++ b/tests/device_quote.rs @@ -1,4 +1,4 @@ -//! 端末側シェルへのクォートと入力の検査(仕様 ⑤「接続の構成」) +//! 端末側シェルへのクォートと入力の検査(SPEC §7.11 Android「接続の構成」) use std::process::Command; diff --git a/tests/device_recover.rs b/tests/device_recover.rs index a32bf3d5..9ed34114 100644 --- a/tests/device_recover.rs +++ b/tests/device_recover.rs @@ -1,4 +1,4 @@ -//! 接続のたびの回復(仕様 ⑤「ジャーナルと中断からの回復」の表) +//! 接続のたびの回復(SPEC §7.11 Android「ジャーナルと中断からの回復」の表) mod device_support; diff --git a/tests/device_store.rs b/tests/device_store.rs index 650b4106..8d412df9 100644 --- a/tests/device_store.rs +++ b/tests/device_store.rs @@ -1,4 +1,4 @@ -//! 耐久化の順序と登録時の初期化(仕様 ⑤「耐久化の順序」「登録」) +//! 耐久化の順序と登録時の初期化(SPEC §7.11 Android「耐久化の順序」「登録」) mod device_support; diff --git a/tests/device_sync.rs b/tests/device_sync.rs index 5bb9bae1..8d09717e 100644 --- a/tests/device_sync.rs +++ b/tests/device_sync.rs @@ -1,4 +1,4 @@ -//! 保存した計画の実行(仕様 ⑤「同期」) +//! 保存した計画の実行(SPEC §7.11 Android「同期」) mod device_support; diff --git a/tests/device_verify.rs b/tests/device_verify.rs index bf2c9ce2..130e723e 100644 --- a/tests/device_verify.rs +++ b/tests/device_verify.rs @@ -1,4 +1,4 @@ -//! 内容の検証(仕様 ⑤「内容の検証」) +//! 内容の検証(SPEC §7.11 Android「内容の検証」) mod device_support; diff --git a/tests/devices_db.rs b/tests/devices_db.rs index f6fc9e90..9c4d3643 100644 --- a/tests/devices_db.rs +++ b/tests/devices_db.rs @@ -1,4 +1,4 @@ -//! 端末配信の DB 層(仕様 ②③)。`:memory:` に全マイグレーションを流して確かめる +//! 端末配信の DB 層(SPEC §6「端末への配信の表」③)。`:memory:` に全マイグレーションを流して確かめる use rusqlite::{params, Connection}; use spindle::config::{DerivedConfig, OpusVariantConfig}; @@ -391,7 +391,7 @@ fn materialize_rolls_back_evaluated_at_when_item_rewrite_fails() { ); } -/// 仕様 ③「送る元のハッシュ」: ハッシュ行の identity が `tracks` の現在の物理同一性 +/// SPEC §7.11「送る元のハッシュ」: ハッシュ行の identity が `tracks` の現在の物理同一性 /// (inode, size, mtime_ns, ctime_ns。dev は見ない。D-62) とずれていれば、外部がファイルを /// 書き換えた可能性があるので使わず、再ハッシュを要求する #[test] diff --git a/tests/devices_plans.rs b/tests/devices_plans.rs index 49680813..5a785954 100644 --- a/tests/devices_plans.rs +++ b/tests/devices_plans.rs @@ -1,4 +1,4 @@ -//! 計画の確定・終端とキャッシュの全置換(仕様 ②③「計画の終端」) +//! 計画の確定・終端とキャッシュの全置換(SPEC §6「端末への配信の表」③「計画の終端」) use rusqlite::{params, Connection}; use spindle::config::{DerivedConfig, OpusVariantConfig}; diff --git a/tests/devices_schema.rs b/tests/devices_schema.rs index 8eb38d8b..ee2368f9 100644 --- a/tests/devices_schema.rs +++ b/tests/devices_schema.rs @@ -1,4 +1,4 @@ -//! 0002_devices の性質(仕様 ②)。空 DB にマイグレーションを流して確かめる +//! 0002_devices の性質(SPEC §6「端末への配信の表」)。空 DB にマイグレーションを流して確かめる use rusqlite::{params, Connection}; use spindle::db::jobs::JobType; diff --git a/tests/dsl.rs b/tests/dsl.rs index b1e88af9..c5774659 100644 --- a/tests/dsl.rs +++ b/tests/dsl.rs @@ -1,4 +1,4 @@ -//! スマートプレイリスト DSL のパース(docs/DSL.md、D-16、D-54)。 +//! スマートプレイリスト DSL のパース(docs/user-guide/dsl.md、D-16、D-54)。 //! 文法・優先順位・値の引用・キーワードの大小文字・AST の JSON 形を固定する use serde_json::json; diff --git a/tests/dsl_compile.rs b/tests/dsl_compile.rs index 6ac55ddb..0ab656c3 100644 --- a/tests/dsl_compile.rs +++ b/tests/dsl_compile.rs @@ -1,4 +1,4 @@ -//! AST → パラメータ化 SQL と `:memory:` での評価(docs/DSL.md「SQL 生成」、D-54)。 +//! AST → パラメータ化 SQL と `:memory:` での評価(docs/user-guide/dsl.md「SQL 生成」、D-54)。 //! 列名はホワイトリスト、値はすべてバインド、任意タグは track_tags の EXISTS、 //! `missing` を参照しない限り active だけ diff --git a/tests/dsl_device_fields.rs b/tests/dsl_device_fields.rs index 00e9f182..86a97174 100644 --- a/tests/dsl_device_fields.rs +++ b/tests/dsl_device_fields.rs @@ -1,4 +1,4 @@ -//! DSL の端末フィールド(仕様 ④ C)。値は端末名、演算子は IS だけ。device_pending は呼び出し側が渡す集合を引く +//! DSL の端末フィールド(D-95 の可視化 C)。値は端末名、演算子は IS だけ。device_pending は呼び出し側が渡す集合を引く use std::collections::HashMap; use std::sync::Arc; diff --git a/tests/edits.rs b/tests/edits.rs index f7db4b19..df36f1d9 100644 --- a/tests/edits.rs +++ b/tests/edits.rs @@ -1,4 +1,4 @@ -//! 編集履歴の記録機構(SPEC §7.5、docs/TASKS.md P0-9、D-24)。 +//! 編集履歴の記録機構(SPEC §7.5、docs/development/tasks-history.md P0-9、D-24)。 //! 記録 → DB 先行更新 → track 単位の tagwrite → 事前条件 → overlay の解消 → 集計 → キャンセル → //! 起動時リカバリ。合成ファイルは ffmpeg で作る(無ければ skip)。 diff --git a/tests/fb2k.rs b/tests/fb2k.rs index 666e5885..43ab150d 100644 --- a/tests/fb2k.rs +++ b/tests/fb2k.rs @@ -1,5 +1,5 @@ //! スマートプレイリストのルール → foobar2000 Autoplaylist のクエリ + ソートパターン -//! (docs/DSL.md「foobar2000 へのエクスポート」、D-55)。 +//! (docs/user-guide/dsl.md「foobar2000 へのエクスポート」、D-55)。 //! 写像表・演算子・引用規則・変換不能な項の脱落・ORDER BY の分離を固定する use spindle::playlist::dsl::parse; diff --git a/tests/fingerprint.rs b/tests/fingerprint.rs index 3b81303a..f5d6843a 100644 --- a/tests/fingerprint.rs +++ b/tests/fingerprint.rs @@ -1,6 +1,6 @@ //! 音声フィンガープリント(SPEC §6「変更検出と版の遷移」、D-30)。 //! FLAC は STREAMINFO の MD5、ALAC / WAV はデコードした PCM の MD5、非可逆はパケット列の SHA-256。 -//! 受け入れ: docs/TASKS.md P0-5 (c) (l) +//! 受け入れ: docs/development/tasks-history.md P0-5 (c) (l) //! //! 圧縮形式のファイルは ffmpeg で作る。ffmpeg が無い環境ではそのテストを skip する //! (CI には ffmpeg を入れる)。 diff --git a/tests/flaccheck_job.rs b/tests/flaccheck_job.rs index 99b7798f..4427aeba 100644 --- a/tests/flaccheck_job.rs +++ b/tests/flaccheck_job.rs @@ -1,4 +1,4 @@ -//! `flaccheck` ジョブ(SPEC §7.9 / §8、docs/TASKS.md P1-5、D-57)。`flac -t` と STREAMINFO の MD5 から +//! `flaccheck` ジョブ(SPEC §7.9 / §8、docs/development/tasks-history.md P1-5、D-57)。`flac -t` と STREAMINFO の MD5 から //! ok / md5_missing / decode_error を版付きで記録する。ファイルは書かない。 //! 合成ファイルは ffmpeg で作り、検査は flac で行う(無ければ skip) diff --git a/tests/fsroot.rs b/tests/fsroot.rs index f2ec978f..1c46cd6e 100644 --- a/tests/fsroot.rs +++ b/tests/fsroot.rs @@ -1,6 +1,6 @@ //! root dirfd 基準のファイル操作(SPEC §5「パスの表現と境界」、D-31)。 //! `openat2(RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS)`、`O_EXCL` の tmp、`RENAME_NOREPLACE`。 -//! 受け入れ: docs/TASKS.md P0-5 (i) +//! 受け入れ: docs/development/tasks-history.md P0-5 (i) #![cfg(target_os = "linux")] diff --git a/tests/gc.rs b/tests/gc.rs index 9e299ee8..f78c77e1 100644 --- a/tests/gc.rs +++ b/tests/gc.rs @@ -1,4 +1,4 @@ -//! `gc` ジョブ(SPEC §6「論理削除」/ §8、docs/TASKS.md P1-11、D-56)。物理削除を行う唯一の経路。 +//! `gc` ジョブ(SPEC §6「論理削除」/ §8、docs/development/tasks-history.md P1-11、D-56)。物理削除を行う唯一の経路。 //! 5 区分(missing トラック / missing アルバム / Archive の退避 / Derived の孤児 / アートワークの //! 孤児)の判定と実行、dry-run が何も消さないこと、scan 実行中は走らないことを固定する diff --git a/tests/hires_job.rs b/tests/hires_job.rs index 642bba57..1c9f605c 100644 --- a/tests/hires_job.rs +++ b/tests/hires_job.rs @@ -1,4 +1,4 @@ -//! `hirescheck` ジョブ(SPEC §7.10 / §8、docs/TASKS.md P3-5、D-71)。合成した 24/96 の可逆を +//! `hirescheck` ジョブ(SPEC §7.10 / §8、docs/development/tasks-history.md P3-5、D-71)。合成した 24/96 の可逆を //! デコードして判定と計測値を版付きで記録する。ファイルは書かない。 //! 合成ファイルは ffmpeg で作る(無ければ skip) diff --git a/tests/history_api.rs b/tests/history_api.rs index 45d7aa88..8ba5210c 100644 --- a/tests/history_api.rs +++ b/tests/history_api.rs @@ -1,5 +1,5 @@ //! `GET /api/history`、`GET /api/history/:id`、`POST /api/history/:id/revert`、 -//! `POST /api/history/:id/cancel`(SPEC §9 / §7.5 / §12.4、docs/TASKS.md P0-12)。 +//! `POST /api/history/:id/cancel`(SPEC §9 / §7.5 / §12.4、docs/development/tasks-history.md P0-12)。 //! 合成ファイルは ffmpeg で 1 本作り、コピーして増やす #![cfg(target_os = "linux")] diff --git a/tests/identity.rs b/tests/identity.rs index 39d1923f..450c6222 100644 --- a/tests/identity.rs +++ b/tests/identity.rs @@ -1,6 +1,6 @@ //! 同一性解決: `(dev, inode)` → `audio_md5` → `rel_path_key` の順に、inventory 全体を入力に //! 取って各段で候補を検証する(SPEC §6「同一性解決の優先順位」、D-26 / D-29 / D-30)。 -//! 受け入れ: docs/TASKS.md P0-5 (a) (b) (c) (d) (e) (f) (g) (h) (k) +//! 受け入れ: docs/development/tasks-history.md P0-5 (a) (b) (c) (d) (e) (f) (g) (h) (k) use std::cell::RefCell; use std::collections::HashMap; diff --git a/tests/jobs.rs b/tests/jobs.rs index 1ba5c9a3..3e3bb0a6 100644 --- a/tests/jobs.rs +++ b/tests/jobs.rs @@ -1,6 +1,6 @@ //! ジョブシステム(P0-4): dedup、起動時リカバリ、バックオフ、協調キャンセル、進捗 SSE、 //! `track_locks` による直列化、版付きジョブの stale 判定、`/api/jobs`。 -//! 仕様: docs/SPEC.md §8 / §9、docs/DECISIONS.md D-23 +//! 仕様: SPEC §8 / §9、D-23 use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; diff --git a/tests/md5fill.rs b/tests/md5fill.rs index a37bed56..cff34de8 100644 --- a/tests/md5fill.rs +++ b/tests/md5fill.rs @@ -1,4 +1,4 @@ -//! FLAC の MD5 補填(SPEC §7.9、docs/TASKS.md P1-5b、D-57 / D-59)。 +//! FLAC の MD5 補填(SPEC §7.9、docs/development/tasks-history.md P1-5b、D-57 / D-59)。 //! STREAMINFO の MD5 が全ゼロの FLAC に、デコードした PCM MD5 を書く編集バッチ(`md5` op)。 //! 音声もタグも変わらないので `audio_version` / `tag_version` は据え置き、inode / mtime は追随、 //! 旧値(全ゼロ)を `edits` に残して巻き戻せる。合成ファイルは ffmpeg で作る(無ければ skip) diff --git a/tests/md5fill_api.rs b/tests/md5fill_api.rs index 23cf897d..0094383e 100644 --- a/tests/md5fill_api.rs +++ b/tests/md5fill_api.rs @@ -1,4 +1,4 @@ -//! `POST /api/rg/write`(SPEC §9、docs/TASKS.md P1-2)。selection の解析済みトラックへ解析値を +//! `POST /api/rg/write`(SPEC §9、docs/development/tasks-history.md P1-2)。selection の解析済みトラックへ解析値を //! タグとして書く編集バッチを記録する。合成ファイルは ffmpeg で作る(無ければ skip) #![cfg(target_os = "linux")] diff --git a/tests/normalize.rs b/tests/normalize.rs index f4e11786..e1ec560c 100644 --- a/tests/normalize.rs +++ b/tests/normalize.rs @@ -1,4 +1,4 @@ -//! ロスレス → FLAC 正規化の coordinator(SPEC §7.4、D-10 / D-45 / D-46、docs/TASKS.md P1-4)。 +//! ロスレス → FLAC 正規化の coordinator(SPEC §7.4、D-10 / D-45 / D-46、docs/development/tasks-history.md P1-4)。 //! 記録 → normalize ジョブ → MD5 照合 → Archive 退避 → 台帳 → 巻き戻し(restore / redo)→ //! 事前条件 → クラッシュ復旧 → スキャンとの並走 → キャンセル。 //! 合成ファイルは ffmpeg で作り、エンコードには flac を使う(どちらかが無ければ skip) diff --git a/tests/normalize_api.rs b/tests/normalize_api.rs index 992a8392..be40153d 100644 --- a/tests/normalize_api.rs +++ b/tests/normalize_api.rs @@ -1,4 +1,4 @@ -//! `POST /api/normalize/preview` / `POST /api/normalize/apply`(SPEC §9 / §7.4、docs/TASKS.md P1-4、 +//! `POST /api/normalize/preview` / `POST /api/normalize/apply`(SPEC §9 / §7.4、docs/development/tasks-history.md P1-4、 //! D-33)。合成ファイルは ffmpeg で作り、エンコードには flac を使う(どちらかが無ければ skip) #![cfg(target_os = "linux")] diff --git a/tests/opus_encode.rs b/tests/opus_encode.rs index 954c5ce4..e509a48a 100644 --- a/tests/opus_encode.rs +++ b/tests/opus_encode.rs @@ -1,4 +1,4 @@ -//! Derived 用の Opus エンコード(SPEC §7.6、D-9、docs/TASKS.md P1-10)。ffmpeg でデコード → +//! Derived 用の Opus エンコード(SPEC §7.6、D-9、docs/development/tasks-history.md P1-10)。ffmpeg でデコード → //! opusenc。タグは lofty で後から書く。ffmpeg / opusenc が無い環境では skip #![cfg(target_os = "linux")] diff --git a/tests/pathgen.rs b/tests/pathgen.rs index 124c493a..ccc7aa68 100644 --- a/tests/pathgen.rs +++ b/tests/pathgen.rs @@ -1,4 +1,4 @@ -//! パス生成(SPEC §5「パステンプレート」「ファイル名正規化」、D-7、docs/TASKS.md P0-11)。 +//! パス生成(SPEC §5「パステンプレート」「ファイル名正規化」、D-7、docs/development/tasks-history.md P0-11)。 //! テンプレート展開・置換テーブル・SMB 制約・切り詰め・衝突降格の単体テスト use std::collections::{HashMap, HashSet}; diff --git a/tests/playlist_export.rs b/tests/playlist_export.rs index 0bcb797b..ae7b6da6 100644 --- a/tests/playlist_export.rs +++ b/tests/playlist_export.rs @@ -1,4 +1,4 @@ -//! m3u8 の生成とパス写像(SPEC §10、docs/TASKS.md P1-6、D-53)。 +//! m3u8 の生成とパス写像(SPEC §10、docs/development/tasks-history.md P1-6、D-53)。 //! 純粋関数: プロファイル + 行 → 文字列。ファイルへの書き出しは API テストで見る use spindle::playlist::export::{render_m3u8, ExportProfile, ExportTrack, PathStyle, Source}; diff --git a/tests/playlist_import.rs b/tests/playlist_import.rs index c8b17705..607afa5e 100644 --- a/tests/playlist_import.rs +++ b/tests/playlist_import.rs @@ -1,4 +1,4 @@ -//! m3u8 の取り込み: 行の解析と Library のトラックへの解決(docs/TASKS.md P1-6、D-53)。 +//! m3u8 の取り込み: 行の解析と Library のトラックへの解決(docs/development/tasks-history.md P1-6、D-53)。 //! 旧ライブラリの `Playlists/m3u8/` は `../Anime/…/1.01. Crow Song.opus` のような旧 `Opus/` //! 相対で、拡張子が今の Library(`.m4a` → 正規化後 `.flac`)と違う。完全一致 → stem 一致で解決する diff --git a/tests/playlists_api.rs b/tests/playlists_api.rs index 70adbc0e..4c5646d2 100644 --- a/tests/playlists_api.rs +++ b/tests/playlists_api.rs @@ -1,4 +1,4 @@ -//! `/api/playlists`(SPEC §9 / §10、docs/TASKS.md P1-6、D-53): CRUD、項目の追加・除外・移動、 +//! `/api/playlists`(SPEC §9 / §10、docs/development/tasks-history.md P1-6、D-53): CRUD、項目の追加・除外・移動、 //! m3u8 の書き出し(GET は本文、POST は Playlists root へ書く)、Playlists root からの取り込み #![cfg(target_os = "linux")] diff --git a/tests/playlists_db.rs b/tests/playlists_db.rs index 5bccb6e8..ec454d85 100644 --- a/tests/playlists_db.rs +++ b/tests/playlists_db.rs @@ -1,4 +1,4 @@ -//! `playlists` / `playlist_items` / `playlist_exports` の読み書き(docs/TASKS.md P1-6、D-53)。 +//! `playlists` / `playlist_items` / `playlist_exports` の読み書き(docs/development/tasks-history.md P1-6、D-53)。 //! `:memory:` にマイグレーションを流し、tracks を直接 INSERT する use rusqlite::{params, Connection}; diff --git a/tests/process.rs b/tests/process.rs index 1740cf67..fa0e3267 100644 --- a/tests/process.rs +++ b/tests/process.rs @@ -1,6 +1,6 @@ //! 外部コマンドの共通ラッパ(SPEC §5「パスの表現と境界」、コーディング規約)。 //! 引数配列、`--` / `./` 前置、タイムアウト、終了コード検査、stderr の保持。 -//! 受け入れ: docs/TASKS.md P0-5 (i) `-x.flac` を外部コマンドへ安全に渡せる +//! 受け入れ: docs/development/tasks-history.md P0-5 (i) `-x.flac` を外部コマンドへ安全に渡せる use std::fs; use std::time::{Duration, Instant}; diff --git a/tests/relpath.rs b/tests/relpath.rs index ba8c8816..6e003dd0 100644 --- a/tests/relpath.rs +++ b/tests/relpath.rs @@ -1,5 +1,5 @@ //! 相対パスの検証と canonical key(SPEC §5「パスの表現と境界」、D-31)。 -//! 受け入れ: docs/TASKS.md P0-5 (i) (j) +//! 受け入れ: docs/development/tasks-history.md P0-5 (i) (j) use spindle::domain::relpath::{canonical_key, RelPath, RelPathError}; diff --git a/tests/rename.rs b/tests/rename.rs index 052568c4..cee2aa8d 100644 --- a/tests/rename.rs +++ b/tests/rename.rs @@ -1,4 +1,4 @@ -//! 一括リネームの coordinator(SPEC §7.5「リネーム」、§7.1、docs/TASKS.md P0-11)。 +//! 一括リネームの coordinator(SPEC §7.5「リネーム」、§7.1、docs/development/tasks-history.md P0-11)。 //! 記録 → DB 先行更新(2 段階)→ rename ジョブの 2 phase → 事前条件 → 衝突 → キャンセル → //! phase 境界でのクラッシュ復旧 → album の追随。合成ファイルは ffmpeg で作る(無ければ skip) diff --git a/tests/rename_api.rs b/tests/rename_api.rs index c8e765ec..af520839 100644 --- a/tests/rename_api.rs +++ b/tests/rename_api.rs @@ -1,4 +1,4 @@ -//! `POST /api/rename/preview` / `POST /api/rename/apply`(SPEC §9 / §7.5、docs/TASKS.md P0-11、 +//! `POST /api/rename/preview` / `POST /api/rename/apply`(SPEC §9 / §7.5、docs/development/tasks-history.md P0-11、 //! D-33)。合成ファイルは ffmpeg で 1 本作り、コピーして増やす #![cfg(target_os = "linux")] diff --git a/tests/replaygain.rs b/tests/replaygain.rs index 72ea3003..fd7edf69 100644 --- a/tests/replaygain.rs +++ b/tests/replaygain.rs @@ -1,4 +1,4 @@ -//! ReplayGain の解析(SPEC §6「ReplayGain の内部表現」、docs/TASKS.md P1-1)。 +//! ReplayGain の解析(SPEC §6「ReplayGain の内部表現」、docs/development/tasks-history.md P1-1)。 //! 内部表現は RG 2.0 / -18 LUFS 基準の dB。ebur128 の積分ラウドネスと true peak から //! track / album の値を出す diff --git a/tests/revert.rs b/tests/revert.rs index 5dc067d8..e5b41499 100644 --- a/tests/revert.rs +++ b/tests/revert.rs @@ -1,4 +1,4 @@ -//! 巻き戻し(SPEC §7.5「巻き戻し」、docs/TASKS.md P0-12)。tags / rename / delete の逆バッチ、 +//! 巻き戻し(SPEC §7.5「巻き戻し」、docs/development/tasks-history.md P0-12)。tags / rename / delete の逆バッチ、 //! 対象集合(applied − 逆バッチで applied 済み)、conflict、reverted_at、redo。 //! 合成ファイルは ffmpeg で 1 本作り、コピーして増やす diff --git a/tests/rg_api.rs b/tests/rg_api.rs index 8a989e82..b02537fb 100644 --- a/tests/rg_api.rs +++ b/tests/rg_api.rs @@ -1,4 +1,4 @@ -//! `POST /api/rg`(SPEC §9、docs/TASKS.md P1-1)。selection に含まれるトラックの album ごとに +//! `POST /api/rg`(SPEC §9、docs/development/tasks-history.md P1-1)。selection に含まれるトラックの album ごとに //! `rg` ジョブを投入する。album を持たないトラックは track 単位。合成ファイルは ffmpeg で作る #![cfg(target_os = "linux")] diff --git a/tests/rg_job.rs b/tests/rg_job.rs index 8c6fc325..9362eda1 100644 --- a/tests/rg_job.rs +++ b/tests/rg_job.rs @@ -1,4 +1,4 @@ -//! `rg` ジョブ(SPEC §6「ReplayGain の内部表現」/ §8、D-22、docs/TASKS.md P1-1)。 +//! `rg` ジョブ(SPEC §6「ReplayGain の内部表現」/ §8、D-22、docs/development/tasks-history.md P1-1)。 //! album 単位で構成トラックを解析し、track / album の gain と peak を DB に書く。 //! タグには書かない(P1-2)。合成ファイルは ffmpeg で作る(無ければ skip) diff --git a/tests/rg_write.rs b/tests/rg_write.rs index 8a0a3433..d9462ac5 100644 --- a/tests/rg_write.rs +++ b/tests/rg_write.rs @@ -1,4 +1,4 @@ -//! ReplayGain のタグ書き込み(SPEC §6「ReplayGain の内部表現」、docs/TASKS.md P1-2)。 +//! ReplayGain のタグ書き込み(SPEC §6「ReplayGain の内部表現」、docs/development/tasks-history.md P1-2)。 //! 解析値(DB の `rg_*`)を形式ごとのタグへ変換し、通常の編集バッチ(tags op)として書く。 //! `rg_written_at` は「ファイルの RG タグが解析値と一致している」ことを確認した時刻で、 //! 書き込みの applied だけでなく、既に一致していた行・外れた行の追随でも動く。 diff --git a/tests/rg_write_api.rs b/tests/rg_write_api.rs index e03b6634..287b1ac2 100644 --- a/tests/rg_write_api.rs +++ b/tests/rg_write_api.rs @@ -1,4 +1,4 @@ -//! `POST /api/rg/write`(SPEC §9、docs/TASKS.md P1-2)。selection の解析済みトラックへ解析値を +//! `POST /api/rg/write`(SPEC §9、docs/development/tasks-history.md P1-2)。selection の解析済みトラックへ解析値を //! タグとして書く編集バッチを記録する。合成ファイルは ffmpeg で作る(無ければ skip) #![cfg(target_os = "linux")] diff --git a/tests/scan_job.rs b/tests/scan_job.rs index 6659e41d..8a8aad3a 100644 --- a/tests/scan_job.rs +++ b/tests/scan_job.rs @@ -1,4 +1,4 @@ -//! `scan` ジョブハンドラと `POST /api/scan`(SPEC §8 / §9、docs/TASKS.md P0-6、D-38)。 +//! `scan` ジョブハンドラと `POST /api/scan`(SPEC §8 / §9、docs/development/tasks-history.md P0-6、D-38)。 #![cfg(target_os = "linux")] diff --git a/tests/scanner.rs b/tests/scanner.rs index 49b856c4..4d74e140 100644 --- a/tests/scanner.rs +++ b/tests/scanner.rs @@ -1,4 +1,4 @@ -//! 4 相スキャン(SPEC §7.1、§6、D-24 / D-30 / D-32、docs/TASKS.md P0-6)。 +//! 4 相スキャン(SPEC §7.1、§6、D-24 / D-30 / D-32、docs/development/tasks-history.md P0-6)。 //! 合成ツリーは ffmpeg で作る(無ければ skip)。 #![cfg(target_os = "linux")] diff --git a/tests/smart_playlists.rs b/tests/smart_playlists.rs index ea7dfde4..a9db300c 100644 --- a/tests/smart_playlists.rs +++ b/tests/smart_playlists.rs @@ -1,4 +1,4 @@ -//! スマートプレイリストの API(SPEC §9 / §10、docs/TASKS.md P1-7、D-54): ルール付きの作成・更新、 +//! スマートプレイリストの API(SPEC §9 / §10、docs/development/tasks-history.md P1-7、D-54): ルール付きの作成・更新、 //! プレビュー、再評価、materialize された項目、手動用の項目操作の拒否 #![cfg(target_os = "linux")] diff --git a/tests/source_hash_job.rs b/tests/source_hash_job.rs index 3e313c00..e89e5705 100644 --- a/tests/source_hash_job.rs +++ b/tests/source_hash_job.rs @@ -1,4 +1,4 @@ -//! `source_hash` ジョブ(仕様 ③「送る元のハッシュ」)。実ファイルを tempdir に置いて確かめる。 +//! `source_hash` ジョブ(SPEC §7.11「送る元のハッシュ」)。実ファイルを tempdir に置いて確かめる。 //! 下半分([`Lib`] 以降)はジョブシステムを実際に通す結合テスト(レビュー指摘: ハンドラの //! `run` 自体を試す統合テストが無かった) diff --git a/tests/stream_api.rs b/tests/stream_api.rs index 3a195e69..4eab307d 100644 --- a/tests/stream_api.rs +++ b/tests/stream_api.rs @@ -1,4 +1,4 @@ -//! `GET /api/stream/:id`(SPEC §11、docs/TASKS.md P1-9、D-52)。原本と Derived の Range 直送、 +//! `GET /api/stream/:id`(SPEC §11、docs/development/tasks-history.md P1-9、D-52)。原本と Derived の Range 直送、 //! Derived が無いときの ffmpeg によるオンザフライ変換。ffmpeg が無ければ skip #![cfg(target_os = "linux")] diff --git a/tests/tagops.rs b/tests/tagops.rs index c3479d58..3765f268 100644 --- a/tests/tagops.rs +++ b/tests/tagops.rs @@ -1,4 +1,4 @@ -//! 一括編集の操作(SPEC §12.3、docs/TASKS.md P0-10): 固定値代入 / フィールド参照 / +//! 一括編集の操作(SPEC §12.3、docs/development/tasks-history.md P0-10): 固定値代入 / フィールド参照 / //! 正規表現置換 / 連番 / 削除。JSON から解釈し、正規化タグ集合へ順に適用する use spindle::domain::tagops::{apply_ops, parse_ops, Op, TagOpsError}; diff --git a/tests/tags_read.rs b/tests/tags_read.rs index 5ee1d3aa..4b1409aa 100644 --- a/tests/tags_read.rs +++ b/tests/tags_read.rs @@ -1,4 +1,4 @@ -//! lofty ラッパによるタグ・音声属性の読み取り(SPEC §6、docs/TASKS.md P0-6)。 +//! lofty ラッパによるタグ・音声属性の読み取り(SPEC §6、docs/development/tasks-history.md P0-6)。 //! 形式差を Vorbis Comment 名に揃え、任意キー・多値を保つ。 mod common; diff --git a/tests/tags_write.rs b/tests/tags_write.rs index fb2e2157..b51c53a8 100644 --- a/tests/tags_write.rs +++ b/tests/tags_write.rs @@ -1,4 +1,4 @@ -//! lofty ラッパによるタグ書き換え(SPEC §7.5、docs/TASKS.md P0-9)。 +//! lofty ラッパによるタグ書き換え(SPEC §7.5、docs/development/tasks-history.md P0-9)。 //! 指定キーだけを置き換え・削除し、他のキー(任意キー・多値・画像)は保つ。 mod common; diff --git a/tests/thumbnail_job.rs b/tests/thumbnail_job.rs index f99e7a60..f86ac382 100644 --- a/tests/thumbnail_job.rs +++ b/tests/thumbnail_job.rs @@ -1,4 +1,4 @@ -//! `thumbnail` ジョブ(SPEC §8、docs/TASKS.md P1-3、D-49)。原画像から WebP のサムネイルを +//! `thumbnail` ジョブ(SPEC §8、docs/development/tasks-history.md P1-3、D-49)。原画像から WebP のサムネイルを //! ffmpeg で作る。ffmpeg が無ければ skip #![cfg(target_os = "linux")] diff --git a/tests/tracks_api.rs b/tests/tracks_api.rs index 499f09c1..80e17848 100644 --- a/tests/tracks_api.rs +++ b/tests/tracks_api.rs @@ -1,5 +1,5 @@ //! `GET /api/tracks` / `/api/tracks/:id` / `/api/search` / `/api/albums`(P0-7)と -//! selection のスナップショット(D-33)。仕様: docs/SPEC.md §9、docs/DECISIONS.md D-27 / D-39 +//! selection のスナップショット(D-33)。仕様: SPEC §9、D-27 / D-39 use std::net::SocketAddr; use std::sync::Arc; diff --git a/tests/tracks_perf.rs b/tests/tracks_perf.rs index 8d6f28df..3269dabc 100644 --- a/tests/tracks_perf.rs +++ b/tests/tracks_perf.rs @@ -3,7 +3,7 @@ //! //! debug ビルドでは bundled SQLite も最適化なしで遅いので `#[ignore]`。計測は //! `cargo test --release --test tracks_perf -- --ignored --nocapture` で行い、結果は -//! docs/TASKS.md に参照マシンと一緒に記す +//! docs/development/tasks-history.md に参照マシンと一緒に記す use std::time::{Duration, Instant}; diff --git a/tests/tracks_query.rs b/tests/tracks_query.rs index 293340b8..e9476fee 100644 --- a/tests/tracks_query.rs +++ b/tests/tracks_query.rs @@ -1,6 +1,6 @@ //! トラック一覧クエリ(P0-7): キーセットページング、ソート、フィルタ、検索の FTS / LIKE //! フォールバック、バッジ列、selection の解決、EXPLAIN QUERY PLAN。 -//! 仕様: docs/SPEC.md §9、docs/DECISIONS.md D-33 / D-39 +//! 仕様: SPEC §9、D-33 / D-39 use std::collections::HashSet; diff --git a/tests/transcode_job.rs b/tests/transcode_job.rs index c085f033..38805940 100644 --- a/tests/transcode_job.rs +++ b/tests/transcode_job.rs @@ -1,4 +1,4 @@ -//! `transcode` ジョブ(SPEC §7.6 / §8、docs/TASKS.md P1-10、D-51)。Library の可逆から Derived の +//! `transcode` ジョブ(SPEC §7.6 / §8、docs/development/tasks-history.md P1-10、D-51)。Library の可逆から Derived の //! Opus を作り、音声版・タグ版・パス・アートワークの変化に追随する。`delivery` ビューの版一致 //! フォールバックもここで見る。ffmpeg / opusenc が無ければ skip diff --git a/tests/zfs_corpus.rs b/tests/zfs_corpus.rs index 1438a64b..152448da 100644 --- a/tests/zfs_corpus.rs +++ b/tests/zfs_corpus.rs @@ -73,7 +73,7 @@ fn record_fs_vs_key_equivalence_on_zfs() { if differences.is_empty() { eprintln!("差なし"); } else { - eprintln!("差あり(D-31 の限界として docs/DECISIONS.md に記録すること):"); + eprintln!("差あり(D-31 の限界として docs/decisions/ に記録すること):"); for d in &differences { eprintln!(" - {d}"); } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index dffbec2f..cfbaf330 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -272,7 +272,7 @@ export type Playlist = { id: number name: string kind: 'manual' | 'smart' - /** smart のルール原文(docs/DSL.md)。manual は null */ + /** smart のルール原文(docs/user-guide/dsl.md)。manual は null */ rule_source: string | null auto_export: boolean created_at: number diff --git a/web/src/components/PlaylistSection.tsx b/web/src/components/PlaylistSection.tsx index 39836db1..ce81b862 100644 --- a/web/src/components/PlaylistSection.tsx +++ b/web/src/components/PlaylistSection.tsx @@ -53,7 +53,7 @@ export function PlaylistSection({ /** 直近の操作結果(App が持つ。追加・除外は表側からも起きる) */ notice: string | null onNotice: (text: string | null) => void - /** 端末の一覧(チップと「端末へ送る」。仕様 ④ 選曲の近道) */ + /** 端末の一覧(チップと「端末へ送る」。SPEC §12.6「端末」の選曲の近道) */ devices: Device[] /** 「端末へ送る」の印を付け外しする(結果は App が notice に出す) */ onToggleDevice: (device: Device, playlistId: number) => void diff --git a/web/src/components/SmartRuleEditor.tsx b/web/src/components/SmartRuleEditor.tsx index 7a1130b9..ac44418f 100644 --- a/web/src/components/SmartRuleEditor.tsx +++ b/web/src/components/SmartRuleEditor.tsx @@ -1,4 +1,4 @@ -// スマートプレイリストのルール編集(P1-7、docs/DSL.md、D-54)。中央ペインの表の上に出す。 +// スマートプレイリストのルール編集(P1-7、docs/user-guide/dsl.md、D-54)。中央ペインの表の上に出す。 // 入力から 250ms 後にサーバで検証・件数を取り、表は `filter.dsl` で WHERE の結果を追随する // (ORDER BY / LIMIT は保存時の評価にだけ効く) diff --git a/web/src/index.css b/web/src/index.css index 8e9e0bd4..58fa9e84 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -3400,7 +3400,7 @@ table.inbox-tracks tbody tr:hover td.inbox-cell-changed { color: var(--error); } -/* プレイリストの行の送り先の印(P5-2、仕様 ④ 選曲の近道)。端末ごとにアイコン 1 つ、端末名はホバーで出す */ +/* プレイリストの行の送り先の印(P5-2、SPEC §12.6「端末」の選曲の近道)。端末ごとにアイコン 1 つ、端末名はホバーで出す */ .device-chip { display: inline-block; margin-left: 4px; diff --git a/web/src/lib/devicePicker.ts b/web/src/lib/devicePicker.ts index 881e6edd..b84a1e84 100644 --- a/web/src/lib/devicePicker.ts +++ b/web/src/lib/devicePicker.ts @@ -1,4 +1,4 @@ -// ライブラリの左サイドバーのプレイリストの行から、端末の選曲(device_playlists)を触る(仕様 ④「選曲の近道」) +// ライブラリの左サイドバーのプレイリストの行から、端末の選曲(device_playlists)を触る(SPEC §12.6「端末」「選曲の近道」) import type { Device } from '../api/types' diff --git a/web/src/lib/devices.ts b/web/src/lib/devices.ts index a8ff443f..1b132e9e 100644 --- a/web/src/lib/devices.ts +++ b/web/src/lib/devices.ts @@ -95,7 +95,7 @@ export function volumeLabel(v: AdbVolume): string { return v.volume === 'emulated' ? '内部共有ストレージ' : `SD カード(${v.volume})` } -/** 通常の登録先にできるか(空か存在しないときだけ。仕様 ⑤「登録」4) */ +/** 通常の登録先にできるか(空か存在しないときだけ。SPEC §7.11 Android「登録」4) */ export function volumeUsable(v: AdbVolume): boolean { return v.state === 'missing' || v.state === 'empty' } diff --git a/web/src/lib/views.ts b/web/src/lib/views.ts index 67ad78d2..cb03a72b 100644 --- a/web/src/lib/views.ts +++ b/web/src/lib/views.ts @@ -11,7 +11,7 @@ export const VIEWS: Array<[View, string]> = [ ['cd', 'CD'], ['youtube', 'YouTube'], ['inbox', 'Inbox'], - // 取り込みの流れの終点(仕様 ④)。端末に未反映の件数を黄色で出す + // 取り込みの流れの終点(SPEC §12.6「端末」)。端末に未反映の件数を黄色で出す ['devices', '端末'], ] diff --git a/web/src/mobile/ruleBuilder.ts b/web/src/mobile/ruleBuilder.ts index 9ca497a7..02e2a124 100644 --- a/web/src/mobile/ruleBuilder.ts +++ b/web/src/mobile/ruleBuilder.ts @@ -1,4 +1,4 @@ -// スマートプレイリストの組み立て画面のモデル(docs/DSL.md)。 +// スマートプレイリストの組み立て画面のモデル(docs/user-guide/dsl.md)。 // - astToModel: サーバの AST(POST /api/playlists/preview の ast)を組み立て画面の形にする。 // 表せない式(括弧の入れ子、AND と OR の混在、NOT の下の and / or / present / missing)は null // → ルール文の画面で開く @@ -17,7 +17,7 @@ export type BuilderModel = { export const CMP_OPS: readonly CmpOp[] = ['is', 'has', 'greater', 'less', 'matches'] -/** DSL.md の標準タグ + 拡張フィールド(端末のフィールド on_device / device_pending は除く)。任意のタグ名は自由入力 */ +/** docs/user-guide/dsl.md の標準タグ + 拡張フィールド(端末のフィールド on_device / device_pending は除く)。任意のタグ名は自由入力 */ export const FIELD_CHOICES: readonly string[] = [ 'title', 'artist', diff --git a/web/src/mobile/subscriptions.ts b/web/src/mobile/subscriptions.ts index 557ad109..99829798 100644 --- a/web/src/mobile/subscriptions.ts +++ b/web/src/mobile/subscriptions.ts @@ -1,5 +1,5 @@ // スマホ版の購読タブの純粋ロジック: 同期の相(ダウンロードが全部終わるまで running)、件数、カードの 1 行、 -// 曲ごとの行。「完了」は投入した ytdl がすべて終わった時点(設計書「購読」) +// 曲ごとの行。「完了」は投入した ytdl がすべて終わった時点(SPEC §12.7「購読」、D-115) import type { Job, Subscription, SyncResult, UnavailableKind } from '../api/types' import { noteHeadline } from '../lib/jobs'