---
title: "曲対比機能 データ設計書"
---

最終更新: 2026-03-29

## 1. 概要

同名曲のオリジナルとリメイク（バリアント）を比較できるようにするためのデータ設計。
`releases.json` からトラックを抽出し、曲名の前方一致でペアリングした対比インデックスを生成する。

---

## 2. 背景・目的

| 目的 | 詳細 |
| --- | --- |
| オリジナル/リメイクの対比 | 同一曲名をベースとする複数バリアントを1組のペアとして管理する |
| 動画の横断比較 | YouTube 動画 ID をインデックス生成時に解決し、対比画面での即時表示を実現する |
| 曲詳細画面との連携 | `track-comparison-map.json` で O(1) 検索を可能にし、曲詳細画面での対比リンク表示に利用する |

---

## 3. 型定義

### `ComparisonTrack` インターフェース（`types/comparison.ts`）

```typescript
/** 対比ペア内の 1 曲 */
export interface ComparisonTrack {
  /** Track.id（MusicBrainz recording UUID） */
  trackId: string;
  /** 曲タイトル（バリアント名を含む） */
  title: string;
  /** YouTube 動画 ID（Track.links に youtube リンクがある場合。なければ null） */
  youtubeVideoId: string | null;
  /** 代表リリース ID（findPreferredRelease による解決結果。なければ null） */
  releaseId: string | null;
  /** 代表リリースタイトル（表示用。なければ null） */
  releaseTitle: string | null;
  /** 代表リリースのフォーマット（"single" / "ep" / "album" / "other"。なければ null） */
  releaseFormat: string | null;
}
```

### `ComparisonPair` インターフェース（`types/comparison.ts`）

```typescript
/** 対比ペア（1 組）*/
export interface ComparisonPair {
  /**
   * ペア ID。
   * グループ内でタイトルが baseTitle と一致するトラックの Track.id を使用する。
   * 該当トラックが存在しない場合（バリアントのみのグループ）は、
   * タイトル長昇順・辞書順で先頭のトラックの Track.id を使用する。
   */
  id: string;
  /** 共通ベースタイトル（グループ内の最短タイトル、例: "XXX"） */
  baseTitle: string;
  /** 収録曲の配列。ベース曲が先頭、以降タイトル長昇順・辞書順 */
  tracks: ComparisonTrack[];
}
```

---

## 4. 生成スクリプト

### スクリプト: `scripts/build-comparison-index.ts`

`releases.json` から全トラックを収集し、対比インデックス 2 ファイルを Vercel Blob に書き込む。

#### 実行コマンド

```bash
BLOB_READ_WRITE_TOKEN=... RELEASES_BLOB_URL=... bun run scripts/build-comparison-index.ts
```

#### 処理フロー

1. `scripts/comparison-exclude-patterns.json` から除外ベースタイトル一覧を読み込む
2. `RELEASES_BLOB_URL` から `releases.json` を取得する
3. 全リリースからトラックを収集し、`Track.id` で重複排除する
4. 前方一致判定でトラックをグループ化する（§5 参照）
5. 2 曲以上かつ除外パターンに一致しないグループを対比ペアとして採用する
6. 各トラックの YouTube 動画 ID・代表リリース（フォーマット含む）を解決する
7. `comparison-index.json` と `track-comparison-map.json` を Blob に書き込む

#### 除外パターン設定

`scripts/comparison-exclude-patterns.json` に除外するベースタイトルを列挙する（完全一致）。

```json
{
  "baseTitles": ["除外したい曲名A", "除外したい曲名B"]
}
```

ファイルが存在しない・フォーマットが不正な場合は除外なしとして処理する。

#### 出力ファイル

| ファイル名 | 構造 | 環境変数 |
| --- | --- | --- |
| `comparison-index.json` | `{ "pairs": ComparisonPair[] }` | `COMPARISON_INDEX_BLOB_URL` |
| `track-comparison-map.json` | `{ [trackId: string]: pairId }` | `TRACK_COMPARISON_MAP_BLOB_URL` |

#### 必要な環境変数

| 変数名 | 説明 |
| --- | --- |
| `BLOB_READ_WRITE_TOKEN` | Vercel Blob の書き込みトークン |
| `RELEASES_BLOB_URL` | releases.json の Blob URL |

---

## 5. 前方一致判定ロジック

### 定義

トラック B が トラック A のバリアントである条件:

1. `B.title.startsWith(A.title)` が true
2. `B.title.length > A.title.length`（同一タイトルは除外）
3. `B.title[A.title.length]` がセパレータ文字（`/[\s(（〜\-]/`）に一致する

### 例

| ベース | バリアント | 判定 |
| --- | --- | --- |
| `"XXX"` | `"XXX (A Ver.)"` | ✅ セパレータ `"("` |
| `"XXX"` | `"XXX〜ライブ〜"` | ✅ セパレータ `"〜"` |
| `"XXX"` | `"XXX -リミックス-"` | ✅ セパレータ `"-"` |
| `"XXX"` | `"XXXYYY"` | ❌ セパレータなし |
| `"XXX"` | `"XXX"` | ❌ 同一タイトル |

### ベースタイトルの算出

各タイトルについて、上記条件を満たす最短の他タイトルを「ベースタイトル」とする。
該当するものが存在しない場合は自身がベースタイトル。

```
titles = ["A", "A (B Ver.)", "A (B Ver.) [C Mix]"]

"A"               → 自身がベース → group["A"]
"A (B Ver.)"      → "A" が前方一致の最短候補 → group["A"]
"A (B Ver.) [C Mix]" → "A" が前方一致の最短候補 → group["A"]
```

---

## 6. Blob 読み取り関数

`lib/blob.ts` に以下の関数を追加する。

```typescript
/** comparison-index.json から対比ペア一覧を取得する。未設定・失敗時は空配列を返す */
export async function getComparisonIndexFromBlob(): Promise<ComparisonPair[]>

/** track-comparison-map.json から trackId → { pairId, baseTitle } マップを取得する。未設定・失敗時は空オブジェクトを返す */
export async function getTrackComparisonMapFromBlob(): Promise<Record<string, { pairId: string; baseTitle: string }>>
```

両関数とも環境変数未設定・Blob 取得失敗時はフェイルセーフとして空を返す（プレイリストインデックスと同方針）。
