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,120 @@
1
+ /**
2
+ * The title a file's own name states, for a track nothing else names.
3
+ *
4
+ * A collection is not all tags. One of its parts arrives folder-organized with
5
+ * a `readme` and nothing else (requirements:39 §1, the Kroogi sample), and the
6
+ * name of the file is then the only witness left. The contract's chain is cue →
7
+ * tag → *this*: a document about the album speaks with authority, a tag speaks
8
+ * for its own file, and a file name is the weakest of the three — which is why
9
+ * it is asked last and never overrides either.
10
+ *
11
+ * ## What a name carries that is not the track
12
+ *
13
+ * Three things, and each is dropped only on evidence:
14
+ *
15
+ * - **The track number.** `01-`, `01. `, `01 - ` — a leading count, never part
16
+ * of a title. Dropped on sight, because nothing else in a file name looks
17
+ * like it: no title in these collections opens with two digits and a dot.
18
+ * - **The artist.** The scene writes `Artist - Title` in front of every track,
19
+ * and a split record gives each track a *different* one, so the album's own
20
+ * artist cannot find these. The album's folder **name** can: it lists every
21
+ * player on the record, and a field whose words all appear there is the
22
+ * album's, not the track's.
23
+ * - **The release group.** `-kroogi` glued to the tail is the same class as
24
+ * `-Kroogi.com` in the folder name — where the rip came from, not what the
25
+ * record is.
26
+ *
27
+ * Two rules were narrowed by measuring the rule against the collection's 850
28
+ * audio files (88 fields dropped, all of them credits: `Cock E.S.P.` ×45,
29
+ * `Emil Hagstrom` ×8, `Suffering Bastard` ×7, …), and both narrowings answer a
30
+ * real file:
31
+ *
32
+ * - **Only a *leading* field is a field.** `Кино - Спасём мир (MASHCD-148)`
33
+ * sits in `1986 ● Концерт «Спасём мир» …`, and the folder agrees with its
34
+ * tail word for word. Dropping a trailing ` - ` field because the folder
35
+ * agrees leaves `Кино` as the title of a song called `Кино - Спасём мир`:
36
+ * an album named after the piece it holds is the ordinary case, not the
37
+ * exception, so the tail of a name is never a field.
38
+ * - **The scene's own marks need the scene's written separator.** `_` for a
39
+ * space and a glued `-site` tail are conventions of one layout, and `_-_`
40
+ * is where that layout declares itself — the same "written, not incidental"
41
+ * test `SCENE` in `classify/folder-name.ts` rests on. Without it,
42
+ * `Amp-Destroyer` is a hyphen in a title, and `my_cool_track` keeps its
43
+ * underscore.
44
+ *
45
+ * Nothing is invented. A name written in lower case stays in lower case: that
46
+ * is what the file says, and a title with its capitals belongs to a source that
47
+ * has them (a tag, or the `readme` tracklist — [[task:2710]]).
48
+ */
49
+ import { stemOf } from "../util/names.js";
50
+ /** `01-`, `01.`, `01 - ` — a leading count and the separator after it. */
51
+ const TRACK_NUMBER = /^\d{1,2}\s*[.\-_)\]]\s*/;
52
+ /** The separator the scene layout writes between artist and title. */
53
+ const SCENE_SEPARATOR = '_-_';
54
+ /**
55
+ * A word glued to the tail by a dash — `archangelsk-kroogi`.
56
+ *
57
+ * The lookbehind is the whole of the rule: a dash with a space in front of it
58
+ * opens a field, and a field is never the release group it was measured against.
59
+ * Letters, digits, marks and dots only, so `(MASHCD-148)` — a bracket and a
60
+ * number — cannot be read as one.
61
+ */
62
+ const GLUED_TAIL = /(?<=\S)-([\p{L}\p{N}\p{M}.]+)$/u;
63
+ /**
64
+ * The words a name is made of, folded for comparison.
65
+ *
66
+ * Unicode-aware for the same reason `artistName` is: `Кино` has to survive, and
67
+ * `\p{M}` matters because a tree ripped on macOS stores `Björk` decomposed.
68
+ */
69
+ function words(text) {
70
+ return text
71
+ .toLowerCase()
72
+ .replace(/[^\p{L}\p{N}\p{M}]+/gu, ' ')
73
+ .trim()
74
+ .split(' ')
75
+ .filter((word) => word !== '');
76
+ }
77
+ /**
78
+ * The title this file's name states.
79
+ *
80
+ * `albumName` is the album's own folder name — the whole of the external
81
+ * evidence, and null when nothing supplies one. Without it the file still
82
+ * speaks; it is simply not corroborated, so no field of it is claimed as the
83
+ * album's.
84
+ */
85
+ export function titleFromFileName(fileName, albumName) {
86
+ const stem = stemOf(fileName);
87
+ // Both of the scene's marks stand or fall with its separator, so it is read
88
+ // once, before the folding that loses it.
89
+ const scene = stem.includes(SCENE_SEPARATOR);
90
+ const rest = scene ? stem.replace(/_/g, ' ') : stem;
91
+ const evidence = new Set(words(albumName ?? ''));
92
+ const stated = (field) => {
93
+ const found = words(field);
94
+ return found.length > 0 && found.every((word) => evidence.has(word));
95
+ };
96
+ let text = rest.trim();
97
+ // The number goes first: it is the one mark that is proof in itself, and the
98
+ // fields below are read on what is left of the name.
99
+ const number = TRACK_NUMBER.exec(text);
100
+ if (number !== null && number[0].length < text.length)
101
+ text = text.slice(number[0].length).trim();
102
+ // Never the whole name: a file called `01.mp3` states a number and nothing
103
+ // else, and eating it would leave no title at all.
104
+ let fields = text.split(/\s+-\s+/);
105
+ while (fields.length > 1 && stated(fields[0] ?? '') && fields.slice(1).join(' - ').trim() !== '') {
106
+ fields = fields.slice(1);
107
+ }
108
+ text = fields.join(' - ');
109
+ // The scene's tail: the dash is glued to the word, so it never opened a field.
110
+ const glued = scene ? GLUED_TAIL.exec(text) : null;
111
+ if (glued !== null && stated(glued[1] ?? '') && text.slice(0, glued.index).trim() !== '') {
112
+ text = text.slice(0, glued.index).trim();
113
+ }
114
+ // The underscore fold is a rewrite of somebody's name, so the spacing it
115
+ // leaves behind is this module's to tidy. Nothing else is collapsed: a double
116
+ // space in a name nobody folded is what was written.
117
+ if (scene)
118
+ text = text.replace(/\s+/g, ' ');
119
+ return text === '' ? stem : text;
120
+ }
@@ -0,0 +1,163 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { readFileSync, readdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ /**
5
+ * Access to the meta layer.
6
+ *
7
+ * This module is the single seam between the scanner and SQLite: everything
8
+ * else talks to it through `DatabaseSync`, so swapping the driver (say, to
9
+ * better-sqlite3) stays a one-file change. We use the built-in `node:sqlite`
10
+ * because it ships with Node 24 already compiled with FTS5, which keeps the
11
+ * project dependency-free.
12
+ */
13
+ const MIGRATIONS_DIR = join(import.meta.dirname, 'migrations');
14
+ export const SCHEMA_VERSION = 38;
15
+ /**
16
+ * Where the meta layer lives when nobody says otherwise.
17
+ *
18
+ * The scanner and the server both open it, so the name is the meta layer's own
19
+ * and not either command's: a server started with no arguments has to find the
20
+ * database the scan wrote, or the two halves of the project would need telling
21
+ * about each other.
22
+ */
23
+ export const DEFAULT_DB = 'funoteka.db';
24
+ /** Migrations are `NNN_name.sql`; the leading number is the target version. */
25
+ function migrations() {
26
+ return readdirSync(MIGRATIONS_DIR)
27
+ .filter((name) => name.endsWith('.sql'))
28
+ .map((name) => ({ version: Number.parseInt(name.slice(0, 3), 10), path: join(MIGRATIONS_DIR, name) }))
29
+ .filter((m) => Number.isInteger(m.version))
30
+ .sort((a, b) => a.version - b.version);
31
+ }
32
+ /**
33
+ * Bring the database up to `SCHEMA_VERSION`.
34
+ *
35
+ * Idempotent by design: every scan opens the database, so running this against
36
+ * an already-migrated file has to be a no-op rather than an error.
37
+ */
38
+ export function migrate(db) {
39
+ let current = db.prepare('PRAGMA user_version').get().user_version;
40
+ for (const migration of migrations()) {
41
+ if (migration.version <= current)
42
+ continue;
43
+ // `IMMEDIATE`, like every other transaction in this project — the rule is
44
+ // stated below `withTransaction`, and this one broke it. It was survivable
45
+ // only by accident: each file here happens to write first (020 opens with a
46
+ // bare `ANALYZE`), and a deferred transaction whose *first* statement is a
47
+ // write does ask the busy handler. A later migration that reads before it
48
+ // writes would have been refused at once instead — and this runs inside
49
+ // `openDb`, before any caller has had a chance to raise the timeout.
50
+ db.exec('BEGIN IMMEDIATE');
51
+ try {
52
+ db.exec(readFileSync(migration.path, 'utf8'));
53
+ db.exec(`PRAGMA user_version = ${migration.version}`);
54
+ db.exec('COMMIT');
55
+ }
56
+ catch (err) {
57
+ try {
58
+ db.exec('ROLLBACK');
59
+ }
60
+ catch {
61
+ // Already unwound; the original error is what matters.
62
+ }
63
+ throw err;
64
+ }
65
+ current = migration.version;
66
+ }
67
+ }
68
+ /**
69
+ * Open the meta layer, creating and migrating it if needed.
70
+ *
71
+ * Pass ':memory:' for an ephemeral database (tests). On disk we use WAL so a
72
+ * scan can write while the API reads.
73
+ */
74
+ export function openDb(path) {
75
+ const db = new DatabaseSync(path);
76
+ // Per-connection, and required for the ON DELETE CASCADE rules to fire.
77
+ db.exec('PRAGMA foreign_keys = ON');
78
+ if (path !== ':memory:')
79
+ db.exec('PRAGMA journal_mode = WAL');
80
+ // How long a writer waits for the other writer.
81
+ //
82
+ // Two things write this file: the scan, which is a process of its own, and the
83
+ // API, which writes playlists. SQLite's default answer to the second arriving
84
+ // while the first holds the lock is to give up at once, and "at once" is about
85
+ // 20 ms: measured on two connections to one file, the save failed with
86
+ // "database is locked" while the other held the lock.
87
+ //
88
+ // **Short, because the wait is served by blocking the thread.** `node:sqlite`
89
+ // is synchronous, so this sleep is not a sleep this server can take while
90
+ // answering anybody else: measured with the second writer holding the lock for
91
+ // seven seconds, a `ping` sent during the wait came back after 5133 ms — the
92
+ // whole daemon, stopped, for one client's save.
93
+ //
94
+ // A quarter of a second is set against how long the scan now holds the lock,
95
+ // which is no longer a root: `scan.ts` writes in batches and lets go between
96
+ // them — measured on the live collection, one batch holds the write lock **12
97
+ // ms at the median and 96 ms at its worst**. So a save that meets a scan waits
98
+ // out a single batch and gets in, which is what this number was always meant
99
+ // to buy and could not buy while a scan held the lock for its whole run.
100
+ //
101
+ // The scan's own connection is the other half of this, and takes ten seconds
102
+ // (`cli.ts`): there the wait holds up nobody, because a scan answers nobody.
103
+ // What is left owed here is the length of the *stages* — `cues` still writes
104
+ // one transaction of about two seconds, and a save arriving inside it is
105
+ // refused (task:2880).
106
+ db.exec('PRAGMA busy_timeout = 250');
107
+ migrate(db);
108
+ return db;
109
+ }
110
+ /**
111
+ * A mutation, as one transaction.
112
+ *
113
+ * **Measured, and it is the whole of why this exists.** On a copy of the live
114
+ * meta layer, a hundred entries written one statement at a time — which is what
115
+ * SQLite does without a transaction, each commit a write to disk — took
116
+ * **4452 ms**. The same hundred inside one transaction took **74 ms**, and a
117
+ * single statement costs about 26 ms on its own. A server that answers every
118
+ * request in one thread cannot spend four seconds of that on one client's save.
119
+ *
120
+ * The figures come from the live database and not from the suite on purpose: on
121
+ * an empty database a commit costs about nothing, so both forms measure under a
122
+ * millisecond there and a test cannot see the difference this makes. What it
123
+ * costs is paid in proportion to a database that exists.
124
+ *
125
+ * **`IMMEDIATE`, and the word is load-bearing.** A plain `BEGIN` is deferred:
126
+ * the transaction takes its read snapshot first and only tries to become a
127
+ * writer at the first write — and a write that has to *upgrade* a snapshot when
128
+ * somebody else holds the lock is refused at once, without consulting the busy
129
+ * handler `PRAGMA busy_timeout` installs. Measured against a second connection
130
+ * holding the lock: deferred `BEGIN` failed in 2.6–17 ms, while `BEGIN
131
+ * IMMEDIATE` (which asks for the lock up front, where waiting is allowed) and a
132
+ * bare `DELETE` both waited as configured. The writers here read before they
133
+ * write — a counter, a list of entries — so a deferred begin would refuse every
134
+ * save made while a scan was running, which is the one moment this arrangement
135
+ * exists for.
136
+ *
137
+ * Nested calls join the outer transaction rather than opening a second: SQLite
138
+ * has no nested `BEGIN`, and a stage that fell over halfway through should leave
139
+ * nothing of itself behind.
140
+ */
141
+ export function withTransaction(db, work) {
142
+ if (db.isTransaction)
143
+ return work();
144
+ db.exec('BEGIN IMMEDIATE');
145
+ try {
146
+ const result = work();
147
+ db.exec('COMMIT');
148
+ return result;
149
+ }
150
+ catch (err) {
151
+ // Guarded, and the guard is the point: whatever threw may have unwound the
152
+ // transaction already (`SQLITE_FULL`, a constraint that aborts), and a
153
+ // `ROLLBACK` with nothing to roll back throws its own error — which would
154
+ // replace the reason the caller needs with a complaint about the clean-up.
155
+ try {
156
+ db.exec('ROLLBACK');
157
+ }
158
+ catch {
159
+ // Already unwound; the original error is what matters.
160
+ }
161
+ throw err;
162
+ }
163
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Clear the issues a stage owns, so it can write the current run's.
3
+ *
4
+ * A stage that *derives* its rows must call this or it grows without bound:
5
+ * every reader scopes by run, so an earlier run's rows are read by nobody and
6
+ * removed by nobody. A collection rescanned daily grew one run's worth per day.
7
+ *
8
+ * A stage that records *events* must not. A healed root path and a twin whose
9
+ * rows were dropped happen once, and an unchanged root raises nothing on the
10
+ * next run — clearing would not replace those rows with an equal report, it
11
+ * would delete the only record that the event happened.
12
+ *
13
+ * Which of the two a row is decides the scope, and there are three:
14
+ *
15
+ * - the whole stage, for `cues` and `artists`, which walk every root on every
16
+ * run and re-derive all of it;
17
+ * - one kind, for `scan`, which does both: its root-settlement issues are
18
+ * events, while `walk_skipped` is re-read off the filesystem every time —
19
+ * the symlink it could not follow is still there next run;
20
+ * - one file, for `tags` and `playlists`, which re-read only the files the
21
+ * ledger reports changed or never read. Their rows describe the last
22
+ * attempt on that file, so the replacement is per file — and a file left
23
+ * unstamped on purpose (a permission error is transient, so the next scan
24
+ * retries) is reported again each run, which is exactly what a per-file
25
+ * clear keeps bounded.
26
+ */
27
+ /**
28
+ * Write a stage failure down, and say the run it belongs to is not ok.
29
+ *
30
+ * `scan_run.status` is a property of the whole run, and `scan` settles it when
31
+ * its own walk is done — which is right for the walk and wrong for the run: the
32
+ * stages after it can throw, and each rolls back only its own transaction, so
33
+ * the database is left holding some stages and not others under a row that says
34
+ * `ok`. A reader of that dump sees a healthy run over albums with no tracks, and
35
+ * cannot tell it from a collection that really is that empty. The status is
36
+ * settled here instead, and the failure is written down as well as raised:
37
+ * stderr is gone by the time anyone opens the database.
38
+ *
39
+ * `scan` is the other caller, and the reason this cannot simply live in the run:
40
+ * it throws before its transaction too — a statement that will not compile, a
41
+ * disk that will not read — and the run row is inserted before that transaction,
42
+ * so no rollback reaches it. The run id is what `scan` returns, so the caller
43
+ * cannot file the failure either. Left unwritten, the database says the scan is
44
+ * still going, indefinitely.
45
+ *
46
+ * The row is an *event*, not a derivation — nothing re-reads a stage failure off
47
+ * the filesystem — so no stage clears it wholesale. It describes one attempt,
48
+ * and it is scoped to the run that made it, which is what every reader scopes
49
+ * by. A later run that succeeds supersedes it in the only sense that matters.
50
+ */
51
+ export function recordStageFailure(db, runId, stage, err) {
52
+ const message = err instanceof Error ? err.message : String(err);
53
+ db.prepare(`INSERT INTO issue (scan_run_id, stage, root_id, rel_path, kind, severity, detail)
54
+ VALUES (?, ?, NULL, NULL, ?, 'warn', ?)`).run(runId, stage, `${stage}-failed`, `${stage} threw: ${message}`);
55
+ db.prepare('UPDATE scan_run SET status = ? WHERE id = ?').run('failed', runId);
56
+ }
57
+ export function clearIssues(db, stage, scope = {}) {
58
+ const conditions = ['stage = ?'];
59
+ const values = [stage];
60
+ if (scope.kind !== undefined) {
61
+ conditions.push('kind = ?');
62
+ values.push(scope.kind);
63
+ }
64
+ if (scope.rootId !== undefined) {
65
+ conditions.push('root_id = ?');
66
+ values.push(scope.rootId);
67
+ }
68
+ if (scope.relPath !== undefined) {
69
+ conditions.push('rel_path = ?');
70
+ values.push(scope.relPath);
71
+ }
72
+ db.prepare(`DELETE FROM issue WHERE ${conditions.join(' AND ')}`).run(...values);
73
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The ledger: what the last walk observed of each file, and what that is worth.
3
+ *
4
+ * `scan_state` is written by the scan and read by stages that own nothing of
5
+ * it. Tags asks which files need reading, the cue stage whether a stored
6
+ * measurement is still good, the dump whether a run found anything unmoved —
7
+ * and each of them used to join the table and decide for itself what `changed`
8
+ * and the run stamp meant, which made one rule with four copies (task:2683).
9
+ * The key, the verdict, and the currency of a verdict live here; a reader asks
10
+ * for the fragment it needs by the alias it gave the ledger.
11
+ *
12
+ * The sweep of the same table is the other reading of the same stamp, and lives
13
+ * in `sweep.ts`: this module asks what a row says about a file that is still
14
+ * there, that one asks what to do with a row whose file is not.
15
+ */
16
+ /**
17
+ * Join a file to the ledger entry describing it.
18
+ *
19
+ * A file and its entry are keyed alike — the root and the relative path — and
20
+ * that pair is the whole of the relationship, so it is written once and asked
21
+ * for by alias. Whether the caller writes `JOIN` or `LEFT JOIN` stays the
22
+ * caller's business: a file the walk has not seen twice has no entry at all,
23
+ * and whether that disqualifies it depends on the question being asked.
24
+ */
25
+ export function ledgerEntry(file, ledger) {
26
+ return `${ledger}.root_id = ${file}.root_id AND ${ledger}.rel_path = ${file}.rel_path`;
27
+ }
28
+ /**
29
+ * The recorded verdict that the filesystem reported the file exactly as the
30
+ * previous observation left it.
31
+ *
32
+ * Read, never recomputed. The comparison against the filesystem happened once,
33
+ * in the walk, where the size and the mtime were already in hand — `movedSince`
34
+ * is that comparison, and a reader that made it again would be stat-ing a file
35
+ * the walk has just walked. False for a file with no entry: nothing was
36
+ * observed, so nothing is known to be unchanged.
37
+ */
38
+ export function unmoved(ledger) {
39
+ return `${ledger}.changed = 0`;
40
+ }
41
+ /** The recorded verdict that the file moved since the previous observation. */
42
+ export function moved(ledger) {
43
+ return `${ledger}.changed = 1`;
44
+ }
45
+ /**
46
+ * The currency of a verdict: the entry was written by the run that last walked
47
+ * this file's root.
48
+ *
49
+ * A verdict is only worth anything while the run that wrote it is the run that
50
+ * last walked *that root*: naming one root in an invocation says nothing about
51
+ * another, and a verdict from a run that never saw the file would be worse than
52
+ * none. Asking for this is the caller's call — a stored whole-file duration is
53
+ * forgiven a stale verdict, the closing bound of a cue split is not.
54
+ */
55
+ export function current(ledger) {
56
+ const earlier = `earlier_${ledger}`;
57
+ return (`${ledger}.last_seen_run_id = ` +
58
+ `(SELECT MAX(${earlier}.last_seen_run_id) FROM scan_state ${earlier} ` +
59
+ `WHERE ${earlier}.root_id = ${ledger}.root_id)`);
60
+ }
61
+ /**
62
+ * Did the file move since the ledger recorded it?
63
+ *
64
+ * The comparison behind `changed`, and the only place it is made. It is then
65
+ * *stored* rather than left to readers, because the walk has already read the
66
+ * filesystem's answer and every later stage would have to read it again to
67
+ * reach the same verdict — which is the whole cost the ledger exists to avoid.
68
+ * A file the ledger holds no observation of has moved as far as anyone knows.
69
+ */
70
+ export function movedSince(previous, observed) {
71
+ return previous === undefined || previous.size !== observed.size || previous.mtime_ms !== observed.mtimeMs;
72
+ }
73
+ /**
74
+ * How many files the given run found exactly as the previous one left them.
75
+ *
76
+ * Scoped by the run named, not by the root: this answers "what did this run
77
+ * report", which is what a dump of that run is about.
78
+ */
79
+ export function unchangedCount(db, runId) {
80
+ const row = db
81
+ .prepare(`SELECT COUNT(*) AS n FROM scan_state WHERE last_seen_run_id = ? AND ${unmoved('scan_state')}`)
82
+ .get(runId);
83
+ return row.n;
84
+ }
@@ -0,0 +1,196 @@
1
+ -- Meta layer (v1) — the classified model the API will read from.
2
+ --
3
+ -- Two rules shape this schema and both come straight from the contract
4
+ -- (requirements:39):
5
+ --
6
+ -- 1. Album identity is the PATH (root-qualified). Nothing is ever merged by
7
+ -- tags or content, so pressings of the same album, or a copy of one album
8
+ -- sitting in two roots, are distinct rows.
9
+ -- 2. The filesystem is raw truth. Every row here is derived from it, and the
10
+ -- collection files themselves are never mutated — this layer is an overlay.
11
+
12
+ -- Scan bookkeeping -----------------------------------------------------------
13
+
14
+ CREATE TABLE scan_run (
15
+ id INTEGER PRIMARY KEY,
16
+ started_at TEXT NOT NULL,
17
+ finished_at TEXT,
18
+ status TEXT NOT NULL, -- running | ok | failed
19
+ roots_json TEXT NOT NULL -- JSON array of the configured root paths
20
+ );
21
+
22
+ -- Sources --------------------------------------------------------------------
23
+
24
+ CREATE TABLE root (
25
+ id INTEGER PRIMARY KEY,
26
+ path TEXT NOT NULL UNIQUE, -- absolute, as configured by the user
27
+ alias TEXT,
28
+ created_at TEXT NOT NULL
29
+ );
30
+
31
+ -- Folders, with the classification role the classifier assigns (album /
32
+ -- category / box / disc). NULL until the classifier has run over this folder.
33
+ CREATE TABLE folder (
34
+ id INTEGER PRIMARY KEY,
35
+ root_id INTEGER NOT NULL REFERENCES root(id) ON DELETE CASCADE,
36
+ rel_path TEXT NOT NULL, -- '' is the root itself
37
+ parent_rel_path TEXT,
38
+ role TEXT,
39
+ UNIQUE (root_id, rel_path)
40
+ );
41
+
42
+ -- Every file met on disk. Audio files are the primary truth; the rest are
43
+ -- sidecars (cue/nfo/log), artwork, playlists, or noise.
44
+ CREATE TABLE file (
45
+ id INTEGER PRIMARY KEY,
46
+ root_id INTEGER NOT NULL REFERENCES root(id) ON DELETE CASCADE,
47
+ rel_path TEXT NOT NULL,
48
+ folder_rel_path TEXT NOT NULL,
49
+ name TEXT NOT NULL,
50
+ kind TEXT NOT NULL, -- audio | cue | image | nfo | log | playlist | other
51
+ ext TEXT NOT NULL,
52
+ size INTEGER NOT NULL,
53
+ mtime_ms INTEGER NOT NULL,
54
+ first_seen_run_id INTEGER REFERENCES scan_run(id),
55
+ last_seen_run_id INTEGER REFERENCES scan_run(id),
56
+ UNIQUE (root_id, rel_path)
57
+ );
58
+
59
+ CREATE INDEX idx_file_kind ON file (root_id, kind);
60
+ CREATE INDEX idx_file_folder ON file (root_id, folder_rel_path);
61
+
62
+ -- ffprobe results. `probe_ok = 0` with a `probe_err` is a legitimate state:
63
+ -- ffprobe is optional, and a missing probe must degrade, not abort the scan.
64
+ CREATE TABLE audio_probe (
65
+ file_id INTEGER PRIMARY KEY REFERENCES file(id) ON DELETE CASCADE,
66
+ duration_ms INTEGER,
67
+ codec TEXT,
68
+ sample_rate INTEGER,
69
+ channels INTEGER,
70
+ bitrate INTEGER,
71
+ probe_ok INTEGER NOT NULL DEFAULT 0,
72
+ probe_err TEXT
73
+ );
74
+
75
+ -- Artists. `name_key` is the normalised merge key (Cure / The Cure / Cure, The
76
+ -- collapse to one). It stays UNIQUE, so one row still cannot be two artists at
77
+ -- once — but a homonym no longer has to share a key: the second artist folder to
78
+ -- claim a name gets `nirvana#2`, which cannot collide with a bare key because a
79
+ -- key carrying `#` has no letter or digit in it (`qualifiedKey`, src/artist).
80
+ --
81
+ -- `ambiguous = 1` therefore records that a row's identity is a *guess*, whichever
82
+ -- way it was reached: a key that merged spellings which fold differently, or a
83
+ -- key split apart because two artist folders both claimed it ("разные арт-папки
84
+ -- = разные артисты", wiki:3498 §3). An artist that won its name outright is the
85
+ -- only kind left unflagged; `artist-homonym` issues name what a split threw
86
+ -- together, and `ambiguous` alone never says which of the two happened.
87
+ CREATE TABLE artist (
88
+ id INTEGER PRIMARY KEY,
89
+ name TEXT NOT NULL,
90
+ name_key TEXT NOT NULL UNIQUE,
91
+ sort_key TEXT,
92
+ ambiguous INTEGER NOT NULL DEFAULT 0
93
+ );
94
+
95
+ -- A box set: one release spanning several discs, which also projects each disc
96
+ -- as its own album row. Discs show up two ways — as CD subfolders, or, when the
97
+ -- rip is flat, as one image+cue pair per disc.
98
+ CREATE TABLE release (
99
+ id INTEGER PRIMARY KEY,
100
+ root_id INTEGER NOT NULL REFERENCES root(id) ON DELETE CASCADE,
101
+ rel_path TEXT NOT NULL,
102
+ title TEXT,
103
+ artist_id INTEGER REFERENCES artist(id),
104
+ UNIQUE (root_id, rel_path)
105
+ );
106
+
107
+ CREATE TABLE album (
108
+ id INTEGER PRIMARY KEY,
109
+ root_id INTEGER NOT NULL REFERENCES root(id) ON DELETE CASCADE,
110
+ rel_path TEXT NOT NULL, -- identity — see rule 1 above
111
+ title TEXT,
112
+ artist_id INTEGER REFERENCES artist(id),
113
+ release_id INTEGER REFERENCES release(id),
114
+ disc_number INTEGER,
115
+ UNIQUE (root_id, rel_path)
116
+ );
117
+
118
+ CREATE INDEX idx_album_release ON album (release_id);
119
+
120
+ -- Cue documents. `audio_file_id` is resolved by matching the audio files that
121
+ -- are actually present, NOT by the cue's FILE tag — real rips carry a stale tag
122
+ -- (a .m4a declared as WAVE). NULL means no audio in the folder matched.
123
+ CREATE TABLE cue (
124
+ id INTEGER PRIMARY KEY,
125
+ file_id INTEGER NOT NULL UNIQUE REFERENCES file(id) ON DELETE CASCADE,
126
+ encoding TEXT,
127
+ encoding_confidence REAL,
128
+ audio_file_id INTEGER REFERENCES file(id)
129
+ );
130
+
131
+ -- Raw cue TRACK entries, in file order. Times are milliseconds; `index00_ms` is
132
+ -- the pregap and may legitimately be absent.
133
+ CREATE TABLE cue_track (
134
+ id INTEGER PRIMARY KEY,
135
+ cue_id INTEGER NOT NULL REFERENCES cue(id) ON DELETE CASCADE,
136
+ ordinal INTEGER NOT NULL,
137
+ title TEXT,
138
+ performer TEXT,
139
+ index00_ms INTEGER,
140
+ index01_ms INTEGER NOT NULL,
141
+ UNIQUE (cue_id, ordinal)
142
+ );
143
+
144
+ -- Playable units. A whole-file track leaves the segment NULL; a cue-split track
145
+ -- points at the image file and carries a time segment. Note that the *last*
146
+ -- split track has no following index to bound it — its end comes from the audio
147
+ -- file's real duration, which is why audio_probe exists.
148
+ CREATE TABLE track (
149
+ id INTEGER PRIMARY KEY,
150
+ album_id INTEGER REFERENCES album(id) ON DELETE CASCADE,
151
+ artist_id INTEGER REFERENCES artist(id),
152
+ ordinal INTEGER NOT NULL,
153
+ title TEXT,
154
+ file_id INTEGER NOT NULL REFERENCES file(id),
155
+ segment_start_ms INTEGER,
156
+ segment_end_ms INTEGER,
157
+ duration_ms INTEGER,
158
+ UNIQUE (album_id, ordinal)
159
+ );
160
+
161
+ CREATE INDEX idx_track_file ON track (file_id);
162
+
163
+ -- Incremental rescan bookkeeping: a file whose size and mtime are unchanged is
164
+ -- not re-read.
165
+ CREATE TABLE scan_state (
166
+ root_id INTEGER NOT NULL REFERENCES root(id) ON DELETE CASCADE,
167
+ rel_path TEXT NOT NULL,
168
+ size INTEGER NOT NULL,
169
+ mtime_ms INTEGER NOT NULL,
170
+ last_seen_run_id INTEGER NOT NULL REFERENCES scan_run(id),
171
+ PRIMARY KEY (root_id, rel_path)
172
+ );
173
+
174
+ -- "What the scanner did not understand." Every guess, skip and unmatched cue
175
+ -- lands here so a scan can never lose information silently.
176
+ CREATE TABLE issue (
177
+ id INTEGER PRIMARY KEY,
178
+ scan_run_id INTEGER REFERENCES scan_run(id) ON DELETE CASCADE,
179
+ root_id INTEGER REFERENCES root(id),
180
+ rel_path TEXT,
181
+ kind TEXT NOT NULL,
182
+ severity TEXT NOT NULL DEFAULT 'warn',
183
+ detail TEXT
184
+ );
185
+
186
+ CREATE INDEX idx_issue_run ON issue (scan_run_id, kind);
187
+
188
+ -- Search layer (spec §2) ----------------------------------------------------
189
+ -- Populated by a later stage; declared here so the meta layer is complete.
190
+
191
+ CREATE VIRTUAL TABLE track_fts USING fts5 (
192
+ title,
193
+ artist,
194
+ album,
195
+ tokenize = 'unicode61 remove_diacritics 2'
196
+ );
@@ -0,0 +1,35 @@
1
+ -- Two things a cue sheet can legally do, neither of which the first schema
2
+ -- could store. Both crashed a scan outright rather than being reported.
3
+ --
4
+ -- 1. Declare a TRACK with no INDEX 01. The parser reports this honestly as a
5
+ -- null index, and the planner already flags it — but `index01_ms` was
6
+ -- NOT NULL, so the insert threw and took the whole run down with it.
7
+ --
8
+ -- 2. Span several files, restarting TRACK numbering at each FILE. The matrix
9
+ -- calls this out explicitly ("FILE CD1.flac" + "FILE CD2.flac") and the
10
+ -- parser records the file index, but the old UNIQUE (cue_id, ordinal)
11
+ -- saw the second FILE's TRACK 01 as a duplicate and threw.
12
+ --
13
+ -- SQLite cannot alter a constraint, so the table is rebuilt.
14
+
15
+ CREATE TABLE cue_track_new (
16
+ id INTEGER PRIMARY KEY,
17
+ cue_id INTEGER NOT NULL REFERENCES cue(id) ON DELETE CASCADE,
18
+ -- Which FILE of the cue this track belongs to; 0 for the common single-file
19
+ -- case, so numbering restarts legitimately per file.
20
+ file_index INTEGER NOT NULL DEFAULT 0,
21
+ ordinal INTEGER NOT NULL,
22
+ title TEXT,
23
+ performer TEXT,
24
+ index00_ms INTEGER,
25
+ -- Nullable: a declared track may have no index. Callers report it.
26
+ index01_ms INTEGER,
27
+ UNIQUE (cue_id, file_index, ordinal)
28
+ );
29
+
30
+ INSERT INTO cue_track_new (id, cue_id, file_index, ordinal, title, performer, index00_ms, index01_ms)
31
+ SELECT id, cue_id, 0, ordinal, title, performer, index00_ms, index01_ms FROM cue_track;
32
+
33
+ DROP TABLE cue_track;
34
+
35
+ ALTER TABLE cue_track_new RENAME TO cue_track;