コンテンツにスキップ
Documents for MorningStatusApp
Esc
↑↓移動↵開く⌘Jプレビュー
このページの内容

曲対比機能 データ設計書

最終更新: 2026-09-25

1. 概要

同名曲のオリジナルとリメイク(バリアント)を比較できるようにするための機能を、手動で対比グループを作成・編集・削除する方式で提供する。 対比グループ(ComparisonGroup)・グループ所属トラック(ComparisonGroupTrack)は Neon DB(Prisma)で管理する。


2. 背景・目的

目的 詳細
オリジナル/リメイクの対比 同一曲をベースとする複数バージョンを1組のグループとして手動管理する
誤対比の排除 タイトル前方一致による自動判定を廃止し、明示的な作成操作に置き換える
動画の横断比較 YouTube 動画 ID を Track.links(既存の TrackLink テーブル)から解決し、対比画面での即時表示を実現する
曲詳細画面との連携 trackId から所属グループを検索し、曲詳細画面での対比リンク表示に利用する

備考: 旧方式(タイトル前方一致による自動生成、v1.1.0〜v10.x)からの変更経緯

旧方式は releases.json 全件からタイトルの前方一致(isVariantOf)でグループ化し、comparison-index.json・track-comparison-map.json を Vercel Blob に書き出すバッチ方式だった。この方式は構造的に誤対比を生みやすく(例: "XXX (Live)" のような無関係なタイトルもバリアントと誤判定される)、comparison-exclude-patterns.json・title-aliases.json による都度の手動抑制が運用上の負担になっていた(think-issue、MorningStatusApp#1633)。既存の自動生成データは移行せず全て破棄し、明示的な作成方式へ再構築する。


3. Prisma モデル設計

ComparisonGroup(対比グループ)

可変数(2曲以上)のトラックを持つグループ。title はグループ作成・編集のたびに、グループ内で最も古く追加されたトラック(ComparisonGroupTrack.addedAt が最小のもの)の曲名で自動的に再計算する(§5参照)。

/// 対比グループ(手動作成。旧自動生成方式はMorningStatusApp#1633で廃止)
model ComparisonGroup {
  groupId   String   @id @default(uuid()) @db.Uuid
  title     String   @db.VarChar(255)
  createdAt DateTime @default(now()) @db.Timestamptz(3)
  updatedAt DateTime @updatedAt @db.Timestamptz(3)

  tracks ComparisonGroupTrack[]

  @@map("comparison_groups")
}

ComparisonGroupTrack(対比グループ×トラック紐付け)

ComparisonGroup と既存の Track(コア音楽データ ER 図、MusicBrainz recording UUID trackId を主キーとする)を関連付ける中間テーブル。表示順は登録順固定とし、順序を保持する専用カラム(seq 等)は持たない。addedAt の昇順がそのまま表示順になる。

/// 対比グループ×トラック紐付け(登録順=表示順。可変数のトラックを許容)
model ComparisonGroupTrack {
  groupId String   @db.Uuid
  trackId String   @db.VarChar(255)
  addedAt DateTime @default(now()) @db.Timestamptz(3)

  group ComparisonGroup @relation(fields: [groupId], references: [groupId], onDelete: Cascade)
  track Track           @relation(fields: [trackId], references: [trackId])

  @@id([groupId, trackId])
  @@unique([trackId])
  @@map("comparison_group_tracks")
}
  • @@id([groupId, trackId]): 同一グループに同一トラックを重複登録できないようにする複合主キー(既存の SetlistTrack・RadioOnairSongTrack・EventSongTrack と同じパターン。コア音楽データ ER 図参照)
  • @@unique([trackId]): 1トラックが複数の対比グループに同時に所属できないようにする制約。SetlistTrack等の既存の中間テーブルは1曲が複数の親(セットリスト等)に属することを許容するためtrackId単体のユニーク制約を持たないが、ComparisonGroupTrackは§5「曲詳細画面の対比リンク」がtrackIdから所属グループを一意に検索する設計のため、この制約でDBレベルに「1トラック1グループ」を強制する(WHERE句なしの単純なユニーク制約のため、CLAUDE.md「Prisma利用時の制約」の禁止事項には該当しない)
  • onDelete: Cascade: ComparisonGroup 削除時に紐づく ComparisonGroupTrack も自動削除する
  • track 側に ComparisonGroupTrack[] の逆参照を追加する(Track モデルの既存の逆参照フィールド一覧に追記)

表示順の安定化(addedAt の採番方法)

createMany で複数行を一括挿入する場合、同一ミリ秒内のリクエストでは @default(now()) が同じタイムスタンプを返し、順序が不定になりうる。これを避けるため、API 層(§4)でトラック配列のインデックスに応じて addedAt をアプリケーション側で明示的に1ミリ秒刻みでずらして生成し、createMany の data に含める。

const now = Date.now();
const rows = trackIds.map((trackId, index) => ({
  groupId,
  trackId,
  addedAt: new Date(now + index), // 1ms刻みでずらし、登録順を保証する
}));

4. API設計(デスクトップモード限定)

他の書き込み機能(ライブ・フェス機能 テーブル設計書 のセットリスト編集 API 等)と同様、lib/desktop-mode.ts の requireDesktopMode() で保護する。各エンドポイントの先頭で呼び出し、DESKTOP_MODE !== '1' の場合は 403 を返す。

エンドポイント 責務
POST /api/comparisons 新規対比グループを作成する
PUT /api/comparisons/[id] 既存対比グループのトラック構成を更新する(全差し替え)
DELETE /api/comparisons/[id] 対比グループを削除する

POST /api/comparisons

リクエストボディ: { trackIds: string[] }(2件以上必須。1件以下は 400)

処理:

  1. trackIds に重複があれば 400 で拒否する(同一 trackId を複数指定した場合、ComparisonGroupTrack.createMany が複合主キー制約違反になるため事前に弾く)
  2. trackIds の各要素が tracks テーブルに実在するか確認する(存在しない trackId があれば 400)
  3. trackIds の各要素が既にいずれかの ComparisonGroupTrack に所属していないか確認する(@@unique([trackId]) により所属していれば 409。「対比候補」として選べるのは未所属のトラックのみ)
  4. crypto.randomUUID() で groupId を採番する
  5. trackIds[0] の曲名を取得し title とする
  6. prisma.$transaction で ComparisonGroup.create と ComparisonGroupTrack.createMany(§3の採番方法で addedAt を生成)を実行する
  7. 成功時は作成した groupId を含むレスポンスを返す(クライアント側は /comparisons/[groupId] へ遷移する)

備考: 手順3の事前チェックは、チェック時点から手順6の$transaction実行までの間に別リクエストが同じtrackIdを登録した場合(競合)を防げない。この場合、$transactionはComparisonGroupTrack.@@unique([trackId])制約違反(Prisma P2002)で失敗する。これを汎用エラーとして500にせず、手順3の事前チェックと同じ409として個別にハンドリングする(PR #1660のレビューで検出、コミットb27b1d4で対応)。

PUT /api/comparisons/[id]

リクエストボディ: { trackIds: string[] }(2件以上必須。1件以下は 400)

処理:

  1. 対象 ComparisonGroup の存在確認(存在しなければ 404)
  2. trackIds に重複があれば 400 で拒否する
  3. trackIds の各要素が tracks テーブルに実在するか確認する(400)
  4. trackIds の各要素について、対象グループ以外の ComparisonGroupTrack(groupId !== 対象id)に所属していないか確認する(所属していれば 409。対象グループに既に属しているトラックはこの編集で引き続き使えるため許可する)
  5. prisma.$transaction で以下を実行する(ライブ・フェス機能 テーブル設計書 のセットリスト編集 API と同じ「全差し替え」パターン。開発ノート「複数リソースの一括登録はアトミックに行う」に準拠):
    • ComparisonGroupTrack.deleteMany({ where: { groupId } })
    • ComparisonGroupTrack.createMany(§3の採番方法で addedAt を再生成)
    • ComparisonGroup.update(trackIds[0] の曲名で title を再計算)

備考: POSTと同様、手順4の事前チェックをすり抜けて$transactionが@@unique([trackId])制約違反(P2002)で失敗した場合も、汎用500にせず409として個別にハンドリングする(PR #1660のレビューで検出、コミットb27b1d4で対応)。

DELETE /api/comparisons/[id]

処理: 対象 ComparisonGroup の存在確認後、prisma.comparisonGroup.delete() を実行する(onDelete: Cascade により ComparisonGroupTrack も削除される)。存在しなければ 404。


5. 画面のデータ取得(Neon DB参照)

対比グループ一覧・詳細の取得

const groups = await prisma.comparisonGroup.findMany({
  include: {
    tracks: {
      orderBy: { addedAt: 'asc' },
      include: {
        track: {
          include: { release: true, links: true },
        },
      },
    },
  },
  orderBy: { createdAt: 'desc' },
});
  • 対比詳細画面(/comparisons/[id])は groups.find(g => g.groupId === id) ではなく、prisma.comparisonGroup.findUnique({ where: { groupId: id }, include: { ... } } }) で1件取得する
  • 各トラックの YouTube 動画 ID は track.links(TrackLink、type === 'youtube')から解決する(旧方式のように生成時に解決・保存済みの値を使うのではなく、画面表示時にその場で解決する)
  • 各トラックの代表リリース・フォーマットは track.release(Track.releaseId FK 経由で1件のみ)から取得する(旧方式の findPreferredRelease による複数リリースからの優先選択は行わない。Track は1件の Release にのみ属するため)

曲詳細画面(app/songs/[id])の対比リンク

const groupTrack = await prisma.comparisonGroupTrack.findFirst({
  where: { trackId: id },
  include: { group: true },
});
  • groupTrack が存在する場合のみ「対比候補」セクションを表示し、groupTrack.group.title と groupTrack.group.groupId でリンクを構成する
  • ComparisonGroupTrack.@@unique([trackId])(§3)により、同一 trackId を持つ行は高々1件しか存在しないため、findFirst は実質 findUnique と同じ挙動になる(where: { trackId: id } は必ず0件または1件を返す)

6. トラック検索UI

ライブ・フェス機能 画面設計書 §11「セットリスト画面」の SetlistEditView と同じパターン(テキスト検索 + <select> + 追加/削除)を対比グループの作成・編集 UI(新規 ComparisonGroupEditView 等)にも採用する。詳細は 曲対比機能 画面設計書 を参照。


7. 既存Blobデータ・生成バッチ・関連JSONの廃止範囲

以下を全て削除する。移行(データの引き継ぎ)は行わない。

対象 種別 備考
scripts/console/build-comparison-index.ts(テスト含む) 生成バッチ バッチ設計書 O1 も削除する
comparison-index.json Blob出力ファイル Blobから物理削除は任意(読み取り側を廃止すれば参照されなくなるため必須ではない)
track-comparison-map.json Blob出力ファイル 同上
scripts/comparison-exclude-patterns.json 補助設定ファイル
scripts/title-aliases.json 補助設定ファイル
types/comparison.ts(ComparisonTrack・ComparisonPair) 型定義 ComparisonGroup・ComparisonGroupTrack のAPIレスポンス用 Zod スキーマに置き換える
lib/blob.ts の getComparisonIndexFromBlob()・getTrackComparisonMapFromBlob() 読み取り関数 Prismaクエリ(§5)に置き換える
COMPARISON_INDEX_BLOB_URL・TRACK_COMPARISON_MAP_BLOB_URL 環境変数 環境変数一覧 から削除する

8. 更新履歴

版 更新日 変更内容 関連 Issue
2.1 2026-09-25 §4のPOST/PUT処理に、事前チェックをすり抜けた競合時(@@unique([trackId])制約違反)も500ではなく事前チェックと同じ409で統一する旨を備考として追記(レビューで検出) MorningStatusApp#1660
2.0 2026-09-22 対比機能をタイトル前方一致による自動生成方式(Vercel Blob)から、手動作成方式(Neon DB/Prisma)へ全面再構築。ComparisonGroup・ComparisonGroupTrack モデル、作成/編集/削除API、画面のデータ取得方法、旧方式の廃止範囲を追記。ComparisonGroupTrackに@@unique([trackId])を追加し「1トラック1グループ」をDBレベルで強制、POST/PUT APIにtrackIds重複・他グループ所属済みトラックのバリデーション(400/409)を追加(レビューで検出) MorningStatusApp#1633, morning-status-blume#148
1.0 2026-03-29 初版作成 #474

このページは役に立ちましたか?