trackparse 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3135 @@
1
+ 'use strict';
2
+
3
+ // src/words.ts
4
+ function isWhitespaceCode(cp) {
5
+ return cp >= 9 && cp <= 13 || cp === 32 || cp === 133 || cp === 160 || cp === 5760 || cp >= 8192 && cp <= 8202 || cp === 8232 || cp === 8233 || cp === 8239 || cp === 8287 || cp === 12288;
6
+ }
7
+ function isWhitespace(ch) {
8
+ return ch !== void 0 && ch.length > 0 && isWhitespaceCode(ch.charCodeAt(0));
9
+ }
10
+ function isDigit(ch) {
11
+ return ch !== void 0 && ch.length === 1 && ch >= "0" && ch <= "9";
12
+ }
13
+ function isAllDigits(s) {
14
+ if (s.length === 0) return false;
15
+ for (let i = 0; i < s.length; i++) if (!isDigit(s[i])) return false;
16
+ return true;
17
+ }
18
+ var LETTER = /^\p{L}$/u;
19
+ function isLetterAt(s, index) {
20
+ const cp = s.codePointAt(index);
21
+ if (cp === void 0) return false;
22
+ return LETTER.test(String.fromCodePoint(cp));
23
+ }
24
+ function isLetterBefore(s, index) {
25
+ const low = s.charCodeAt(index - 1);
26
+ const high = s.charCodeAt(index - 2);
27
+ const pair = low >= 56320 && low <= 57343 && high >= 55296 && high <= 56319;
28
+ const start = pair ? index - 2 : index - 1;
29
+ return start >= 0 && isLetterAt(s, start);
30
+ }
31
+ var UPPER = /^\p{Lu}$/u;
32
+ function isUpperAt(s, index) {
33
+ const cp = s.codePointAt(index);
34
+ if (cp === void 0) return false;
35
+ return UPPER.test(String.fromCodePoint(cp));
36
+ }
37
+ function asciiLower(s) {
38
+ let out = "";
39
+ for (let i = 0; i < s.length; i++) {
40
+ const c = s.charCodeAt(i);
41
+ out += c >= 65 && c <= 90 ? String.fromCharCode(c + 32) : s[i];
42
+ }
43
+ return out;
44
+ }
45
+ var KEY_STRIPPABLE = ".,!?:;";
46
+ function stripKeyPunct(lower) {
47
+ const last = lower[lower.length - 1];
48
+ return last !== void 0 && KEY_STRIPPABLE.includes(last) ? lower.slice(0, -1) : lower;
49
+ }
50
+ function wordKey(word) {
51
+ return stripKeyPunct(asciiLower(word));
52
+ }
53
+ function wordMatches(word, token) {
54
+ const lower = asciiLower(word);
55
+ return lower === token || stripKeyPunct(lower) === token;
56
+ }
57
+ function inVocab(word, vocab) {
58
+ const lower = asciiLower(word);
59
+ return vocab.has(lower) || vocab.has(stripKeyPunct(lower));
60
+ }
61
+ function collapseSpaces(s) {
62
+ let out = "";
63
+ let pendingSpace = false;
64
+ for (const ch of s) {
65
+ if (isWhitespaceCode(ch.codePointAt(0) ?? 0)) {
66
+ pendingSpace = out.length > 0;
67
+ } else {
68
+ if (pendingSpace) out += " ";
69
+ pendingSpace = false;
70
+ out += ch;
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+ function splitWords(s) {
76
+ const words = [];
77
+ let current = "";
78
+ for (const ch of s) {
79
+ if (isWhitespaceCode(ch.codePointAt(0) ?? 0)) {
80
+ if (current) words.push(current);
81
+ current = "";
82
+ } else {
83
+ current += ch;
84
+ }
85
+ }
86
+ if (current) words.push(current);
87
+ return words;
88
+ }
89
+ function wordSpans(s) {
90
+ const spans = [];
91
+ let start = -1;
92
+ for (let i = 0; i <= s.length; i++) {
93
+ const boundary = i === s.length || isWhitespace(s[i]);
94
+ if (boundary) {
95
+ if (start >= 0) spans.push({ text: s.slice(start, i), start, end: i });
96
+ start = -1;
97
+ } else if (start < 0) {
98
+ start = i;
99
+ }
100
+ }
101
+ return spans;
102
+ }
103
+ var COMBINING_START = 768;
104
+ var COMBINING_END = 879;
105
+ function dedupKey(s) {
106
+ let out = "";
107
+ for (const ch of s.toLowerCase().normalize("NFD")) {
108
+ const cp = ch.codePointAt(0) ?? 0;
109
+ if (cp >= COMBINING_START && cp <= COMBINING_END) continue;
110
+ out += ch;
111
+ }
112
+ return collapseSpaces(out);
113
+ }
114
+ function yearOf(word) {
115
+ const key = wordKey(word);
116
+ if (key.length !== 4 || !isAllDigits(key)) return null;
117
+ const n = Number(key);
118
+ return n >= 1900 && n <= 2099 ? n : null;
119
+ }
120
+ var PhraseTable = class {
121
+ constructor(entries) {
122
+ this.byFirst = /* @__PURE__ */ new Map();
123
+ this.byLast = /* @__PURE__ */ new Map();
124
+ this.keys = /* @__PURE__ */ new Map();
125
+ for (const [rawKey, value] of entries) this.add(rawKey, value);
126
+ }
127
+ add(rawKey, value) {
128
+ const key = asciiLower(rawKey).trim();
129
+ const tokens = splitWords(key);
130
+ const first = tokens[0];
131
+ const last = tokens[tokens.length - 1];
132
+ if (first === void 0 || last === void 0 || this.keys.has(key)) return;
133
+ this.keys.set(key, value);
134
+ const entry = { key, tokens, value };
135
+ insertSorted(this.byFirst, first, entry);
136
+ insertSorted(this.byLast, last, entry);
137
+ }
138
+ has(key) {
139
+ return this.keys.has(key);
140
+ }
141
+ /** Longest entry matching words[start …]. */
142
+ matchAt(words, start) {
143
+ const word = words[start];
144
+ if (word === void 0) return null;
145
+ let best = null;
146
+ for (const entry of candidates(this.byFirst, word)) {
147
+ const n = entry.tokens.length;
148
+ if (best && n <= best.length) continue;
149
+ if (start + n > words.length) continue;
150
+ if (tokensMatch(words, start, entry.tokens)) best = { entry, length: n };
151
+ }
152
+ return best;
153
+ }
154
+ /** Longest entry matching words[… end-1] (end exclusive). */
155
+ matchEnding(words, end) {
156
+ const word = words[end - 1];
157
+ if (word === void 0) return null;
158
+ let best = null;
159
+ for (const entry of candidates(this.byLast, word)) {
160
+ const n = entry.tokens.length;
161
+ if (best && n <= best.length) continue;
162
+ if (end - n < 0) continue;
163
+ if (tokensMatch(words, end - n, entry.tokens)) best = { entry, length: n };
164
+ }
165
+ return best;
166
+ }
167
+ /** The whole word list is exactly one entry. */
168
+ matchWhole(words) {
169
+ const m = this.matchAt(words, 0);
170
+ return m && m.length === words.length ? m : null;
171
+ }
172
+ };
173
+ function insertSorted(map, k, e) {
174
+ const list = map.get(k) ?? [];
175
+ list.push(e);
176
+ list.sort((a, b) => b.tokens.length - a.tokens.length);
177
+ map.set(k, list);
178
+ }
179
+ function candidates(map, word) {
180
+ const lower = asciiLower(word);
181
+ const stripped = stripKeyPunct(lower);
182
+ const a = map.get(lower) ?? [];
183
+ if (stripped === lower) return a;
184
+ const b = map.get(stripped) ?? [];
185
+ return a.length === 0 ? b : b.length === 0 ? a : [...a, ...b];
186
+ }
187
+ function tokensMatch(words, start, tokens) {
188
+ for (let k = 0; k < tokens.length; k++) {
189
+ const w = words[start + k];
190
+ const t = tokens[k];
191
+ if (w === void 0 || t === void 0 || !wordMatches(w, t)) return false;
192
+ }
193
+ return true;
194
+ }
195
+
196
+ // src/tracklist/split.ts
197
+ function splitLines(text) {
198
+ const lines = [];
199
+ let start = 0;
200
+ for (let i = 0; i < text.length; i++) {
201
+ const c = text.charCodeAt(i);
202
+ if (c === 10) {
203
+ lines.push({ raw: text.slice(start, i), eol: "\n" });
204
+ start = i + 1;
205
+ } else if (c === 13) {
206
+ if (text.charCodeAt(i + 1) === 10) {
207
+ lines.push({ raw: text.slice(start, i), eol: "\r\n" });
208
+ i++;
209
+ } else {
210
+ lines.push({ raw: text.slice(start, i), eol: "\r" });
211
+ }
212
+ start = i + 1;
213
+ }
214
+ }
215
+ if (start < text.length) lines.push({ raw: text.slice(start), eol: "" });
216
+ return lines;
217
+ }
218
+ function joinLines(lines) {
219
+ let out = "";
220
+ for (const l of lines) out += l.raw + l.eol;
221
+ return out;
222
+ }
223
+ function hasLineBreak(s) {
224
+ for (let i = 0; i < s.length; i++) {
225
+ const c = s.charCodeAt(i);
226
+ if (c === 10 || c === 13) return true;
227
+ }
228
+ return false;
229
+ }
230
+
231
+ // src/digits.ts
232
+ function digitRun(s, from) {
233
+ let i = from;
234
+ while (isDigit(s[i])) i++;
235
+ return i - from;
236
+ }
237
+ function twoDigitsUpTo59(s, at) {
238
+ if (!isDigit(s[at]) || !isDigit(s[at + 1]) || isDigit(s[at + 2])) return null;
239
+ const n = Number(s.slice(at, at + 2));
240
+ return n <= 59 ? n : null;
241
+ }
242
+
243
+ // src/tracklist/times.ts
244
+ function isGapSeparator(ch) {
245
+ return ch === "-" || ch === "\u2013" || ch === "\u2014" || ch === "|" || ch === "." || ch === ":";
246
+ }
247
+ function skipWhitespace(s, from) {
248
+ let i = from;
249
+ while (i < s.length && isWhitespace(s[i])) i++;
250
+ return i;
251
+ }
252
+ function isBullet(ch) {
253
+ return ch === "-" || ch === "\u2013" || ch === "\u2014" || ch === "*" || ch === "\u2022";
254
+ }
255
+ function untimed(raw, invalid) {
256
+ const at = skipWhitespace(raw, 0);
257
+ let lead = "";
258
+ if (isBullet(raw[at]) && isWhitespace(raw[at + 1]))
259
+ lead = raw.slice(0, skipWhitespace(raw, at + 1));
260
+ return {
261
+ parts: { lead, time: "", gap: "", rest: raw.slice(lead.length) },
262
+ time: null,
263
+ style: null,
264
+ invalid
265
+ };
266
+ }
267
+ function secondsOf(whole, frac) {
268
+ return frac.length === 0 ? whole : Number(`${whole}.${frac}`);
269
+ }
270
+ function shapeAt(s, at) {
271
+ const na = digitRun(s, at);
272
+ if (na === 0 || s[at + na] !== ":" || !isDigit(s[at + na + 1])) return null;
273
+ const parts = [s.slice(at, at + na)];
274
+ let end = at + na;
275
+ while (s[end] === ":" && isDigit(s[end + 1])) {
276
+ const n = digitRun(s, end + 1);
277
+ parts.push(s.slice(end + 1, end + 1 + n));
278
+ end += 1 + n;
279
+ }
280
+ return { parts, end };
281
+ }
282
+ function shapeSeconds(parts) {
283
+ if (parts.length < 2 || parts.length > 3) return null;
284
+ const [a, b, c] = parts;
285
+ if (c !== void 0) {
286
+ if (a.length < 1 || a.length > 2 || b.length !== 2 || c.length !== 2) return null;
287
+ const m = Number(b);
288
+ const sec2 = Number(c);
289
+ if (m > 59 || sec2 > 59) return null;
290
+ return Number(a) * 3600 + m * 60 + sec2;
291
+ }
292
+ if (a.length < 1 || a.length > 3 || b.length !== 2) return null;
293
+ const sec = Number(b);
294
+ if (sec > 59) return null;
295
+ return Number(a) * 60 + sec;
296
+ }
297
+ function scanTime(raw) {
298
+ let i = skipWhitespace(raw, 0);
299
+ const closer = raw[i] === "[" ? "]" : raw[i] === "(" ? ")" : null;
300
+ if (closer) i++;
301
+ const leadEnd = i;
302
+ const shape = shapeAt(raw, i);
303
+ if (!shape) return untimed(raw, false);
304
+ const whole = shapeSeconds(shape.parts);
305
+ if (whole === null) return untimed(raw, true);
306
+ let j = shape.end;
307
+ let fracSep = "";
308
+ let frac = "";
309
+ const sep = raw[j];
310
+ if (sep === "." || sep === ",") {
311
+ const n = digitRun(raw, j + 1);
312
+ if (n >= 1 && n <= 3) {
313
+ fracSep = sep;
314
+ frac = raw.slice(j + 1, j + 1 + n);
315
+ j += 1 + n;
316
+ }
317
+ }
318
+ const timeEnd = j;
319
+ if (closer) {
320
+ if (raw[j] !== closer) return untimed(raw, false);
321
+ j++;
322
+ } else if (j < raw.length && !isWhitespace(raw[j]) && !isGapSeparator(raw[j])) {
323
+ return untimed(raw, false);
324
+ }
325
+ j = skipWhitespace(raw, j);
326
+ if (isGapSeparator(raw[j])) j = skipWhitespace(raw, j + 1);
327
+ if (raw.startsWith("==>", j)) j = skipWhitespace(raw, j + 3);
328
+ else if (raw.startsWith("=>", j)) j = skipWhitespace(raw, j + 2);
329
+ const timeRaw = raw.slice(leadEnd, timeEnd);
330
+ const hours = shape.parts.length === 3;
331
+ const first = shape.parts[0];
332
+ const longMinutes = !hours && Number(first) >= 60;
333
+ return {
334
+ parts: {
335
+ lead: raw.slice(0, leadEnd),
336
+ time: timeRaw,
337
+ gap: raw.slice(timeEnd, j),
338
+ rest: raw.slice(j)
339
+ },
340
+ time: { raw: timeRaw, seconds: secondsOf(whole, frac) },
341
+ style: {
342
+ hours,
343
+ hourWidth: hours ? first.length : 0,
344
+ // R13.9.2: a long-minute time's minimum minute width is min(width, 2), so it never
345
+ // keeps 3 minute digits once it is short again (`125:30` − 7000 s → `08:50`).
346
+ minuteWidth: hours ? shape.parts[1].length : longMinutes ? Math.min(first.length, 2) : first.length,
347
+ longMinutes,
348
+ fracSep,
349
+ fracDigits: frac.length
350
+ },
351
+ invalid: false
352
+ };
353
+ }
354
+ var pad = (n, width) => String(n).padStart(width, "0");
355
+ function formatTime(seconds, style, oneDigitHours2) {
356
+ const scale = 10 ** style.fracDigits;
357
+ const units = Math.floor(seconds * scale + 0.5);
358
+ if (!Number.isSafeInteger(units) || units < 0) return null;
359
+ const whole = Math.floor(units / scale);
360
+ const fracUnits = units - whole * scale;
361
+ const frac = style.fracDigits > 0 ? style.fracSep + pad(fracUnits, style.fracDigits) : "";
362
+ const sec = whole % 60;
363
+ if (whole >= 100 * 3600) return null;
364
+ if (style.hours) {
365
+ const h = Math.floor(whole / 3600);
366
+ const m2 = Math.floor(whole % 3600 / 60);
367
+ return {
368
+ raw: `${pad(h, style.hourWidth)}:${pad(m2, style.minuteWidth)}:${pad(sec, 2)}${frac}`,
369
+ upgraded: false
370
+ };
371
+ }
372
+ if (style.longMinutes) {
373
+ const m2 = Math.floor(whole / 60);
374
+ if (m2 > 999) return null;
375
+ return { raw: `${pad(m2, style.minuteWidth)}:${pad(sec, 2)}${frac}`, upgraded: false };
376
+ }
377
+ if (whole >= 3600) {
378
+ const h = Math.floor(whole / 3600);
379
+ const m2 = Math.floor(whole % 3600 / 60);
380
+ return {
381
+ raw: `${pad(h, oneDigitHours2 ? 1 : 2)}:${pad(m2, 2)}:${pad(sec, 2)}${frac}`,
382
+ upgraded: true
383
+ };
384
+ }
385
+ const m = Math.floor(whole / 60);
386
+ return { raw: `${pad(m, style.minuteWidth)}:${pad(sec, 2)}${frac}`, upgraded: false };
387
+ }
388
+
389
+ // src/tracklist/edits.ts
390
+ var InvalidEdit = class extends Error {
391
+ };
392
+ function workLine(raw, eol, changed) {
393
+ return { raw, eol, changed, scan: scanTime(raw) };
394
+ }
395
+ function countHours(ctx, line, by) {
396
+ const style = line.scan.style;
397
+ if (!style?.hours) return;
398
+ if (style.hourWidth === 1) ctx.oneDigit += by;
399
+ else ctx.wideHours += by;
400
+ }
401
+ function countEol(ctx, eol, by) {
402
+ if (eol !== "") ctx.eols.set(eol, (ctx.eols.get(eol) ?? 0) + by);
403
+ }
404
+ function setEol(ctx, line, eol) {
405
+ countEol(ctx, line.eol, -1);
406
+ line.eol = eol;
407
+ line.changed = true;
408
+ countEol(ctx, eol, 1);
409
+ }
410
+ function countLine(ctx, line, by) {
411
+ countHours(ctx, line, by);
412
+ countEol(ctx, line.eol, by);
413
+ }
414
+ function setRaw(ctx, line, raw) {
415
+ countHours(ctx, line, -1);
416
+ line.raw = raw;
417
+ line.scan = scanTime(raw);
418
+ line.changed = true;
419
+ countHours(ctx, line, 1);
420
+ }
421
+ function warnEdit(ctx, code, line) {
422
+ ctx.warnings.push({ code, edit: ctx.index, line });
423
+ }
424
+ function field(edit, key) {
425
+ return edit[key];
426
+ }
427
+ function has(edit, key) {
428
+ return field(edit, key) !== void 0;
429
+ }
430
+ function lineIndex(ctx, value) {
431
+ if (typeof value !== "number" || !Number.isInteger(value)) throw new InvalidEdit();
432
+ if (value < 1 || value > ctx.lines.length) throw new InvalidEdit();
433
+ return value - 1;
434
+ }
435
+ function finite(value) {
436
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new InvalidEdit();
437
+ return value;
438
+ }
439
+ function singleLine(value) {
440
+ if (typeof value !== "string" || hasLineBreak(value)) throw new InvalidEdit();
441
+ return value;
442
+ }
443
+ function oneDigitHours(ctx) {
444
+ return ctx.oneDigit > 0 && ctx.wideHours === 0;
445
+ }
446
+ function retime(ctx, index, value, oneDigit) {
447
+ const scan2 = ctx.lines[index].scan;
448
+ if (!scan2.style || !scan2.time) throw new InvalidEdit();
449
+ const clamped = value < 0;
450
+ const f = formatTime(clamped ? 0 : value, scan2.style, oneDigit);
451
+ if (!f) throw new InvalidEdit();
452
+ const p = scan2.parts;
453
+ return { index, raw: p.lead + f.raw + p.gap + p.rest, clamped, upgraded: f.upgraded };
454
+ }
455
+ function commit(ctx, changes) {
456
+ for (const c of changes) {
457
+ setRaw(ctx, ctx.lines[c.index], c.raw);
458
+ if (c.clamped) warnEdit(ctx, "time-clamped", c.index + 1);
459
+ if (c.upgraded) warnEdit(ctx, "style-upgraded", c.index + 1);
460
+ }
461
+ }
462
+ function setTime(ctx, edit) {
463
+ const index = lineIndex(ctx, field(edit, "line"));
464
+ const value = finite(field(edit, "seconds"));
465
+ commit(ctx, [retime(ctx, index, value, oneDigitHours(ctx))]);
466
+ return [];
467
+ }
468
+ function shift(ctx, edit) {
469
+ const delta = finite(field(edit, "seconds"));
470
+ let targets;
471
+ if (has(edit, "lines")) {
472
+ const list = field(edit, "lines");
473
+ if (!Array.isArray(list)) throw new InvalidEdit();
474
+ const set = new Set(list.map((v) => lineIndex(ctx, v)));
475
+ targets = [...set].sort((a, b) => a - b);
476
+ } else {
477
+ const from = has(edit, "from") ? lineIndex(ctx, field(edit, "from")) : 0;
478
+ targets = [];
479
+ for (let i = from; i < ctx.lines.length; i++) targets.push(i);
480
+ }
481
+ const oneDigit = oneDigitHours(ctx);
482
+ const changes = [];
483
+ for (const i of targets) {
484
+ const time = ctx.lines[i].scan.time;
485
+ if (time) changes.push(retime(ctx, i, time.seconds + delta, oneDigit));
486
+ }
487
+ commit(ctx, changes);
488
+ return [];
489
+ }
490
+ function sortByTime(ctx) {
491
+ const lines = ctx.lines;
492
+ const first = lines.findIndex((l) => l.scan.time !== null);
493
+ if (first < 0) return [];
494
+ const blocks = [];
495
+ for (let i = first; i < lines.length; i++) {
496
+ const t = lines[i].scan.time;
497
+ if (t) blocks.push({ seconds: t.seconds, from: i, to: i + 1 });
498
+ else blocks[blocks.length - 1].to = i + 1;
499
+ }
500
+ const sorted = blocks.map((b, k) => ({ b, k })).sort((x, y) => x.b.seconds - y.b.seconds || x.k - y.k).map((x) => x.b);
501
+ const order = [];
502
+ for (let i = 0; i < first; i++) order.push(i);
503
+ for (const b of sorted) for (let i = b.from; i < b.to; i++) order.push(i);
504
+ const old = ctx.lines;
505
+ ctx.lines = order.map((src, slot) => {
506
+ const line = old[src];
507
+ const eol = old[slot].eol;
508
+ return { raw: line.raw, eol, changed: line.changed || src !== slot, scan: line.scan };
509
+ });
510
+ return null;
511
+ }
512
+ function setRest(ctx, edit) {
513
+ const index = lineIndex(ctx, field(edit, "line"));
514
+ const rest = singleLine(field(edit, "rest"));
515
+ const line = ctx.lines[index];
516
+ const p = line.scan.parts;
517
+ const spaced = p.gap.length > 0 && isWhitespace(p.gap[p.gap.length - 1]);
518
+ const gap = p.time !== "" && !spaced && rest !== "" ? `${p.gap} ` : p.gap;
519
+ setRaw(ctx, line, p.lead + p.time + gap + rest);
520
+ return [index];
521
+ }
522
+ function commonEol(ctx) {
523
+ let best = 0;
524
+ const top = [];
525
+ for (const [eol, n] of ctx.eols) {
526
+ if (n <= 0 || n < best) continue;
527
+ if (n > best) top.length = 0;
528
+ best = n;
529
+ top.push(eol);
530
+ }
531
+ if (top.length === 0) return "\n";
532
+ if (top.length === 1) return top[0];
533
+ for (const l of ctx.lines) if (top.includes(l.eol)) return l.eol;
534
+ return "\n";
535
+ }
536
+ function insert(ctx, edit) {
537
+ const after = field(edit, "after");
538
+ if (typeof after !== "number" || !Number.isInteger(after)) throw new InvalidEdit();
539
+ if (after < 0 || after > ctx.lines.length) throw new InvalidEdit();
540
+ const text = singleLine(field(edit, "text"));
541
+ const eol = commonEol(ctx);
542
+ const last = ctx.lines[ctx.lines.length - 1];
543
+ const line = workLine(text, ctx.lines.length === 0 ? "" : eol, true);
544
+ if (after === ctx.lines.length && last && last.eol === "") {
545
+ setEol(ctx, last, eol);
546
+ line.eol = "";
547
+ }
548
+ ctx.lines.splice(after, 0, line);
549
+ countLine(ctx, line, 1);
550
+ return [after];
551
+ }
552
+ function remove(ctx, edit) {
553
+ const index = lineIndex(ctx, field(edit, "line"));
554
+ const [gone] = ctx.lines.splice(index, 1);
555
+ if (gone) countLine(ctx, gone, -1);
556
+ const last = ctx.lines[ctx.lines.length - 1];
557
+ if (gone && index === ctx.lines.length && last && last.eol !== gone.eol) {
558
+ setEol(ctx, last, gone.eol);
559
+ }
560
+ return [index];
561
+ }
562
+ function mergeAt(ctx, k) {
563
+ const prev = ctx.lines[k];
564
+ const next = ctx.lines[k + 1];
565
+ if (!prev || !next || prev.eol !== "\r" || next.raw !== "" || next.eol !== "\n") return;
566
+ setEol(ctx, prev, "\r\n");
567
+ ctx.lines.splice(k + 1, 1);
568
+ countLine(ctx, next, -1);
569
+ }
570
+ function canonical(ctx, touched) {
571
+ const lines = ctx.lines;
572
+ let starts;
573
+ if (touched === null) {
574
+ starts = [];
575
+ for (let k = lines.length - 2; k >= 0; k--) starts.push(k);
576
+ } else {
577
+ const set = /* @__PURE__ */ new Set([lines.length - 2]);
578
+ for (const t of touched) {
579
+ set.add(t - 1);
580
+ set.add(t);
581
+ }
582
+ starts = [...set].sort((a, b) => b - a);
583
+ }
584
+ for (const k of starts) mergeAt(ctx, k);
585
+ const last = lines[lines.length - 1];
586
+ if (last && last.raw === "" && last.eol === "") {
587
+ lines.pop();
588
+ countLine(ctx, last, -1);
589
+ }
590
+ }
591
+ function applyOne(ctx, edit) {
592
+ if (typeof edit !== "object" || edit === null || Array.isArray(edit)) throw new InvalidEdit();
593
+ const op = field(edit, "op");
594
+ if (op === "set-time") return setTime(ctx, edit);
595
+ if (op === "shift") return shift(ctx, edit);
596
+ if (op === "sort-by-time") return sortByTime(ctx);
597
+ if (op === "set-rest") return setRest(ctx, edit);
598
+ if (op === "insert") return insert(ctx, edit);
599
+ if (op === "delete") return remove(ctx, edit);
600
+ throw new InvalidEdit();
601
+ }
602
+ function invalidLine(edit) {
603
+ if (typeof edit !== "object" || edit === null) return null;
604
+ try {
605
+ for (const key of ["line", "after"]) {
606
+ const value = field(edit, key);
607
+ if (typeof value === "number" && Number.isInteger(value)) return value;
608
+ }
609
+ return null;
610
+ } catch {
611
+ return null;
612
+ }
613
+ }
614
+ function applyEdits(text, edits, options) {
615
+ const source = typeof text === "string" ? text : "";
616
+ const list = Array.isArray(edits) ? edits : [];
617
+ if (list.length === 0) return { text: source, changed: [], warnings: [] };
618
+ const ctx = {
619
+ lines: splitLines(source).map((l) => workLine(l.raw, l.eol, false)),
620
+ index: 0,
621
+ warnings: [],
622
+ oneDigit: 0,
623
+ wideHours: 0,
624
+ eols: /* @__PURE__ */ new Map()
625
+ };
626
+ for (const l of ctx.lines) countLine(ctx, l, 1);
627
+ for (let i = 0; i < list.length; i++) {
628
+ ctx.index = i;
629
+ const warningsBefore = ctx.warnings.length;
630
+ try {
631
+ canonical(ctx, applyOne(ctx, list[i]));
632
+ } catch {
633
+ ctx.warnings.length = warningsBefore;
634
+ warnEdit(ctx, "edit-invalid", invalidLine(list[i]));
635
+ }
636
+ }
637
+ const changed = [];
638
+ ctx.lines.forEach((l, i) => {
639
+ if (l.changed) changed.push(i + 1);
640
+ });
641
+ return { text: joinLines(ctx.lines), changed, warnings: ctx.warnings };
642
+ }
643
+
644
+ // src/data.generated.ts
645
+ var SPEC_VERSION = "0.3.1";
646
+ var descriptorsData = {
647
+ "descriptors": [
648
+ "radio",
649
+ "extended",
650
+ "club",
651
+ "original",
652
+ "instrumental",
653
+ "dub",
654
+ "vip",
655
+ "acoustic",
656
+ "live",
657
+ "demo",
658
+ "vocal",
659
+ "clean",
660
+ "dirty",
661
+ "explicit",
662
+ "short",
663
+ "long",
664
+ "full",
665
+ "album",
666
+ "single",
667
+ "alternate",
668
+ "alt",
669
+ "alternative",
670
+ "main",
671
+ "special",
672
+ "bonus",
673
+ "deluxe",
674
+ "mono",
675
+ "stereo",
676
+ "new",
677
+ "official",
678
+ "orchestral",
679
+ "piano",
680
+ "unplugged",
681
+ '12"',
682
+ '7"',
683
+ "12''",
684
+ "7''",
685
+ "12 inch",
686
+ "7 inch",
687
+ "re",
688
+ "final",
689
+ "second",
690
+ "2nd",
691
+ "3rd"
692
+ ]
693
+ };
694
+ var extensionsData = {
695
+ "extensions": [
696
+ "mp3",
697
+ "flac",
698
+ "wav",
699
+ "m4a",
700
+ "ogg",
701
+ "opus",
702
+ "aac",
703
+ "aiff",
704
+ "aif",
705
+ "wma",
706
+ "alac",
707
+ "mp4",
708
+ "webm",
709
+ "mkv"
710
+ ]
711
+ };
712
+ var featMarkersData = {
713
+ "markers": [
714
+ "feat.",
715
+ "feat",
716
+ "ft.",
717
+ "ft",
718
+ "featuring",
719
+ "feat:",
720
+ "ft:"
721
+ ],
722
+ "bracketOnly": [
723
+ "with",
724
+ "w/"
725
+ ],
726
+ "producer": [
727
+ "prod.",
728
+ "prod",
729
+ "produced",
730
+ "prod.by",
731
+ "prod:"
732
+ ],
733
+ "titleMarkers": [
734
+ "feat.",
735
+ "ft.",
736
+ "featuring"
737
+ ],
738
+ "titleProducer": [
739
+ "prod.",
740
+ "prod. by",
741
+ "produced by",
742
+ "prod.by"
743
+ ]};
744
+ var genresData = {
745
+ "genres": [
746
+ "d'n'b",
747
+ "dnb",
748
+ "d&b",
749
+ "d & b",
750
+ "drum & bass",
751
+ "drum&bass",
752
+ "drum and bass",
753
+ "drum n bass",
754
+ "drum 'n' bass",
755
+ "drum'n'bass",
756
+ "neurofunk",
757
+ "neuro",
758
+ "liquid",
759
+ "liquid funk",
760
+ "jump up",
761
+ "jump-up",
762
+ "jungle",
763
+ "halftime",
764
+ "dancefloor",
765
+ "dubstep",
766
+ "riddim",
767
+ "brostep",
768
+ "trap",
769
+ "future bass",
770
+ "house",
771
+ "deep house",
772
+ "tech house",
773
+ "progressive house",
774
+ "electro house",
775
+ "techno",
776
+ "trance",
777
+ "psytrance",
778
+ "psy",
779
+ "hardstyle",
780
+ "hardcore",
781
+ "breaks",
782
+ "breakbeat",
783
+ "garage",
784
+ "uk garage",
785
+ "ukg",
786
+ "2step",
787
+ "2-step",
788
+ "grime",
789
+ "bass",
790
+ "bass house",
791
+ "edm",
792
+ "electronic",
793
+ "idm",
794
+ "synthwave",
795
+ "phonk",
796
+ "hip hop",
797
+ "hip-hop",
798
+ "rap",
799
+ "pop",
800
+ "rock",
801
+ "metal",
802
+ "ambient",
803
+ "chillout",
804
+ "lofi",
805
+ "lo-fi"
806
+ ]
807
+ };
808
+ var headerWordsData = {
809
+ "words": [
810
+ "tracklist",
811
+ "setlist",
812
+ "playlist",
813
+ "part",
814
+ "hour",
815
+ "cd",
816
+ "disc",
817
+ "side",
818
+ "chapter",
819
+ "segment",
820
+ "set"
821
+ ]
822
+ };
823
+ var joinersData = {
824
+ "spaced": [
825
+ {
826
+ "raw": "&",
827
+ "canonical": "&"
828
+ },
829
+ {
830
+ "raw": "and",
831
+ "canonical": "&",
832
+ "kind": "and"
833
+ },
834
+ {
835
+ "raw": "x",
836
+ "canonical": "x",
837
+ "caseSensitive": true
838
+ },
839
+ {
840
+ "raw": "\xD7",
841
+ "canonical": "x"
842
+ },
843
+ {
844
+ "raw": "vs.",
845
+ "canonical": "vs."
846
+ },
847
+ {
848
+ "raw": "vs",
849
+ "canonical": "vs."
850
+ },
851
+ {
852
+ "raw": "versus",
853
+ "canonical": "vs."
854
+ },
855
+ {
856
+ "raw": "/",
857
+ "canonical": "/"
858
+ },
859
+ {
860
+ "raw": "+",
861
+ "canonical": "+"
862
+ },
863
+ {
864
+ "raw": "with",
865
+ "canonical": "with"
866
+ },
867
+ {
868
+ "raw": "meets",
869
+ "canonical": "meets"
870
+ },
871
+ {
872
+ "raw": "pres.",
873
+ "canonical": "pres."
874
+ },
875
+ {
876
+ "raw": "presents",
877
+ "canonical": "pres."
878
+ },
879
+ {
880
+ "raw": "b2b",
881
+ "canonical": "b2b"
882
+ }
883
+ ],
884
+ "tight": [
885
+ {
886
+ "raw": ",",
887
+ "canonical": ","
888
+ },
889
+ {
890
+ "raw": ";",
891
+ "canonical": ","
892
+ }
893
+ ],
894
+ "featCanonical": "feat."
895
+ };
896
+ var junkData = {
897
+ "phrases": {
898
+ "official video": "video",
899
+ "official music video": "video",
900
+ "music video": "video",
901
+ "official video clip": "video",
902
+ "video clip": "video",
903
+ "official clip": "video",
904
+ "clip officiel": "video",
905
+ "official hd video": "video",
906
+ "official 4k video": "video",
907
+ "official mv": "video",
908
+ "mv": "video",
909
+ "m/v": "video",
910
+ "video": "video",
911
+ "official visualizer": "video",
912
+ "official visualiser": "video",
913
+ "visualizer": "video",
914
+ "visualiser": "video",
915
+ "audio visualizer": "video",
916
+ "official animated video": "video",
917
+ "animated video": "video",
918
+ "live video": "video",
919
+ "official live video": "video",
920
+ "performance video": "video",
921
+ "official performance video": "video",
922
+ "official audio": "audio",
923
+ "audio": "audio",
924
+ "audio only": "audio",
925
+ "full audio": "audio",
926
+ "official lyric video": "lyrics",
927
+ "official lyrics video": "lyrics",
928
+ "lyric video": "lyrics",
929
+ "lyrics video": "lyrics",
930
+ "lyrics": "lyrics",
931
+ "lyric": "lyrics",
932
+ "with lyrics": "lyrics",
933
+ "lyrics on screen": "lyrics",
934
+ "letra": "lyrics",
935
+ "hd": "quality",
936
+ "hq": "quality",
937
+ "uhd": "quality",
938
+ "4k": "quality",
939
+ "8k": "quality",
940
+ "1080p": "quality",
941
+ "720p": "quality",
942
+ "480p": "quality",
943
+ "high quality": "quality",
944
+ "full hd": "quality",
945
+ "free download": "promo",
946
+ "free dl": "promo",
947
+ "out now": "promo",
948
+ "premiere": "promo",
949
+ "exclusive": "promo",
950
+ "preview": "promo",
951
+ "teaser": "promo",
952
+ "snippet": "promo",
953
+ "new song": "promo",
954
+ "available now": "promo",
955
+ "ncs release": "label",
956
+ "monstercat release": "label",
957
+ "official": "other"
958
+ },
959
+ "connectors": [
960
+ "-",
961
+ "|",
962
+ "/",
963
+ "&",
964
+ "+",
965
+ ","
966
+ ],
967
+ "labelSuffixes": [
968
+ "release",
969
+ "records",
970
+ "recordings"
971
+ ]
972
+ };
973
+ var markerWordsData = {
974
+ "words": [
975
+ "intro",
976
+ "outro",
977
+ "break",
978
+ "interlude",
979
+ "intermission",
980
+ "jingle",
981
+ "ad",
982
+ "advert",
983
+ "advertisement",
984
+ "talk",
985
+ "interview",
986
+ "speech",
987
+ "announcement",
988
+ "shoutout",
989
+ "shout-out",
990
+ "pause",
991
+ "start",
992
+ "end",
993
+ "silence",
994
+ "applause",
995
+ "skit"
996
+ ]
997
+ };
998
+ var noSplitBeforeData = {
999
+ "words": [
1000
+ "sons",
1001
+ "daughters",
1002
+ "friends",
1003
+ "co",
1004
+ "co.",
1005
+ "company",
1006
+ "his",
1007
+ "her",
1008
+ "their",
1009
+ "orchestra"
1010
+ ]
1011
+ };
1012
+ var platformSuffixesData = {
1013
+ "suffixes": [
1014
+ " - YouTube Music",
1015
+ " - YouTube",
1016
+ " | Free Listening on SoundCloud",
1017
+ " - SoundCloud",
1018
+ " | SoundCloud",
1019
+ " - Topic",
1020
+ " | Bandcamp",
1021
+ " - Bandcamp"
1022
+ ]
1023
+ };
1024
+ var stopwordsData = {
1025
+ "words": [
1026
+ "you",
1027
+ "me",
1028
+ "him",
1029
+ "her",
1030
+ "us",
1031
+ "them",
1032
+ "it",
1033
+ "i",
1034
+ "love",
1035
+ "my",
1036
+ "your",
1037
+ "our",
1038
+ "the",
1039
+ "a",
1040
+ "an",
1041
+ "this",
1042
+ "that",
1043
+ "myself",
1044
+ "yourself",
1045
+ "everyone",
1046
+ "everybody",
1047
+ "somebody",
1048
+ "someone",
1049
+ "nobody",
1050
+ "nothing",
1051
+ "all",
1052
+ "night",
1053
+ "side",
1054
+ "day"
1055
+ ]
1056
+ };
1057
+ var unknownTokensData = {
1058
+ "caseSensitive": [
1059
+ "ID",
1060
+ "IDs",
1061
+ "ID?"
1062
+ ],
1063
+ "caseInsensitive": [
1064
+ "unknown",
1065
+ "unknown artist",
1066
+ "unknown title",
1067
+ "untitled",
1068
+ "?",
1069
+ "??",
1070
+ "???"
1071
+ ]
1072
+ };
1073
+ var versionKeywordsData = {
1074
+ "heads": {
1075
+ "remix": "remix",
1076
+ "rmx": "remix",
1077
+ "mix": "mix",
1078
+ "bootleg": "bootleg",
1079
+ "vip": "vip",
1080
+ "edit": "edit",
1081
+ "re-edit": "edit",
1082
+ "reedit": "edit",
1083
+ "flip": "flip",
1084
+ "refix": "refix",
1085
+ "rework": "rework",
1086
+ "re-work": "rework",
1087
+ "mashup": "mashup",
1088
+ "mash-up": "mashup",
1089
+ "mash up": "mashup",
1090
+ "blend": "blend",
1091
+ "dub": "dub",
1092
+ "version": "version",
1093
+ "ver": "version",
1094
+ "remaster": "remaster",
1095
+ "remastered": "remaster",
1096
+ "cover": "cover",
1097
+ "reprise": "reprise"
1098
+ },
1099
+ "genericHeads": [
1100
+ "mix",
1101
+ "edit",
1102
+ "version"
1103
+ ],
1104
+ "typeCapableModifiers": {
1105
+ "radio": "radio",
1106
+ "extended": "extended",
1107
+ "club": "club",
1108
+ "original": "original",
1109
+ "instrumental": "instrumental",
1110
+ "dub": "dub",
1111
+ "vip": "vip",
1112
+ "acoustic": "acoustic",
1113
+ "live": "live",
1114
+ "acapella": "acapella",
1115
+ "a cappella": "acapella",
1116
+ "a capella": "acapella",
1117
+ "acappella": "acapella",
1118
+ "demo": "demo"
1119
+ },
1120
+ "prefixForms": {
1121
+ "live": "live",
1122
+ "remastered": "remaster",
1123
+ "remaster": "remaster",
1124
+ "acoustic": "acoustic",
1125
+ "instrumental": "instrumental",
1126
+ "acapella": "acapella",
1127
+ "a cappella": "acapella",
1128
+ "demo": "demo",
1129
+ "sped up": "spedUp",
1130
+ "speed up": "spedUp",
1131
+ "slowed + reverb": "slowed",
1132
+ "slowed & reverb": "slowed",
1133
+ "slowed and reverb": "slowed",
1134
+ "slowed": "slowed",
1135
+ "nightcore": "nightcore"
1136
+ }};
1137
+
1138
+ // src/tables.ts
1139
+ function stringList(value) {
1140
+ return Array.isArray(value) ? value.filter((s) => typeof s === "string") : [];
1141
+ }
1142
+ function stringEntries(value) {
1143
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return [];
1144
+ return Object.entries(value).filter((e) => typeof e[1] === "string");
1145
+ }
1146
+ function lowerKeys(list) {
1147
+ return stringList(list).map((s) => asciiLower(s).trim()).filter((s) => s.length > 0);
1148
+ }
1149
+ function buildTables(keywords) {
1150
+ const featMarkers = new PhraseTable(
1151
+ [...featMarkersData.markers, ...lowerKeys(keywords?.featMarkers)].map((m) => [m, true])
1152
+ );
1153
+ const bracketOnlyFeat = new PhraseTable(featMarkersData.bracketOnly.map((m) => [m, true]));
1154
+ const producerMarkers = new PhraseTable(featMarkersData.producer.map((m) => [m, true]));
1155
+ const titleFeatMarkers = new PhraseTable(
1156
+ [...featMarkersData.titleMarkers, ...lowerKeys(keywords?.featMarkers)].map((m) => [m, true])
1157
+ );
1158
+ const titleProducerMarkers = new PhraseTable(
1159
+ featMarkersData.titleProducer.map((m) => [m, true])
1160
+ );
1161
+ const junkEntries = [
1162
+ ...Object.entries(junkData.phrases),
1163
+ ...stringEntries(keywords?.junk)
1164
+ ];
1165
+ const genres = [...genresData.genres, ...lowerKeys(keywords?.genres)];
1166
+ const junkPhrases = new PhraseTable(junkEntries);
1167
+ const junkOrGenre = new PhraseTable([
1168
+ ...junkEntries,
1169
+ ...genres.map((g) => [g, "genre"])
1170
+ ]);
1171
+ const headEntries = [
1172
+ ...Object.entries(versionKeywordsData.heads),
1173
+ ...stringEntries(keywords?.versionHeads)
1174
+ ];
1175
+ const heads = new PhraseTable(headEntries);
1176
+ const runInfo = /* @__PURE__ */ new Map();
1177
+ const note = (key, patch) => {
1178
+ const k = asciiLower(key).trim();
1179
+ runInfo.set(k, { ...runInfo.get(k), ...patch });
1180
+ };
1181
+ for (const [k, t] of Object.entries(versionKeywordsData.typeCapableModifiers)) {
1182
+ note(k, { typeCapable: t });
1183
+ }
1184
+ for (const d of [...descriptorsData.descriptors, ...lowerKeys(keywords?.descriptors)]) {
1185
+ note(d, { descriptor: asciiLower(d).trim() });
1186
+ }
1187
+ for (const g of genres) note(g, { genre: true });
1188
+ for (const [k, t] of headEntries) note(k, { head: t });
1189
+ return {
1190
+ featMarkers,
1191
+ bracketOnlyFeat,
1192
+ producerMarkers,
1193
+ titleFeatMarkers,
1194
+ titleProducerMarkers,
1195
+ junkOrGenre,
1196
+ junkPhrases,
1197
+ junkConnectors: new Set(junkData.connectors),
1198
+ labelSuffixes: new Set(junkData.labelSuffixes),
1199
+ heads,
1200
+ genericHeadTypes: new Set(versionKeywordsData.genericHeads),
1201
+ runWords: new PhraseTable(runInfo),
1202
+ prefixForms: new PhraseTable(
1203
+ Object.entries(versionKeywordsData.prefixForms)
1204
+ ),
1205
+ spacedJoiners: joinersData.spaced.map((j) => ({
1206
+ raw: j.raw,
1207
+ canonical: j.canonical,
1208
+ isAnd: "kind" in j && j.kind === "and",
1209
+ caseSensitive: "caseSensitive" in j && j.caseSensitive === true
1210
+ })),
1211
+ tightJoiners: new Map(joinersData.tight.map((j) => [j.raw, j.canonical])),
1212
+ featCanonical: joinersData.featCanonical,
1213
+ noSplitBefore: new Set(noSplitBeforeData.words),
1214
+ stopwords: new Set(stopwordsData.words),
1215
+ unknownCaseSensitive: new Set(unknownTokensData.caseSensitive),
1216
+ unknownCaseInsensitive: new Set(unknownTokensData.caseInsensitive),
1217
+ extensions: new Set(extensionsData.extensions),
1218
+ platformSuffixes: [...platformSuffixesData.suffixes].sort((a, b) => b.length - a.length)
1219
+ };
1220
+ }
1221
+ var defaultTables = null;
1222
+ function getDefaultTables() {
1223
+ defaultTables ?? (defaultTables = buildTables());
1224
+ return defaultTables;
1225
+ }
1226
+ function tablesFor(keywords) {
1227
+ return keywords ? buildTables(keywords) : getDefaultTables();
1228
+ }
1229
+ function isUnknownToken(text, t) {
1230
+ return t.unknownCaseSensitive.has(text) || t.unknownCaseInsensitive.has(asciiLower(text));
1231
+ }
1232
+
1233
+ // src/context.ts
1234
+ function makeCtx(t, options) {
1235
+ const known = stringList(options?.knownArtists);
1236
+ const knownKeys = new Set(known.map(dedupKey).filter((k) => k.length > 0));
1237
+ const knownMaxLen = known.reduce((m, k) => Math.max(m, k.length), 0);
1238
+ const splitAnd = options?.splitAnd ?? "auto";
1239
+ return { t, knownKeys, knownMaxLen, splitAnd, warnings: [], listSeq: 0 };
1240
+ }
1241
+ function warn(ctx, w) {
1242
+ if (!ctx.warnings.includes(w)) ctx.warnings.push(w);
1243
+ }
1244
+ function matchKnownAt(text, start, ctx, extraBoundary = "") {
1245
+ if (ctx.knownKeys.size === 0) return 0;
1246
+ const limit = Math.min(text.length, start + ctx.knownMaxLen * 2 + 8);
1247
+ for (let end = limit; end > start; end--) {
1248
+ const next = text[end];
1249
+ const boundary = end === text.length || next === "," || next === ";" || isWhitespace(next) || extraBoundary !== "" && next === extraBoundary;
1250
+ if (!boundary) continue;
1251
+ if (ctx.knownKeys.has(dedupKey(text.slice(start, end)))) return end - start;
1252
+ }
1253
+ return 0;
1254
+ }
1255
+ function isKnownArtist(text, ctx) {
1256
+ return ctx.knownKeys.size > 0 && ctx.knownKeys.has(dedupKey(text));
1257
+ }
1258
+
1259
+ // src/scanner.ts
1260
+ var PH = "\uE000";
1261
+ var CLOSER = { "(": ")", "[": "]", "{": "}" };
1262
+ function isOpener(ch) {
1263
+ return ch === "(" || ch === "[" || ch === "{";
1264
+ }
1265
+ function scan(str, base = 0) {
1266
+ let text = "";
1267
+ const pos = [];
1268
+ const groups = /* @__PURE__ */ new Map();
1269
+ const stack = [];
1270
+ let groupStart = -1;
1271
+ for (let i = 0; i < str.length; i++) {
1272
+ const ch = str[i];
1273
+ if (stack.length === 0) {
1274
+ if (isOpener(ch)) {
1275
+ stack.push(CLOSER[ch]);
1276
+ groupStart = i;
1277
+ } else {
1278
+ text += ch;
1279
+ pos.push(base + i);
1280
+ }
1281
+ continue;
1282
+ }
1283
+ if (isOpener(ch)) {
1284
+ stack.push(CLOSER[ch]);
1285
+ } else if (ch === stack[stack.length - 1]) {
1286
+ stack.pop();
1287
+ if (stack.length === 0) {
1288
+ addGroup(str, groupStart, i + 1, true, base, groups);
1289
+ text += PH;
1290
+ pos.push(base + groupStart);
1291
+ }
1292
+ }
1293
+ }
1294
+ const unbalanced = stack.length > 0;
1295
+ if (unbalanced) {
1296
+ addGroup(str, groupStart, str.length, false, base, groups);
1297
+ text += PH;
1298
+ pos.push(base + groupStart);
1299
+ }
1300
+ return { skel: { text, pos, groups }, unbalanced };
1301
+ }
1302
+ function addGroup(str, start, end, closed, base, groups) {
1303
+ const open = str[start];
1304
+ const inner = str.slice(start + 1, closed ? end - 1 : end);
1305
+ groups.set(base + start, {
1306
+ open,
1307
+ inner: collapseSpaces(inner),
1308
+ raw: str.slice(start, end),
1309
+ closed,
1310
+ pos: base + start,
1311
+ end: base + end
1312
+ });
1313
+ }
1314
+ function sliceSkel(s, start, end = s.text.length) {
1315
+ const a = Math.max(0, Math.min(start, s.text.length));
1316
+ const b = Math.max(a, Math.min(end, s.text.length));
1317
+ return { text: s.text.slice(a, b), pos: s.pos.slice(a, b), groups: s.groups };
1318
+ }
1319
+ function trimSkel(s) {
1320
+ let a = 0;
1321
+ let b = s.text.length;
1322
+ while (a < b && isWhitespace(s.text[a])) a++;
1323
+ while (b > a && isWhitespace(s.text[b - 1])) b--;
1324
+ return sliceSkel(s, a, b);
1325
+ }
1326
+ function concatSkel(a, b) {
1327
+ return { text: a.text + b.text, pos: [...a.pos, ...b.pos], groups: a.groups };
1328
+ }
1329
+ function cutSkel(s, start, end) {
1330
+ return concatSkel(sliceSkel(s, 0, start), sliceSkel(s, end));
1331
+ }
1332
+ function groupAt(s, index) {
1333
+ if (s.text[index] !== PH) return void 0;
1334
+ const p = s.pos[index];
1335
+ return p === void 0 ? void 0 : s.groups.get(p);
1336
+ }
1337
+ function groupsOf(s) {
1338
+ const out = [];
1339
+ for (let i = 0; i < s.text.length; i++) {
1340
+ const g = groupAt(s, i);
1341
+ if (g) out.push({ index: i, group: g });
1342
+ }
1343
+ return out;
1344
+ }
1345
+ function hasGroup(s) {
1346
+ return s.text.includes(PH);
1347
+ }
1348
+ function renderSkel(s) {
1349
+ if (!s.text.includes(PH)) return s.text;
1350
+ let out = "";
1351
+ for (let i = 0; i < s.text.length; i++) {
1352
+ const g = groupAt(s, i);
1353
+ out += g ? g.raw : s.text[i];
1354
+ }
1355
+ return out;
1356
+ }
1357
+ function removeGroups(s, remove2) {
1358
+ if (remove2.size === 0) return s;
1359
+ let text = "";
1360
+ const pos = [];
1361
+ for (let i = 0; i < s.text.length; i++) {
1362
+ const p = s.pos[i];
1363
+ if (s.text[i] === PH && remove2.has(p)) continue;
1364
+ text += s.text[i];
1365
+ pos.push(p);
1366
+ }
1367
+ return { text, pos, groups: s.groups };
1368
+ }
1369
+ function isSpacedDashAt(text, i) {
1370
+ return text[i] === "-" && text[i - 1] === " " && text[i + 1] === " ";
1371
+ }
1372
+ function hasSpacedDash(text) {
1373
+ for (let i = 1; i < text.length - 1; i++) if (isSpacedDashAt(text, i)) return true;
1374
+ return false;
1375
+ }
1376
+ function trimmedSegment(s, start, end) {
1377
+ let a = start;
1378
+ let b = Math.max(start, end);
1379
+ while (a < b && isWhitespace(s.text[a])) a++;
1380
+ while (b > a && isWhitespace(s.text[b - 1])) b--;
1381
+ return { skel: sliceSkel(s, a, b), start: a, end: b };
1382
+ }
1383
+ function splitAtSpacedDashes(s, allowLeading = false) {
1384
+ const segments = [];
1385
+ const t = s.text;
1386
+ let start = 0;
1387
+ if (allowLeading && t[0] === "-" && t[1] === " ") {
1388
+ segments.push({ skel: sliceSkel(s, 0, 0), start: 0, end: 0 });
1389
+ start = 2;
1390
+ }
1391
+ for (let i = Math.max(start, 1); i < t.length - 1; i++) {
1392
+ if (isSpacedDashAt(t, i)) {
1393
+ segments.push(trimmedSegment(s, start, i - 1));
1394
+ start = i + 2;
1395
+ }
1396
+ }
1397
+ segments.push(trimmedSegment(s, start, t.length));
1398
+ return segments;
1399
+ }
1400
+
1401
+ // src/credits.ts
1402
+ var AND_LIKE = /* @__PURE__ */ new Set(["&", "and", "+"]);
1403
+ function wordAt(text, start) {
1404
+ let end = start;
1405
+ while (end < text.length && !isWhitespace(text[end])) end++;
1406
+ return end;
1407
+ }
1408
+ function nextWord(text, from) {
1409
+ let a = from;
1410
+ while (a < text.length && isWhitespace(text[a])) a++;
1411
+ let b = a;
1412
+ while (b < text.length && !isWhitespace(text[b]) && text[b] !== "," && text[b] !== ";") b++;
1413
+ return text.slice(a, b);
1414
+ }
1415
+ function findJoiner(text, from, splitAtComma, opts, ctx) {
1416
+ for (let p = from; p < text.length; p++) {
1417
+ const ch = text[p];
1418
+ if (ch === "," || ch === ";") {
1419
+ if (ch === "," && isDigit(text[p - 1]) && isDigit(text[p + 1])) continue;
1420
+ return { start: p, end: p + 1, raw: ch };
1421
+ }
1422
+ if (p === 0 || text[p - 1] !== " " || isWhitespace(ch)) continue;
1423
+ const end = wordAt(text, p);
1424
+ if (end >= text.length || text[end] !== " ") continue;
1425
+ const word = text.slice(p, end);
1426
+ const hit = spacedJoiner(word, text, end, splitAtComma, opts, ctx);
1427
+ if (hit) return { start: p, end, raw: word };
1428
+ }
1429
+ return null;
1430
+ }
1431
+ function spacedJoiner(word, text, end, splitAtComma, opts, ctx) {
1432
+ if (opts.featMarkersJoin && ctx.t.featMarkers.matchAt([word], 0)) return true;
1433
+ for (const j of ctx.t.spacedJoiners) {
1434
+ const matches = j.caseSensitive ? word === j.raw : wordMatches(word, j.raw);
1435
+ if (!matches) continue;
1436
+ if (j.raw === "with" && !opts.splitWith) return false;
1437
+ if (j.isAnd) {
1438
+ if (ctx.splitAnd === "never") return false;
1439
+ if (ctx.splitAnd === "auto" && !splitAtComma) return false;
1440
+ }
1441
+ if (AND_LIKE.has(j.raw) && inVocab(nextWord(text, end), ctx.t.noSplitBefore)) return false;
1442
+ return true;
1443
+ }
1444
+ return false;
1445
+ }
1446
+ function cleanName(raw) {
1447
+ let name = collapseSpaces(raw);
1448
+ const first = name[0];
1449
+ if (name.length >= 2 && (first === '"' || first === "'") && name[name.length - 1] === first) {
1450
+ name = collapseSpaces(name.slice(1, -1));
1451
+ }
1452
+ while (name.endsWith(",") || name.endsWith(";")) name = name.slice(0, -1).trimEnd();
1453
+ const space = name.indexOf(" ");
1454
+ if (space > 0 && wordKey(name.slice(0, space)) === "by") name = name.slice(space + 1);
1455
+ return collapseSpaces(name);
1456
+ }
1457
+ function unwrapList(list) {
1458
+ const t = trimSkel(list);
1459
+ const q = t.text[0];
1460
+ if (t.text.length < 2 || q !== '"' && q !== "'" || t.text[t.text.length - 1] !== q) return t;
1461
+ if (t.text.indexOf(q, 1) !== t.text.length - 1) return t;
1462
+ return trimSkel(sliceSkel(t, 1, t.text.length - 1));
1463
+ }
1464
+ function splitCredits(input, opts, ctx) {
1465
+ const list = unwrapList(input);
1466
+ const text = list.text;
1467
+ const raw = [];
1468
+ let joiner = opts.lead;
1469
+ let splitAtComma = false;
1470
+ let start = 0;
1471
+ while (start <= text.length) {
1472
+ while (start < text.length && isWhitespace(text[start])) start++;
1473
+ const known = matchKnownAt(text, start, ctx, "-");
1474
+ const hit = findJoiner(text, start + known, splitAtComma, opts, ctx);
1475
+ const end = hit ? hit.start : text.length;
1476
+ raw.push({
1477
+ name: cleanName(renderSkel(sliceSkel(list, start, end))),
1478
+ joiner,
1479
+ pos: list.pos[start] ?? list.pos[list.pos.length - 1] ?? 0
1480
+ });
1481
+ if (!hit) break;
1482
+ if (hit.raw === "," || hit.raw === ";") splitAtComma = true;
1483
+ joiner = hit.raw;
1484
+ start = hit.end;
1485
+ }
1486
+ return finishNames(raw, opts, ctx);
1487
+ }
1488
+ function finishNames(raw, opts, ctx) {
1489
+ const named = raw.filter((r) => r.name.length > 0);
1490
+ let unknown = false;
1491
+ const years = [];
1492
+ const kept = named.filter((r) => {
1493
+ if (isUnknownToken(r.name, ctx.t)) {
1494
+ unknown = true;
1495
+ return false;
1496
+ }
1497
+ const year = isAllDigits(r.name) ? yearOf(r.name) : null;
1498
+ if (year !== null && named.length > 1) {
1499
+ years.push({ year, pos: r.pos });
1500
+ return false;
1501
+ }
1502
+ return true;
1503
+ });
1504
+ const list = ctx.listSeq++;
1505
+ const credits = kept.map(
1506
+ (r, i) => ({
1507
+ name: r.name,
1508
+ role: opts.role,
1509
+ joiner: i === 0 ? opts.lead : r.joiner,
1510
+ source: opts.source,
1511
+ pos: r.pos,
1512
+ list
1513
+ })
1514
+ );
1515
+ return { credits, unknown, years };
1516
+ }
1517
+
1518
+ // src/classify.ts
1519
+ var EXPLICIT_FLAGS = /* @__PURE__ */ new Set(["explicit", "explicit version", "dirty", "dirty version"]);
1520
+ var CLEAN_FLAGS = /* @__PURE__ */ new Set(["clean", "clean version", "radio clean"]);
1521
+ function classify(inner, site, ctx) {
1522
+ const words = splitWords(inner);
1523
+ if (words.length === 0) return { kind: "unknown" };
1524
+ const junkKind = junkKindOf(words, ctx);
1525
+ if (junkKind) return { kind: "junk", junkKind };
1526
+ const credit = featOrProducer(inner, site, ctx);
1527
+ if (credit) return credit;
1528
+ const flag = flagOf(words);
1529
+ if (flag) return { kind: "flag", flag };
1530
+ if (words.length === 1 && isAllDigits(words[0])) {
1531
+ const year = yearOf(words[0]);
1532
+ if (year !== null) return { kind: "year", year };
1533
+ }
1534
+ const versions = multiVersion(inner, site, ctx) ?? singleVersion(inner, site, ctx);
1535
+ if (versions) return { kind: "versions", versions };
1536
+ return { kind: "unknown" };
1537
+ }
1538
+ function junkKindOf(words, ctx) {
1539
+ let firstKind = null;
1540
+ let i = 0;
1541
+ while (i < words.length) {
1542
+ const m = ctx.t.junkOrGenre.matchAt(words, i);
1543
+ if (m) {
1544
+ firstKind ?? (firstKind = m.entry.value);
1545
+ i += m.length;
1546
+ continue;
1547
+ }
1548
+ const w = words[i];
1549
+ if (ctx.t.junkConnectors.has(w) || ctx.t.junkConnectors.has(wordKey(w)) || yearOf(w) !== null) {
1550
+ i++;
1551
+ continue;
1552
+ }
1553
+ firstKind = null;
1554
+ break;
1555
+ }
1556
+ if (firstKind) return firstKind;
1557
+ const last = words[words.length - 1];
1558
+ if (words.length >= 2 && last !== void 0 && inVocab(last, ctx.t.labelSuffixes)) return "label";
1559
+ return null;
1560
+ }
1561
+ function featOrProducer(inner, site, ctx) {
1562
+ const spans = wordSpans(inner);
1563
+ const words = spans.map((s) => s.text);
1564
+ const feat = ctx.t.featMarkers.matchAt(words, 0);
1565
+ const bracketOnly = feat ? null : ctx.t.bracketOnlyFeat.matchAt(words, 0);
1566
+ const featLen = feat?.length ?? bracketOnly?.length ?? 0;
1567
+ if (featLen > 0 && words.length > featLen) {
1568
+ const next = words[featLen];
1569
+ if (!bracketOnly || !inVocab(next, ctx.t.stopwords)) {
1570
+ const r = creditsAfter(inner, spans, featLen, "featured", site, ctx);
1571
+ return { kind: "feat", ...r };
1572
+ }
1573
+ }
1574
+ const prod = ctx.t.producerMarkers.matchAt(words, 0);
1575
+ if (prod) {
1576
+ let len = prod.length;
1577
+ if (words[len] !== void 0 && wordKey(words[len]) === "by") len++;
1578
+ if (words.length > len) {
1579
+ const r = creditsAfter(inner, spans, len, "producer", site, ctx);
1580
+ return { kind: "producer", ...r };
1581
+ }
1582
+ }
1583
+ return null;
1584
+ }
1585
+ function creditsAfter(inner, spans, markerWords2, role, site, ctx) {
1586
+ const lastMarker = spans[markerWords2 - 1];
1587
+ const firstName = spans[markerWords2];
1588
+ const lead = inner.slice(spans[0].start, lastMarker.end);
1589
+ return listFromText(inner.slice(firstName.start), site.pos + 1 + firstName.start, ctx, {
1590
+ role,
1591
+ source: site.source,
1592
+ lead,
1593
+ splitWith: false,
1594
+ featMarkersJoin: role === "featured"
1595
+ });
1596
+ }
1597
+ function listFromText(text, base, ctx, opts) {
1598
+ return splitCredits(scan(text, base).skel, opts, ctx);
1599
+ }
1600
+ function flagOf(words) {
1601
+ const key = words.map(wordKey).join(" ");
1602
+ if (EXPLICIT_FLAGS.has(key)) return "explicit";
1603
+ if (CLEAN_FLAGS.has(key)) return "clean";
1604
+ return null;
1605
+ }
1606
+ function splitMultiParts(inner) {
1607
+ const parts = [];
1608
+ let depth = 0;
1609
+ let start = 0;
1610
+ for (let i = 0; i < inner.length; i++) {
1611
+ const ch = inner[i];
1612
+ if (ch === "(" || ch === "[" || ch === "{") depth++;
1613
+ else if ((ch === ")" || ch === "]" || ch === "}") && depth > 0) depth--;
1614
+ else if (depth === 0 && ch === "/" && inner[i - 1] === " " && inner[i + 1] === " ") {
1615
+ parts.push(inner.slice(start, i - 1));
1616
+ start = i + 2;
1617
+ } else if (depth === 0 && ch === ";" && inner[i + 1] === " ") {
1618
+ parts.push(inner.slice(start, i));
1619
+ start = i + 2;
1620
+ }
1621
+ }
1622
+ parts.push(inner.slice(start));
1623
+ return parts.map((p) => p.trim());
1624
+ }
1625
+ function multiVersion(inner, site, ctx) {
1626
+ const parts = splitMultiParts(inner);
1627
+ if (parts.length < 2) return null;
1628
+ const out = [];
1629
+ for (const part of parts) {
1630
+ const v = part ? singleVersion(part, site, ctx) : null;
1631
+ if (!v) return null;
1632
+ out.push(...v);
1633
+ }
1634
+ return out;
1635
+ }
1636
+ function singleVersion(raw, site, ctx) {
1637
+ const spans = wordSpans(raw);
1638
+ const words = spans.map((s) => s.text);
1639
+ const v = headBy(raw, spans, words, site, ctx) ?? headScan(raw, words, site, ctx);
1640
+ const result = v ?? prefixForm(raw, words, site, ctx);
1641
+ return result ? [result] : null;
1642
+ }
1643
+ function baseVersion(raw, type, site) {
1644
+ return {
1645
+ type,
1646
+ raw,
1647
+ artists: [],
1648
+ modifiers: [],
1649
+ descriptor: null,
1650
+ year: null,
1651
+ unknownArtist: false,
1652
+ delimiter: site.delimiter,
1653
+ pos: site.pos,
1654
+ ambiguousMix: false,
1655
+ prefixForm: false
1656
+ };
1657
+ }
1658
+ function remixerOpts() {
1659
+ return {
1660
+ role: "remixer",
1661
+ source: "version",
1662
+ lead: null,
1663
+ splitWith: false,
1664
+ featMarkersJoin: false
1665
+ };
1666
+ }
1667
+ function dedupRemixers(credits) {
1668
+ const seen = /* @__PURE__ */ new Set();
1669
+ return credits.filter((c) => {
1670
+ const k = dedupKey(c.name);
1671
+ if (seen.has(k)) return false;
1672
+ seen.add(k);
1673
+ return true;
1674
+ });
1675
+ }
1676
+ function setCredits(v, text, site, ctx) {
1677
+ const r = listFromText(text, site.pos, ctx, remixerOpts());
1678
+ v.artists = dedupRemixers(r.credits);
1679
+ if (r.unknown) v.unknownArtist = true;
1680
+ const dropped = r.years[0];
1681
+ if (dropped) v.year ?? (v.year = dropped.year);
1682
+ }
1683
+ function headBy(raw, spans, words, site, ctx) {
1684
+ const head = ctx.t.heads.matchAt(words, 0);
1685
+ if (!head) return null;
1686
+ const by = words[head.length];
1687
+ const rest = spans[head.length + 1];
1688
+ if (by === void 0 || wordKey(by) !== "by" || rest === void 0) return null;
1689
+ const v = baseVersion(raw, head.entry.value, site);
1690
+ setCredits(v, raw.slice(rest.start), site, ctx);
1691
+ return v;
1692
+ }
1693
+ function collectRun(words, end, ctx) {
1694
+ const items = [];
1695
+ let j = end;
1696
+ while (j > 0) {
1697
+ const m = ctx.t.runWords.matchEnding(words, j);
1698
+ if (m) {
1699
+ items.unshift({ start: j - m.length, end: j, info: m.entry.value, year: null });
1700
+ j -= m.length;
1701
+ continue;
1702
+ }
1703
+ const year = yearOf(words[j - 1]);
1704
+ if (year === null) break;
1705
+ items.unshift({ start: j - 1, end: j, info: {}, year });
1706
+ j--;
1707
+ }
1708
+ return items;
1709
+ }
1710
+ function headScan(raw, words, site, ctx) {
1711
+ const head = ctx.t.heads.matchEnding(words, words.length);
1712
+ if (!head) return null;
1713
+ const headType = head.entry.value;
1714
+ const headStart = words.length - head.length;
1715
+ const run2 = collectRun(words, headStart, ctx);
1716
+ const creditEnd = run2[0]?.start ?? headStart;
1717
+ const creditWords = words.slice(0, creditEnd);
1718
+ const left = run2[run2.length - 1];
1719
+ const generic = ctx.t.genericHeadTypes.has(headType);
1720
+ let type = headType;
1721
+ let typeItem = null;
1722
+ let viaMix = false;
1723
+ const nearestHead = generic ? nearestNonGenericHead(run2, ctx) : null;
1724
+ if (generic && left?.info.typeCapable) {
1725
+ type = left.info.typeCapable;
1726
+ typeItem = left;
1727
+ } else if (nearestHead?.info.head) {
1728
+ type = nearestHead.info.head;
1729
+ typeItem = nearestHead;
1730
+ } else if (headType === "mix" && creditWords.length > 0) {
1731
+ type = "remix";
1732
+ viaMix = true;
1733
+ }
1734
+ const v = baseVersion(raw, type, site);
1735
+ const descriptors = [];
1736
+ for (const item of run2) {
1737
+ const text = words.slice(item.start, item.end).join(" ");
1738
+ if (item.year !== null) {
1739
+ v.year ?? (v.year = item.year);
1740
+ continue;
1741
+ }
1742
+ const modifier = item.info.typeCapable ?? item.info.descriptor;
1743
+ if (modifier !== void 0) {
1744
+ if (item !== typeItem && !v.modifiers.includes(modifier)) v.modifiers.push(modifier);
1745
+ } else if (item.info.genre) {
1746
+ descriptors.push(text);
1747
+ }
1748
+ }
1749
+ if (creditWords.length > 0) {
1750
+ const credit = creditWords.join(" ");
1751
+ const lastWord = asciiLower(creditWords[creditWords.length - 1]);
1752
+ if (isUnknownToken(credit, ctx.t)) {
1753
+ v.unknownArtist = true;
1754
+ } else if (type === "version" || type === "cover" || isAllJunkPhrases(creditWords, ctx)) {
1755
+ descriptors.unshift(credit);
1756
+ } else {
1757
+ const name = lastWord.endsWith("'s") ? credit.slice(0, -2) : credit;
1758
+ setCredits(v, name, site, ctx);
1759
+ const lastName = v.artists[v.artists.length - 1];
1760
+ if (viaMix && lastName && splitWords(lastName.name).length >= 3) {
1761
+ v.ambiguousMix = true;
1762
+ }
1763
+ }
1764
+ }
1765
+ v.descriptor = descriptors.length > 0 ? descriptors.join(" ") : null;
1766
+ return v;
1767
+ }
1768
+ function nearestNonGenericHead(run2, ctx) {
1769
+ for (let i = run2.length - 1; i >= 0; i--) {
1770
+ const head = run2[i].info.head;
1771
+ if (head !== void 0 && !ctx.t.genericHeadTypes.has(head)) return run2[i];
1772
+ }
1773
+ return null;
1774
+ }
1775
+ function isAllJunkPhrases(words, ctx) {
1776
+ let i = 0;
1777
+ while (i < words.length) {
1778
+ const m = ctx.t.junkPhrases.matchAt(words, i);
1779
+ if (!m) return false;
1780
+ i += m.length;
1781
+ }
1782
+ return words.length > 0;
1783
+ }
1784
+ function prefixForm(raw, words, site, ctx) {
1785
+ const m = ctx.t.prefixForms.matchAt(words, 0);
1786
+ if (!m) return null;
1787
+ const v = baseVersion(raw, m.entry.value, site);
1788
+ v.prefixForm = true;
1789
+ const rest = [];
1790
+ for (const w of words.slice(m.length)) {
1791
+ const year = v.year === null ? yearOf(w) : null;
1792
+ if (year !== null) v.year = year;
1793
+ else rest.push(w);
1794
+ }
1795
+ v.descriptor = rest.length > 0 ? rest.join(" ") : null;
1796
+ return v;
1797
+ }
1798
+
1799
+ // src/artists.ts
1800
+ function findFeatMarker(s, ctx, from = 0) {
1801
+ const spans = wordSpans(s.text);
1802
+ const words = spans.map((w) => w.text);
1803
+ for (let i = 0; i < spans.length; i++) {
1804
+ const span = spans[i];
1805
+ if (!span || span.start < from) continue;
1806
+ const before = s.text[span.start - 1];
1807
+ if (before !== " " && before !== PH) continue;
1808
+ const m = ctx.t.featMarkers.matchAt(words, i);
1809
+ if (!m) continue;
1810
+ const last = spans[i + m.length - 1];
1811
+ if (!last || s.text[last.end] !== " ") continue;
1812
+ return { start: span.start, end: last.end, marker: s.text.slice(span.start, last.end) };
1813
+ }
1814
+ return null;
1815
+ }
1816
+ function parseArtistSide(side, ctx) {
1817
+ const result = {
1818
+ credits: [],
1819
+ junk: [],
1820
+ years: [],
1821
+ unknownArtist: false,
1822
+ explicit: false,
1823
+ clean: false
1824
+ };
1825
+ const removed = /* @__PURE__ */ new Set();
1826
+ for (const { group } of groupsOf(side)) {
1827
+ const site = { source: "artist", delimiter: group.open, pos: group.pos };
1828
+ const c = classify(group.inner, site, ctx);
1829
+ if (c.kind === "feat" || c.kind === "producer") {
1830
+ result.credits.push(...c.credits);
1831
+ result.years.push(...c.years);
1832
+ if (c.unknown) result.unknownArtist = true;
1833
+ } else if (c.kind === "flag") {
1834
+ result[c.flag] = true;
1835
+ } else if (c.kind === "year") {
1836
+ result.years.push({ year: c.year, pos: group.pos });
1837
+ } else if (c.kind === "junk") {
1838
+ result.junk.push({ raw: group.inner, kind: c.junkKind, pos: group.pos });
1839
+ } else {
1840
+ continue;
1841
+ }
1842
+ removed.add(group.pos);
1843
+ }
1844
+ const text = removeGroups(side, removed);
1845
+ const feat = findFeatMarker(text, ctx);
1846
+ const main = feat ? trimSkel(sliceSkel(text, 0, feat.start)) : trimSkel(text);
1847
+ const mainList = splitCredits(
1848
+ main,
1849
+ { role: "primary", source: "artist", lead: null, splitWith: true, featMarkersJoin: false },
1850
+ ctx
1851
+ );
1852
+ result.credits.push(...mainList.credits);
1853
+ result.years.push(...mainList.years);
1854
+ if (mainList.unknown) result.unknownArtist = true;
1855
+ if (feat) {
1856
+ const featured = splitCredits(
1857
+ trimSkel(sliceSkel(text, feat.end)),
1858
+ {
1859
+ role: "featured",
1860
+ source: "artist",
1861
+ lead: feat.marker,
1862
+ splitWith: false,
1863
+ featMarkersJoin: true
1864
+ },
1865
+ ctx
1866
+ );
1867
+ result.credits.push(...featured.credits);
1868
+ result.years.push(...featured.years);
1869
+ if (featured.unknown) result.unknownArtist = true;
1870
+ }
1871
+ result.credits.sort((a, b) => a.pos - b.pos);
1872
+ return result;
1873
+ }
1874
+
1875
+ // src/assemble.ts
1876
+ var PRECEDENCE = { primary: 2, featured: 1, producer: 0, remixer: 0 };
1877
+ function toArtist(c) {
1878
+ return { name: c.name, role: c.role, joiner: c.joiner, source: c.source };
1879
+ }
1880
+ function passJoiner(entries, e, joiner) {
1881
+ const members = entries.filter((x) => x.alive && x.list === e.list);
1882
+ if (members[0] !== e) return;
1883
+ const next = members[1];
1884
+ if (next) next.a.joiner = joiner;
1885
+ }
1886
+ function dedupArtists(credits) {
1887
+ const entries = credits.map((c, i) => ({
1888
+ a: toArtist(c),
1889
+ list: "list" in c ? c.list : -1 - i,
1890
+ alive: true
1891
+ }));
1892
+ const index = /* @__PURE__ */ new Map();
1893
+ for (const e of entries) {
1894
+ const key = `${e.a.role === "producer" ? "p" : "c"}:${dedupKey(e.a.name)}`;
1895
+ const kept = index.get(key);
1896
+ if (!kept) {
1897
+ index.set(key, e);
1898
+ continue;
1899
+ }
1900
+ if (PRECEDENCE[e.a.role] > PRECEDENCE[kept.a.role]) {
1901
+ passJoiner(entries, kept, kept.a.joiner);
1902
+ kept.a.role = e.a.role;
1903
+ kept.a.joiner = e.a.joiner;
1904
+ kept.a.source = e.a.source;
1905
+ kept.list = e.list;
1906
+ e.alive = false;
1907
+ } else {
1908
+ passJoiner(entries, e, e.a.joiner);
1909
+ e.alive = false;
1910
+ }
1911
+ }
1912
+ return entries.filter((e) => e.alive).map((e) => e.a);
1913
+ }
1914
+ function toVersion(v) {
1915
+ return {
1916
+ type: v.type,
1917
+ raw: v.raw,
1918
+ artists: v.artists.map(toArtist),
1919
+ modifiers: [...v.modifiers],
1920
+ descriptor: v.descriptor,
1921
+ year: v.year,
1922
+ unknownArtist: v.unknownArtist,
1923
+ delimiter: v.delimiter
1924
+ };
1925
+ }
1926
+ function renderVersion(v) {
1927
+ switch (v.delimiter) {
1928
+ case "(":
1929
+ return `(${v.raw})`;
1930
+ case "[":
1931
+ return `[${v.raw}]`;
1932
+ case "{":
1933
+ return `{${v.raw}}`;
1934
+ default:
1935
+ return `- ${v.raw}`;
1936
+ }
1937
+ }
1938
+ function fullTitleOf(title, versions) {
1939
+ return [title, ...versions.map(renderVersion)].filter((s) => s.length > 0).join(" ");
1940
+ }
1941
+ function byPos(items) {
1942
+ return [...items].sort((a, b) => a.pos - b.pos);
1943
+ }
1944
+
1945
+ // src/mode.ts
1946
+ function stripPlatformSuffixes(s, ctx, junk) {
1947
+ let str = s;
1948
+ let stripped = true;
1949
+ while (stripped) {
1950
+ stripped = false;
1951
+ const lower = asciiLower(str);
1952
+ for (const suffix of ctx.t.platformSuffixes) {
1953
+ if (!lower.endsWith(asciiLower(suffix))) continue;
1954
+ const start = str.length - suffix.length;
1955
+ junk.push({ raw: str.slice(start + 3), kind: "platform", pos: start + 3 });
1956
+ str = str.slice(0, start);
1957
+ stripped = true;
1958
+ break;
1959
+ }
1960
+ }
1961
+ return str;
1962
+ }
1963
+ function extensionOf(s, ctx) {
1964
+ const dot = s.lastIndexOf(".");
1965
+ if (dot < 0) return null;
1966
+ const ext = asciiLower(s.slice(dot + 1));
1967
+ return ctx.t.extensions.has(ext) ? ext : null;
1968
+ }
1969
+ function splitPipes(skel) {
1970
+ const t = skel.text;
1971
+ const cuts = [];
1972
+ for (let i = 1; i < t.length; i++) {
1973
+ if (t[i - 1] !== " " || t[i] !== "|") continue;
1974
+ const bars = t[i + 1] === "|" ? 2 : 1;
1975
+ if (t[i + bars] === " " && t[i + bars] !== void 0) {
1976
+ cuts.push([i - 1, i + bars + 1]);
1977
+ i += bars;
1978
+ }
1979
+ }
1980
+ const segments = [];
1981
+ let start = 0;
1982
+ for (const [a, b] of [...cuts, [t.length, t.length]]) {
1983
+ let s = start;
1984
+ let e = a;
1985
+ while (s < e && t[s] === " ") s++;
1986
+ while (e > s && t[e - 1] === " ") e--;
1987
+ segments.push({ skel: sliceSkel(skel, s, e), start: s, end: e });
1988
+ start = b;
1989
+ }
1990
+ return segments;
1991
+ }
1992
+ function trailingJunkPhrase(text, ctx) {
1993
+ const spans = wordSpans(text);
1994
+ const m = ctx.t.junkPhrases.matchEnding(
1995
+ spans.map((s) => s.text),
1996
+ spans.length
1997
+ );
1998
+ if (!m) return null;
1999
+ const first = spans[spans.length - m.length];
2000
+ return first ? { start: first.start, kind: m.entry.value } : null;
2001
+ }
2002
+ function isJunkText(text, ctx) {
2003
+ const words = splitWords(text);
2004
+ return words.length > 0 ? junkKindOf(words, ctx) : null;
2005
+ }
2006
+ function looksLikeYoutube(skel, ctx) {
2007
+ if (splitPipes(skel).length > 1) return true;
2008
+ for (const { group } of groupsOf(skel)) {
2009
+ const kind = isJunkText(group.inner, ctx);
2010
+ if (kind && kind !== "genre") return true;
2011
+ }
2012
+ return trailingJunkPhrase(skel.text, ctx) !== null;
2013
+ }
2014
+ function prelude(normalized, requested, ctx) {
2015
+ const junk = [];
2016
+ let str = stripPlatformSuffixes(normalized, ctx, junk);
2017
+ const hadPlatform = junk.length > 0;
2018
+ let mode;
2019
+ if (requested === "clean" || requested === "youtube" || requested === "filename" || requested === "tracklist") {
2020
+ mode = requested;
2021
+ } else if (extensionOf(str, ctx)) {
2022
+ mode = "filename";
2023
+ } else {
2024
+ mode = "clean";
2025
+ }
2026
+ if (mode === "filename") {
2027
+ if (extensionOf(str, ctx)) str = str.slice(0, str.lastIndexOf("."));
2028
+ str = str.trim();
2029
+ if (!str.includes(" ") && str.includes("_")) str = str.split("_").join(" ").trim();
2030
+ }
2031
+ const { skel, unbalanced } = scan(str, 0);
2032
+ if (unbalanced) warn(ctx, "unbalancedBrackets");
2033
+ if (requested === "auto" || requested === void 0) {
2034
+ if (mode === "clean" && (hadPlatform || looksLikeYoutube(skel, ctx))) mode = "youtube";
2035
+ }
2036
+ const whole = { text: str, base: 0 };
2037
+ if (mode !== "youtube") return { mode, working: whole, pipeTitle: null, junk };
2038
+ return { mode, ...splitYoutubePipes(skel, ctx, junk), junk };
2039
+ }
2040
+ function pieceOf(seg) {
2041
+ return { text: renderSkel(seg.skel), base: seg.skel.pos[0] ?? 0 };
2042
+ }
2043
+ function splitYoutubePipes(skel, ctx, junk) {
2044
+ const segments = splitPipes(skel);
2045
+ if (segments.length <= 1)
2046
+ return { working: { text: renderSkel(skel), base: 0 }, pipeTitle: null };
2047
+ const remaining = [];
2048
+ for (const seg of segments) {
2049
+ const text = collapseSpaces(renderSkel(seg.skel));
2050
+ const kind = hasSpacedDash(seg.skel.text) ? null : isJunkText(text, ctx);
2051
+ if (kind) junk.push({ raw: text, kind, pos: seg.skel.pos[0] ?? 0 });
2052
+ else if (text.length > 0) remaining.push(seg);
2053
+ }
2054
+ const pushOther = (keep, title = null) => {
2055
+ for (const seg of remaining) {
2056
+ if (seg === keep || seg === title) continue;
2057
+ junk.push({
2058
+ raw: collapseSpaces(renderSkel(seg.skel)),
2059
+ kind: "other",
2060
+ pos: seg.skel.pos[0] ?? 0
2061
+ });
2062
+ }
2063
+ };
2064
+ const withDash = remaining.find((seg) => hasSpacedDash(seg.skel.text));
2065
+ if (withDash) {
2066
+ pushOther(withDash);
2067
+ return { working: pieceOf(withDash), pipeTitle: null };
2068
+ }
2069
+ const [first, second] = remaining;
2070
+ if (remaining.length === 2 && first && second) {
2071
+ warn(ctx, "ambiguousSeparator");
2072
+ return { working: pieceOf(first), pipeTitle: pieceOf(second) };
2073
+ }
2074
+ if (first) {
2075
+ pushOther(first);
2076
+ return { working: pieceOf(first), pipeTitle: null };
2077
+ }
2078
+ return { working: { text: "", base: 0 }, pipeTitle: null };
2079
+ }
2080
+
2081
+ // src/normalize.ts
2082
+ var REMOVED = /* @__PURE__ */ new Set([8203, 8288, 65279, 173, 57344]);
2083
+ var CHAR_MAP = /* @__PURE__ */ new Map([
2084
+ [65288, "("],
2085
+ [65289, ")"],
2086
+ [65339, "["],
2087
+ [12304, "["],
2088
+ [12308, "["],
2089
+ [65341, "]"],
2090
+ [12305, "]"],
2091
+ [12309, "]"],
2092
+ [65371, "{"],
2093
+ [65373, "}"],
2094
+ [8220, '"'],
2095
+ [8221, '"'],
2096
+ [8222, '"'],
2097
+ [171, '"'],
2098
+ [187, '"'],
2099
+ [12300, '"'],
2100
+ [12301, '"'],
2101
+ [12302, '"'],
2102
+ [12303, '"'],
2103
+ [65282, '"'],
2104
+ [8216, "'"],
2105
+ [8217, "'"],
2106
+ [8218, "'"],
2107
+ [96, "'"],
2108
+ [180, "'"],
2109
+ [8722, "-"],
2110
+ [65112, "-"],
2111
+ [65123, "-"],
2112
+ [65293, "-"]
2113
+ ]);
2114
+ function mapChar(cp) {
2115
+ if (REMOVED.has(cp)) return null;
2116
+ if (cp >= 8208 && cp <= 8213) return "-";
2117
+ return CHAR_MAP.get(cp) ?? String.fromCodePoint(cp);
2118
+ }
2119
+ function collapseSpacedDashRuns(chars) {
2120
+ const out = [];
2121
+ let i = 0;
2122
+ while (i < chars.length) {
2123
+ if (chars[i] !== "-") {
2124
+ out.push(chars[i]);
2125
+ i++;
2126
+ continue;
2127
+ }
2128
+ let j = i;
2129
+ while (chars[j] === "-") j++;
2130
+ const before = chars[i - 1];
2131
+ const after = chars[j];
2132
+ const spaced = j - i >= 2 && before !== void 0 && after !== void 0 && isWhitespaceCode(before.codePointAt(0) ?? 0) && isWhitespaceCode(after.codePointAt(0) ?? 0);
2133
+ if (spaced) out.push("-");
2134
+ else for (let k = i; k < j; k++) out.push("-");
2135
+ i = j;
2136
+ }
2137
+ return out;
2138
+ }
2139
+ function collapseWhitespace(chars) {
2140
+ let out = "";
2141
+ let pending = false;
2142
+ for (const ch of chars) {
2143
+ if (isWhitespaceCode(ch.codePointAt(0) ?? 0)) {
2144
+ pending = out.length > 0;
2145
+ continue;
2146
+ }
2147
+ if (pending) out += " ";
2148
+ pending = false;
2149
+ out += ch;
2150
+ }
2151
+ return out;
2152
+ }
2153
+ function normalize(input) {
2154
+ if (typeof input !== "string") return "";
2155
+ const chars = [];
2156
+ for (const ch of input.normalize("NFC")) {
2157
+ const mapped = mapChar(ch.codePointAt(0) ?? 0);
2158
+ if (mapped !== null) chars.push(mapped);
2159
+ }
2160
+ return collapseWhitespace(collapseSpacedDashRuns(chars)).normalize("NFC");
2161
+ }
2162
+
2163
+ // src/prefix.ts
2164
+ function matchTime(s, at) {
2165
+ const lead = digitRun(s, at);
2166
+ if (lead < 1 || lead > 2 || s[at + lead] !== ":") return null;
2167
+ const first = Number(s.slice(at, at + lead));
2168
+ const mid = twoDigitsUpTo59(s, at + lead + 1);
2169
+ if (mid === null) return null;
2170
+ const afterMid = at + lead + 3;
2171
+ if (s[afterMid] === ":") {
2172
+ const sec = twoDigitsUpTo59(s, afterMid + 1);
2173
+ if (sec !== null) {
2174
+ const end = afterMid + 3;
2175
+ return { raw: s.slice(at, end), seconds: first * 3600 + mid * 60 + sec, end };
2176
+ }
2177
+ }
2178
+ if (lead === 2 && first > 59) return null;
2179
+ return { raw: s.slice(at, afterMid), seconds: first * 60 + mid, end: afterMid };
2180
+ }
2181
+ function skipSpaceAndDash(s, at) {
2182
+ let i = at;
2183
+ if (s[i] === " ") i++;
2184
+ if (s[i] === "-" && s[i + 1] === " ") i += 2;
2185
+ return i;
2186
+ }
2187
+ function matchTimestamp(s) {
2188
+ const bracket = s[0] === "[" ? "]" : s[0] === "(" ? ")" : null;
2189
+ const t = matchTime(s, bracket ? 1 : 0);
2190
+ if (!t) return null;
2191
+ let end = t.end;
2192
+ if (bracket) {
2193
+ if (s[end] !== bracket) return null;
2194
+ end++;
2195
+ }
2196
+ if (end < s.length && s[end] !== " ") return null;
2197
+ return { ts: { raw: t.raw, seconds: t.seconds }, end: skipSpaceAndDash(s, end) };
2198
+ }
2199
+ function containsSpacedDash(s) {
2200
+ return hasSpacedDash(scan(s).skel.text);
2201
+ }
2202
+ function matchPosition(s, mode) {
2203
+ const n = digitRun(s, 0);
2204
+ if (n < 1 || n > 3) return null;
2205
+ const c = s[n];
2206
+ const zeroPadded = s[0] === "0" && n >= 2;
2207
+ if ((c === "." || c === ")") && (s[n + 1] === " " || n + 1 === s.length)) {
2208
+ return skipSpaceAndDash(s, n + 1);
2209
+ }
2210
+ if (zeroPadded && c === " ") return skipSpaceAndDash(s, n);
2211
+ if (c === " " && s[n + 1] === "-" && s[n + 2] === " ") {
2212
+ if (mode === "filename" || containsSpacedDash(s.slice(n + 3))) return n + 3;
2213
+ return null;
2214
+ }
2215
+ if (mode === "filename") {
2216
+ if ((c === "-" || c === "_" || c === ".") && s[n + 1] !== void 0 && s[n + 1] !== " ") {
2217
+ return n + 1;
2218
+ }
2219
+ if (c === " " && !containsSpacedDash(s.slice(n + 1))) return skipSpaceAndDash(s, n);
2220
+ }
2221
+ return null;
2222
+ }
2223
+ function stripPrefixes(str, base, mode, ctx) {
2224
+ let rest = str;
2225
+ let offset = base;
2226
+ let timestamp = null;
2227
+ let position = null;
2228
+ const ts = matchTimestamp(rest);
2229
+ if (ts) {
2230
+ timestamp = ts.ts;
2231
+ rest = rest.slice(ts.end);
2232
+ offset += ts.end;
2233
+ }
2234
+ if (matchKnownAt(rest, 0, ctx, "-") === 0) {
2235
+ const end = matchPosition(rest, mode);
2236
+ if (end !== null && end < rest.length) {
2237
+ const raw = rest.slice(0, digitRun(rest, 0));
2238
+ position = { raw, number: Number(raw) };
2239
+ rest = rest.slice(end);
2240
+ offset += end;
2241
+ }
2242
+ }
2243
+ return { rest, base: offset, timestamp, position };
2244
+ }
2245
+
2246
+ // src/separator.ts
2247
+ function classifySegment(seg, ctx) {
2248
+ if (hasGroup(seg)) return { kind: "unknown" };
2249
+ const text = collapseSpaces(seg.text);
2250
+ return classify(text, { source: "title", delimiter: "-", pos: seg.pos[0] ?? 0 }, ctx);
2251
+ }
2252
+ function isPeelable(c) {
2253
+ return c.kind === "junk" || c.kind === "flag" || c.kind === "year" || c.kind === "versions";
2254
+ }
2255
+ function peelSuffixes(segments, relaxed, ctx) {
2256
+ const remaining = [...segments];
2257
+ const peeled = [];
2258
+ while (remaining.length >= 2) {
2259
+ const last = remaining[remaining.length - 1];
2260
+ const cls = classifySegment(last.skel, ctx);
2261
+ if (!isPeelable(cls)) break;
2262
+ const words = splitWords(last.skel.text);
2263
+ const prefixForm2 = cls.kind === "versions" && cls.versions.every((v) => v.prefixForm);
2264
+ const ok = relaxed || remaining.length - 1 >= 2 || words.length >= 2 && !prefixForm2 || words.some((w) => yearOf(w) !== null) || cls.kind === "junk";
2265
+ if (!ok) break;
2266
+ peeled.unshift({ cls, raw: collapseSpaces(last.skel.text), pos: last.skel.pos[0] ?? 0 });
2267
+ remaining.pop();
2268
+ }
2269
+ return { remaining, peeled };
2270
+ }
2271
+ function nonEmpty(s) {
2272
+ const t = trimSkel(s);
2273
+ return t.text.length > 0 ? t : null;
2274
+ }
2275
+ function splitAt(s, cutStart, cutEnd) {
2276
+ return { artist: nonEmpty(sliceSkel(s, 0, cutStart)), title: trimSkel(sliceSkel(s, cutEnd)) };
2277
+ }
2278
+ function findSeparator(input, ctx) {
2279
+ const { skel, mode } = input;
2280
+ const segments = splitAtSpacedDashes(skel, true);
2281
+ if (mode === "tracklist")
2282
+ return tracklistSeparator(skel, segments, input.listContext === true, ctx);
2283
+ const { remaining, peeled } = peelSuffixes(segments, false, ctx);
2284
+ if (remaining.length >= 2) {
2285
+ const first = remaining[0];
2286
+ const second = remaining[1];
2287
+ const lastSeg = remaining[remaining.length - 1];
2288
+ const artist2 = first.skel.text.length > 0 ? first.skel : null;
2289
+ const title = sliceSkel(skel, second.start, lastSeg.end);
2290
+ return { artist: artist2, title, split: artist2 !== null, peeled };
2291
+ }
2292
+ const rest = remaining[0].skel;
2293
+ const other = splitWithoutSpacedDash(rest, mode, ctx);
2294
+ if (other) return { ...other, split: other.artist !== null, peeled };
2295
+ const artist = mode === "youtube" ? input.uploader : null;
2296
+ if (rest.text.length > 0 && artist === null) warn(ctx, "noSeparator");
2297
+ return { artist, title: rest, split: false, peeled };
2298
+ }
2299
+ function tracklistSeparator(skel, segments, listContext, ctx) {
2300
+ if (segments.length >= 2) {
2301
+ const first = segments[0];
2302
+ const second = segments[1];
2303
+ const lastSeg = segments[segments.length - 1];
2304
+ const artist = first.skel.text.length > 0 ? first.skel : null;
2305
+ const title = sliceSkel(skel, second.start, lastSeg.end);
2306
+ return { artist, title, split: artist !== null, peeled: [] };
2307
+ }
2308
+ const rest = segments[0].skel;
2309
+ const asym = asymmetricDash(rest.text);
2310
+ if (asym >= 0) {
2311
+ warn(ctx, "asymmetricDashSplit");
2312
+ const parts = splitAt(rest, asym, asym + 1);
2313
+ return { ...parts, split: parts.artist !== null, peeled: [] };
2314
+ }
2315
+ const underscore = rest.text.indexOf("_-_");
2316
+ if (underscore >= 0) {
2317
+ warn(ctx, "unspacedDashSplit");
2318
+ const parts = splitAt(rest, underscore, underscore + 3);
2319
+ return { ...parts, split: parts.artist !== null, peeled: [] };
2320
+ }
2321
+ if (listContext) {
2322
+ const tight = tightUpperDash(rest.text);
2323
+ if (tight >= 0) {
2324
+ warn(ctx, "unspacedDashSplit");
2325
+ const parts = splitAt(rest, tight, tight + 1);
2326
+ return { ...parts, split: parts.artist !== null, peeled: [] };
2327
+ }
2328
+ }
2329
+ if (rest.text.length > 0) warn(ctx, "noSeparator");
2330
+ return { artist: null, title: rest, split: false, peeled: [] };
2331
+ }
2332
+ function tightUpperDash(t) {
2333
+ for (let i = t.length - 2; i >= 1; i--) {
2334
+ if (t[i] !== "-") continue;
2335
+ if (isLetterBefore(t, i) && isUpperAt(t, i + 1)) return i;
2336
+ }
2337
+ return -1;
2338
+ }
2339
+ function splitWithoutSpacedDash(s, mode, ctx) {
2340
+ const asym = asymmetricDash(s.text);
2341
+ if (asym >= 0) {
2342
+ warn(ctx, "asymmetricDashSplit");
2343
+ return splitAt(s, asym, asym + 1);
2344
+ }
2345
+ if (mode === "youtube") {
2346
+ const quoted = quotedTitle(s);
2347
+ if (quoted) {
2348
+ warn(ctx, "quotedTitleSplit");
2349
+ return quoted;
2350
+ }
2351
+ const by = bySplit(s, ctx);
2352
+ if (by >= 0) {
2353
+ warn(ctx, "bySplit");
2354
+ const parts = splitAt(s, by, by + 4);
2355
+ return { artist: parts.title.text.length > 0 ? parts.title : null, title: parts.artist ?? s };
2356
+ }
2357
+ }
2358
+ if (mode === "youtube" || mode === "filename") {
2359
+ const dash = unspacedDash(s, ctx);
2360
+ if (dash >= 0) {
2361
+ warn(ctx, "unspacedDashSplit");
2362
+ return splitAt(s, dash, dash + 1);
2363
+ }
2364
+ }
2365
+ return null;
2366
+ }
2367
+ function asymmetricDash(t) {
2368
+ for (let i = 1; i < t.length - 1; i++) {
2369
+ if (t[i] !== "-") continue;
2370
+ const left = isWhitespace(t[i - 1]);
2371
+ const right = isWhitespace(t[i + 1]);
2372
+ if (left !== right) return i;
2373
+ }
2374
+ return -1;
2375
+ }
2376
+ function quotedTitle(s) {
2377
+ const t = s.text;
2378
+ const open = t.indexOf('"');
2379
+ if (open <= 0) return null;
2380
+ const close = t.indexOf('"', open + 1);
2381
+ if (close < 0) return null;
2382
+ let artist = trimSkel(sliceSkel(s, 0, open));
2383
+ const last = artist.text[artist.text.length - 1];
2384
+ if (last === ":" || last === "-") artist = trimSkel(sliceSkel(artist, 0, artist.text.length - 1));
2385
+ const title = trimSkel(concatSkel(sliceSkel(s, open + 1, close), sliceSkel(s, close + 1)));
2386
+ return { artist: artist.text.length > 0 ? artist : null, title };
2387
+ }
2388
+ function bySplit(s, ctx) {
2389
+ const t = s.text;
2390
+ for (let i = t.lastIndexOf(" by "); i > 0; i = t.lastIndexOf(" by ", i - 1)) {
2391
+ const right = splitWords(t.slice(i + 4)).filter((w) => w !== PH);
2392
+ if (right.length === 0) continue;
2393
+ if (right.length === 1 && inVocab(right[0], ctx.t.stopwords)) continue;
2394
+ return i;
2395
+ }
2396
+ return -1;
2397
+ }
2398
+ function unspacedDash(s, ctx) {
2399
+ const t = s.text;
2400
+ let lastCandidate = -1;
2401
+ for (let i = 1; i < t.length - 1; i++) {
2402
+ if (t[i] !== "-" || isWhitespace(t[i - 1]) || isWhitespace(t[i + 1])) continue;
2403
+ if (!(t[i + 1] === '"' || isLetterAt(t, i + 1))) continue;
2404
+ if (ctx.knownKeys.size > 0 && isKnownArtist(renderSkel(sliceSkel(s, 0, i)), ctx)) return i;
2405
+ lastCandidate = i;
2406
+ }
2407
+ return lastCandidate;
2408
+ }
2409
+
2410
+ // src/title.ts
2411
+ function emptyExtracted() {
2412
+ return {
2413
+ credits: [],
2414
+ versions: [],
2415
+ junk: [],
2416
+ years: [],
2417
+ flags: { explicit: false, clean: false, unknownArtist: false }
2418
+ };
2419
+ }
2420
+ function addPeeled(out, peeled) {
2421
+ for (const p of peeled) {
2422
+ const c = p.cls;
2423
+ if (c.kind === "versions") out.versions.push(...c.versions);
2424
+ else if (c.kind === "junk") out.junk.push({ raw: p.raw, kind: c.junkKind, pos: p.pos });
2425
+ else if (c.kind === "flag") out.flags[c.flag] = true;
2426
+ else out.years.push({ year: c.year, pos: p.pos });
2427
+ }
2428
+ }
2429
+ function parseTitleSide(input, out, ctx) {
2430
+ let skel = input.skel;
2431
+ const removed = extractGroups(skel, out, ctx);
2432
+ const segments = splitAtSpacedDashes(skel);
2433
+ const { remaining, peeled } = peelSuffixes(segments, input.relaxed, ctx);
2434
+ addPeeled(out, peeled);
2435
+ const lastKept = remaining[remaining.length - 1];
2436
+ skel = sliceSkel(skel, 0, lastKept.end);
2437
+ skel = extractUnbracketedCredits(skel, out, ctx);
2438
+ skel = removeGroups(skel, removed);
2439
+ if (input.youtube) skel = stripTrailingJunk(skel, out, ctx);
2440
+ const title = cleanupTitle(renderSkel(skel));
2441
+ return { title, unknownTitle: title.length > 0 && isUnknownToken(title, ctx.t) };
2442
+ }
2443
+ function extractGroups(skel, out, ctx) {
2444
+ const removed = /* @__PURE__ */ new Set();
2445
+ for (const { group } of groupsOf(skel)) {
2446
+ const site = { source: "title", delimiter: group.open, pos: group.pos };
2447
+ const c = classify(group.inner, site, ctx);
2448
+ switch (c.kind) {
2449
+ case "versions":
2450
+ out.versions.push(...c.versions);
2451
+ break;
2452
+ case "junk":
2453
+ out.junk.push({ raw: group.inner, kind: c.junkKind, pos: group.pos });
2454
+ break;
2455
+ case "flag":
2456
+ out.flags[c.flag] = true;
2457
+ break;
2458
+ case "year":
2459
+ out.years.push({ year: c.year, pos: group.pos });
2460
+ break;
2461
+ case "feat":
2462
+ case "producer":
2463
+ out.credits.push(...c.credits);
2464
+ out.years.push(...c.years);
2465
+ if (c.unknown) out.flags.unknownArtist = true;
2466
+ break;
2467
+ case "unknown":
2468
+ continue;
2469
+ }
2470
+ removed.add(group.pos);
2471
+ }
2472
+ return removed;
2473
+ }
2474
+ function titleMarkers(s, ctx) {
2475
+ const spans = wordSpans(s.text);
2476
+ const words = spans.map((w) => w.text);
2477
+ const hits = [];
2478
+ for (let i = 0; i < spans.length; i++) {
2479
+ const span = spans[i];
2480
+ if (s.text[span.start - 1] !== " ") continue;
2481
+ let role = "featured";
2482
+ let lastIndex = -1;
2483
+ const feat = ctx.t.titleFeatMarkers.matchAt(words, i);
2484
+ if (feat) {
2485
+ lastIndex = i + feat.length - 1;
2486
+ } else {
2487
+ const prod = ctx.t.titleProducerMarkers.matchAt(words, i);
2488
+ if (!prod) continue;
2489
+ role = "producer";
2490
+ lastIndex = i + prod.length - 1;
2491
+ }
2492
+ const last = spans[lastIndex];
2493
+ if (s.text[last.end] !== " ") continue;
2494
+ hits.push({
2495
+ start: span.start,
2496
+ end: last.end,
2497
+ marker: s.text.slice(span.start, last.end),
2498
+ role
2499
+ });
2500
+ i = lastIndex;
2501
+ }
2502
+ return hits;
2503
+ }
2504
+ function extractUnbracketedCredits(input, out, ctx) {
2505
+ const markers = titleMarkers(input, ctx);
2506
+ const cuts = [];
2507
+ let i = 0;
2508
+ while (i < markers.length) {
2509
+ const hit = markers[i];
2510
+ let listEnd = input.text.indexOf(PH, hit.end);
2511
+ if (listEnd < 0) listEnd = input.text.length;
2512
+ const other = markers.find((m, k) => k > i && m.role !== hit.role && m.start >= hit.end);
2513
+ if (other && other.start < listEnd) listEnd = other.start;
2514
+ const list = trimSkel(sliceSkel(input, hit.end, listEnd));
2515
+ if (list.text.length > 0) {
2516
+ const r = splitCredits(
2517
+ list,
2518
+ {
2519
+ role: hit.role,
2520
+ source: "title",
2521
+ lead: hit.marker,
2522
+ splitWith: false,
2523
+ featMarkersJoin: hit.role === "featured"
2524
+ },
2525
+ ctx
2526
+ );
2527
+ out.credits.push(...r.credits);
2528
+ out.years.push(...r.years);
2529
+ if (r.unknown) out.flags.unknownArtist = true;
2530
+ cuts.push([hit.start, listEnd]);
2531
+ } else {
2532
+ listEnd = hit.end;
2533
+ }
2534
+ while (i < markers.length && markers[i].start < listEnd) i++;
2535
+ }
2536
+ let skel = input;
2537
+ for (const [start, end] of cuts.reverse()) skel = cutSkel(skel, start, end);
2538
+ return skel;
2539
+ }
2540
+ function stripTrailingJunk(input, out, ctx) {
2541
+ const skel = trimSkel(input);
2542
+ const spans = wordSpans(skel.text);
2543
+ const words = spans.map((s) => s.text);
2544
+ let end = spans.length;
2545
+ for (; ; ) {
2546
+ const m = ctx.t.junkPhrases.matchEnding(words, end);
2547
+ if (!m) break;
2548
+ const first = end - m.length;
2549
+ let keep = first;
2550
+ if (keep > 0 && isLoneEdge(words[keep - 1])) keep--;
2551
+ if (keep === 0) break;
2552
+ const from = spans[first]?.start ?? 0;
2553
+ const to = spans[end - 1]?.end ?? skel.text.length;
2554
+ out.junk.push({
2555
+ raw: collapseSpaces(skel.text.slice(from, to)),
2556
+ kind: m.entry.value,
2557
+ pos: skel.pos[from] ?? 0
2558
+ });
2559
+ end = keep;
2560
+ }
2561
+ if (end === spans.length) return skel;
2562
+ return trimSkel(sliceSkel(skel, 0, spans[end - 1]?.end ?? 0));
2563
+ }
2564
+ function isLoneEdge(word) {
2565
+ return word === "-" || word === "|";
2566
+ }
2567
+ function stripLoneEdges(s) {
2568
+ const words = s.split(" ");
2569
+ while (words.length > 0 && isLoneEdge(words[0])) words.shift();
2570
+ while (words.length > 0 && isLoneEdge(words[words.length - 1])) words.pop();
2571
+ return words.join(" ");
2572
+ }
2573
+ function cleanupTitle(raw) {
2574
+ let title = stripLoneEdges(collapseSpaces(raw));
2575
+ const q = title[0];
2576
+ if (title.length >= 2 && (q === '"' || q === "'") && title[title.length - 1] === q) {
2577
+ title = stripLoneEdges(collapseSpaces(title.slice(1, -1)));
2578
+ }
2579
+ return title;
2580
+ }
2581
+
2582
+ // src/parser.ts
2583
+ var UPLOADER_BASE = -1e9;
2584
+ function emptyTrack(input, mode) {
2585
+ return {
2586
+ input,
2587
+ mode,
2588
+ position: null,
2589
+ timestamp: null,
2590
+ artists: [],
2591
+ title: "",
2592
+ fullTitle: "",
2593
+ versions: [],
2594
+ year: null,
2595
+ flags: { explicit: false, clean: false, unknownArtist: false, unknownTitle: false },
2596
+ junk: [],
2597
+ warnings: []
2598
+ };
2599
+ }
2600
+ function skelOf(piece, ctx) {
2601
+ const r = scan(piece.text, piece.base);
2602
+ if (r.unbalanced) warn(ctx, "unbalancedBrackets");
2603
+ return trimSkel(r.skel);
2604
+ }
2605
+ function uploaderSkel(uploader) {
2606
+ if (typeof uploader !== "string") return null;
2607
+ let u = normalize(uploader);
2608
+ if (u.endsWith(" - Topic")) u = u.slice(0, -" - Topic".length);
2609
+ if (u.endsWith("VEVO")) u = u.slice(0, -"VEVO".length).trimEnd();
2610
+ if (u.length === 0) return null;
2611
+ return scan(u, UPLOADER_BASE).skel;
2612
+ }
2613
+ function stage(input, options, t) {
2614
+ const ctx = makeCtx(t, options);
2615
+ const norm = normalize(input);
2616
+ const requested = options.mode ?? "auto";
2617
+ const pre = prelude(norm, requested, ctx);
2618
+ const track = emptyTrack(typeof input === "string" ? input : "", pre.mode);
2619
+ if (norm.length === 0) {
2620
+ return { ctx, options, pre, track, working: null };
2621
+ }
2622
+ const prefix = stripPrefixes(pre.working.text, pre.working.base, pre.mode, ctx);
2623
+ track.timestamp = prefix.timestamp;
2624
+ track.position = prefix.position;
2625
+ const working = skelOf({ text: prefix.rest, base: prefix.base }, ctx);
2626
+ return { ctx, options, pre, track, working };
2627
+ }
2628
+ function run(input, options, t, listContext = false) {
2629
+ return finish(stage(input, options, t), listContext);
2630
+ }
2631
+ function finish(st, listContext) {
2632
+ const { ctx, options, pre, track, working } = st;
2633
+ if (working === null) return track;
2634
+ const out = emptyExtracted();
2635
+ let artistSkel;
2636
+ let titleSkel;
2637
+ let relaxed;
2638
+ if (pre.pipeTitle) {
2639
+ artistSkel = working.text.length > 0 ? working : null;
2640
+ titleSkel = skelOf(pre.pipeTitle, ctx);
2641
+ relaxed = artistSkel !== null;
2642
+ } else {
2643
+ const sep = findSeparator(
2644
+ { skel: working, mode: pre.mode, uploader: uploaderSkel(options.uploader), listContext },
2645
+ ctx
2646
+ );
2647
+ addPeeled(out, sep.peeled);
2648
+ artistSkel = sep.artist;
2649
+ titleSkel = sep.title;
2650
+ relaxed = sep.split;
2651
+ }
2652
+ const artistSide = artistSkel ? parseArtistSide(artistSkel, ctx) : null;
2653
+ const titleSide = parseTitleSide(
2654
+ { skel: titleSkel, relaxed, youtube: pre.mode === "youtube" },
2655
+ out,
2656
+ ctx
2657
+ );
2658
+ const versions = byPos(out.versions);
2659
+ for (const v of versions) if (v.ambiguousMix) warn(ctx, "ambiguousMixCredit");
2660
+ track.artists = dedupArtists([...artistSide?.credits ?? [], ...byPos(out.credits)]);
2661
+ track.title = titleSide.title;
2662
+ track.versions = versions.map(toVersion);
2663
+ track.fullTitle = fullTitleOf(track.title, track.versions);
2664
+ const years = byPos([...artistSide?.years ?? [], ...out.years]);
2665
+ track.year = years[0]?.year ?? null;
2666
+ track.flags = {
2667
+ explicit: out.flags.explicit || (artistSide?.explicit ?? false),
2668
+ clean: out.flags.clean || (artistSide?.clean ?? false),
2669
+ unknownArtist: (artistSide?.unknownArtist ?? false) || out.flags.unknownArtist,
2670
+ unknownTitle: titleSide.unknownTitle
2671
+ };
2672
+ track.junk = byPos([...pre.junk, ...artistSide?.junk ?? [], ...out.junk]).map((j) => ({
2673
+ raw: j.raw,
2674
+ kind: j.kind
2675
+ }));
2676
+ track.warnings = ctx.warnings;
2677
+ return track;
2678
+ }
2679
+ function runArtists(input, options, t) {
2680
+ const ctx = makeCtx(t, options);
2681
+ const norm = normalize(input);
2682
+ if (norm.length === 0) return [];
2683
+ const side = parseArtistSide(scan(norm, 0).skel, ctx);
2684
+ return dedupArtists(side.credits).map((a) => ({ ...a, source: "artist" }));
2685
+ }
2686
+ function safeOptions(options) {
2687
+ if (options === null || typeof options !== "object") return {};
2688
+ return Object.fromEntries(
2689
+ Object.entries(options).filter(([, v]) => v !== void 0)
2690
+ );
2691
+ }
2692
+ function createParser(base = {}) {
2693
+ const baseOptions = safeOptions(base);
2694
+ const baseTables = tablesFor(baseOptions.keywords);
2695
+ const resolve = (options) => {
2696
+ const merged = { ...baseOptions, ...safeOptions(options) };
2697
+ const tables = options?.keywords ? tablesFor(mergeKeywords(baseOptions, options)) : baseTables;
2698
+ return { merged, tables };
2699
+ };
2700
+ return {
2701
+ parse(input, options) {
2702
+ const { merged, tables } = resolve(options);
2703
+ return run(input, merged, tables);
2704
+ },
2705
+ parseArtists(input, options) {
2706
+ const { merged, tables } = resolve(options);
2707
+ return runArtists(input, merged, tables);
2708
+ }
2709
+ };
2710
+ }
2711
+ function probe(input, options, t) {
2712
+ const st = stage(input, options, t);
2713
+ return {
2714
+ position: st.track.position !== null,
2715
+ spacedDash: st.working !== null && !st.pre.pipeTitle && splitAtSpacedDashes(st.working, true).length >= 2,
2716
+ empty: st.working === null,
2717
+ finish: (listContext) => finish(st, listContext)
2718
+ };
2719
+ }
2720
+ function createListParser(base) {
2721
+ const options = safeOptions(base);
2722
+ const tables = tablesFor(options.keywords);
2723
+ return {
2724
+ parse: (input, listContext) => run(input, options, tables, listContext),
2725
+ probe: (input) => probe(input, options, tables)
2726
+ };
2727
+ }
2728
+ function mergeKeywords(a, b) {
2729
+ const x = isRecord(a.keywords) ? a.keywords : {};
2730
+ const y = isRecord(b.keywords) ? b.keywords : {};
2731
+ const map = (k) => Object.fromEntries([...stringEntries(x[k]), ...stringEntries(y[k])]);
2732
+ const list = (k) => [...stringList(x[k]), ...stringList(y[k])];
2733
+ return {
2734
+ versionHeads: map("versionHeads"),
2735
+ descriptors: list("descriptors"),
2736
+ genres: list("genres"),
2737
+ junk: map("junk"),
2738
+ featMarkers: list("featMarkers")
2739
+ };
2740
+ }
2741
+ function isRecord(v) {
2742
+ return typeof v === "object" && v !== null && !Array.isArray(v);
2743
+ }
2744
+ createParser();
2745
+
2746
+ // src/tracklist/kinds.ts
2747
+ var headerWords = new Set(headerWordsData.words);
2748
+ var defaultMarkers = null;
2749
+ function markerTable(extra) {
2750
+ const more = stringList(extra);
2751
+ const entry = (w) => [markerWords(w).join(" "), true];
2752
+ if (more.length === 0) {
2753
+ defaultMarkers ?? (defaultMarkers = new PhraseTable(markerWordsData.words.map(entry)));
2754
+ return defaultMarkers;
2755
+ }
2756
+ return new PhraseTable([...markerWordsData.words, ...more].map(entry));
2757
+ }
2758
+ var isMarkerBreak = (ch) => ch === "/" || ch === "-" || isWhitespace(ch);
2759
+ var isBracket = (ch) => ch === "(" || ch === ")" || ch === "[" || ch === "]" || ch === "{" || ch === "}";
2760
+ function markerWords(text) {
2761
+ const out = [];
2762
+ let current = "";
2763
+ const flush = () => {
2764
+ let a = 0;
2765
+ let b = current.length;
2766
+ while (a < b && isBracket(current[a])) a++;
2767
+ while (b > a && isBracket(current[b - 1])) b--;
2768
+ if (b > a) out.push(current.slice(a, b));
2769
+ current = "";
2770
+ };
2771
+ for (const ch of text) {
2772
+ if (isMarkerBreak(ch)) flush();
2773
+ else current += ch;
2774
+ }
2775
+ flush();
2776
+ return out;
2777
+ }
2778
+ function isBlank(raw) {
2779
+ for (let i = 0; i < raw.length; i++) if (!isWhitespace(raw[i])) return false;
2780
+ return true;
2781
+ }
2782
+ function trimWs(s) {
2783
+ let a = 0;
2784
+ let b = s.length;
2785
+ while (a < b && isWhitespace(s[a])) a++;
2786
+ while (b > a && isWhitespace(s[b - 1])) b--;
2787
+ return s.slice(a, b);
2788
+ }
2789
+ function isLink(rest) {
2790
+ const t = asciiLower(trimWs(rest).slice(0, 8));
2791
+ return t.startsWith("http://") || t.startsWith("https://") || t.startsWith("www.");
2792
+ }
2793
+ function isHeader(rest) {
2794
+ const t = trimWs(rest);
2795
+ if (t.endsWith(":")) return true;
2796
+ const words = splitWords(t);
2797
+ const first = words[0];
2798
+ return first !== void 0 && words.length <= 2 && inVocab(first, headerWords);
2799
+ }
2800
+ function hasMarkerWord(trackPart, markers) {
2801
+ const words = markerWords(trackPart);
2802
+ for (let i = 0; i < words.length; i++) if (markers.matchAt(words, i)) return true;
2803
+ return false;
2804
+ }
2805
+ function hasSeparator(track, empty) {
2806
+ if (empty || track.warnings.includes("noSeparator")) return false;
2807
+ return track.flags.unknownArtist || track.artists.some((a) => a.role === "primary");
2808
+ }
2809
+
2810
+ // src/tracklist/options.ts
2811
+ var MODES = /* @__PURE__ */ new Set(["auto", "clean", "youtube", "filename", "tracklist"]);
2812
+ var SPLIT_AND = /* @__PURE__ */ new Set(["auto", "always", "never"]);
2813
+ function read(options, key) {
2814
+ try {
2815
+ return options[key];
2816
+ } catch {
2817
+ return void 0;
2818
+ }
2819
+ }
2820
+ function resolveOptions(options) {
2821
+ const o = typeof options === "object" && options !== null ? options : {};
2822
+ const mode = read(o, "mode");
2823
+ const splitAnd = read(o, "splitAnd");
2824
+ const keywords = read(o, "keywords");
2825
+ const known = read(o, "knownArtists");
2826
+ const audio = read(o, "audioSeconds");
2827
+ const parse = {
2828
+ mode: typeof mode === "string" && MODES.has(mode) ? mode : "tracklist"
2829
+ };
2830
+ if (Array.isArray(known)) parse.knownArtists = known;
2831
+ if (typeof splitAnd === "string" && SPLIT_AND.has(splitAnd)) {
2832
+ parse.splitAnd = splitAnd;
2833
+ }
2834
+ if (typeof keywords === "object" && keywords !== null && !Array.isArray(keywords)) {
2835
+ parse.keywords = keywords;
2836
+ }
2837
+ return {
2838
+ parse,
2839
+ audioSeconds: typeof audio === "number" && Number.isFinite(audio) && audio >= 0 ? audio : null,
2840
+ markerWords: read(o, "markerWords")
2841
+ };
2842
+ }
2843
+
2844
+ // src/tracklist/positions.ts
2845
+ function bareCandidate(rest, positionTaken) {
2846
+ if (positionTaken) return null;
2847
+ const n = digitRun(rest, 0);
2848
+ if (n < 1 || n > 3 || rest[n] !== " ") return null;
2849
+ const stripped = rest.slice(n + 1);
2850
+ for (let i = 0; i < stripped.length; i++) {
2851
+ if (!isWhitespace(stripped[i])) {
2852
+ const raw = rest.slice(0, n);
2853
+ return { raw, number: Number(raw), stripped };
2854
+ }
2855
+ }
2856
+ return null;
2857
+ }
2858
+ function confirmPositions(bare, candidateLine) {
2859
+ const n = bare.length;
2860
+ const prev = new Array(n).fill(-1);
2861
+ const next = new Array(n).fill(-1);
2862
+ let last = -1;
2863
+ for (let i = 0; i < n; i++) {
2864
+ prev[i] = last;
2865
+ if (candidateLine[i]) last = i;
2866
+ }
2867
+ last = -1;
2868
+ for (let i = n - 1; i >= 0; i--) {
2869
+ next[i] = last;
2870
+ if (candidateLine[i]) last = i;
2871
+ }
2872
+ const adjacent = (b, j) => {
2873
+ const o = j >= 0 ? bare[j] : null;
2874
+ return o != null && (o.number === b.number - 1 || o.number === b.number + 1);
2875
+ };
2876
+ return bare.map(
2877
+ (b, i) => b !== null && (adjacent(b, prev[i]) || adjacent(b, next[i]))
2878
+ );
2879
+ }
2880
+
2881
+ // src/tracklist/warnings.ts
2882
+ var ORDER = [
2883
+ "time-missing",
2884
+ "time-out-of-order",
2885
+ "time-duplicate",
2886
+ "no-start-at-zero",
2887
+ "time-after-audio-end",
2888
+ "invalid-time",
2889
+ "position-gap",
2890
+ "position-duplicate",
2891
+ "line-unparsed"
2892
+ ];
2893
+ function searchTime(raw, lo, hi) {
2894
+ let fracStart = raw.length;
2895
+ for (let i = 0; i < raw.length; i++) {
2896
+ if (raw[i] === "." || raw[i] === ",") {
2897
+ fracStart = i;
2898
+ break;
2899
+ }
2900
+ }
2901
+ const frac = raw.slice(fracStart + 1);
2902
+ const values = [0];
2903
+ const slots = [];
2904
+ for (let i = 0; i < fracStart; i++) {
2905
+ const ch = raw[i];
2906
+ if (ch === ":") values.push(0);
2907
+ else if (isDigit(ch)) {
2908
+ const f = values.length - 1;
2909
+ values[f] = values[f] * 10 + Number(ch);
2910
+ slots.push({ at: i, field: f, place: 0, digit: Number(ch) });
2911
+ }
2912
+ }
2913
+ for (let k = slots.length - 1, place = 1, field2 = -1; k >= 0; k--) {
2914
+ const slot = slots[k];
2915
+ if (slot.field !== field2) {
2916
+ field2 = slot.field;
2917
+ place = 1;
2918
+ }
2919
+ slot.place = place;
2920
+ place *= 10;
2921
+ }
2922
+ const mult = values.length === 3 ? [3600, 60, 1] : [60, 1];
2923
+ const valueWith = (changes) => {
2924
+ const v = [...values];
2925
+ for (const [slot, d] of changes) {
2926
+ v[slot.field] = v[slot.field] + (d - slot.digit) * slot.place;
2927
+ }
2928
+ let whole = 0;
2929
+ for (let f = 0; f < v.length; f++) {
2930
+ if (f > 0 && v[f] > 59) return null;
2931
+ whole += v[f] * mult[f];
2932
+ }
2933
+ const t = secondsOf(whole, frac);
2934
+ return (lo === null || lo < t) && (hi === null || t < hi) ? t : null;
2935
+ };
2936
+ const render = (changes) => {
2937
+ const chars = [...raw];
2938
+ for (const [slot, d] of changes) chars[slot.at] = String(d);
2939
+ return chars.join("");
2940
+ };
2941
+ let found = null;
2942
+ let count = 0;
2943
+ for (const slot of slots) {
2944
+ for (let d = 0; d <= 9; d++) {
2945
+ if (d === slot.digit) continue;
2946
+ const change = [[slot, d]];
2947
+ if (valueWith(change) === null) continue;
2948
+ count++;
2949
+ found = change;
2950
+ }
2951
+ }
2952
+ if (count === 0) {
2953
+ for (let x = 0; x < slots.length && count < 2; x++) {
2954
+ for (let y = x + 1; y < slots.length && count < 2; y++) {
2955
+ const a = slots[x];
2956
+ const b = slots[y];
2957
+ for (let da = 0; da <= 9 && count < 2; da++) {
2958
+ if (da === a.digit) continue;
2959
+ for (let db = 0; db <= 9 && count < 2; db++) {
2960
+ if (db === b.digit) continue;
2961
+ const change = [
2962
+ [a, da],
2963
+ [b, db]
2964
+ ];
2965
+ if (valueWith(change) === null) continue;
2966
+ count++;
2967
+ found = change;
2968
+ }
2969
+ }
2970
+ }
2971
+ }
2972
+ }
2973
+ if (count === 0 || found === null) return { kind: "none" };
2974
+ if (count > 1) return { kind: "many" };
2975
+ return { kind: "one", time: { raw: render(found), seconds: valueWith(found) } };
2976
+ }
2977
+ function suggestFix(times, k) {
2978
+ const cur = times[k];
2979
+ const prev = times[k - 1];
2980
+ const next = times[k + 1];
2981
+ const own = searchTime(cur.time.raw, prev.time.seconds, next ? next.time.seconds : null);
2982
+ if (own.kind === "one") return { line: cur.line, ...own.time };
2983
+ if (own.kind === "many") return null;
2984
+ const before = times[k - 2];
2985
+ const back = searchTime(prev.time.raw, before ? before.time.seconds : null, cur.time.seconds);
2986
+ return back.kind === "one" ? { line: prev.line, ...back.time } : null;
2987
+ }
2988
+ function tracklistWarnings(input) {
2989
+ const { entries, invalid, unparsed, audioSeconds } = input;
2990
+ const perLine = entries.map(() => []);
2991
+ const add = (i, code, suggestion = null) => {
2992
+ const line = entries[i].line;
2993
+ const severity = code === "no-start-at-zero" ? "info" : "warning";
2994
+ perLine[i].push({ code, line, severity, suggestion });
2995
+ };
2996
+ const timed = [];
2997
+ entries.forEach((e, i) => {
2998
+ if (e.time) timed.push(i);
2999
+ });
3000
+ entries.forEach((e, i) => {
3001
+ if (e.kind === "track" && e.time === null && timed.length > 0) add(i, "time-missing");
3002
+ if (invalid[i]) add(i, "invalid-time");
3003
+ if (unparsed[i]) add(i, "line-unparsed");
3004
+ });
3005
+ const times = timed.map((i) => {
3006
+ const e = entries[i];
3007
+ return { line: e.line, time: e.time };
3008
+ });
3009
+ timed.forEach((i, k) => {
3010
+ const t = times[k].time;
3011
+ if (k === 0 && t.seconds !== 0) add(i, "no-start-at-zero");
3012
+ if (k > 0) {
3013
+ const p = times[k - 1].time.seconds;
3014
+ if (t.seconds < p) add(i, "time-out-of-order", suggestFix(times, k));
3015
+ else if (t.seconds === p) add(i, "time-duplicate");
3016
+ }
3017
+ if (audioSeconds !== null && t.seconds > audioSeconds) add(i, "time-after-audio-end");
3018
+ });
3019
+ let prevPos = null;
3020
+ entries.forEach((e, i) => {
3021
+ const pos = e.kind === "track" ? e.track?.position : null;
3022
+ if (!pos) return;
3023
+ if (prevPos !== null) {
3024
+ if (pos.number === prevPos) add(i, "position-duplicate");
3025
+ else if (pos.number !== prevPos + 1) add(i, "position-gap");
3026
+ }
3027
+ prevPos = pos.number;
3028
+ });
3029
+ const rank = (c) => ORDER.indexOf(c);
3030
+ return perLine.flatMap((ws) => ws.sort((a, b) => rank(a.code) - rank(b.code)));
3031
+ }
3032
+
3033
+ // src/tracklist/parse.ts
3034
+ function parseTracklist(text, options) {
3035
+ const opts = resolveOptions(options);
3036
+ const markers = markerTable(opts.markerWords);
3037
+ const lines = splitLines(typeof text === "string" ? text : "");
3038
+ const lp = createListParser(opts.parse);
3039
+ const rows = lines.map((line) => {
3040
+ const scan2 = scanTime(line.raw);
3041
+ const blank = isBlank(line.raw);
3042
+ const row = {
3043
+ line,
3044
+ scan: scan2,
3045
+ blank,
3046
+ candidate: false,
3047
+ dash: false,
3048
+ probe: null,
3049
+ bare: null,
3050
+ track: null,
3051
+ separated: false,
3052
+ marker: false
3053
+ };
3054
+ if (blank) return row;
3055
+ const probe2 = lp.probe(scan2.parts.rest);
3056
+ row.probe = probe2;
3057
+ row.dash = probe2.spacedDash;
3058
+ row.candidate = scan2.time !== null || row.dash;
3059
+ row.bare = bareCandidate(scan2.parts.rest, probe2.position);
3060
+ return row;
3061
+ });
3062
+ let candidates2 = 0;
3063
+ let dashes = 0;
3064
+ for (const r of rows) {
3065
+ if (!r.candidate) continue;
3066
+ candidates2++;
3067
+ if (r.dash) dashes++;
3068
+ }
3069
+ const listContext = candidates2 > 0 && 2 * dashes >= candidates2;
3070
+ const confirmed = confirmPositions(
3071
+ rows.map((r) => r.bare),
3072
+ rows.map((r) => r.candidate)
3073
+ );
3074
+ rows.forEach((r, i) => {
3075
+ if (r.blank || !r.probe) return;
3076
+ const bare = confirmed[i] ? r.bare : null;
3077
+ const probe2 = bare ? lp.probe(bare.stripped) : r.probe;
3078
+ r.marker = hasMarkerWord(bare ? bare.stripped : r.scan.parts.rest, markers);
3079
+ const track = probe2.finish(listContext && r.scan.time !== null && !r.marker);
3080
+ if (bare) track.position = { raw: bare.raw, number: bare.number };
3081
+ r.track = track;
3082
+ r.separated = hasSeparator(track, probe2.empty);
3083
+ });
3084
+ const entries = rows.map((r, i) => {
3085
+ const kind = kindOf(r);
3086
+ const keep = kind === "track" || kind === "marker";
3087
+ return {
3088
+ line: i + 1,
3089
+ kind,
3090
+ eol: r.line.eol,
3091
+ raw: r.line.raw,
3092
+ parts: r.scan.parts,
3093
+ time: r.scan.time,
3094
+ track: keep ? r.track : null
3095
+ };
3096
+ });
3097
+ const warnings = tracklistWarnings({
3098
+ entries,
3099
+ invalid: rows.map((r) => r.scan.invalid),
3100
+ // R13.7 line-unparsed: a track entry whose track part has no separator (R13.3).
3101
+ unparsed: rows.map((r, i) => entries[i].kind === "track" && !r.separated),
3102
+ audioSeconds: opts.audioSeconds
3103
+ });
3104
+ return { specVersion: SPEC_VERSION, entries, warnings, stats: statsOf(entries) };
3105
+ }
3106
+ function kindOf(r) {
3107
+ if (r.blank || !r.track) return "blank";
3108
+ const marker = !r.separated && r.marker;
3109
+ if (r.scan.time) return marker ? "marker" : "track";
3110
+ const rest = r.scan.parts.rest;
3111
+ if (isLink(rest)) return "link";
3112
+ if (isHeader(rest)) return "header";
3113
+ if (marker) return "marker";
3114
+ return r.separated ? "track" : "note";
3115
+ }
3116
+ function statsOf(entries) {
3117
+ let tracks = 0;
3118
+ let markers = 0;
3119
+ let timed = 0;
3120
+ let firstTime = null;
3121
+ let lastTime = null;
3122
+ for (const e of entries) {
3123
+ if (e.kind === "track") tracks++;
3124
+ if (e.kind === "marker") markers++;
3125
+ if (e.time) {
3126
+ timed++;
3127
+ firstTime ?? (firstTime = e.time.seconds);
3128
+ lastTime = e.time.seconds;
3129
+ }
3130
+ }
3131
+ return { lines: entries.length, tracks, markers, timed, firstTime, lastTime };
3132
+ }
3133
+
3134
+ exports.applyEdits = applyEdits;
3135
+ exports.parseTracklist = parseTracklist;