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.
Files changed (162) hide show
  1. package/.env.example +90 -0
  2. package/CHANGELOG.md +104 -0
  3. package/DEPLOY.md +375 -0
  4. package/LICENSE +21 -0
  5. package/README.md +138 -0
  6. package/deploy/answers.schema.json +151 -0
  7. package/deploy/build.mjs +88 -0
  8. package/deploy/check-publishable.mjs +246 -0
  9. package/deploy/smoke.mjs +681 -0
  10. package/deploy/systemd/funoteka.env.example +41 -0
  11. package/deploy/systemd/funoteka.service +77 -0
  12. package/deploy/windows/funoteka-service.xml +58 -0
  13. package/deploy/windows/install-service.ps1 +158 -0
  14. package/dist/annotation/store.js +67 -0
  15. package/dist/api/admin-export.js +227 -0
  16. package/dist/api/admin-guard.js +181 -0
  17. package/dist/api/admin-library.js +113 -0
  18. package/dist/api/admin.js +1162 -0
  19. package/dist/api/annotation.js +193 -0
  20. package/dist/api/artistinfo.js +140 -0
  21. package/dist/api/audit.js +32 -0
  22. package/dist/api/auth.js +145 -0
  23. package/dist/api/bookmark.js +108 -0
  24. package/dist/api/browse.js +1241 -0
  25. package/dist/api/config-file.js +202 -0
  26. package/dist/api/config.js +285 -0
  27. package/dist/api/cover.js +304 -0
  28. package/dist/api/download.js +112 -0
  29. package/dist/api/envelope.js +162 -0
  30. package/dist/api/extensions.js +43 -0
  31. package/dist/api/genre.js +81 -0
  32. package/dist/api/health.js +35 -0
  33. package/dist/api/history.js +374 -0
  34. package/dist/api/idempotency.js +66 -0
  35. package/dist/api/keys.js +90 -0
  36. package/dist/api/log-file.js +65 -0
  37. package/dist/api/meta.js +1410 -0
  38. package/dist/api/playlist.js +311 -0
  39. package/dist/api/rescan.js +196 -0
  40. package/dist/api/roots.js +161 -0
  41. package/dist/api/router.js +133 -0
  42. package/dist/api/scanner.js +163 -0
  43. package/dist/api/search.js +82 -0
  44. package/dist/api/server.js +643 -0
  45. package/dist/api/settings.js +67 -0
  46. package/dist/api/stream.js +442 -0
  47. package/dist/api/stubs.js +183 -0
  48. package/dist/api/transcode.js +678 -0
  49. package/dist/api/user.js +73 -0
  50. package/dist/api/virtual.js +362 -0
  51. package/dist/api/visibility.js +21 -0
  52. package/dist/artist/apply.js +752 -0
  53. package/dist/artist/credit.js +95 -0
  54. package/dist/artist/folder.js +65 -0
  55. package/dist/artist/name.js +205 -0
  56. package/dist/artist/nfo.js +101 -0
  57. package/dist/artist/translit.js +79 -0
  58. package/dist/bookmark/store.js +54 -0
  59. package/dist/classify/classify.js +405 -0
  60. package/dist/classify/collision-name.js +109 -0
  61. package/dist/classify/folder-name.js +466 -0
  62. package/dist/classify/roles.js +277 -0
  63. package/dist/classify/shelf-name.js +178 -0
  64. package/dist/classify/tree.js +43 -0
  65. package/dist/cli/args.js +169 -0
  66. package/dist/cli/daemon.js +192 -0
  67. package/dist/cli/entry.js +25 -0
  68. package/dist/cli/keys.js +52 -0
  69. package/dist/cli.js +638 -0
  70. package/dist/cover/pick.js +58 -0
  71. package/dist/cover/picture.js +111 -0
  72. package/dist/cue/engine.js +759 -0
  73. package/dist/cue/match.js +142 -0
  74. package/dist/cue/parse.js +182 -0
  75. package/dist/cue/plan.js +286 -0
  76. package/dist/cue/track-name.js +120 -0
  77. package/dist/db/index.js +163 -0
  78. package/dist/db/issue.js +73 -0
  79. package/dist/db/ledger.js +84 -0
  80. package/dist/db/migrations/001_init.sql +196 -0
  81. package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
  82. package/dist/db/migrations/003_cue_metadata.sql +14 -0
  83. package/dist/db/migrations/004_cue_performer.sql +12 -0
  84. package/dist/db/migrations/005_incremental.sql +23 -0
  85. package/dist/db/migrations/006_tags.sql +57 -0
  86. package/dist/db/migrations/007_artist_credit.sql +35 -0
  87. package/dist/db/migrations/008_issue_stage.sql +39 -0
  88. package/dist/db/migrations/009_cue_title.sql +20 -0
  89. package/dist/db/migrations/010_duration_source.sql +31 -0
  90. package/dist/db/migrations/011_track_title_source.sql +28 -0
  91. package/dist/db/migrations/012_file_container.sql +25 -0
  92. package/dist/db/migrations/013_cover_art.sql +28 -0
  93. package/dist/db/migrations/014_album_year.sql +17 -0
  94. package/dist/db/migrations/015_probe_method.sql +14 -0
  95. package/dist/db/migrations/016_tags_method.sql +20 -0
  96. package/dist/db/migrations/017_cover_indirect.sql +23 -0
  97. package/dist/db/migrations/018_release_year.sql +14 -0
  98. package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
  99. package/dist/db/migrations/020_analyze_after_index.sql +16 -0
  100. package/dist/db/migrations/021_cue_index_files.sql +15 -0
  101. package/dist/db/migrations/022_sidecar_text.sql +19 -0
  102. package/dist/db/migrations/023_track_credit.sql +23 -0
  103. package/dist/db/migrations/024_playlists.sql +63 -0
  104. package/dist/db/migrations/025_playlist_sequence.sql +27 -0
  105. package/dist/db/migrations/026_playlist_source_file.sql +19 -0
  106. package/dist/db/migrations/027_lookup_indexes.sql +30 -0
  107. package/dist/db/migrations/028_annotations.sql +47 -0
  108. package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
  109. package/dist/db/migrations/030_history.sql +108 -0
  110. package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
  111. package/dist/db/migrations/032_bookmarks.sql +27 -0
  112. package/dist/db/migrations/033_bookmark_order.sql +14 -0
  113. package/dist/db/migrations/034_junk.sql +33 -0
  114. package/dist/db/migrations/035_api_key.sql +38 -0
  115. package/dist/db/migrations/036_file_tag_first.sql +88 -0
  116. package/dist/db/migrations/037_admin_idempotency.sql +31 -0
  117. package/dist/db/migrations/038_cue_audio_index.sql +24 -0
  118. package/dist/db/sweep.js +109 -0
  119. package/dist/history/store.js +151 -0
  120. package/dist/inventory/inventory.js +294 -0
  121. package/dist/junk/marks.js +143 -0
  122. package/dist/junk/rule.js +93 -0
  123. package/dist/mcp/client.js +48 -0
  124. package/dist/mcp/server.js +175 -0
  125. package/dist/mcp/tools.js +348 -0
  126. package/dist/playlist/files.js +116 -0
  127. package/dist/playlist/import.js +260 -0
  128. package/dist/playlist/store.js +240 -0
  129. package/dist/probe/ffprobe.js +179 -0
  130. package/dist/run.js +120 -0
  131. package/dist/scan/kinds.js +49 -0
  132. package/dist/scan/scan.js +503 -0
  133. package/dist/scan/schedule.js +132 -0
  134. package/dist/scan/settle.js +172 -0
  135. package/dist/scan/walk.js +132 -0
  136. package/dist/search/index.js +41 -0
  137. package/dist/search/query.js +28 -0
  138. package/dist/stream/flac.js +541 -0
  139. package/dist/stream/mpeg.js +179 -0
  140. package/dist/stream/recode.js +393 -0
  141. package/dist/stream/rewrite.js +163 -0
  142. package/dist/stream/segment.js +624 -0
  143. package/dist/tags/apply.js +703 -0
  144. package/dist/tags/encode.js +164 -0
  145. package/dist/tags/first.js +130 -0
  146. package/dist/tags/flac.js +196 -0
  147. package/dist/tags/genres.js +123 -0
  148. package/dist/tags/id3v1.js +209 -0
  149. package/dist/tags/id3v2.js +754 -0
  150. package/dist/tags/mp4.js +534 -0
  151. package/dist/tags/mpeg.js +355 -0
  152. package/dist/tags/ogg.js +463 -0
  153. package/dist/tags/picture.js +71 -0
  154. package/dist/tags/read.js +269 -0
  155. package/dist/tags/types.js +51 -0
  156. package/dist/tags/vorbis-comment.js +155 -0
  157. package/dist/text/encoding.js +384 -0
  158. package/dist/text/site-name.js +37 -0
  159. package/dist/util/child.js +21 -0
  160. package/dist/util/names.js +97 -0
  161. package/funoteka.json.example +27 -0
  162. 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
+ }
@@ -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
+ }