1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
|
/**
* src/features/segment-editor/metadata.ts — PURE: MusicBrainz sidebar + match logic.
*
* Everything here is pure `input → output`: the search state machine, the
* search-request builders, the sidebar view-model (the "Generated from YouTube"
* entry + MB result cards), and the cut-plan edits that result from clicking a
* card or applying a whole-album match. Zero DOM / fetch / WebSocket / Svelte —
* the injected `MetadataApi` (`src/adapters/metadataApi.ts`) is the only side-effect,
* called from the thin components, which wire the results back through `onaction`
* (the composition root folds the pure `reduce`).
*
* Vocabulary (GLOSSARY, adopted verbatim from the backend): a `MetadataResult`
* is one MB search hit; a `ReleaseDetail` is a release + its GLOBAL track list;
* an `AlbumMatch` pairs one `SegmentDraft` (by `segmentIndex`) with one
* `ReleaseTrack` at a `confidence` of "position" | "title" | "none".
*/
import type {
AlbumMatchResult,
CutDraft,
MetadataResult,
MetadataSearchRequest,
ReleaseDetail,
SegmentDraft,
} from "@yt-music/contract";
import type { EditAction } from "./logic.js";
// ─── Sidebar search state machine (pure) ──────────────────────────────────────
export type SearchStatus = "idle" | "searching" | "results" | "error";
/**
* The sidebar's view of one MB search. `query` is the live text in the search
* box; `results` are the last successful search's hits (kept across a re-search
* so the list doesn't blank while loading). Pure: transitions come only through
* `reduceSearch`.
*/
export interface SearchState {
status: SearchStatus;
query: string;
results: MetadataResult[];
error?: string;
}
export function initialSearchState(query = ""): SearchState {
return { status: "idle", query, results: [] };
}
export type SearchEvent =
| { type: "setQuery"; query: string }
| { type: "searchStarted"; query: string }
| { type: "searchOk"; results: MetadataResult[] }
| { type: "searchFailed"; error: string };
/** Fold one search event into the sidebar state. Pure: (state, event) → state. */
export function reduceSearch(state: SearchState, event: SearchEvent): SearchState {
switch (event.type) {
case "setQuery":
return { ...state, query: event.query };
case "searchStarted":
// Omit `error` (exactOptionalPropertyTypes) rather than set undefined.
return { status: "searching", query: event.query, results: state.results };
case "searchOk":
return { status: "results", query: state.query, results: event.results };
case "searchFailed":
return { status: "error", query: state.query, results: [], error: event.error };
default: {
const _exhaustive: never = event;
return _exhaustive;
}
}
}
// ─── Search-request builders (pure) ──────────────────────────────────────────
/**
* Build the recording-search request for a segment's sidebar. `query` is the
* search-box text (falling back to the segment title when blank). The search is
* title-only — no artist hint is sent. For YouTube downloads the segment/global
* artist is the channel name (e.g. 'Official YouTube Channel'), which MusicBrainz
* has no artist for, so a Lucene `artist:"…" AND recording:"…"` query returns
* zero results. Mirrors the backend's `MetadataSearchRequest`.
*
* `globalArtist` is retained in the signature for backward compatibility (the
* caller still passes it) but is now unused. Pure: (segment, query, globalArtist) → request.
*/
export function buildRecordingSearch(
segment: SegmentDraft,
query: string,
// Prefixed `_` to satisfy Biome's noUnusedFunctionParameters: retained in the
// signature for backward compatibility (the caller still passes it) but the
// artist hint is no longer sent — see the doc comment above.
_globalArtist: string,
): MetadataSearchRequest {
const q = query.trim() || segment.title.trim();
return { query: q, type: "recording" };
}
/**
* Build the release-search request for the "Match Album" dialog. `album` is the
* album-name search text (falling back to the global album). The search is
* album-only — no artist hint is sent. For YouTube downloads the draft's global
* artist is the channel name, and the dialog prefills its artist box with it, so
* sending it as a Lucene `artist:"…"` hint yields zero MB release results.
*
* `artist` and `globalArtist` are retained in the signature for backward
* compatibility (the caller still passes them) but are now unused.
* Pure: (album, artist, globalAlbum, globalArtist) → request.
*/
export function buildReleaseSearch(
album: string,
// Prefixed `_` to satisfy Biome's noUnusedFunctionParameters: retained in the
// signature for backward compatibility (the caller still passes them) but the
// artist hint is no longer sent — see the doc comment above.
_artist: string,
globalAlbum: string,
_globalArtist: string,
): MetadataSearchRequest {
const q = album.trim() || globalAlbum.trim();
return { query: q, type: "release" };
}
// ─── Sidebar view-model (pure) ───────────────────────────────────────────────
/**
* One row in the sidebar's results list. The "Generated from YouTube" entry
* (GLOSSARY: the chapter-parsed title the backend put on the segment) is always
* first and carries no MBID/score; MB result cards follow. A unified shape so
* the component renders one `{#each}` over `sidebarItems`.
*/
export interface SidebarItem {
source: "youtube" | "musicbrainz";
title: string;
artist: string;
album?: string;
trackNumber?: number;
/** MB relevance score (0-100); absent for the YouTube entry. */
score?: number;
/** MusicBrainz MBID; absent for the YouTube entry. */
id?: string;
/** Cover Art Archive front-cover URL; absent when the result has none. */
artUrl?: string;
}
/** The "Generated from YouTube" entry: the segment's current parsed fields. Pure. */
export function youtubeItem(segment: SegmentDraft): SidebarItem {
return {
source: "youtube",
title: segment.title,
artist: segment.artist,
album: segment.album,
trackNumber: segment.trackNumber,
};
}
/** Map one MB result to a sidebar row. Pure. */
export function toSidebarItem(result: MetadataResult): SidebarItem {
// exactOptionalPropertyTypes: include album/trackNumber only when present
// (MB omits these from lightweight recording search entries).
const item: SidebarItem = {
source: "musicbrainz",
id: result.id,
title: result.title,
artist: result.artist,
score: result.score,
};
if (result.album !== undefined) item.album = result.album;
if (result.trackNumber !== undefined) item.trackNumber = result.trackNumber;
if (result.artUrl !== undefined) item.artUrl = result.artUrl;
return item;
}
/**
* The full sidebar list: the "Generated from YouTube" entry first, then the MB
* recording-search results in the backend's relevance order. Pure.
*/
export function sidebarItems(segment: SegmentDraft, results: MetadataResult[]): SidebarItem[] {
return [youtubeItem(segment), ...results.map(toSidebarItem)];
}
// ─── Click-to-fill (pure → EditAction[]) ─────────────────────────────────────
/**
* The cut-plan edits that fill a segment from one sidebar item.
*
* - "Generated from YouTube": leaves the segment fields unchanged (the item
* already mirrors the segment's current parsed values) → no edits.
* - MusicBrainz result: sets title + artist always; album + trackNumber only
* when the result actually carries them (MB omits these from lightweight
* recording search entries). When the result has a Cover Art Archive
* `artUrl`, emits an `editSegmentAlbumArt` action with a url-kind
* `AlbumArtRef` so the backend downloads + embeds the front cover.
*
* Pure: (segmentId, item) → actions. Dispatched by the component through
* `onaction`; the composition root folds each over the pure `reduce`.
*/
export function fillActions(segmentId: string, item: SidebarItem): EditAction[] {
if (item.source === "youtube") return []; // no-op — fields already hold these values
const actions: EditAction[] = [
{ type: "editSegmentTitle", segmentId, title: item.title },
{ type: "editSegmentArtist", segmentId, artist: item.artist },
];
if (item.album !== undefined) {
actions.push({ type: "editSegmentAlbum", segmentId, album: item.album });
}
if (item.trackNumber !== undefined) {
actions.push({ type: "editSegmentTrackNumber", segmentId, trackNumber: item.trackNumber });
}
if (item.artUrl !== undefined) {
actions.push({
type: "editSegmentAlbumArt",
segmentId,
albumArt: { kind: "url", url: item.artUrl },
});
}
return actions;
}
/**
* Convenience: the cut-plan edits that fill a segment directly from an MB result,
* without going through the sidebar view-model. Used where a result is applied
* without a sidebar round-trip. Pure: (segmentId, result) → actions.
*/
export function fillFromResult(segmentId: string, result: MetadataResult): EditAction[] {
return fillActions(segmentId, toSidebarItem(result));
}
// ─── Match-album → cut-plan edits (pure → EditAction[]) ──────────────────────
/**
* The cut-plan edits that apply a whole-album match. For every match whose
* `confidence` is "position" or "title", fills the segment's
* title = track.title, artist = release.artist, album = release.title,
* trackNumber = track.position. "none"-confidence matches are skipped (those
* segments are left as-is). Matches reference segments by `segmentIndex`, which
* is resolved to the segment id via the (unchanged) draft passed to the backend.
*
* Pure: (draft, release, matchResult) → actions. Dispatched through `onaction`.
*/
export function albumMatchActions(
draft: CutDraft,
release: ReleaseDetail,
matchResult: AlbumMatchResult,
): EditAction[] {
const actions: EditAction[] = [];
for (const match of matchResult.matches) {
if (match.confidence === "none") continue;
const segment = draft.segments[match.segmentIndex];
if (!segment) continue; // index out of range → skip defensively
const track = match.track;
actions.push({ type: "editSegmentTitle", segmentId: segment.id, title: track.title });
actions.push({ type: "editSegmentArtist", segmentId: segment.id, artist: release.artist });
actions.push({ type: "editSegmentAlbum", segmentId: segment.id, album: release.title });
actions.push({
type: "editSegmentTrackNumber",
segmentId: segment.id,
trackNumber: track.position,
});
}
return actions;
}
/**
* Count how many segments a match result would actually fill (confidence
* "position" or "title"). Display-only helper for the match-album dialog's
* "N of M segments matched" summary. Pure.
*/
export function matchedSegmentCount(matchResult: AlbumMatchResult): number {
return matchResult.matches.filter((m) => m.confidence !== "none").length;
}
|