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,151 @@
1
+ import { withTransaction } from "../db/index.js";
2
+ /**
3
+ * What the listener did: plays that happened, what is on now, and the queue they
4
+ * left behind.
5
+ *
6
+ * The third part of the listener's own layer — playlists are what they arranged,
7
+ * stars are what they thought — and the same rule holds: nothing on disk states
8
+ * any of it, so no scan writes here, and what a scan takes away goes through the
9
+ * cascade (`030_history.sql`).
10
+ *
11
+ * The five tables are the protocol's own split and not an accident of storage:
12
+ * a **play** is history and the row is the only record it happened; **now
13
+ * playing** is state, one row per player, replaced as the player moves; the
14
+ * **queue** is state too, but the listener's rather than a player's, which is
15
+ * why `getPlayQueue` takes no player at all; the queue's entries are their own
16
+ * table beside it; and **player** is the row that turns a client's own name into
17
+ * the integer the protocol asks for.
18
+ */
19
+ /**
20
+ * The row for a client, by the name it gave.
21
+ *
22
+ * The protocol requires a `playerId` — an integer — on every now-playing entry
23
+ * and no endpoint accepts one, so the server derives it. A client names itself
24
+ * with the protocol's `c` parameter; this turns that name into the row the
25
+ * integer is, and finds it again on the next call.
26
+ */
27
+ export function player(db, name) {
28
+ const found = db.prepare('SELECT id FROM player WHERE name = ?').get(name);
29
+ if (found !== undefined)
30
+ return found.id;
31
+ const inserted = db.prepare('INSERT INTO player (name) VALUES (?)').run(name);
32
+ return Number(inserted.lastInsertRowid);
33
+ }
34
+ /**
35
+ * That a track was played, in the history and in the rollup.
36
+ *
37
+ * Both, because they answer different questions and neither can answer the
38
+ * other's: `play` is what happened (which track, which client, when) and
39
+ * `track_play` is what the protocol *shows* — `playCount` and `played` are
40
+ * fields on every `Child`, and a field on every Child cannot be an aggregate
41
+ * over a listing (`031_queue_position_and_plays.sql`).
42
+ *
43
+ * One transaction around the pair, so the history and the rollup cannot
44
+ * disagree: a scrobble that wrote one and not the other would leave a count that
45
+ * does not match the rows behind it.
46
+ */
47
+ export function recordPlay(db, playerId, played) {
48
+ withTransaction(db, () => {
49
+ const insert = db.prepare('INSERT INTO play (track_id, player_id, played_at) VALUES (?, ?, ?)');
50
+ const roll = db.prepare(`INSERT INTO track_play (track_id, play_count, played_at) VALUES (?, 1, ?)
51
+ ON CONFLICT (track_id) DO UPDATE SET
52
+ play_count = track_play.play_count + 1,
53
+ played_at = excluded.played_at`);
54
+ // Each play carries its own time: the protocol pairs `time` with `id` one
55
+ // for one, so a client uploading a day of offline listening dates each of
56
+ // them separately and they must not all be stamped now.
57
+ for (const play of played) {
58
+ insert.run(play.trackId, playerId, play.at);
59
+ roll.run(play.trackId, play.at);
60
+ }
61
+ });
62
+ }
63
+ /**
64
+ * What a player is on, and how far in.
65
+ *
66
+ * One row per player, replaced rather than accumulated: the protocol's question
67
+ * is what is playing *now*, and a table that kept every track a client had
68
+ * opened would be a history with a different name.
69
+ *
70
+ * The three fields beside the track come from `reportPlayback` and are all the
71
+ * server's to remember — the client is told them back by a *different* client
72
+ * through `getNowPlaying`, which is the whole reason they are stored rather than
73
+ * answered from the request.
74
+ */
75
+ export function markPlaying(db, playerId, trackId, at, details = {}) {
76
+ db.prepare(`INSERT INTO now_playing (player_id, track_id, updated_at, state, position_ms, playback_rate)
77
+ VALUES (?, ?, ?, ?, ?, ?)
78
+ ON CONFLICT (player_id) DO UPDATE SET
79
+ track_id = excluded.track_id,
80
+ updated_at = excluded.updated_at,
81
+ state = excluded.state,
82
+ position_ms = excluded.position_ms,
83
+ playback_rate = excluded.playback_rate`).run(playerId, trackId, at, details.state ?? null, details.positionMs ?? null, details.playbackRate ?? null);
84
+ }
85
+ /**
86
+ * What every player is on, most recently updated first.
87
+ *
88
+ * A row whose track is gone is gone with it — the cascade — so nothing here has
89
+ * to be filtered: what comes back is a list of tracks that are still in the
90
+ * collection.
91
+ */
92
+ export function nowPlaying(db) {
93
+ return db
94
+ .prepare(`SELECT np.player_id AS playerId,
95
+ p.name AS playerName,
96
+ np.track_id AS trackId,
97
+ np.updated_at AS updatedAt,
98
+ np.state AS state,
99
+ np.position_ms AS positionMs,
100
+ np.playback_rate AS playbackRate
101
+ FROM now_playing np
102
+ JOIN player p ON p.id = np.player_id
103
+ ORDER BY np.updated_at DESC, np.player_id`)
104
+ .all();
105
+ }
106
+ /**
107
+ * The queue, seat by seat.
108
+ *
109
+ * The seats are returned with their numbers rather than as a bare list, because
110
+ * the current track is stored as one of those numbers and a caller has to be
111
+ * able to find it. Reading the list and indexing it would work until a deletion
112
+ * ahead of the current seat moved everything after it — which is what happened
113
+ * (task:2863).
114
+ */
115
+ export function queue(db) {
116
+ const row = db
117
+ .prepare(`SELECT current_position AS currentPosition, position_ms AS positionMs,
118
+ changed_at AS changedAt, changed_by AS changedBy
119
+ FROM play_queue WHERE only_row = 1`)
120
+ .get();
121
+ if (row === undefined)
122
+ return null;
123
+ const entries = db
124
+ .prepare('SELECT position, track_id AS trackId FROM play_queue_entry ORDER BY position')
125
+ .all();
126
+ return { ...row, entries };
127
+ }
128
+ /**
129
+ * Replace the queue, whole.
130
+ *
131
+ * Every save lays the positions out from zero again, the rule every other
132
+ * ordered write in this project follows: a queue is addressed by index, so a
133
+ * save that appended would leave the numbers meaning something the client did
134
+ * not send. An empty list is a queue with nothing in it — how a client says
135
+ * "forget this" — and it is saved rather than deleted, so `changedBy` still says
136
+ * who did it.
137
+ */
138
+ export function saveQueue(db, input, at) {
139
+ withTransaction(db, () => {
140
+ db.prepare('DELETE FROM play_queue_entry').run();
141
+ const insert = db.prepare('INSERT INTO play_queue_entry (position, track_id) VALUES (?, ?)');
142
+ input.trackIds.forEach((trackId, position) => insert.run(position, trackId));
143
+ db.prepare(`INSERT INTO play_queue (only_row, current_position, position_ms, changed_at, changed_by)
144
+ VALUES (1, ?, ?, ?, ?)
145
+ ON CONFLICT (only_row) DO UPDATE SET
146
+ current_position = excluded.current_position,
147
+ position_ms = excluded.position_ms,
148
+ changed_at = excluded.changed_at,
149
+ changed_by = excluded.changed_by`).run(input.currentPosition, input.positionMs, at, input.changedBy);
150
+ });
151
+ }
@@ -0,0 +1,294 @@
1
+ import { unchangedCount } from "../db/ledger.js";
2
+ import { hidden } from "../junk/marks.js";
3
+ /**
4
+ * Render the classified collection as a document a human reads.
5
+ *
6
+ * This is the acceptance instrument (requirements:39 §9): every check v1 is
7
+ * judged by — pressings staying separate albums, a box reading as one release
8
+ * with its discs, the same album in two roots staying two, a cue album listing
9
+ * N tracks rather than one, Cyrillic surviving the trip, and the counters
10
+ * adding up — has to be visible here, or it is not checkable by the person the
11
+ * contract answers to.
12
+ *
13
+ * Read-only, and deterministic by construction: same database, same bytes.
14
+ * Two dumps of an untouched collection differ only in the lines naming the run
15
+ * they describe, which is what makes "the rescan changed nothing" something one
16
+ * looks at rather than something one believes. The ledger line says how much of
17
+ * the collection the last walk found unmoved, so that claim is legible from the
18
+ * dump alone.
19
+ *
20
+ * The machine-readable form remains the database itself.
21
+ */
22
+ /** Minutes and seconds, or an explicit dash when nothing measured it. */
23
+ function duration(ms) {
24
+ if (ms === null || ms < 0)
25
+ return '--:--';
26
+ const total = Math.round(ms / 1000);
27
+ const seconds = total % 60;
28
+ const minutes = Math.floor(total / 60) % 60;
29
+ const hours = Math.floor(total / 3600);
30
+ const tail = `${minutes}:${String(seconds).padStart(2, '0')}`;
31
+ return hours > 0
32
+ ? `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
33
+ : tail;
34
+ }
35
+ /** `label total (key n, key n)`, omitting the keys that are zero. */
36
+ function breakdown(total, label, counts = {}) {
37
+ const parts = Object.entries(counts)
38
+ .filter(([, n]) => n > 0)
39
+ .map(([key, n]) => `${key} ${n}`);
40
+ return ` ${label.padEnd(9)}${String(total).padEnd(7)}${parts.length === 0 ? '' : `(${parts.join(', ')})`}`;
41
+ }
42
+ function tally(rows) {
43
+ return Object.fromEntries(rows.map((row) => [row.key ?? 'none', row.n]));
44
+ }
45
+ function count(db, sql, ...args) {
46
+ return db.prepare(sql).get(...args).n;
47
+ }
48
+ /**
49
+ * Every row of an album-keyed query, gathered under its album.
50
+ *
51
+ * **One query for the collection rather than one per album.** Three of the things
52
+ * the dump asks for are per-album reads, and asking them inside the loop below
53
+ * is what made `GET /inventory` cost **233.9 ms** on a 472-album collection: 1416
54
+ * statement executions, three for every album. Grouped here, the same three
55
+ * queries are executed three times in all.
56
+ *
57
+ * The order inside a group is the order the query was written with, which is
58
+ * what keeps the dump byte-identical — the promise this file opens with.
59
+ */
60
+ function byAlbum(rows) {
61
+ const out = new Map();
62
+ for (const row of rows) {
63
+ const list = out.get(row.albumId);
64
+ if (list === undefined)
65
+ out.set(row.albumId, [row]);
66
+ else
67
+ list.push(row);
68
+ }
69
+ return out;
70
+ }
71
+ export function inventory(db, options = {}) {
72
+ const lines = [];
73
+ const where = options.dbPath === undefined ? '' : ` — ${options.dbPath}`;
74
+ lines.push(`funoteka inventory${where}`);
75
+ const run = db
76
+ .prepare('SELECT id, status, started_at FROM scan_run ORDER BY id DESC LIMIT 1')
77
+ .get();
78
+ if (run === undefined) {
79
+ lines.push('', 'No scan has run against this database.', '');
80
+ return lines.join('\n');
81
+ }
82
+ lines.push(`run ${run.id} · ${run.status} · ${run.started_at}`, '');
83
+ const roots = db.prepare('SELECT id, path FROM root ORDER BY id').all();
84
+ // Albums carry the root they belong to, and identity is root-qualified, so a
85
+ // short marker is what lets two roots hold the same relative path and stay
86
+ // legibly two albums.
87
+ const marks = new Map(roots.map((root, index) => [root.id, `#${index + 1}`]));
88
+ const perRoot = (sql) => new Map(db.prepare(sql).all().map((row) => [row.root_id, row.n]));
89
+ const foldersByRoot = perRoot('SELECT root_id, COUNT(*) AS n FROM folder GROUP BY root_id');
90
+ const filesByRoot = perRoot('SELECT root_id, COUNT(*) AS n FROM file GROUP BY root_id');
91
+ const albumsByRoot = perRoot('SELECT root_id, COUNT(*) AS n FROM album GROUP BY root_id');
92
+ lines.push('roots');
93
+ for (const root of roots) {
94
+ lines.push(` ${(marks.get(root.id) ?? '?').padEnd(4)}${root.path}`, ` folders ${foldersByRoot.get(root.id) ?? 0} files ${filesByRoot.get(root.id) ?? 0} albums ${albumsByRoot.get(root.id) ?? 0}`);
95
+ }
96
+ const fileTotal = count(db, 'SELECT COUNT(*) AS n FROM file');
97
+ const unmoved = unchangedCount(db, run.id);
98
+ lines.push('', 'counters');
99
+ lines.push(breakdown(count(db, 'SELECT COUNT(*) AS n FROM folder'), 'folders', tally(db.prepare('SELECT role AS key, COUNT(*) AS n FROM folder GROUP BY role').all())));
100
+ lines.push(breakdown(fileTotal, 'files', tally(db.prepare('SELECT kind AS key, COUNT(*) AS n FROM file GROUP BY kind').all())));
101
+ // `scanned = visible + hidden`, stated here because the dump is where the
102
+ // contract promised it would be (Q14, brainstorm:190) and because a filter
103
+ // nobody can see the size of is a filter nobody can check. What is hidden is
104
+ // named below the counters, with its reason — a count alone cannot tell a rule
105
+ // that overreached from a folder somebody marked.
106
+ const hiddenAlbums = hidden(db);
107
+ lines.push(breakdown(count(db, 'SELECT COUNT(*) AS n FROM album'), 'albums', {
108
+ hidden: hiddenAlbums.length,
109
+ }));
110
+ lines.push(breakdown(count(db, 'SELECT COUNT(*) AS n FROM track'), 'tracks'));
111
+ lines.push(breakdown(count(db, 'SELECT COUNT(*) AS n FROM release'), 'releases'));
112
+ lines.push(breakdown(count(db, 'SELECT COUNT(*) AS n FROM artist'), 'artists', {
113
+ ambiguous: count(db, 'SELECT COUNT(*) AS n FROM artist WHERE ambiguous = 1'),
114
+ }));
115
+ lines.push(breakdown(count(db, 'SELECT COUNT(*) AS n FROM issue WHERE scan_run_id = ?', run.id), 'issues', tally(db
116
+ .prepare('SELECT severity AS key, COUNT(*) AS n FROM issue WHERE scan_run_id = ? GROUP BY severity')
117
+ .all(run.id))));
118
+ // What the last walk found unmoved. Without it the dump cannot say whether a
119
+ // rescan did anything, which is half of what it is for.
120
+ lines.push(` ${'ledger'.padEnd(9)}${unmoved} of ${fileTotal} files unmoved since the last walk`);
121
+ // "No silent loss", stated as the things that must be zero. An audio file is
122
+ // accounted for when the meta layer can name it: a whole-file track points at
123
+ // it, or it is the image a cue splits.
124
+ lines.push('', 'unaccounted');
125
+ const unaccounted = [
126
+ ['folders with no role', count(db, 'SELECT COUNT(*) AS n FROM folder WHERE role IS NULL')],
127
+ [
128
+ 'albums with no track',
129
+ count(db, 'SELECT COUNT(*) AS n FROM album a WHERE NOT EXISTS (SELECT 1 FROM track t WHERE t.album_id = a.id)'),
130
+ ],
131
+ [
132
+ 'audio files with no track or cue',
133
+ count(db, `SELECT COUNT(*) AS n FROM file f
134
+ WHERE f.kind = 'audio'
135
+ AND NOT EXISTS (SELECT 1 FROM track t WHERE t.file_id = f.id)
136
+ AND NOT EXISTS (SELECT 1 FROM cue c WHERE c.audio_file_id = f.id)`),
137
+ ],
138
+ ];
139
+ for (const [label, n] of unaccounted)
140
+ lines.push(` ${label.padEnd(34)}${n}`);
141
+ // What the filter keeps out, by name. Printed before the collection rather
142
+ // than after, because it is the one part of the dump that is an *absence*:
143
+ // everything below is what is there, and a reader who does not see this
144
+ // section cannot tell a hidden folder from a folder that never existed.
145
+ lines.push('', `hidden (${hiddenAlbums.length})`);
146
+ if (hiddenAlbums.length === 0) {
147
+ lines.push(' none');
148
+ }
149
+ for (const row of hiddenAlbums) {
150
+ const where = row.relPath === '' ? '(the root itself)' : row.relPath;
151
+ lines.push(` ${(row.source === 'hand' ? 'hand' : 'scan').padEnd(6)}${(marks.get(row.rootId) ?? '?').padEnd(4)}${where.padEnd(44)}${row.title ?? ''} — ${row.junkReason}`);
152
+ }
153
+ const albums = db
154
+ .prepare(`SELECT a.id AS id, a.root_id AS root_id, a.rel_path AS rel_path, a.title AS title,
155
+ a.disc_number AS disc_number, rel.title AS release_title,
156
+ a.credit_raw AS credit_raw,
157
+ a.artist_id AS artist_id, ar.name AS artist_name, ar.name_key AS artist_key
158
+ FROM album a
159
+ LEFT JOIN artist ar ON ar.id = a.artist_id
160
+ LEFT JOIN release rel ON rel.id = a.release_id
161
+ ORDER BY (ar.name_key IS NULL), ar.name_key, a.root_id, a.rel_path`)
162
+ .all();
163
+ // Counted once rather than rescanning the album list per artist, and counted
164
+ // by id rather than by name: two artists can share a name — that is the whole
165
+ // point of the folder split — and counting by name would show them as one
166
+ // artist owning both, hiding the split exactly where a human looks for it.
167
+ const ownedByArtist = new Map();
168
+ for (const album of albums) {
169
+ ownedByArtist.set(album.artist_id, (ownedByArtist.get(album.artist_id) ?? 0) + 1);
170
+ }
171
+ // Only ever read to show a credit that is more than one name: a single name
172
+ // is already on the album's line, and repeating it would be noise on every
173
+ // one of the collection's albums.
174
+ const creditOf = byAlbum(db
175
+ .prepare(`SELECT ac.album_id AS albumId, ar.name AS name, ac.join_phrase AS join_phrase
176
+ FROM artist_credit ac JOIN artist ar ON ar.id = ac.artist_id
177
+ ORDER BY ac.album_id, ac.position`)
178
+ .all());
179
+ // A track's credit, resolved to the rows the artist stage made of it. Written
180
+ // only where the track says something the record does not — see that stage —
181
+ // so a track with no row here is one whose record already speaks for it.
182
+ const trackCreditOf = byAlbum(db
183
+ .prepare(`SELECT t.album_id AS albumId, tc.track_id AS track_id, ar.name AS name,
184
+ tc.join_phrase AS join_phrase
185
+ FROM track_credit tc
186
+ JOIN track t ON t.id = tc.track_id
187
+ JOIN artist ar ON ar.id = tc.artist_id
188
+ ORDER BY t.album_id, tc.track_id, tc.position`)
189
+ .all());
190
+ // A track's own PERFORMER, reached through the cue bound to the file it plays
191
+ // from. That binding is the one `applyCues` settled: a second cue naming the
192
+ // same audio is diagnosed and left unbound (`cue-unmatched`), so there is one
193
+ // answer here rather than a choice between documents.
194
+ //
195
+ // The raw string and not an artist: `artist/apply.ts` argues why
196
+ // `track.artist_id` stays empty, and what a reader needs to see is that the
197
+ // parser did not drop what the cue said. A compilation is where that matters
198
+ // — the record is credited `Various Artists`, which is true of the record and
199
+ // false of every track on it (task:2727 §6).
200
+ const tracksOf = byAlbum(db
201
+ .prepare(`SELECT t.album_id AS albumId, t.id AS id, t.ordinal AS ordinal, t.title AS title,
202
+ t.segment_start_ms AS start_ms, t.segment_end_ms AS end_ms,
203
+ t.duration_ms AS duration_ms, f.rel_path AS file_rel_path,
204
+ (SELECT ct.performer
205
+ FROM cue c JOIN cue_track ct ON ct.cue_id = c.id
206
+ WHERE c.audio_file_id = t.file_id AND ct.ordinal = t.ordinal
207
+ ORDER BY c.id LIMIT 1) AS performer
208
+ FROM track t JOIN file f ON f.id = t.file_id
209
+ ORDER BY t.album_id, t.ordinal`)
210
+ .all());
211
+ lines.push('', 'artists');
212
+ let currentArtist;
213
+ for (const album of albums) {
214
+ if (album.artist_id !== currentArtist) {
215
+ currentArtist = album.artist_id;
216
+ const owned = ownedByArtist.get(currentArtist) ?? 0;
217
+ // A suffixed key is the one mark that tells two same-named artists apart,
218
+ // and the dump shows only the display name — so without this a split
219
+ // prints as two identical blocks and the reader cannot tell which is
220
+ // which. The key is noise for every artist that won its name outright,
221
+ // so it is shown only when it carries the split's `#`.
222
+ const key = album.artist_key;
223
+ const mark = key === null || !key.includes('#') ? '' : ` [${key}]`;
224
+ lines.push('', ` ${(album.artist_name ?? '(unattributed)').padEnd(40)} ${owned} album${owned === 1 ? '' : 's'}${mark}`);
225
+ }
226
+ const tracks = tracksOf.get(album.id) ?? [];
227
+ const split = tracks.some((track) => track.start_ms !== null);
228
+ const label = album.rel_path === '' ? '(the root itself)' : album.rel_path;
229
+ const disc = album.disc_number === null ? '' : ` disc ${album.disc_number}`;
230
+ const box = album.release_title === null ? '' : ` of ${album.release_title}`;
231
+ // What varies in length — the path, the title, the release — goes last.
232
+ // Padded columns read better until a name outgrows them, at which point
233
+ // they run into the next column and the dump stops being readable exactly
234
+ // where the interesting records are.
235
+ const facts = `${tracks.length} track${tracks.length === 1 ? '' : 's'}${split ? ' split' : ''}${disc}${box}`;
236
+ lines.push(` ${(marks.get(album.root_id) ?? '?').padEnd(4)}${facts.padEnd(30)} · ${label} — ${album.title ?? ''}`);
237
+ const credit = creditOf.get(album.id) ?? [];
238
+ if (credit.length > 1) {
239
+ // Rebuilt from the parts rather than echoed from `credit_raw`, so what is
240
+ // printed proves the list and its phrases really do reassemble.
241
+ const rebuilt = credit.map((entry) => entry.join_phrase + entry.name).join('');
242
+ lines.push(` credit ${rebuilt}`);
243
+ }
244
+ // The tracks whose credit is not the record's, resolved to people. A track
245
+ // line that showed the cue's own string would be showing a string where the
246
+ // meta layer holds an identity — and where the stage declined to resolve one
247
+ // (the 120 names only a track states), the string is what is left and is
248
+ // shown instead, so a refusal is visible rather than an empty column
249
+ // (task:2729).
250
+ const trackCredits = new Map();
251
+ for (const row of trackCreditOf.get(album.id) ?? []) {
252
+ const soFar = trackCredits.get(row.track_id) ?? '';
253
+ trackCredits.set(row.track_id, soFar + row.join_phrase + row.name);
254
+ }
255
+ for (const track of tracks) {
256
+ const segment = track.start_ms === null ? '' : ` [${duration(track.start_ms)}–${duration(track.end_ms)}]`;
257
+ // Only where the track says something the record does not. Eleven of the
258
+ // sample's albums state one performer and it is the record's own; printing
259
+ // it on every line is the repetition the credit has already refused to
260
+ // make, and it would bury the tracks that do differ — a compilation's
261
+ // every track, a live disc's `Публика` — in a column of the same name.
262
+ //
263
+ // The resolved credit is what is shown when there is one, because that is
264
+ // what the meta layer holds: a row in `artist`, which search and the tree
265
+ // can follow. The cue's own string is the fallback — a name the stage
266
+ // declined to resolve, or one it refused as a note about the room — and it
267
+ // is shown rather than dropped so the refusal is visible here too.
268
+ const credit = trackCredits.get(track.id);
269
+ const performer = credit !== undefined
270
+ ? ` · ${credit}`
271
+ : track.performer !== null &&
272
+ track.performer.trim() !== '' &&
273
+ track.performer !== album.credit_raw
274
+ ? ` · ${track.performer}`
275
+ : '';
276
+ lines.push(` ${`${String(track.ordinal).padStart(2, '0')}.`.padEnd(5)}${duration(track.duration_ms).padEnd(7)}` +
277
+ `· ${track.title ?? '(untitled)'} · ${track.file_rel_path}${segment}${performer}`);
278
+ }
279
+ }
280
+ const issues = db
281
+ .prepare(`SELECT kind, severity, rel_path, root_id, detail FROM issue
282
+ WHERE scan_run_id = ?
283
+ ORDER BY severity DESC, kind, root_id, rel_path`)
284
+ .all(run.id);
285
+ lines.push('', `issues (run ${run.id})`);
286
+ if (issues.length === 0)
287
+ lines.push(' none');
288
+ for (const issue of issues) {
289
+ const mark = issue.root_id === null ? '' : (marks.get(issue.root_id) ?? '');
290
+ lines.push(` ${issue.severity.padEnd(6)}${issue.kind.padEnd(24)}${mark.padEnd(4)}${(issue.rel_path ?? '').padEnd(40)}${issue.detail ?? ''}`);
291
+ }
292
+ lines.push('');
293
+ return lines.join('\n');
294
+ }
@@ -0,0 +1,143 @@
1
+ import { withTransaction } from "../db/index.js";
2
+ import { junkReason } from "./rule.js";
3
+ /**
4
+ * A filesystem path, as the root and relative path the meta layer keys on.
5
+ *
6
+ * The operator names a folder the way the disk does — `D:\music\Telegram
7
+ * Desktop` — and identity here is `(root, rel_path)`. Resolution is by the
8
+ * deepest root that contains the path, so a root inside another root still
9
+ * answers about itself, and the comparison is on separators normalised to `/`
10
+ * because that is what `scan/walk.ts` writes.
11
+ *
12
+ * Nothing is resolved by guessing: a path under no root is not an error about
13
+ * the collection, it is a path this server has never heard of, and the caller is
14
+ * told so by `undefined` rather than by a mark on something else.
15
+ */
16
+ export function resolvePath(db, path) {
17
+ const normal = path.replace(/\\/g, '/').replace(/\/+$/, '');
18
+ const roots = db.prepare('SELECT id, path FROM root ORDER BY LENGTH(path) DESC').all();
19
+ for (const root of roots) {
20
+ const prefix = root.path.replace(/\\/g, '/').replace(/\/+$/, '');
21
+ if (normal === prefix)
22
+ return { rootId: root.id, relPath: '' };
23
+ if (normal.toLowerCase().startsWith(`${prefix.toLowerCase()}/`)) {
24
+ return { rootId: root.id, relPath: normal.slice(prefix.length + 1) };
25
+ }
26
+ }
27
+ return undefined;
28
+ }
29
+ /**
30
+ * The album a mark is about, and what the rule makes of the files under it.
31
+ *
32
+ * Both verbs below open with exactly this and differ only in the verdict they
33
+ * hand the rule: a person's word for `mark`, nothing for `unmark`, which is how
34
+ * the rule gets to speak again. It is written once because the refusal in it is
35
+ * the part that must not drift — a path that names no album is a statement about
36
+ * nothing, and both verbs owe the operator that sentence rather than a mark
37
+ * quietly landing on something else.
38
+ */
39
+ function albumAndReason(db, rootId, relPath, mark) {
40
+ const row = db
41
+ .prepare('SELECT id FROM album WHERE root_id = ? AND rel_path = ?')
42
+ .get(rootId, relPath);
43
+ if (row === undefined) {
44
+ throw new Error(`no album at that path: ${relPath === '' ? '(the root itself)' : relPath}`);
45
+ }
46
+ const files = db
47
+ .prepare('SELECT kind FROM file WHERE root_id = ? AND folder_rel_path = ?')
48
+ .all(rootId, relPath);
49
+ return { albumId: row.id, reason: junkReason(files, mark) };
50
+ }
51
+ /**
52
+ * Record what a person said about a folder, and make it so at once.
53
+ *
54
+ * Returns the reason the album now carries, or null when there is none — so a
55
+ * caller reads the outcome off one value instead of two: `trust` on a folder the
56
+ * rule would hide gives null, and `junk` on anything at all gives the sentence
57
+ * the listings are keeping it out by. What differs between the two verdicts is
58
+ * *which* answer comes back, not whether one does.
59
+ *
60
+ * A folder that is not an album at all is refused by name: the mark is keyed on
61
+ * a path, and a path with no album row under it is a statement about nothing —
62
+ * a typo the operator would otherwise never hear about.
63
+ */
64
+ export function mark(db, rootId, relPath, verdict, note) {
65
+ const { albumId, reason } = albumAndReason(db, rootId, relPath, verdict);
66
+ withTransaction(db, () => {
67
+ db.prepare(`INSERT INTO junk_mark (root_id, rel_path, verdict, note, marked_at)
68
+ VALUES (?, ?, ?, ?, ?)
69
+ ON CONFLICT (root_id, rel_path) DO UPDATE SET
70
+ verdict = excluded.verdict,
71
+ note = excluded.note,
72
+ marked_at = excluded.marked_at`).run(rootId, relPath, verdict, note, new Date().toISOString());
73
+ db.prepare('UPDATE album SET junk_reason = ? WHERE id = ?').run(reason, albumId);
74
+ });
75
+ return reason;
76
+ }
77
+ /**
78
+ * Take a hand mark back, and let the rule speak again.
79
+ *
80
+ * The third verb, and the one that makes the other two reversible: `block` and
81
+ * `allow` write a person's word over the rule's, and without this there is no
82
+ * way to withdraw it — the album would carry "marked junk by hand" for ever, in
83
+ * a database nobody remembers to edit.
84
+ *
85
+ * The row is re-derived here exactly as `mark` re-derives it, from the same
86
+ * function and in the same transaction, so the two cannot disagree about what a
87
+ * folder is: one rule, one caller of it, whichever way the mark moved.
88
+ */
89
+ export function unmark(db, rootId, relPath) {
90
+ const { albumId, reason } = albumAndReason(db, rootId, relPath);
91
+ withTransaction(db, () => {
92
+ db.prepare('DELETE FROM junk_mark WHERE root_id = ? AND rel_path = ?').run(rootId, relPath);
93
+ db.prepare('UPDATE album SET junk_reason = ? WHERE id = ?').run(reason, albumId);
94
+ });
95
+ return reason;
96
+ }
97
+ /**
98
+ * The hand edits themselves, whether or not they hid anything.
99
+ *
100
+ * `hidden` answers "what is being kept out", which is not the same list: an
101
+ * `allow` on a folder the rule would have hidden keeps nothing out, and it is
102
+ * exactly the mark an operator wants to find again to take back. Listing the
103
+ * marks is also how a deployment's own judgement becomes readable — the rule's
104
+ * answers are derivable from the files, and these are not.
105
+ */
106
+ export function marks(db) {
107
+ return db
108
+ .prepare(`SELECT m.root_id AS rootId, r.path AS rootPath, m.rel_path AS relPath,
109
+ m.verdict AS verdict, m.note AS note, m.marked_at AS markedAt
110
+ FROM junk_mark m JOIN root r ON r.id = m.root_id
111
+ ORDER BY r.path, m.rel_path`)
112
+ .all();
113
+ }
114
+ /**
115
+ * Every album the filter is keeping out, and why it is out.
116
+ *
117
+ * The reason and the source both, because "hidden" alone is not checkable: a
118
+ * rule that overreached and a person who marked something look identical from a
119
+ * count, and the difference is the whole of what an operator checking the
120
+ * filter's work needs. `source` is the album's own reason matching the hand
121
+ * mark's text — the two readings are distinguishable because `rule.ts` writes
122
+ * one sentence and the mark writes another, and neither is ever a substring of
123
+ * the other.
124
+ */
125
+ export function hidden(db) {
126
+ return db
127
+ .prepare(`SELECT al.root_id AS rootId, r.path AS rootPath, al.rel_path AS relPath,
128
+ al.title AS title, al.junk_reason AS junkReason,
129
+ EXISTS (SELECT 1 FROM junk_mark m
130
+ WHERE m.root_id = al.root_id AND m.rel_path = al.rel_path
131
+ AND m.verdict = 'junk') AS marked
132
+ FROM album al JOIN root r ON r.id = al.root_id
133
+ WHERE al.junk_reason IS NOT NULL
134
+ ORDER BY r.path, al.rel_path`)
135
+ .all().map((row) => ({
136
+ rootId: row.rootId,
137
+ rootPath: row.rootPath,
138
+ relPath: row.relPath,
139
+ title: row.title,
140
+ junkReason: row.junkReason,
141
+ source: row.marked === 1 ? 'hand' : 'scan',
142
+ }));
143
+ }