funoteka 0.1.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/.env.example +90 -0
- package/CHANGELOG.md +104 -0
- package/DEPLOY.md +375 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/deploy/answers.schema.json +151 -0
- package/deploy/build.mjs +88 -0
- package/deploy/check-publishable.mjs +246 -0
- package/deploy/smoke.mjs +681 -0
- package/deploy/systemd/funoteka.env.example +41 -0
- package/deploy/systemd/funoteka.service +77 -0
- package/deploy/windows/funoteka-service.xml +58 -0
- package/deploy/windows/install-service.ps1 +158 -0
- package/dist/annotation/store.js +67 -0
- package/dist/api/admin-export.js +227 -0
- package/dist/api/admin-guard.js +181 -0
- package/dist/api/admin-library.js +113 -0
- package/dist/api/admin.js +1162 -0
- package/dist/api/annotation.js +193 -0
- package/dist/api/artistinfo.js +140 -0
- package/dist/api/audit.js +32 -0
- package/dist/api/auth.js +145 -0
- package/dist/api/bookmark.js +108 -0
- package/dist/api/browse.js +1241 -0
- package/dist/api/config-file.js +202 -0
- package/dist/api/config.js +285 -0
- package/dist/api/cover.js +304 -0
- package/dist/api/download.js +112 -0
- package/dist/api/envelope.js +162 -0
- package/dist/api/extensions.js +43 -0
- package/dist/api/genre.js +81 -0
- package/dist/api/health.js +35 -0
- package/dist/api/history.js +374 -0
- package/dist/api/idempotency.js +66 -0
- package/dist/api/keys.js +90 -0
- package/dist/api/log-file.js +65 -0
- package/dist/api/meta.js +1410 -0
- package/dist/api/playlist.js +311 -0
- package/dist/api/rescan.js +196 -0
- package/dist/api/roots.js +161 -0
- package/dist/api/router.js +133 -0
- package/dist/api/scanner.js +163 -0
- package/dist/api/search.js +82 -0
- package/dist/api/server.js +643 -0
- package/dist/api/settings.js +67 -0
- package/dist/api/stream.js +442 -0
- package/dist/api/stubs.js +183 -0
- package/dist/api/transcode.js +678 -0
- package/dist/api/user.js +73 -0
- package/dist/api/virtual.js +362 -0
- package/dist/api/visibility.js +21 -0
- package/dist/artist/apply.js +752 -0
- package/dist/artist/credit.js +95 -0
- package/dist/artist/folder.js +65 -0
- package/dist/artist/name.js +205 -0
- package/dist/artist/nfo.js +101 -0
- package/dist/artist/translit.js +79 -0
- package/dist/bookmark/store.js +54 -0
- package/dist/classify/classify.js +405 -0
- package/dist/classify/collision-name.js +109 -0
- package/dist/classify/folder-name.js +466 -0
- package/dist/classify/roles.js +277 -0
- package/dist/classify/shelf-name.js +178 -0
- package/dist/classify/tree.js +43 -0
- package/dist/cli/args.js +169 -0
- package/dist/cli/daemon.js +192 -0
- package/dist/cli/entry.js +25 -0
- package/dist/cli/keys.js +52 -0
- package/dist/cli.js +638 -0
- package/dist/cover/pick.js +58 -0
- package/dist/cover/picture.js +111 -0
- package/dist/cue/engine.js +759 -0
- package/dist/cue/match.js +142 -0
- package/dist/cue/parse.js +182 -0
- package/dist/cue/plan.js +286 -0
- package/dist/cue/track-name.js +120 -0
- package/dist/db/index.js +163 -0
- package/dist/db/issue.js +73 -0
- package/dist/db/ledger.js +84 -0
- package/dist/db/migrations/001_init.sql +196 -0
- package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
- package/dist/db/migrations/003_cue_metadata.sql +14 -0
- package/dist/db/migrations/004_cue_performer.sql +12 -0
- package/dist/db/migrations/005_incremental.sql +23 -0
- package/dist/db/migrations/006_tags.sql +57 -0
- package/dist/db/migrations/007_artist_credit.sql +35 -0
- package/dist/db/migrations/008_issue_stage.sql +39 -0
- package/dist/db/migrations/009_cue_title.sql +20 -0
- package/dist/db/migrations/010_duration_source.sql +31 -0
- package/dist/db/migrations/011_track_title_source.sql +28 -0
- package/dist/db/migrations/012_file_container.sql +25 -0
- package/dist/db/migrations/013_cover_art.sql +28 -0
- package/dist/db/migrations/014_album_year.sql +17 -0
- package/dist/db/migrations/015_probe_method.sql +14 -0
- package/dist/db/migrations/016_tags_method.sql +20 -0
- package/dist/db/migrations/017_cover_indirect.sql +23 -0
- package/dist/db/migrations/018_release_year.sql +14 -0
- package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
- package/dist/db/migrations/020_analyze_after_index.sql +16 -0
- package/dist/db/migrations/021_cue_index_files.sql +15 -0
- package/dist/db/migrations/022_sidecar_text.sql +19 -0
- package/dist/db/migrations/023_track_credit.sql +23 -0
- package/dist/db/migrations/024_playlists.sql +63 -0
- package/dist/db/migrations/025_playlist_sequence.sql +27 -0
- package/dist/db/migrations/026_playlist_source_file.sql +19 -0
- package/dist/db/migrations/027_lookup_indexes.sql +30 -0
- package/dist/db/migrations/028_annotations.sql +47 -0
- package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
- package/dist/db/migrations/030_history.sql +108 -0
- package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
- package/dist/db/migrations/032_bookmarks.sql +27 -0
- package/dist/db/migrations/033_bookmark_order.sql +14 -0
- package/dist/db/migrations/034_junk.sql +33 -0
- package/dist/db/migrations/035_api_key.sql +38 -0
- package/dist/db/migrations/036_file_tag_first.sql +88 -0
- package/dist/db/migrations/037_admin_idempotency.sql +31 -0
- package/dist/db/migrations/038_cue_audio_index.sql +24 -0
- package/dist/db/sweep.js +109 -0
- package/dist/history/store.js +151 -0
- package/dist/inventory/inventory.js +294 -0
- package/dist/junk/marks.js +143 -0
- package/dist/junk/rule.js +93 -0
- package/dist/mcp/client.js +48 -0
- package/dist/mcp/server.js +175 -0
- package/dist/mcp/tools.js +348 -0
- package/dist/playlist/files.js +116 -0
- package/dist/playlist/import.js +260 -0
- package/dist/playlist/store.js +240 -0
- package/dist/probe/ffprobe.js +179 -0
- package/dist/run.js +120 -0
- package/dist/scan/kinds.js +49 -0
- package/dist/scan/scan.js +503 -0
- package/dist/scan/schedule.js +132 -0
- package/dist/scan/settle.js +172 -0
- package/dist/scan/walk.js +132 -0
- package/dist/search/index.js +41 -0
- package/dist/search/query.js +28 -0
- package/dist/stream/flac.js +541 -0
- package/dist/stream/mpeg.js +179 -0
- package/dist/stream/recode.js +393 -0
- package/dist/stream/rewrite.js +163 -0
- package/dist/stream/segment.js +624 -0
- package/dist/tags/apply.js +703 -0
- package/dist/tags/encode.js +164 -0
- package/dist/tags/first.js +130 -0
- package/dist/tags/flac.js +196 -0
- package/dist/tags/genres.js +123 -0
- package/dist/tags/id3v1.js +209 -0
- package/dist/tags/id3v2.js +754 -0
- package/dist/tags/mp4.js +534 -0
- package/dist/tags/mpeg.js +355 -0
- package/dist/tags/ogg.js +463 -0
- package/dist/tags/picture.js +71 -0
- package/dist/tags/read.js +269 -0
- package/dist/tags/types.js +51 -0
- package/dist/tags/vorbis-comment.js +155 -0
- package/dist/text/encoding.js +384 -0
- package/dist/text/site-name.js +37 -0
- package/dist/util/child.js +21 -0
- package/dist/util/names.js +97 -0
- package/funoteka.json.example +27 -0
- package/package.json +55 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { basenameOf, folderOf, stemOf } from "../util/names.js";
|
|
2
|
+
/**
|
|
3
|
+
* A cue's `FILE` reference, resolved to a root-relative path.
|
|
4
|
+
*
|
|
5
|
+
* References are written relative to the cue's own folder, and rips do use
|
|
6
|
+
* `..` — a cue sitting above the audio it describes is a real layout. Without
|
|
7
|
+
* resolving, a `FILE "../x.flac"` could only ever match a same-named file
|
|
8
|
+
* beside the cue, which is the one place the file is not.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveRef(cueFolder, ref) {
|
|
11
|
+
const parts = cueFolder === '' ? [] : cueFolder.split('/');
|
|
12
|
+
for (const segment of ref.replace(/\\/g, '/').split('/')) {
|
|
13
|
+
if (segment === '' || segment === '.')
|
|
14
|
+
continue;
|
|
15
|
+
if (segment === '..') {
|
|
16
|
+
parts.pop();
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
parts.push(segment);
|
|
20
|
+
}
|
|
21
|
+
return parts.join('/');
|
|
22
|
+
}
|
|
23
|
+
/** Higher wins. A name is better evidence than a stem. */
|
|
24
|
+
const SCORE = { 'exact-name': 3, stem: 2 };
|
|
25
|
+
/**
|
|
26
|
+
* Every landing a cue's own `FILE` tags make on the files the folder actually
|
|
27
|
+
* holds, in reference order.
|
|
28
|
+
*
|
|
29
|
+
* The one loop both questions below are put through. Written once because
|
|
30
|
+
* "which file does this reference name" must not answer differently depending
|
|
31
|
+
* on who is asking: the matcher wants the single best landing, and the plan
|
|
32
|
+
* wants to know whether the cue named the folder whole or only a part of it.
|
|
33
|
+
*/
|
|
34
|
+
function refMatches(cue, audioFiles) {
|
|
35
|
+
const cueFolder = folderOf(cue.relPath);
|
|
36
|
+
const found = [];
|
|
37
|
+
for (const ref of cue.doc.files) {
|
|
38
|
+
const resolved = resolveRef(cueFolder, ref.name).toLowerCase();
|
|
39
|
+
const resolvedFolder = folderOf(resolved);
|
|
40
|
+
const resolvedStem = stemOf(basenameOf(resolved));
|
|
41
|
+
for (const audio of audioFiles) {
|
|
42
|
+
const path = audio.relPath.toLowerCase();
|
|
43
|
+
let how = null;
|
|
44
|
+
if (path === resolved) {
|
|
45
|
+
how = 'exact-name';
|
|
46
|
+
}
|
|
47
|
+
else if (folderOf(path) === resolvedFolder && stemOf(basenameOf(path)) === resolvedStem) {
|
|
48
|
+
// Same folder, same stem, different extension. A cue declaring `.wav`
|
|
49
|
+
// for a rip that is really `.flac` lands here — and only here, since
|
|
50
|
+
// the folder has to agree, so it cannot reach across albums.
|
|
51
|
+
how = 'stem';
|
|
52
|
+
}
|
|
53
|
+
if (how === null)
|
|
54
|
+
continue;
|
|
55
|
+
found.push({ audio, how });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return found;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The audio file a cue's own `FILE` tags name, resolved against the files the
|
|
62
|
+
* folder actually holds — or null when they name nothing that is there.
|
|
63
|
+
*
|
|
64
|
+
* This is `chooseCue`'s per-cue half, split out because a caller has a second
|
|
65
|
+
* question to put to it: a cue the matcher did *not* pick still has to be told
|
|
66
|
+
* apart from a cue that describes nothing at all, and only that cue's own
|
|
67
|
+
* references can answer which one it is. Asking the album's match instead
|
|
68
|
+
* answers about the wrong cue.
|
|
69
|
+
*/
|
|
70
|
+
export function audioNamedBy(cue, audioFiles) {
|
|
71
|
+
let best = null;
|
|
72
|
+
for (const match of refMatches(cue, audioFiles)) {
|
|
73
|
+
if (best === null || SCORE[match.how] > SCORE[best.how])
|
|
74
|
+
best = match;
|
|
75
|
+
}
|
|
76
|
+
return best;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The audio files a cue's own `FILE` tags name — the plural of the question
|
|
80
|
+
* `audioNamedBy` answers.
|
|
81
|
+
*
|
|
82
|
+
* The plan has to put this one, because "does this cue describe these files" is
|
|
83
|
+
* a question about the whole folder and not about the one file a match settles
|
|
84
|
+
* on. Counting a cue's TRACK tags against a folder's audio files looks like the
|
|
85
|
+
* same test and is not: a cue naming one file under two TRACK tags has the same
|
|
86
|
+
* count as a folder holding two files, so the second file was handed a title
|
|
87
|
+
* the cue never wrote for it (task:2723). Only the references can answer it.
|
|
88
|
+
*
|
|
89
|
+
* Answers in the folder's own order, and names each file once however many
|
|
90
|
+
* references reach it — a cue that repeats `FILE` per track is ordinary.
|
|
91
|
+
*/
|
|
92
|
+
export function audioNamedByCue(cue, audioFiles) {
|
|
93
|
+
const named = new Set();
|
|
94
|
+
for (const match of refMatches(cue, audioFiles))
|
|
95
|
+
named.add(match.audio);
|
|
96
|
+
return audioFiles.filter((audio) => named.has(audio));
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Decide which cue in a folder describes which audio file.
|
|
100
|
+
*
|
|
101
|
+
* The cue's `FILE` tag is treated as a hint, never as truth — real rips declare
|
|
102
|
+
* `WAVE` for an `.m4a` and `.wav` for a `.flac`. Resolution runs against the
|
|
103
|
+
* audio files that are actually present, in descending order of evidence:
|
|
104
|
+
*
|
|
105
|
+
* 1. the basename matches exactly (ignoring case)
|
|
106
|
+
* 2. the basename matches once extensions are dropped — this is what catches
|
|
107
|
+
* the `.wav`-declared `.flac`
|
|
108
|
+
*
|
|
109
|
+
* Those two are the whole of it: a cue whose `FILE` tags land on none of the
|
|
110
|
+
* folder's audio is answered `null`, because it does not describe these files.
|
|
111
|
+
*
|
|
112
|
+
* A third rule used to stand below them — nothing matched, but the folder held
|
|
113
|
+
* exactly one audio file and at least one cue, so take the two as belonging
|
|
114
|
+
* together — and it was a guess wearing the clothes of a reading. Its cost was
|
|
115
|
+
* total: a stale cue for a rip that is not here ([[task:2712]]) sat above a
|
|
116
|
+
* one-file, fully tagged album, cut that file into the stranger's three
|
|
117
|
+
* segments, gave them the stranger's three titles and wrote them under
|
|
118
|
+
* `title_source = 'cue'` — the value `011_track_title_source.sql` reserves for
|
|
119
|
+
* a cue that *describes* these files. The album's real track was gone and
|
|
120
|
+
* nothing was reported. Which cue describes which audio is the question this
|
|
121
|
+
* module exists to answer; "neither does, so probably this one" is not an
|
|
122
|
+
* answer it can give, and being wrong costs an album its tracks. A cue that
|
|
123
|
+
* names audio the folder no longer holds is reported instead — see the loser
|
|
124
|
+
* loop in `cue/engine.ts`.
|
|
125
|
+
*
|
|
126
|
+
* A folder with several cues is normal (rippers leave a stale one behind); the
|
|
127
|
+
* best-scoring cue wins, and ties fall to the first path so repeated scans
|
|
128
|
+
* agree.
|
|
129
|
+
*/
|
|
130
|
+
export function chooseCue(cues, audioFiles) {
|
|
131
|
+
const ordered = [...cues].sort((a, b) => (a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0));
|
|
132
|
+
let best = null;
|
|
133
|
+
for (const cue of ordered) {
|
|
134
|
+
const named = audioNamedBy(cue, audioFiles);
|
|
135
|
+
if (named === null)
|
|
136
|
+
continue;
|
|
137
|
+
if (best === null || SCORE[named.how] > SCORE[best.how]) {
|
|
138
|
+
best = { cue, audio: named.audio, how: named.how };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return best;
|
|
142
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A cue sheet, reported exactly as written.
|
|
3
|
+
*
|
|
4
|
+
* The parser deliberately makes no judgement calls. It does not decide that a
|
|
5
|
+
* `FILE ... WAVE` line is really an `.m4a`, does not fall back to the album
|
|
6
|
+
* PERFORMER for a track that lacks one, and does not tidy a missing INDEX 01.
|
|
7
|
+
* Those are decisions with consequences, and they belong to the matcher, which
|
|
8
|
+
* can record what it assumed. Here, what the file says is what comes out.
|
|
9
|
+
*/
|
|
10
|
+
/** 75 CD frames to the second — the unit INDEX timestamps are counted in. */
|
|
11
|
+
const FRAMES_PER_SECOND = 75;
|
|
12
|
+
/**
|
|
13
|
+
* `mm:ss:ff` to milliseconds, or null when the value is not a cue time at all.
|
|
14
|
+
*
|
|
15
|
+
* Frames are optional because some rippers write plain `mm:ss`. Seconds and
|
|
16
|
+
* frames are range-checked so a malformed timestamp is reported rather than
|
|
17
|
+
* quietly turned into a plausible-looking offset.
|
|
18
|
+
*/
|
|
19
|
+
export function parseCueTime(value) {
|
|
20
|
+
const match = /^(\d{1,3}):(\d{2})(?::(\d{2}))?$/.exec(value.trim());
|
|
21
|
+
if (!match)
|
|
22
|
+
return null;
|
|
23
|
+
const minutes = Number(match[1]);
|
|
24
|
+
const seconds = Number(match[2]);
|
|
25
|
+
const frames = match[3] === undefined ? 0 : Number(match[3]);
|
|
26
|
+
if (seconds > 59 || frames >= FRAMES_PER_SECOND)
|
|
27
|
+
return null;
|
|
28
|
+
return minutes * 60_000 + seconds * 1000 + Math.round((frames * 1000) / FRAMES_PER_SECOND);
|
|
29
|
+
}
|
|
30
|
+
const FILE_LINE = /^FILE\s+(?:"([^"]*)"|(\S+))(?:\s+(\S+))?\s*$/i;
|
|
31
|
+
const TRACK_LINE = /^TRACK\s+(\d+)\s+(\S+)/i;
|
|
32
|
+
const INDEX_LINE = /^INDEX\s+(\d{1,2})\s+(\S+)/i;
|
|
33
|
+
const REM_LINE = /^REM\s+(\S+)\s*(.*)$/i;
|
|
34
|
+
const FIELD_LINE = /^(TITLE|PERFORMER)\s+(.*)$/i;
|
|
35
|
+
/**
|
|
36
|
+
* Commands a cue sheet carries that this reader reads nothing out of.
|
|
37
|
+
*
|
|
38
|
+
* Dropping one of these is a decision, not a failure to understand: the pregap
|
|
39
|
+
* is where the split stage already gets its answer from `INDEX 00`, the flags
|
|
40
|
+
* and the ISRC describe the disc, and the songwriter is a credit the artist
|
|
41
|
+
* stage does not take from a cue. Counting them as unrecognised would make the
|
|
42
|
+
* counter fire on most cues in the library and say nothing by doing so.
|
|
43
|
+
*
|
|
44
|
+
* `CATALOG` is deliberately **not** here, though it reads like the others. It
|
|
45
|
+
* was, until the counter was pointed at the library and the vocabulary of all
|
|
46
|
+
* 263 cues was counted: 21 of them state a catalogue number as a bare `CATALOG`
|
|
47
|
+
* line, and not one of those 21 writes it as `REM CATALOG` as well. The column
|
|
48
|
+
* that exists for it is filled from the `REM` form alone, so those 21 numbers —
|
|
49
|
+
* real EANs, `4988015085082`, `0602475036746` — reached no column and no report.
|
|
50
|
+
* A command whose value goes nowhere was never "understood and not kept"; it
|
|
51
|
+
* was the same silence this counter is here to end (task:2756, finding 2).
|
|
52
|
+
*/
|
|
53
|
+
const IGNORED_LINE = /^(?:CDTEXTFILE|FLAGS|ISRC|POSTGAP|PREGAP|SONGWRITER)\b/i;
|
|
54
|
+
/**
|
|
55
|
+
* The media catalogue number, which a cue states under its own command.
|
|
56
|
+
*
|
|
57
|
+
* Kept in the same map the `REM` form lands in, so the caller reads one key —
|
|
58
|
+
* `cue.catalog` is filled from `doc.rem['CATALOG']` and does not have to learn
|
|
59
|
+
* about a second spelling. When a cue writes both, the *command* is the one
|
|
60
|
+
* that stands: `REM` is a comment convention and `CATALOG` is the format's own
|
|
61
|
+
* field for the number, so the reading is settled by position in the grammar
|
|
62
|
+
* rather than by line order. No cue in the collection writes both; the rule is
|
|
63
|
+
* here so that the first one that does is read the same way twice.
|
|
64
|
+
*/
|
|
65
|
+
const CATALOG_LINE = /^CATALOG\s+(.*)$/i;
|
|
66
|
+
/** The commands this parser does read, for telling a broken line from a foreign one. */
|
|
67
|
+
const READ_COMMAND = /^(?:FILE|TRACK|INDEX|REM|TITLE|PERFORMER)\b/i;
|
|
68
|
+
function unquote(value) {
|
|
69
|
+
const trimmed = value.trim();
|
|
70
|
+
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
71
|
+
return trimmed.slice(1, -1);
|
|
72
|
+
}
|
|
73
|
+
return trimmed;
|
|
74
|
+
}
|
|
75
|
+
export function parseCue(text) {
|
|
76
|
+
const doc = {
|
|
77
|
+
title: null,
|
|
78
|
+
performer: null,
|
|
79
|
+
rem: {},
|
|
80
|
+
files: [],
|
|
81
|
+
tracks: [],
|
|
82
|
+
unrecognized: [],
|
|
83
|
+
};
|
|
84
|
+
let current = null;
|
|
85
|
+
// Held apart from `rem` until the end so that the command outranks the
|
|
86
|
+
// comment however the two are ordered in the file — see `CATALOG_LINE`.
|
|
87
|
+
let statedCatalog = null;
|
|
88
|
+
// A BOM would otherwise attach itself to the first key and silently lose it.
|
|
89
|
+
const lines = text.replace(/^/, '').split(/\r?\n/);
|
|
90
|
+
for (let lineNo = 0; lineNo < lines.length; lineNo += 1) {
|
|
91
|
+
const line = (lines[lineNo] ?? '').trim();
|
|
92
|
+
if (line === '')
|
|
93
|
+
continue;
|
|
94
|
+
const file = FILE_LINE.exec(line);
|
|
95
|
+
if (file) {
|
|
96
|
+
doc.files.push({ name: file[1] ?? file[2] ?? '', type: file[3] ?? null });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const track = TRACK_LINE.exec(line);
|
|
100
|
+
if (track) {
|
|
101
|
+
current = {
|
|
102
|
+
ordinal: Number(track[1]),
|
|
103
|
+
type: (track[2] ?? 'AUDIO').toUpperCase(),
|
|
104
|
+
title: null,
|
|
105
|
+
performer: null,
|
|
106
|
+
index00Ms: null,
|
|
107
|
+
index01Ms: null,
|
|
108
|
+
index00FileIndex: null,
|
|
109
|
+
index01FileIndex: null,
|
|
110
|
+
fileIndex: Math.max(0, doc.files.length - 1),
|
|
111
|
+
};
|
|
112
|
+
doc.tracks.push(current);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const index = INDEX_LINE.exec(line);
|
|
116
|
+
if (index && current) {
|
|
117
|
+
const ms = parseCueTime(index[2] ?? '');
|
|
118
|
+
if (ms !== null) {
|
|
119
|
+
// The file in force at *this* line, which is what makes the time mean
|
|
120
|
+
// something: a cue writes a track's pregap at the end of the file before
|
|
121
|
+
// the one the track opens, so the two marks can be in different files
|
|
122
|
+
// and their times are then counted from different starts.
|
|
123
|
+
const at = Math.max(0, doc.files.length - 1);
|
|
124
|
+
if (index[1] === '00') {
|
|
125
|
+
current.index00Ms = ms;
|
|
126
|
+
current.index00FileIndex = at;
|
|
127
|
+
}
|
|
128
|
+
else if (index[1] === '01') {
|
|
129
|
+
current.index01Ms = ms;
|
|
130
|
+
current.index01FileIndex = at;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const rem = REM_LINE.exec(line);
|
|
136
|
+
if (rem) {
|
|
137
|
+
const key = (rem[1] ?? '').toUpperCase();
|
|
138
|
+
if (key !== '')
|
|
139
|
+
doc.rem[key] = unquote(rem[2] ?? '');
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const catalog = CATALOG_LINE.exec(line);
|
|
143
|
+
if (catalog) {
|
|
144
|
+
const value = unquote(catalog[1] ?? '');
|
|
145
|
+
if (value !== '')
|
|
146
|
+
statedCatalog = value;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const field = FIELD_LINE.exec(line);
|
|
150
|
+
if (field) {
|
|
151
|
+
const key = (field[1] ?? '').toUpperCase();
|
|
152
|
+
const value = unquote(field[2] ?? '');
|
|
153
|
+
// Album-level fields precede the first TRACK; per-track fields follow it.
|
|
154
|
+
if (current === null) {
|
|
155
|
+
if (key === 'TITLE')
|
|
156
|
+
doc.title = value;
|
|
157
|
+
else
|
|
158
|
+
doc.performer = value;
|
|
159
|
+
}
|
|
160
|
+
else if (key === 'TITLE') {
|
|
161
|
+
current.title = value;
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
current.performer = value;
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
// Understood and deliberately not kept — see `IGNORED_LINE`.
|
|
169
|
+
if (IGNORED_LINE.test(line))
|
|
170
|
+
continue;
|
|
171
|
+
// Nothing claimed it. The parser's contract is to report what the file says,
|
|
172
|
+
// and this is a thing it says that nothing here read.
|
|
173
|
+
doc.unrecognized.push({
|
|
174
|
+
line: lineNo + 1,
|
|
175
|
+
text: line,
|
|
176
|
+
malformed: READ_COMMAND.test(line),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
if (statedCatalog !== null)
|
|
180
|
+
doc.rem['CATALOG'] = statedCatalog;
|
|
181
|
+
return doc;
|
|
182
|
+
}
|
package/dist/cue/plan.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { compareNatural, folderOf } from "../util/names.js";
|
|
2
|
+
import { isSiteName } from "../text/site-name.js";
|
|
3
|
+
import { audioNamedByCue } from "./match.js";
|
|
4
|
+
import { titleFromFileName } from "./track-name.js";
|
|
5
|
+
// Digit-aware, so `2 - x.flac` precedes `10 - y.flac`. Lexicographic order put
|
|
6
|
+
// the tenth track second and handed its name to the wrong file.
|
|
7
|
+
function byName(a, b) {
|
|
8
|
+
return compareNatural(a.name, b.name);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Make ordinals unique within an album without discarding the cue's numbering.
|
|
12
|
+
*
|
|
13
|
+
* Cues repeat track numbers — a spanning cue restarts at TRACK 01 per FILE, and
|
|
14
|
+
* broken rips simply duplicate one. The album's track list is unique by
|
|
15
|
+
* ordinal, so a repeat would abort the insert and take the scan with it. The
|
|
16
|
+
* first claim on a number keeps it; later ones fall to the next free slot.
|
|
17
|
+
*/
|
|
18
|
+
function uniqueOrdinals(ordinals) {
|
|
19
|
+
const used = new Set();
|
|
20
|
+
return ordinals.map((ordinal, index) => {
|
|
21
|
+
if (!used.has(ordinal)) {
|
|
22
|
+
used.add(ordinal);
|
|
23
|
+
return ordinal;
|
|
24
|
+
}
|
|
25
|
+
let candidate = index + 1;
|
|
26
|
+
while (used.has(candidate))
|
|
27
|
+
candidate += 1;
|
|
28
|
+
used.add(candidate);
|
|
29
|
+
return candidate;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A ripper's marker standing where a track's name should be.
|
|
34
|
+
*
|
|
35
|
+
* EAC writes `TITLE "(empty)"` for a division of the disc that carries no name —
|
|
36
|
+
* Undertow's tracks 10..68, the silence before its hidden track. The division is
|
|
37
|
+
* real, and stays: it has an INDEX 01, so it is part of the disc's structure.
|
|
38
|
+
* The *string* is not a name, and a client showing `(empty)` is showing what the
|
|
39
|
+
* ripper wrote in place of one.
|
|
40
|
+
*
|
|
41
|
+
* Deliberately short, and matched whole. A missing marker leaves a word visible
|
|
42
|
+
* in the dump, which costs nothing; an invented one erases a real title —
|
|
43
|
+
* `Empty Spaces` is a song, and any containment rule kills it. `(data track)`
|
|
44
|
+
* cannot arrive from a cue (`planAlbum` reads AUDIO tracks only) but a tagged
|
|
45
|
+
* file can carry it, and the list is the vocabulary rather than one source.
|
|
46
|
+
*
|
|
47
|
+
* The marker becomes null rather than a friendlier stand-in like `[silence]`.
|
|
48
|
+
* Inventing one would have the layer assert a meaning the ripper never stated —
|
|
49
|
+
* `(empty)` does not say *why* the division has no name — and null already means
|
|
50
|
+
* "no name here", which is the thing that is true.
|
|
51
|
+
*
|
|
52
|
+
* Nothing is lost by dropping it. `cue_track` keeps the parsed value verbatim
|
|
53
|
+
* and a tag's own text stays in `file_tag`, so this is the projection deciding
|
|
54
|
+
* what may be *shown* as a name — the planner's business, not the parser's,
|
|
55
|
+
* whose rule is that what was written is what came out.
|
|
56
|
+
*
|
|
57
|
+
* Named for the ripper rather than for `placeholder`, which this codebase
|
|
58
|
+
* already uses for a different question entirely: whether an album's title
|
|
59
|
+
* source is weak enough to be overwritten (`applyAlbumTitle` in `cue/engine.ts`).
|
|
60
|
+
*/
|
|
61
|
+
const RIPPER_MARKER = /^\((?:empty|silence|untitled|data track)\)$/i;
|
|
62
|
+
function dropRipperMarker(raw) {
|
|
63
|
+
if (raw === null)
|
|
64
|
+
return null;
|
|
65
|
+
return RIPPER_MARKER.test(raw.trim()) ? null : raw;
|
|
66
|
+
}
|
|
67
|
+
export function planAlbum(audioFiles, cue, deps = {}) {
|
|
68
|
+
const issues = [];
|
|
69
|
+
/**
|
|
70
|
+
* Track titles a cue stated that were a rip's source rather than a name.
|
|
71
|
+
*
|
|
72
|
+
* Counted by `titleFor` and reported once, because a rip names every track of
|
|
73
|
+
* the disc after the same host and one finding per track would be the same
|
|
74
|
+
* sentence four times (task:2756).
|
|
75
|
+
*/
|
|
76
|
+
let declinedTrackTitles = 0;
|
|
77
|
+
/** One finding for the disc, not one per track — see `declinedTrackTitles`. */
|
|
78
|
+
const reportDeclinedTitles = () => {
|
|
79
|
+
if (declinedTrackTitles === 0)
|
|
80
|
+
return;
|
|
81
|
+
issues.push({
|
|
82
|
+
kind: 'track-title-cue-declined',
|
|
83
|
+
detail: `${declinedTrackTitles} track title(s) named the rip's source rather than the track — the file's own tag, and then the name it carries, was used instead`,
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
const audio = [...audioFiles].sort(byName);
|
|
87
|
+
const cueTracks = (cue?.tracks ?? []).filter((t) => t.type === 'AUDIO');
|
|
88
|
+
const albumPerformer = cue?.performer ?? null;
|
|
89
|
+
// The cue as the matcher sees it, so its references resolve the way the
|
|
90
|
+
// matcher resolved them. Only the directory of `relPath` is ever read —
|
|
91
|
+
// `resolveRef` splits the folder off and ignores the name — so the file name
|
|
92
|
+
// here carries nothing and is named for what it is.
|
|
93
|
+
const cueFolder = deps.cuePath != null ? folderOf(deps.cuePath) : (audio[0]?.folderRelPath ?? '');
|
|
94
|
+
const cueCandidate = cue === null ? null : { relPath: `${cueFolder}/cue.cue`, doc: cue };
|
|
95
|
+
/**
|
|
96
|
+
* The name the file's own bytes state — its tag, then its name.
|
|
97
|
+
*
|
|
98
|
+
* Sources two and three of the priority chain: a cue is a document about
|
|
99
|
+
* *this* album and speaks with authority, while the file only speaks for
|
|
100
|
+
* itself, so both are asked only where the cue has already declined to name a
|
|
101
|
+
* track — never as a tie-break, and never for an image, where one file covers
|
|
102
|
+
* every track and its TITLE describes the file rather than track 7.
|
|
103
|
+
*
|
|
104
|
+
* Between the two, the tag wins: it is a statement somebody made about the
|
|
105
|
+
* track, where the name is a hint the filesystem happens to carry. But a name
|
|
106
|
+
* beats an empty cell, which is the whole of the untagged, folder-organized
|
|
107
|
+
* part of a collection (`track-name.ts` holds that rule and its evidence).
|
|
108
|
+
*/
|
|
109
|
+
const titleOf = (file) => deps.titleOf?.(file) ?? null;
|
|
110
|
+
const nameOf = (file) => titleFromFileName(file.name, deps.albumName ?? null);
|
|
111
|
+
// How many names the ripper had marked rather than written. Counted here and
|
|
112
|
+
// reported below, because the projection is the only place that knows: the
|
|
113
|
+
// value it drops is not wrong, it is absent, and a dump showing `(untitled)`
|
|
114
|
+
// where the cue said `(empty)` has no way to say why without this.
|
|
115
|
+
let markers = 0;
|
|
116
|
+
const named = (raw) => {
|
|
117
|
+
const title = dropRipperMarker(raw);
|
|
118
|
+
if (title === null && raw !== null && raw.trim() !== '')
|
|
119
|
+
markers += 1;
|
|
120
|
+
return title;
|
|
121
|
+
};
|
|
122
|
+
/** Says how many names were markers, once the album's tracks are known. */
|
|
123
|
+
const reportMarkers = () => {
|
|
124
|
+
if (markers === 0)
|
|
125
|
+
return;
|
|
126
|
+
issues.push({
|
|
127
|
+
kind: 'ripper-marker-titles',
|
|
128
|
+
detail: `${markers} track(s) were marked rather than named by the ripper and are left unnamed`,
|
|
129
|
+
severity: 'info',
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* What one source says, and whether it can be shown as a name.
|
|
134
|
+
*
|
|
135
|
+
* A source that *spoke* has answered the question even when its answer is a
|
|
136
|
+
* ripper's marker: `(empty)` says this division of the disc has no name, and
|
|
137
|
+
* the title is then null — which is what "no name here" means. Null here is
|
|
138
|
+
* therefore two different things, and the caller below reads it as both.
|
|
139
|
+
*/
|
|
140
|
+
const claim = (raw, source) => {
|
|
141
|
+
if (raw === null || raw === undefined)
|
|
142
|
+
return null;
|
|
143
|
+
const title = named(raw);
|
|
144
|
+
return { title, titleSource: title === null ? null : source };
|
|
145
|
+
};
|
|
146
|
+
/** A track no source names at all — the shape `claim` does not produce. */
|
|
147
|
+
const UNNAMED = { title: null, titleSource: null };
|
|
148
|
+
/**
|
|
149
|
+
* The track's name, and which source stated it — the first source to speak.
|
|
150
|
+
*
|
|
151
|
+
* A marker ends the chain rather than falling through it: asking the tag, or
|
|
152
|
+
* the file name, after the ripper has said there is no name would dress the
|
|
153
|
+
* hole up rather than fill it.
|
|
154
|
+
*/
|
|
155
|
+
const titleFor = (file, fromCue) => {
|
|
156
|
+
// A cue that names where the rip came from has not named the track. That is
|
|
157
|
+
// the rule the record's own name already follows — `text/site-name.ts`, and
|
|
158
|
+
// the album path refuses it on both the cue and the tag — applied one level
|
|
159
|
+
// down, where it was missing: `TITLE "lossless-galaxy.ru"` named every track
|
|
160
|
+
// of the rip (task:2756, and task:2725 for the record).
|
|
161
|
+
//
|
|
162
|
+
// Skipped rather than refused outright, so the chain falls through to the
|
|
163
|
+
// tag and then to the file's own name, which is exactly what "no cue named
|
|
164
|
+
// this track" already means here. The refusal is counted, and reported once
|
|
165
|
+
// per plan below: four tracks of one rip carry the same host, and four
|
|
166
|
+
// findings would say the same thing four times.
|
|
167
|
+
if (fromCue !== null && fromCue !== undefined && isSiteName(fromCue)) {
|
|
168
|
+
declinedTrackTitles += 1;
|
|
169
|
+
fromCue = null;
|
|
170
|
+
}
|
|
171
|
+
// Asked one at a time, not collected first: a source below the one that
|
|
172
|
+
// answered is never consulted, and the tag reader is a database lookup that
|
|
173
|
+
// a cue-named track must not pay for.
|
|
174
|
+
const sources = [
|
|
175
|
+
[() => fromCue, 'cue'],
|
|
176
|
+
[() => titleOf(file), 'tag'],
|
|
177
|
+
[() => nameOf(file), 'name'],
|
|
178
|
+
];
|
|
179
|
+
for (const [ask, source] of sources) {
|
|
180
|
+
const claimed = claim(ask(), source);
|
|
181
|
+
if (claimed !== null)
|
|
182
|
+
return claimed;
|
|
183
|
+
}
|
|
184
|
+
return UNNAMED;
|
|
185
|
+
};
|
|
186
|
+
const untimed = cueTracks.filter((t) => t.index01Ms === null);
|
|
187
|
+
if (untimed.length > 0) {
|
|
188
|
+
issues.push({
|
|
189
|
+
kind: 'track-without-index',
|
|
190
|
+
detail: `${untimed.length} track(s) declare no INDEX 01 and were left out`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
const timed = cueTracks
|
|
194
|
+
.filter((t) => t.index01Ms !== null)
|
|
195
|
+
.sort((a, b) => a.index01Ms - b.index01Ms);
|
|
196
|
+
const image = audio[0];
|
|
197
|
+
// One file, several tracks: the cue describes an image, so the album lives
|
|
198
|
+
// inside it as time windows rather than as files.
|
|
199
|
+
if (image !== undefined && audio.length === 1 && timed.length > 1) {
|
|
200
|
+
const duration = deps.durationMs?.(image) ?? null;
|
|
201
|
+
const ordinals = uniqueOrdinals(timed.map((cueTrack) => cueTrack.ordinal));
|
|
202
|
+
const tracks = timed.map((cueTrack, index) => ({
|
|
203
|
+
ordinal: ordinals[index] ?? index + 1,
|
|
204
|
+
// The cue, and nothing else. Every segment plays from this one file, so
|
|
205
|
+
// the only title that describes a *track* here is the one the cue wrote
|
|
206
|
+
// next to it — see the note on priority above.
|
|
207
|
+
...(claim(cueTrack.title, 'cue') ?? UNNAMED),
|
|
208
|
+
performer: cueTrack.performer ?? albumPerformer,
|
|
209
|
+
file: image,
|
|
210
|
+
segmentStartMs: cueTrack.index01Ms,
|
|
211
|
+
segmentEndMs: timed[index + 1]?.index01Ms ?? duration,
|
|
212
|
+
}));
|
|
213
|
+
reportMarkers();
|
|
214
|
+
if (duration === null) {
|
|
215
|
+
issues.push({
|
|
216
|
+
kind: 'unbounded-last-segment',
|
|
217
|
+
detail: `${image.name}: duration unknown, the closing track has no end`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return { shape: 'image-cue', tracks, issues };
|
|
221
|
+
}
|
|
222
|
+
if (cueCandidate !== null && cueTracks.length > 0) {
|
|
223
|
+
// A cue earns the right to name these files only if it plausibly describes
|
|
224
|
+
// them. A folder can hold several discs ripped flat, each with its own cue;
|
|
225
|
+
// the folder is one album by identity, but no single cue covers it. Taking
|
|
226
|
+
// disc 1's titles anyway would put confidently wrong words on discs 2 and
|
|
227
|
+
// 3, which is worse than leaving them unnamed.
|
|
228
|
+
//
|
|
229
|
+
// "Describes them" is a reading of the cue's own `FILE` references, and a
|
|
230
|
+
// count is not a reading of anything. A cue naming one file under two TRACK
|
|
231
|
+
// tags has the same count as a folder holding two files, so counting handed
|
|
232
|
+
// the second file a title the cue never wrote for it, threw away the name
|
|
233
|
+
// the file carried, and recorded the guess under `title_source = 'cue'` —
|
|
234
|
+
// the value the schema reserves for a cue that *does* describe these files
|
|
235
|
+
// (task:2723). The same false knowledge as task:2712, by the other door.
|
|
236
|
+
const named = audioNamedByCue(cueCandidate, audio);
|
|
237
|
+
const countsAgree = cueTracks.length === audio.length;
|
|
238
|
+
const describesTheseFiles = countsAgree && named.length === audio.length;
|
|
239
|
+
if (!countsAgree) {
|
|
240
|
+
issues.push({
|
|
241
|
+
kind: 'track-count-mismatch',
|
|
242
|
+
detail: `cue declares ${cueTracks.length} tracks, folder holds ${audio.length} audio files — cue names were not applied`,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
else if (!describesTheseFiles) {
|
|
246
|
+
issues.push({
|
|
247
|
+
kind: 'cue-describes-other-files',
|
|
248
|
+
detail: `cue's FILE tags name ${named.length} of the folder's ${audio.length} audio files — cue names were not applied`,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
const cueOrdinals = describesTheseFiles
|
|
252
|
+
? uniqueOrdinals(cueTracks.map((track) => track.ordinal))
|
|
253
|
+
: [];
|
|
254
|
+
const tracks = audio.map((file, index) => {
|
|
255
|
+
const cueTrack = describesTheseFiles ? cueTracks[index] : undefined;
|
|
256
|
+
return {
|
|
257
|
+
ordinal: cueOrdinals[index] ?? index + 1,
|
|
258
|
+
// The cue names the track where it names it, and a TRACK with no
|
|
259
|
+
// TITLE is not a refusal to speak — it is silence, and the file's own
|
|
260
|
+
// tag, then its own name, is a better answer than an empty cell. When
|
|
261
|
+
// the cue does not describe these files at all, none of it is applied
|
|
262
|
+
// and the file is the only thing left.
|
|
263
|
+
...titleFor(file, cueTrack?.title),
|
|
264
|
+
performer: cueTrack?.performer ?? (describesTheseFiles ? albumPerformer : null),
|
|
265
|
+
file,
|
|
266
|
+
segmentStartMs: null,
|
|
267
|
+
segmentEndMs: null,
|
|
268
|
+
};
|
|
269
|
+
});
|
|
270
|
+
reportDeclinedTitles();
|
|
271
|
+
reportMarkers();
|
|
272
|
+
return { shape: 'tracks-cue', tracks, issues };
|
|
273
|
+
}
|
|
274
|
+
const tracks = audio.map((file, index) => ({
|
|
275
|
+
ordinal: index + 1,
|
|
276
|
+
// Nothing describes this folder, so the file is the only thing that can
|
|
277
|
+
// name itself — its tag first, then the name it carries.
|
|
278
|
+
...titleFor(file, null),
|
|
279
|
+
performer: albumPerformer,
|
|
280
|
+
file,
|
|
281
|
+
segmentStartMs: null,
|
|
282
|
+
segmentEndMs: null,
|
|
283
|
+
}));
|
|
284
|
+
reportMarkers();
|
|
285
|
+
return { shape: 'tracks-only', tracks, issues };
|
|
286
|
+
}
|