trackparse 0.3.2 → 0.4.0
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.
- package/README.md +29 -2
- package/dist/{chunk-L4WNGZ5H.js → chunk-OY536C3I.js} +7 -2
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/tracklist.cjs +105 -19
- package/dist/tracklist.d.cts +2 -0
- package/dist/tracklist.d.ts +2 -0
- package/dist/tracklist.js +100 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Parse music track names into structured metadata: artists, featured artists, rem
|
|
|
11
11
|
|
|
12
12
|
This is the JavaScript/TypeScript reference implementation of the
|
|
13
13
|
[trackparse spec](https://github.com/4matic/trackparse/blob/main/spec/SPEC.md). It passes all
|
|
14
|
-
|
|
14
|
+
13,124 shared fixtures. Zero dependencies, ESM and CommonJS, TypeScript types included, never
|
|
15
15
|
throws.
|
|
16
16
|
|
|
17
17
|
The principle is **extract, don't delete**: everything recognised lands in a field (artists with
|
|
@@ -29,6 +29,8 @@ Node 18 or later. No Node-specific APIs are used, so it also runs in browsers an
|
|
|
29
29
|
|
|
30
30
|
## Quick start
|
|
31
31
|
|
|
32
|
+
**Docs:** [Getting started](https://4matic.github.io/trackparse/start/getting-started/)
|
|
33
|
+
|
|
32
34
|
```js
|
|
33
35
|
import { parse } from "trackparse";
|
|
34
36
|
// const { parse } = require("trackparse");
|
|
@@ -51,12 +53,16 @@ track.mode; // 'youtube' (auto-detected from the junk gro
|
|
|
51
53
|
|
|
52
54
|
## API
|
|
53
55
|
|
|
56
|
+
**Docs:** [parse and ParsedTrack](https://4matic.github.io/trackparse/reference/parse/) · [Other functions](https://4matic.github.io/trackparse/reference/functions/)
|
|
57
|
+
|
|
54
58
|
### `parse(input, options?) → ParsedTrack`
|
|
55
59
|
|
|
56
60
|
Parses one track string. Every key of the result is always present.
|
|
57
61
|
|
|
58
62
|
### `parseArtists(input, options?) → Artist[]`
|
|
59
63
|
|
|
64
|
+
**Docs:** [parseArtists](https://4matic.github.io/trackparse/reference/functions/#parseartistsinput-options)
|
|
65
|
+
|
|
60
66
|
Splits an artist string (no title) into credits.
|
|
61
67
|
|
|
62
68
|
```js
|
|
@@ -71,6 +77,8 @@ parseArtists("Sub Focus, Wilkinson & Dimension feat. Kojo");
|
|
|
71
77
|
|
|
72
78
|
### `format(track, options?) → string`
|
|
73
79
|
|
|
80
|
+
**Docs:** [format](https://4matic.github.io/trackparse/reference/functions/#formattrack-options) · [Formatting](https://4matic.github.io/trackparse/guides/formatting/)
|
|
81
|
+
|
|
74
82
|
Renders a `ParsedTrack` back to `Artists - Title (versions) (prod. ...)`.
|
|
75
83
|
|
|
76
84
|
```js
|
|
@@ -92,6 +100,8 @@ format(t, { feat: "omit", versions: "none" }); // 'Wilkinson - Afterglow'
|
|
|
92
100
|
|
|
93
101
|
### `normalize(input) → string`
|
|
94
102
|
|
|
103
|
+
**Docs:** [normalize](https://4matic.github.io/trackparse/reference/functions/#normalizeinput)
|
|
104
|
+
|
|
95
105
|
The Unicode cleanup applied before parsing: NFC, zero-width spaces, BOM and soft hyphens removed, fullwidth
|
|
96
106
|
brackets, smart quotes and dash variants mapped to ASCII, whitespace collapsed. Idempotent.
|
|
97
107
|
|
|
@@ -102,6 +112,8 @@ normalize(" Artist — Title (Remix) “Quoted” ");
|
|
|
102
112
|
|
|
103
113
|
### `allArtists(track) → Artist[]`
|
|
104
114
|
|
|
115
|
+
**Docs:** [allArtists](https://4matic.github.io/trackparse/reference/functions/#allartiststrack)
|
|
116
|
+
|
|
105
117
|
Track credits plus every version's remixers, deduplicated.
|
|
106
118
|
|
|
107
119
|
```js
|
|
@@ -112,6 +124,8 @@ allArtists(parse("Camo & Krooked - Kallisto (Noisia Remix)", { knownArtists: ["C
|
|
|
112
124
|
|
|
113
125
|
### `createParser(options) → { parse, parseArtists }`
|
|
114
126
|
|
|
127
|
+
**Docs:** [createParser](https://4matic.github.io/trackparse/reference/functions/#createparseroptions)
|
|
128
|
+
|
|
115
129
|
Compiles options, including extra vocabulary, once. Per-call options are merged over the base.
|
|
116
130
|
|
|
117
131
|
```js
|
|
@@ -133,6 +147,8 @@ The spec version this build implements, e.g. `"0.3.0"`. Always equal to the pack
|
|
|
133
147
|
|
|
134
148
|
## Options
|
|
135
149
|
|
|
150
|
+
**Docs:** [Options](https://4matic.github.io/trackparse/reference/options/)
|
|
151
|
+
|
|
136
152
|
| Option | Default | Effect |
|
|
137
153
|
|---|---|---|
|
|
138
154
|
| `mode` | `"auto"` | `"clean"` (tags, store metadata), `"youtube"` (video titles), `"filename"`, `"tracklist"` (one line of a tracklist), or `"auto"` to detect. |
|
|
@@ -207,9 +223,11 @@ The full field reference, a gallery of examples and the comparison with other li
|
|
|
207
223
|
|
|
208
224
|
## Tracklists
|
|
209
225
|
|
|
226
|
+
**Docs:** [Tracklists](https://4matic.github.io/trackparse/guides/tracklists/) · [Editing tracklists](https://4matic.github.io/trackparse/guides/editing-tracklists/)
|
|
227
|
+
|
|
210
228
|
`trackparse/tracklist` parses a whole tracklist: one entry per line with its kind, start time and
|
|
211
229
|
a full `ParsedTrack`, plus checks with line numbers. Joining `raw + eol` over the entries gives the
|
|
212
|
-
input back byte for byte, whatever the line endings. It's a separate entry point (about
|
|
230
|
+
input back byte for byte, whatever the line endings. It's a separate entry point (about 8.3 KiB
|
|
213
231
|
gzipped), so you only pay for it if you import it.
|
|
214
232
|
|
|
215
233
|
```js
|
|
@@ -271,6 +289,13 @@ Warning codes: `time-missing`, `time-out-of-order` (with a one- or two-digit `su
|
|
|
271
289
|
exactly one fix fits), `time-duplicate`, `no-start-at-zero`, `time-after-audio-end` (pass
|
|
272
290
|
`audioSeconds`), `invalid-time`, `position-gap`, `position-duplicate`, `line-unparsed`.
|
|
273
291
|
|
|
292
|
+
Lists pasted with list bullets or Markdown emphasis (`* 14:21 Artist - Title`,
|
|
293
|
+
`**[**14:21**] Artist - Title**`) read like plain lines; the decoration stays in `raw` and `parts`.
|
|
294
|
+
A line whose track text (after any time) starts with `w/` is `layered`: a track played over the
|
|
295
|
+
previous one
|
|
296
|
+
([Markdown](https://4matic.github.io/trackparse/guides/tracklists/#markdown),
|
|
297
|
+
[layered tracks](https://4matic.github.io/trackparse/guides/tracklists/#layered-tracks)).
|
|
298
|
+
|
|
274
299
|
Options: `mode` (default `"tracklist"`), `knownArtists`, `splitAnd`, `keywords`, `audioSeconds`,
|
|
275
300
|
`markerWords`. Every type is exported (`import type { Tracklist, Edit } from
|
|
276
301
|
"trackparse/tracklist"`).
|
|
@@ -288,6 +313,8 @@ Field tables and the full list of rules: the [tracklists guide](https://4matic.g
|
|
|
288
313
|
|
|
289
314
|
## Limitations
|
|
290
315
|
|
|
316
|
+
**Docs:** [Limitations and roadmap](https://4matic.github.io/trackparse/project/roadmap/)
|
|
317
|
+
|
|
291
318
|
One track string per `parse()` call; tracklists go through `trackparse/tracklist`, which reads
|
|
292
319
|
start times only (no durations, trailing times or ranges). No label/catalog, key or BPM fields, and
|
|
293
320
|
no built-in artist database: pass `knownArtists` for names that contain a joiner.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/data.generated.ts
|
|
2
|
-
var SPEC_VERSION = "0.
|
|
2
|
+
var SPEC_VERSION = "0.4.0";
|
|
3
3
|
var descriptorsData = {
|
|
4
4
|
"descriptors": [
|
|
5
5
|
"radio",
|
|
@@ -327,6 +327,11 @@ var junkData = {
|
|
|
327
327
|
"recordings"
|
|
328
328
|
]
|
|
329
329
|
};
|
|
330
|
+
var layeredWordsData = {
|
|
331
|
+
"words": [
|
|
332
|
+
"w/"
|
|
333
|
+
]
|
|
334
|
+
};
|
|
330
335
|
var markerWordsData = {
|
|
331
336
|
"words": [
|
|
332
337
|
"intro",
|
|
@@ -2311,4 +2316,4 @@ function parseArtists(input, options) {
|
|
|
2311
2316
|
return defaultParser.parseArtists(input, options);
|
|
2312
2317
|
}
|
|
2313
2318
|
|
|
2314
|
-
export { PhraseTable, SPEC_VERSION, asciiLower, createListParser, createParser, dedupKey, digitRun, getDefaultTables, headerWordsData, inVocab, isDigit, isWhitespace, markerWordsData, normalize, parse, parseArtists, renderVersion, splitWords, stringList, wordMatches };
|
|
2319
|
+
export { PhraseTable, SPEC_VERSION, asciiLower, createListParser, createParser, dedupKey, digitRun, getDefaultTables, headerWordsData, inVocab, isDigit, isWhitespace, layeredWordsData, markerWordsData, normalize, parse, parseArtists, renderVersion, splitWords, stringList, wordMatches };
|
package/dist/index.cjs
CHANGED
package/dist/index.d.cts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { P as ParsedTrack, A as Artist, F as FormatOptions, a as ParseOptions } from './types-DqJSn18b.cjs';
|
|
2
2
|
export { b as ArtistRole, c as ArtistSource, d as Flags, J as Junk, e as JunkKind, K as KeywordOptions, M as Mode, f as ModeOption, g as Position, T as Timestamp, V as Version, h as VersionDelimiter, i as VersionType, W as Warning } from './types-DqJSn18b.cjs';
|
|
3
3
|
|
|
4
|
-
declare const SPEC_VERSION = "0.
|
|
4
|
+
declare const SPEC_VERSION = "0.4.0";
|
|
5
5
|
|
|
6
6
|
declare function format(track: ParsedTrack, options?: FormatOptions): string;
|
|
7
7
|
/** All credited names: track artists plus every version's remixers, deduped by R0.4 key. */
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { P as ParsedTrack, A as Artist, F as FormatOptions, a as ParseOptions } from './types-DqJSn18b.js';
|
|
2
2
|
export { b as ArtistRole, c as ArtistSource, d as Flags, J as Junk, e as JunkKind, K as KeywordOptions, M as Mode, f as ModeOption, g as Position, T as Timestamp, V as Version, h as VersionDelimiter, i as VersionType, W as Warning } from './types-DqJSn18b.js';
|
|
3
3
|
|
|
4
|
-
declare const SPEC_VERSION = "0.
|
|
4
|
+
declare const SPEC_VERSION = "0.4.0";
|
|
5
5
|
|
|
6
6
|
declare function format(track: ParsedTrack, options?: FormatOptions): string;
|
|
7
7
|
/** All credited names: track artists plus every version's remixers, deduped by R0.4 key. */
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { getDefaultTables, renderVersion, dedupKey, wordMatches } from './chunk-
|
|
2
|
-
export { SPEC_VERSION, createParser, normalize, parse, parseArtists } from './chunk-
|
|
1
|
+
import { getDefaultTables, renderVersion, dedupKey, wordMatches } from './chunk-OY536C3I.js';
|
|
2
|
+
export { SPEC_VERSION, createParser, normalize, parse, parseArtists } from './chunk-OY536C3I.js';
|
|
3
3
|
|
|
4
4
|
// src/format.ts
|
|
5
5
|
function resolve(o) {
|
package/dist/tracklist.cjs
CHANGED
|
@@ -240,6 +240,45 @@ function twoDigitsUpTo59(s, at) {
|
|
|
240
240
|
return n <= 59 ? n : null;
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
// src/tracklist/emphasis.ts
|
|
244
|
+
function runLength(s, at) {
|
|
245
|
+
const ch = s[at];
|
|
246
|
+
let j = at;
|
|
247
|
+
if (ch === "*" || ch === "_") while (s[j] === ch) j++;
|
|
248
|
+
return j - at;
|
|
249
|
+
}
|
|
250
|
+
function emphasisRun(s, at) {
|
|
251
|
+
const n = runLength(s, at);
|
|
252
|
+
return n <= 2 ? n : 0;
|
|
253
|
+
}
|
|
254
|
+
var isOuter = (ch) => ch === void 0 || isWhitespace(ch) || "[]()*_".includes(ch);
|
|
255
|
+
var isInner = (ch) => ch !== void 0 && !isWhitespace(ch);
|
|
256
|
+
function withoutMarkers(raw, from, open) {
|
|
257
|
+
const marked = [];
|
|
258
|
+
const stacks = { "*": [], "**": [], _: [], __: [] };
|
|
259
|
+
for (let i = from; i < raw.length; ) {
|
|
260
|
+
const len = runLength(raw, i) || 1;
|
|
261
|
+
const own = stacks[raw.slice(i, i + len)];
|
|
262
|
+
const before = raw[i - 1];
|
|
263
|
+
const after = raw[i + len];
|
|
264
|
+
const at = own && isInner(before) && isOuter(after) ? own.pop() : void 0;
|
|
265
|
+
if (at !== void 0) {
|
|
266
|
+
for (let m = 0; m < len; m++) marked[at + m] = marked[i + m] = true;
|
|
267
|
+
for (const st of Object.values(stacks)) while ((st[st.length - 1] ?? -1) > at) st.pop();
|
|
268
|
+
} else if (own && isOuter(before) && isInner(after)) own.push(i);
|
|
269
|
+
i += len;
|
|
270
|
+
}
|
|
271
|
+
let end = raw.length;
|
|
272
|
+
while (end > from && isWhitespace(raw[end - 1])) end--;
|
|
273
|
+
let start = end;
|
|
274
|
+
while (start > from && raw[start - 1] === open) start--;
|
|
275
|
+
if (open && end - start <= 2 && start > from && raw[start - 1] !== open && isInner(raw[start - 1]))
|
|
276
|
+
for (let m = start; m < end && !marked[m]; m++) marked[m] = true;
|
|
277
|
+
let out = "";
|
|
278
|
+
for (let i = from; i < raw.length; i++) if (!marked[i]) out += raw[i];
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
|
|
243
282
|
// src/tracklist/times.ts
|
|
244
283
|
function isGapSeparator(ch) {
|
|
245
284
|
return ch === "-" || ch === "\u2013" || ch === "\u2014" || ch === "|" || ch === "." || ch === ":";
|
|
@@ -261,9 +300,11 @@ function untimed(raw, invalid) {
|
|
|
261
300
|
parts: { lead, time: "", gap: "", rest: raw.slice(lead.length) },
|
|
262
301
|
time: null,
|
|
263
302
|
style: null,
|
|
264
|
-
invalid
|
|
303
|
+
invalid,
|
|
304
|
+
text: withoutMarkers(raw, lead.length)
|
|
265
305
|
};
|
|
266
306
|
}
|
|
307
|
+
var closingRun = (raw, at, open) => open && raw[at] === open ? at + emphasisRun(raw, at) : at;
|
|
267
308
|
function secondsOf(whole, frac) {
|
|
268
309
|
return frac.length === 0 ? whole : Number(`${whole}.${frac}`);
|
|
269
310
|
}
|
|
@@ -296,8 +337,22 @@ function shapeSeconds(parts) {
|
|
|
296
337
|
}
|
|
297
338
|
function scanTime(raw) {
|
|
298
339
|
let i = skipWhitespace(raw, 0);
|
|
340
|
+
if (isBullet(raw[i]) && isWhitespace(raw[i + 1])) i = skipWhitespace(raw, i + 1);
|
|
341
|
+
let outerOpen;
|
|
342
|
+
let n = emphasisRun(raw, i);
|
|
343
|
+
if (n && (raw[i + n] === "[" || raw[i + n] === "(" || isDigit(raw[i + n]))) {
|
|
344
|
+
outerOpen = raw[i];
|
|
345
|
+
i += n;
|
|
346
|
+
}
|
|
299
347
|
const closer = raw[i] === "[" ? "]" : raw[i] === "(" ? ")" : null;
|
|
300
|
-
|
|
348
|
+
let innerOpen;
|
|
349
|
+
if (closer) {
|
|
350
|
+
n = emphasisRun(raw, ++i);
|
|
351
|
+
if (n && isDigit(raw[i + n])) {
|
|
352
|
+
innerOpen = raw[i];
|
|
353
|
+
i += n;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
301
356
|
const leadEnd = i;
|
|
302
357
|
const shape = shapeAt(raw, i);
|
|
303
358
|
if (!shape) return untimed(raw, false);
|
|
@@ -308,19 +363,30 @@ function scanTime(raw) {
|
|
|
308
363
|
let frac = "";
|
|
309
364
|
const sep = raw[j];
|
|
310
365
|
if (sep === "." || sep === ",") {
|
|
311
|
-
const
|
|
312
|
-
if (
|
|
366
|
+
const n2 = digitRun(raw, j + 1);
|
|
367
|
+
if (n2 >= 1 && n2 <= 3) {
|
|
313
368
|
fracSep = sep;
|
|
314
|
-
frac = raw.slice(j + 1, j + 1 +
|
|
315
|
-
j += 1 +
|
|
369
|
+
frac = raw.slice(j + 1, j + 1 + n2);
|
|
370
|
+
j += 1 + n2;
|
|
316
371
|
}
|
|
317
372
|
}
|
|
318
373
|
const timeEnd = j;
|
|
374
|
+
const followsTime = (at) => at >= raw.length || isWhitespace(raw[at]) || isGapSeparator(raw[at]);
|
|
375
|
+
let open;
|
|
319
376
|
if (closer) {
|
|
377
|
+
let k = closingRun(raw, j, innerOpen);
|
|
378
|
+
if (k === j) open = innerOpen;
|
|
379
|
+
j = k;
|
|
320
380
|
if (raw[j] !== closer) return untimed(raw, false);
|
|
321
381
|
j++;
|
|
322
|
-
|
|
323
|
-
|
|
382
|
+
k = closingRun(raw, j, outerOpen);
|
|
383
|
+
if (k > j && followsTime(k)) j = k;
|
|
384
|
+
else open = outerOpen ?? open;
|
|
385
|
+
} else {
|
|
386
|
+
const k = closingRun(raw, j, outerOpen);
|
|
387
|
+
if (k === j) open = outerOpen;
|
|
388
|
+
j = k;
|
|
389
|
+
if (!followsTime(j)) return untimed(raw, false);
|
|
324
390
|
}
|
|
325
391
|
j = skipWhitespace(raw, j);
|
|
326
392
|
if (isGapSeparator(raw[j])) j = skipWhitespace(raw, j + 1);
|
|
@@ -348,7 +414,8 @@ function scanTime(raw) {
|
|
|
348
414
|
fracSep,
|
|
349
415
|
fracDigits: frac.length
|
|
350
416
|
},
|
|
351
|
-
invalid: false
|
|
417
|
+
invalid: false,
|
|
418
|
+
text: withoutMarkers(raw, j, open)
|
|
352
419
|
};
|
|
353
420
|
}
|
|
354
421
|
var pad = (n, width) => String(n).padStart(width, "0");
|
|
@@ -642,7 +709,7 @@ function applyEdits(text, edits, options) {
|
|
|
642
709
|
}
|
|
643
710
|
|
|
644
711
|
// src/data.generated.ts
|
|
645
|
-
var SPEC_VERSION = "0.
|
|
712
|
+
var SPEC_VERSION = "0.4.0";
|
|
646
713
|
var descriptorsData = {
|
|
647
714
|
"descriptors": [
|
|
648
715
|
"radio",
|
|
@@ -970,6 +1037,11 @@ var junkData = {
|
|
|
970
1037
|
"recordings"
|
|
971
1038
|
]
|
|
972
1039
|
};
|
|
1040
|
+
var layeredWordsData = {
|
|
1041
|
+
"words": [
|
|
1042
|
+
"w/"
|
|
1043
|
+
]
|
|
1044
|
+
};
|
|
973
1045
|
var markerWordsData = {
|
|
974
1046
|
"words": [
|
|
975
1047
|
"intro",
|
|
@@ -2745,6 +2817,7 @@ createParser();
|
|
|
2745
2817
|
|
|
2746
2818
|
// src/tracklist/kinds.ts
|
|
2747
2819
|
var headerWords = new Set(headerWordsData.words);
|
|
2820
|
+
var layeredWords = new Set(layeredWordsData.words);
|
|
2748
2821
|
var defaultMarkers = null;
|
|
2749
2822
|
function markerTable(extra) {
|
|
2750
2823
|
const more = stringList(extra);
|
|
@@ -2775,6 +2848,15 @@ function markerWords(text) {
|
|
|
2775
2848
|
flush();
|
|
2776
2849
|
return out;
|
|
2777
2850
|
}
|
|
2851
|
+
function layeredPrefix(text) {
|
|
2852
|
+
let i = 0;
|
|
2853
|
+
while (i < text.length && isWhitespace(text[i])) i++;
|
|
2854
|
+
let j = i;
|
|
2855
|
+
while (j < text.length && !isWhitespace(text[j])) j++;
|
|
2856
|
+
if (j === i || j === text.length || !inVocab(text.slice(i, j), layeredWords)) return 0;
|
|
2857
|
+
while (j < text.length && isWhitespace(text[j])) j++;
|
|
2858
|
+
return j < text.length ? j : 0;
|
|
2859
|
+
}
|
|
2778
2860
|
function isBlank(raw) {
|
|
2779
2861
|
for (let i = 0; i < raw.length; i++) if (!isWhitespace(raw[i])) return false;
|
|
2780
2862
|
return true;
|
|
@@ -2998,7 +3080,8 @@ function tracklistWarnings(input) {
|
|
|
2998
3080
|
if (e.time) timed.push(i);
|
|
2999
3081
|
});
|
|
3000
3082
|
entries.forEach((e, i) => {
|
|
3001
|
-
if (e.kind === "track" && e.time === null && timed.length > 0)
|
|
3083
|
+
if (e.kind === "track" && e.time === null && !e.layered && timed.length > 0)
|
|
3084
|
+
add(i, "time-missing");
|
|
3002
3085
|
if (invalid[i]) add(i, "invalid-time");
|
|
3003
3086
|
if (unparsed[i]) add(i, "line-unparsed");
|
|
3004
3087
|
});
|
|
@@ -3039,9 +3122,12 @@ function parseTracklist(text, options) {
|
|
|
3039
3122
|
const rows = lines.map((line) => {
|
|
3040
3123
|
const scan2 = scanTime(line.raw);
|
|
3041
3124
|
const blank = isBlank(line.raw);
|
|
3125
|
+
const layered = blank ? 0 : layeredPrefix(scan2.text);
|
|
3042
3126
|
const row = {
|
|
3043
3127
|
line,
|
|
3044
3128
|
scan: scan2,
|
|
3129
|
+
text: scan2.text.slice(layered),
|
|
3130
|
+
layered: layered > 0,
|
|
3045
3131
|
blank,
|
|
3046
3132
|
candidate: false,
|
|
3047
3133
|
dash: false,
|
|
@@ -3052,11 +3138,11 @@ function parseTracklist(text, options) {
|
|
|
3052
3138
|
marker: false
|
|
3053
3139
|
};
|
|
3054
3140
|
if (blank) return row;
|
|
3055
|
-
const probe2 = lp.probe(
|
|
3141
|
+
const probe2 = lp.probe(row.text);
|
|
3056
3142
|
row.probe = probe2;
|
|
3057
3143
|
row.dash = probe2.spacedDash;
|
|
3058
3144
|
row.candidate = scan2.time !== null || row.dash;
|
|
3059
|
-
row.bare = bareCandidate(
|
|
3145
|
+
row.bare = row.layered ? null : bareCandidate(row.text, probe2.position);
|
|
3060
3146
|
return row;
|
|
3061
3147
|
});
|
|
3062
3148
|
let candidates2 = 0;
|
|
@@ -3069,13 +3155,13 @@ function parseTracklist(text, options) {
|
|
|
3069
3155
|
const listContext = candidates2 > 0 && 2 * dashes >= candidates2;
|
|
3070
3156
|
const confirmed = confirmPositions(
|
|
3071
3157
|
rows.map((r) => r.bare),
|
|
3072
|
-
rows.map((r) => r.candidate)
|
|
3158
|
+
rows.map((r) => r.candidate && !r.layered)
|
|
3073
3159
|
);
|
|
3074
3160
|
rows.forEach((r, i) => {
|
|
3075
3161
|
if (r.blank || !r.probe) return;
|
|
3076
3162
|
const bare = confirmed[i] ? r.bare : null;
|
|
3077
3163
|
const probe2 = bare ? lp.probe(bare.stripped) : r.probe;
|
|
3078
|
-
r.marker = hasMarkerWord(bare ? bare.stripped : r.
|
|
3164
|
+
r.marker = hasMarkerWord(bare ? bare.stripped : r.text, markers);
|
|
3079
3165
|
const track = probe2.finish(listContext && r.scan.time !== null && !r.marker);
|
|
3080
3166
|
if (bare) track.position = { raw: bare.raw, number: bare.number };
|
|
3081
3167
|
r.track = track;
|
|
@@ -3091,7 +3177,8 @@ function parseTracklist(text, options) {
|
|
|
3091
3177
|
raw: r.line.raw,
|
|
3092
3178
|
parts: r.scan.parts,
|
|
3093
3179
|
time: r.scan.time,
|
|
3094
|
-
track: keep ? r.track : null
|
|
3180
|
+
track: keep ? r.track : null,
|
|
3181
|
+
layered: r.layered
|
|
3095
3182
|
};
|
|
3096
3183
|
});
|
|
3097
3184
|
const warnings = tracklistWarnings({
|
|
@@ -3107,9 +3194,8 @@ function kindOf(r) {
|
|
|
3107
3194
|
if (r.blank || !r.track) return "blank";
|
|
3108
3195
|
const marker = !r.separated && r.marker;
|
|
3109
3196
|
if (r.scan.time) return marker ? "marker" : "track";
|
|
3110
|
-
|
|
3111
|
-
if (
|
|
3112
|
-
if (isHeader(rest)) return "header";
|
|
3197
|
+
if (isLink(r.text)) return "link";
|
|
3198
|
+
if (isHeader(r.text)) return "header";
|
|
3113
3199
|
if (marker) return "marker";
|
|
3114
3200
|
return r.separated ? "track" : "note";
|
|
3115
3201
|
}
|
package/dist/tracklist.d.cts
CHANGED
|
@@ -25,6 +25,8 @@ interface TracklistEntry {
|
|
|
25
25
|
parts: EntryParts;
|
|
26
26
|
time: TracklistTime | null;
|
|
27
27
|
track: ParsedTrack | null;
|
|
28
|
+
/** R13.13: the line starts with `w/`: played over the previous track. */
|
|
29
|
+
layered: boolean;
|
|
28
30
|
}
|
|
29
31
|
type TracklistWarningCode = "time-missing" | "time-out-of-order" | "time-duplicate" | "no-start-at-zero" | "time-after-audio-end" | "invalid-time" | "position-gap" | "position-duplicate" | "line-unparsed";
|
|
30
32
|
interface TracklistWarning {
|
package/dist/tracklist.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ interface TracklistEntry {
|
|
|
25
25
|
parts: EntryParts;
|
|
26
26
|
time: TracklistTime | null;
|
|
27
27
|
track: ParsedTrack | null;
|
|
28
|
+
/** R13.13: the line starts with `w/`: played over the previous track. */
|
|
29
|
+
layered: boolean;
|
|
28
30
|
}
|
|
29
31
|
type TracklistWarningCode = "time-missing" | "time-out-of-order" | "time-duplicate" | "no-start-at-zero" | "time-after-audio-end" | "invalid-time" | "position-gap" | "position-duplicate" | "line-unparsed";
|
|
30
32
|
interface TracklistWarning {
|
package/dist/tracklist.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { headerWordsData, createListParser, SPEC_VERSION, stringList, PhraseTable, markerWordsData,
|
|
1
|
+
import { headerWordsData, layeredWordsData, createListParser, SPEC_VERSION, stringList, PhraseTable, markerWordsData, isWhitespace, isDigit, digitRun, inVocab, asciiLower, splitWords } from './chunk-OY536C3I.js';
|
|
2
2
|
|
|
3
3
|
// src/tracklist/split.ts
|
|
4
4
|
function splitLines(text) {
|
|
@@ -35,6 +35,45 @@ function hasLineBreak(s) {
|
|
|
35
35
|
return false;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// src/tracklist/emphasis.ts
|
|
39
|
+
function runLength(s, at) {
|
|
40
|
+
const ch = s[at];
|
|
41
|
+
let j = at;
|
|
42
|
+
if (ch === "*" || ch === "_") while (s[j] === ch) j++;
|
|
43
|
+
return j - at;
|
|
44
|
+
}
|
|
45
|
+
function emphasisRun(s, at) {
|
|
46
|
+
const n = runLength(s, at);
|
|
47
|
+
return n <= 2 ? n : 0;
|
|
48
|
+
}
|
|
49
|
+
var isOuter = (ch) => ch === void 0 || isWhitespace(ch) || "[]()*_".includes(ch);
|
|
50
|
+
var isInner = (ch) => ch !== void 0 && !isWhitespace(ch);
|
|
51
|
+
function withoutMarkers(raw, from, open) {
|
|
52
|
+
const marked = [];
|
|
53
|
+
const stacks = { "*": [], "**": [], _: [], __: [] };
|
|
54
|
+
for (let i = from; i < raw.length; ) {
|
|
55
|
+
const len = runLength(raw, i) || 1;
|
|
56
|
+
const own = stacks[raw.slice(i, i + len)];
|
|
57
|
+
const before = raw[i - 1];
|
|
58
|
+
const after = raw[i + len];
|
|
59
|
+
const at = own && isInner(before) && isOuter(after) ? own.pop() : void 0;
|
|
60
|
+
if (at !== void 0) {
|
|
61
|
+
for (let m = 0; m < len; m++) marked[at + m] = marked[i + m] = true;
|
|
62
|
+
for (const st of Object.values(stacks)) while ((st[st.length - 1] ?? -1) > at) st.pop();
|
|
63
|
+
} else if (own && isOuter(before) && isInner(after)) own.push(i);
|
|
64
|
+
i += len;
|
|
65
|
+
}
|
|
66
|
+
let end = raw.length;
|
|
67
|
+
while (end > from && isWhitespace(raw[end - 1])) end--;
|
|
68
|
+
let start = end;
|
|
69
|
+
while (start > from && raw[start - 1] === open) start--;
|
|
70
|
+
if (open && end - start <= 2 && start > from && raw[start - 1] !== open && isInner(raw[start - 1]))
|
|
71
|
+
for (let m = start; m < end && !marked[m]; m++) marked[m] = true;
|
|
72
|
+
let out = "";
|
|
73
|
+
for (let i = from; i < raw.length; i++) if (!marked[i]) out += raw[i];
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
38
77
|
// src/tracklist/times.ts
|
|
39
78
|
function isGapSeparator(ch) {
|
|
40
79
|
return ch === "-" || ch === "\u2013" || ch === "\u2014" || ch === "|" || ch === "." || ch === ":";
|
|
@@ -56,9 +95,11 @@ function untimed(raw, invalid) {
|
|
|
56
95
|
parts: { lead, time: "", gap: "", rest: raw.slice(lead.length) },
|
|
57
96
|
time: null,
|
|
58
97
|
style: null,
|
|
59
|
-
invalid
|
|
98
|
+
invalid,
|
|
99
|
+
text: withoutMarkers(raw, lead.length)
|
|
60
100
|
};
|
|
61
101
|
}
|
|
102
|
+
var closingRun = (raw, at, open) => open && raw[at] === open ? at + emphasisRun(raw, at) : at;
|
|
62
103
|
function secondsOf(whole, frac) {
|
|
63
104
|
return frac.length === 0 ? whole : Number(`${whole}.${frac}`);
|
|
64
105
|
}
|
|
@@ -91,8 +132,22 @@ function shapeSeconds(parts) {
|
|
|
91
132
|
}
|
|
92
133
|
function scanTime(raw) {
|
|
93
134
|
let i = skipWhitespace(raw, 0);
|
|
135
|
+
if (isBullet(raw[i]) && isWhitespace(raw[i + 1])) i = skipWhitespace(raw, i + 1);
|
|
136
|
+
let outerOpen;
|
|
137
|
+
let n = emphasisRun(raw, i);
|
|
138
|
+
if (n && (raw[i + n] === "[" || raw[i + n] === "(" || isDigit(raw[i + n]))) {
|
|
139
|
+
outerOpen = raw[i];
|
|
140
|
+
i += n;
|
|
141
|
+
}
|
|
94
142
|
const closer = raw[i] === "[" ? "]" : raw[i] === "(" ? ")" : null;
|
|
95
|
-
|
|
143
|
+
let innerOpen;
|
|
144
|
+
if (closer) {
|
|
145
|
+
n = emphasisRun(raw, ++i);
|
|
146
|
+
if (n && isDigit(raw[i + n])) {
|
|
147
|
+
innerOpen = raw[i];
|
|
148
|
+
i += n;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
96
151
|
const leadEnd = i;
|
|
97
152
|
const shape = shapeAt(raw, i);
|
|
98
153
|
if (!shape) return untimed(raw, false);
|
|
@@ -103,19 +158,30 @@ function scanTime(raw) {
|
|
|
103
158
|
let frac = "";
|
|
104
159
|
const sep = raw[j];
|
|
105
160
|
if (sep === "." || sep === ",") {
|
|
106
|
-
const
|
|
107
|
-
if (
|
|
161
|
+
const n2 = digitRun(raw, j + 1);
|
|
162
|
+
if (n2 >= 1 && n2 <= 3) {
|
|
108
163
|
fracSep = sep;
|
|
109
|
-
frac = raw.slice(j + 1, j + 1 +
|
|
110
|
-
j += 1 +
|
|
164
|
+
frac = raw.slice(j + 1, j + 1 + n2);
|
|
165
|
+
j += 1 + n2;
|
|
111
166
|
}
|
|
112
167
|
}
|
|
113
168
|
const timeEnd = j;
|
|
169
|
+
const followsTime = (at) => at >= raw.length || isWhitespace(raw[at]) || isGapSeparator(raw[at]);
|
|
170
|
+
let open;
|
|
114
171
|
if (closer) {
|
|
172
|
+
let k = closingRun(raw, j, innerOpen);
|
|
173
|
+
if (k === j) open = innerOpen;
|
|
174
|
+
j = k;
|
|
115
175
|
if (raw[j] !== closer) return untimed(raw, false);
|
|
116
176
|
j++;
|
|
117
|
-
|
|
118
|
-
|
|
177
|
+
k = closingRun(raw, j, outerOpen);
|
|
178
|
+
if (k > j && followsTime(k)) j = k;
|
|
179
|
+
else open = outerOpen ?? open;
|
|
180
|
+
} else {
|
|
181
|
+
const k = closingRun(raw, j, outerOpen);
|
|
182
|
+
if (k === j) open = outerOpen;
|
|
183
|
+
j = k;
|
|
184
|
+
if (!followsTime(j)) return untimed(raw, false);
|
|
119
185
|
}
|
|
120
186
|
j = skipWhitespace(raw, j);
|
|
121
187
|
if (isGapSeparator(raw[j])) j = skipWhitespace(raw, j + 1);
|
|
@@ -143,7 +209,8 @@ function scanTime(raw) {
|
|
|
143
209
|
fracSep,
|
|
144
210
|
fracDigits: frac.length
|
|
145
211
|
},
|
|
146
|
-
invalid: false
|
|
212
|
+
invalid: false,
|
|
213
|
+
text: withoutMarkers(raw, j, open)
|
|
147
214
|
};
|
|
148
215
|
}
|
|
149
216
|
var pad = (n, width) => String(n).padStart(width, "0");
|
|
@@ -438,6 +505,7 @@ function applyEdits(text, edits, options) {
|
|
|
438
505
|
|
|
439
506
|
// src/tracklist/kinds.ts
|
|
440
507
|
var headerWords = new Set(headerWordsData.words);
|
|
508
|
+
var layeredWords = new Set(layeredWordsData.words);
|
|
441
509
|
var defaultMarkers = null;
|
|
442
510
|
function markerTable(extra) {
|
|
443
511
|
const more = stringList(extra);
|
|
@@ -468,6 +536,15 @@ function markerWords(text) {
|
|
|
468
536
|
flush();
|
|
469
537
|
return out;
|
|
470
538
|
}
|
|
539
|
+
function layeredPrefix(text) {
|
|
540
|
+
let i = 0;
|
|
541
|
+
while (i < text.length && isWhitespace(text[i])) i++;
|
|
542
|
+
let j = i;
|
|
543
|
+
while (j < text.length && !isWhitespace(text[j])) j++;
|
|
544
|
+
if (j === i || j === text.length || !inVocab(text.slice(i, j), layeredWords)) return 0;
|
|
545
|
+
while (j < text.length && isWhitespace(text[j])) j++;
|
|
546
|
+
return j < text.length ? j : 0;
|
|
547
|
+
}
|
|
471
548
|
function isBlank(raw) {
|
|
472
549
|
for (let i = 0; i < raw.length; i++) if (!isWhitespace(raw[i])) return false;
|
|
473
550
|
return true;
|
|
@@ -691,7 +768,8 @@ function tracklistWarnings(input) {
|
|
|
691
768
|
if (e.time) timed.push(i);
|
|
692
769
|
});
|
|
693
770
|
entries.forEach((e, i) => {
|
|
694
|
-
if (e.kind === "track" && e.time === null && timed.length > 0)
|
|
771
|
+
if (e.kind === "track" && e.time === null && !e.layered && timed.length > 0)
|
|
772
|
+
add(i, "time-missing");
|
|
695
773
|
if (invalid[i]) add(i, "invalid-time");
|
|
696
774
|
if (unparsed[i]) add(i, "line-unparsed");
|
|
697
775
|
});
|
|
@@ -732,9 +810,12 @@ function parseTracklist(text, options) {
|
|
|
732
810
|
const rows = lines.map((line) => {
|
|
733
811
|
const scan = scanTime(line.raw);
|
|
734
812
|
const blank = isBlank(line.raw);
|
|
813
|
+
const layered = blank ? 0 : layeredPrefix(scan.text);
|
|
735
814
|
const row = {
|
|
736
815
|
line,
|
|
737
816
|
scan,
|
|
817
|
+
text: scan.text.slice(layered),
|
|
818
|
+
layered: layered > 0,
|
|
738
819
|
blank,
|
|
739
820
|
candidate: false,
|
|
740
821
|
dash: false,
|
|
@@ -745,11 +826,11 @@ function parseTracklist(text, options) {
|
|
|
745
826
|
marker: false
|
|
746
827
|
};
|
|
747
828
|
if (blank) return row;
|
|
748
|
-
const probe = lp.probe(
|
|
829
|
+
const probe = lp.probe(row.text);
|
|
749
830
|
row.probe = probe;
|
|
750
831
|
row.dash = probe.spacedDash;
|
|
751
832
|
row.candidate = scan.time !== null || row.dash;
|
|
752
|
-
row.bare = bareCandidate(
|
|
833
|
+
row.bare = row.layered ? null : bareCandidate(row.text, probe.position);
|
|
753
834
|
return row;
|
|
754
835
|
});
|
|
755
836
|
let candidates = 0;
|
|
@@ -762,13 +843,13 @@ function parseTracklist(text, options) {
|
|
|
762
843
|
const listContext = candidates > 0 && 2 * dashes >= candidates;
|
|
763
844
|
const confirmed = confirmPositions(
|
|
764
845
|
rows.map((r) => r.bare),
|
|
765
|
-
rows.map((r) => r.candidate)
|
|
846
|
+
rows.map((r) => r.candidate && !r.layered)
|
|
766
847
|
);
|
|
767
848
|
rows.forEach((r, i) => {
|
|
768
849
|
if (r.blank || !r.probe) return;
|
|
769
850
|
const bare = confirmed[i] ? r.bare : null;
|
|
770
851
|
const probe = bare ? lp.probe(bare.stripped) : r.probe;
|
|
771
|
-
r.marker = hasMarkerWord(bare ? bare.stripped : r.
|
|
852
|
+
r.marker = hasMarkerWord(bare ? bare.stripped : r.text, markers);
|
|
772
853
|
const track = probe.finish(listContext && r.scan.time !== null && !r.marker);
|
|
773
854
|
if (bare) track.position = { raw: bare.raw, number: bare.number };
|
|
774
855
|
r.track = track;
|
|
@@ -784,7 +865,8 @@ function parseTracklist(text, options) {
|
|
|
784
865
|
raw: r.line.raw,
|
|
785
866
|
parts: r.scan.parts,
|
|
786
867
|
time: r.scan.time,
|
|
787
|
-
track: keep ? r.track : null
|
|
868
|
+
track: keep ? r.track : null,
|
|
869
|
+
layered: r.layered
|
|
788
870
|
};
|
|
789
871
|
});
|
|
790
872
|
const warnings = tracklistWarnings({
|
|
@@ -800,9 +882,8 @@ function kindOf(r) {
|
|
|
800
882
|
if (r.blank || !r.track) return "blank";
|
|
801
883
|
const marker = !r.separated && r.marker;
|
|
802
884
|
if (r.scan.time) return marker ? "marker" : "track";
|
|
803
|
-
|
|
804
|
-
if (
|
|
805
|
-
if (isHeader(rest)) return "header";
|
|
885
|
+
if (isLink(r.text)) return "link";
|
|
886
|
+
if (isHeader(r.text)) return "header";
|
|
806
887
|
if (marker) return "marker";
|
|
807
888
|
return r.separated ? "track" : "note";
|
|
808
889
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trackparse",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Spec-driven parser for music track strings: artists, feat credits, remixes/versions, junk and more from single track strings (store titles, YouTube titles, filenames) and multi-line tracklists.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Maksim Maksimov",
|