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,30 @@
1
+ -- Two lookups that were scanning a table to answer a question about one row.
2
+ --
3
+ -- Both were found by measuring the playlist-file stage, and neither is about
4
+ -- playlists: they are lookups the API and the importer make, and both grew with
5
+ -- the size of the collection rather than with the size of the question.
6
+
7
+ -- A file by its path.
8
+ --
9
+ -- The unique index on `file (root_id, rel_path)` is declared with the default
10
+ -- BINARY collation, and the importer compares paths with `COLLATE NOCASE` on
11
+ -- Windows — which names the same file there (`01.FLAC` and `01.flac` do not
12
+ -- both exist in one folder). A comparison whose collation differs from the
13
+ -- index's cannot use it, so each entry of a `.m3u` walked every file of the
14
+ -- root: measured on this collection, 80 µs for a path near the start of the
15
+ -- walk and 1083 µs near the end — a list of 5000 entries cost 14 seconds, and a
16
+ -- collection three times larger would have cost three times that with no new
17
+ -- playlist files at all.
18
+ --
19
+ -- On other platforms the importer compares without `COLLATE` and keeps using
20
+ -- the unique index; this one is simply not chosen there, and costs the space it
21
+ -- takes.
22
+ CREATE INDEX file_rel_path_nocase ON file (root_id, rel_path COLLATE NOCASE);
23
+
24
+ -- A playlist by its name.
25
+ --
26
+ -- Asked once per imported `.m3u`, to tell two lists with the same file name
27
+ -- apart (`playlist/import.ts`). Nothing indexed `name`, so each question read
28
+ -- the whole table: quadratic in the number of playlists, cheap today and not
29
+ -- worth leaving as the third such leg.
30
+ CREATE INDEX playlist_name ON playlist (name);
@@ -0,0 +1,47 @@
1
+ -- What the listener marked: stars and ratings.
2
+ --
3
+ -- The second half of the listener's own layer — playlists are the first — and
4
+ -- it shares their rule: a scan does not touch it, because nothing on disk says
5
+ -- what somebody thought of a record.
6
+ --
7
+ -- It differs from a playlist in the one way that decides the schema. A playlist
8
+ -- is a thing of its own; an annotation is *about a row of the model*, so what it
9
+ -- needs is a foreign key to that row and a cascade from it. A star on a track
10
+ -- whose file left the disk is a star on nothing, and nothing is what should be
11
+ -- left of it.
12
+ --
13
+ -- **Three tables rather than one with a `kind` column**, and that is the whole
14
+ -- reason: a polymorphic column cannot carry a foreign key, so `annotation(kind,
15
+ -- item_id)` would leave the cascade to a hand-written sweep somewhere — and a
16
+ -- sweep is a thing that can be forgotten, while `ON DELETE CASCADE` cannot.
17
+ -- The three are written once here and spoken of once in `annotation/store.ts`,
18
+ -- which is where the duplication costs something.
19
+ --
20
+ -- A row exists only while it says something: unstarring the last star and
21
+ -- setting the rating back to zero delete it rather than leaving a row of NULLs,
22
+ -- so `starred_at IS NOT NULL` and `rating` say the same thing in the table as
23
+ -- they do in an answer.
24
+ CREATE TABLE track_annotation (
25
+ track_id INTEGER PRIMARY KEY REFERENCES track(id) ON DELETE CASCADE,
26
+ starred_at TEXT,
27
+ rating INTEGER
28
+ );
29
+
30
+ CREATE TABLE album_annotation (
31
+ album_id INTEGER PRIMARY KEY REFERENCES album(id) ON DELETE CASCADE,
32
+ starred_at TEXT,
33
+ rating INTEGER
34
+ );
35
+
36
+ CREATE TABLE artist_annotation (
37
+ artist_id INTEGER PRIMARY KEY REFERENCES artist(id) ON DELETE CASCADE,
38
+ starred_at TEXT,
39
+ rating INTEGER
40
+ );
41
+
42
+ -- The starred listings are the one query that reads a whole table here rather
43
+ -- than a row, and they order by when: an index keeps that from sorting every
44
+ -- annotation of every kind.
45
+ CREATE INDEX track_annotation_starred ON track_annotation (starred_at);
46
+ CREATE INDEX album_annotation_starred ON album_annotation (starred_at);
47
+ CREATE INDEX artist_annotation_starred ON artist_annotation (starred_at);
@@ -0,0 +1,28 @@
1
+ -- The ledger question `current()` asks, indexed.
2
+ --
3
+ -- `db/ledger.ts` asks whether a ledger row was written by the run that last
4
+ -- walked its root, and it asks by comparing that row's run against the newest
5
+ -- one for the same root:
6
+ --
7
+ -- ss.last_seen_run_id = (SELECT MAX(e.last_seen_run_id) FROM scan_state e
8
+ -- WHERE e.root_id = ss.root_id)
9
+ --
10
+ -- Nothing indexed `(root_id, last_seen_run_id)` on `scan_state`, so that `MAX`
11
+ -- read every row of the root — and the fragment is a predicate inside a lookup,
12
+ -- not a query of its own, so it was asked once per lookup rather than once per
13
+ -- stage. That is the whole difference: measured on the live collection, the cue
14
+ -- stage's stored-probe lookup made **3347** calls and cost **1050 ms** of a
15
+ -- 1756 ms stage. With this index the same stage takes **709 ms**, and the lookup
16
+ -- no longer appears among the stage's costs at all (task:2880).
17
+ --
18
+ -- `folder`, `album` and `release` were given this index in 005, for the same
19
+ -- reason — "did this run see it" is a question about the newest run for a root.
20
+ -- `scan_state` was left out, and that cost nothing while the ledger was read
21
+ -- once per root. It stopped being free when a stage began asking it per file:
22
+ -- `tags` asks it to decide which files to read, and the cue stage asks it
23
+ -- whether a stored measurement is still good.
24
+ --
25
+ -- The pair is the shape the question has, in that order: `root_id` is the
26
+ -- equality, `last_seen_run_id` the aggregate — so the `MAX` is answered by the
27
+ -- last entry of the range rather than by reading it.
28
+ CREATE INDEX scan_state_root_run ON scan_state (root_id, last_seen_run_id);
@@ -0,0 +1,108 @@
1
+ -- What was played, what is playing, and the queue a listener left behind.
2
+ --
3
+ -- The listener's own layer again, and the third part of it: playlists are what
4
+ -- they arranged, stars are what they thought, and these are what they *did*.
5
+ -- Nothing on disk states any of it, so no scan writes here — and, like the other
6
+ -- two, a scan that takes a row away takes what hangs off it through the cascade.
7
+ --
8
+ -- Five tables rather than two, and the split is the protocol's own. A `play` is
9
+ -- history: it happened, and the row is the only record that it did. `now_playing`
10
+ -- is state: one row per player, replaced as the player moves. The queue is state
11
+ -- too, but the listener's rather than a player's — it exists so a queue can be
12
+ -- resumed on another device, which is why `getPlayQueue` takes no player and why
13
+ -- its entries are a table of their own (`play_queue_entry`) beside it. `player`
14
+ -- is the fifth, and it is not one of the three states but the row that turns a
15
+ -- client's own name into the integer the protocol asks for — see below.
16
+ --
17
+ -- The count is stated because it was wrong here: this said four while creating
18
+ -- five, and a reader counting the tables found a comment that disagreed with the
19
+ -- file beside it.
20
+
21
+ -- Who is playing. The protocol requires a `playerId` (an integer) and an
22
+ -- optional `playerName` on every now-playing entry, and **no endpoint accepts
23
+ -- either**: the server is meant to derive them. A client names itself with the
24
+ -- protocol's own `c` parameter, so that is the name, and this table is what
25
+ -- turns it into the stable small integer the protocol asks for. Deriving the
26
+ -- number from the name by hashing would be the other way; a row is simpler and
27
+ -- it can be read back.
28
+ CREATE TABLE player (
29
+ id INTEGER PRIMARY KEY,
30
+ name TEXT NOT NULL UNIQUE
31
+ );
32
+
33
+ -- A play that happened.
34
+ --
35
+ -- **`ON DELETE CASCADE` on the track**, which is a decision rather than an
36
+ -- obvious truth: a scrobble is a statement about a *file*, and Last.fm would
37
+ -- keep it after the file left. This meta layer's rule is the other one — every
38
+ -- row below `root` is a reading of what is on disk, and a reading of a file that
39
+ -- is not there is a reading of nothing. A history that outlived its collection
40
+ -- would also have nowhere to point: `track_id` is NOT NULL, so the alternatives
41
+ -- were a nullable column and a client that has to be told what a row without a
42
+ -- track means.
43
+ CREATE TABLE play (
44
+ id INTEGER PRIMARY KEY,
45
+ track_id INTEGER NOT NULL REFERENCES track(id) ON DELETE CASCADE,
46
+ player_id INTEGER NOT NULL REFERENCES player(id),
47
+ -- When the play happened, as far as this server knows. The protocol lets a
48
+ -- client state it — `time`, optional, and the only thing that dates a play a
49
+ -- client made offline — and when it does not, this is when the server was told.
50
+ played_at TEXT NOT NULL
51
+ );
52
+
53
+ CREATE INDEX play_track ON play (track_id);
54
+ CREATE INDEX play_played_at ON play (played_at);
55
+
56
+ -- What each player is playing now, and how far in.
57
+ --
58
+ -- One row per player and not per user, because the protocol's question is "what
59
+ -- is being played by all users" and the only thing that tells two of them apart
60
+ -- here is the client. `state`, `position_ms` and `playback_rate` come from
61
+ -- `reportPlayback` (the `playbackReport` extension) and are what `getNowPlaying`
62
+ -- reports back when a server supports it.
63
+ --
64
+ -- **Nothing expires a row.** An entry stays until its player says otherwise, so
65
+ -- a client that vanishes mid-track leaves one behind and `minutesAgo` is what
66
+ -- tells a reader how old it is. That is the honest reading of the protocol —
67
+ -- which describes `minutesAgo` as "last update" and leaves the reader to judge —
68
+ -- and the alternative, a timeout, would be this server inventing a rule about
69
+ -- how long a track is that no client agrees with.
70
+ CREATE TABLE now_playing (
71
+ player_id INTEGER PRIMARY KEY REFERENCES player(id),
72
+ track_id INTEGER NOT NULL REFERENCES track(id) ON DELETE CASCADE,
73
+ updated_at TEXT NOT NULL,
74
+ state TEXT,
75
+ position_ms INTEGER,
76
+ playback_rate REAL
77
+ );
78
+
79
+ -- The queue, and there is one of it.
80
+ --
81
+ -- `savePlayQueue`/`getPlayQueue` are per *user*, and this server has one — so
82
+ -- the row is single, like `playlist_sequence`'s counter, and for the same
83
+ -- reason: a table that could hold two rows would be a table where a caller has
84
+ -- to know which one it meant.
85
+ --
86
+ -- `current_index` is the protocol's `currentIndex` — a 0-based position in the
87
+ -- entries — and `getPlayQueue`, which addresses the current track by *id*
88
+ -- instead, is answered by looking that id up at this position. The two endpoints
89
+ -- read one queue because the extension exists to fix the older one's ambiguity
90
+ -- (a queue may hold the same song twice), not to keep a second queue.
91
+ CREATE TABLE play_queue (
92
+ only_row INTEGER PRIMARY KEY CHECK (only_row = 1),
93
+ current_index INTEGER,
94
+ position_ms INTEGER NOT NULL DEFAULT 0,
95
+ changed_at TEXT NOT NULL,
96
+ changed_by TEXT NOT NULL
97
+ );
98
+
99
+ -- A song's place in the queue. `position` is the order, laid out from zero by
100
+ -- every save — and a track deleted by a sweep leaves its number unused, exactly
101
+ -- as it does in `playlist_track`. That is what makes the number stable, and the
102
+ -- stability is load-bearing: the queue's current track is stored *as* one of
103
+ -- these numbers (031), so it has to mean the same seat after a deletion as it
104
+ -- did before one.
105
+ CREATE TABLE play_queue_entry (
106
+ position INTEGER PRIMARY KEY,
107
+ track_id INTEGER NOT NULL REFERENCES track(id) ON DELETE CASCADE
108
+ );
@@ -0,0 +1,46 @@
1
+ -- The queue's current track is a seat, not an index, and what plays add up to.
2
+ --
3
+ -- Three corrections to `030_history.sql`, all found by the two review axes of
4
+ -- the same change (task:2863) — one of them independently reproduced.
5
+
6
+ -- **`current_index` was a position read as an index.**
7
+ --
8
+ -- The column was meant as a position in the saved order, but `queue()` hands
9
+ -- back the entries that survived a sweep, compacted, and the API read one as the
10
+ -- other. A track deleted *ahead* of the current one shifts every ordinal after
11
+ -- it, so `getPlayQueue` announced a different song as playing; delete the
12
+ -- current track itself and the stored number ran past the end, so the field was
13
+ -- dropped while the queue was not empty — breaking the specification's own
14
+ -- "must ensure that `current` exists" for exactly the case it describes.
15
+ --
16
+ -- Reproduced against the module: queue `[tr:1…tr:5]` with `current=tr:3`,
17
+ -- `tr:1` deleted → `current: "tr:4"`.
18
+ --
19
+ -- `play_queue_entry.position` does not shift: a deleted entry leaves its number
20
+ -- unused, which is the whole reason the entries are numbered rather than
21
+ -- indexed. So this is the number to store, and `currentIndex` — the ordinal the
22
+ -- extension reports — is derived by counting the survivors before it.
23
+ ALTER TABLE play_queue RENAME COLUMN current_index TO current_position;
24
+
25
+ -- **What each track's plays add up to**, for the two fields the protocol puts on
26
+ -- every `Child`: `playCount` and `played`.
27
+ --
28
+ -- `play` is the history and stays the record of what happened. This is a rollup
29
+ -- of it, and it exists because the alternative is an aggregate per row in every
30
+ -- listing: `SONG_SELECT` feeds `getAlbum`, `getArtist`, `search3`, the folder
31
+ -- tree and both starred listings, and a correlated count there is one query
32
+ -- multiplied by whatever the listing returns — the cost this project has paid
33
+ -- for twice already (task:2806, task:2807).
34
+ CREATE TABLE track_play (
35
+ track_id INTEGER PRIMARY KEY REFERENCES track(id) ON DELETE CASCADE,
36
+ play_count INTEGER NOT NULL DEFAULT 0,
37
+ played_at TEXT
38
+ );
39
+
40
+ -- **An index with no reader.**
41
+ --
42
+ -- Nothing orders or filters by `played_at`: a track's history is read by track
43
+ -- (`track_play` for the fields, `play_track` for the cascade), and no query asks
44
+ -- "what was played between these times" yet. An index nobody reads is space and
45
+ -- a write cost on every scrobble, so it goes until a query claims it.
46
+ DROP INDEX play_played_at;
@@ -0,0 +1,27 @@
1
+ -- Where the listener stopped.
2
+ --
3
+ -- The fourth part of the listener's own layer — playlists are what they
4
+ -- arranged, stars what they thought, plays what they did, and this is how far
5
+ -- they got. Nothing on disk states it, so no scan writes here, and what a scan
6
+ -- takes away goes through the cascade.
7
+ --
8
+ -- **Keyed by the song, with no id of its own.** One listener, one place per
9
+ -- track, which is the protocol's own rule in its own words: "if a bookmark
10
+ -- already exists for this file it will be overwritten". A second column of
11
+ -- identity would be a second bookmark per song, and a client with two resume
12
+ -- points and no way to choose between them.
13
+ --
14
+ -- `position_ms` is where in the *song* — which for a track cut out of a cue
15
+ -- image is the track's own timeline and not the image's, exactly as `duration`
16
+ -- is: a position is what a client seeks to, and it seeks within what it was
17
+ -- given.
18
+ CREATE TABLE bookmark (
19
+ track_id INTEGER PRIMARY KEY REFERENCES track(id) ON DELETE CASCADE,
20
+ position_ms INTEGER NOT NULL,
21
+ comment TEXT,
22
+ -- Both, and they part company on the second write: a client that moves the
23
+ -- mark has not made a new one, and the difference is the only thing that says
24
+ -- whether a bookmark is the one somebody left or one they keep moving.
25
+ created_at TEXT NOT NULL,
26
+ changed_at TEXT NOT NULL
27
+ );
@@ -0,0 +1,14 @@
1
+ -- The order bookmarks are read in, indexed.
2
+ --
3
+ -- `bookmark/store.ts` reads the whole table `ORDER BY changed_at DESC, track_id`
4
+ -- — a list of where somebody stopped is a list of what they are in the middle
5
+ -- of, and the one they last listened to is the first — and `032_bookmarks.sql`
6
+ -- declared no index at all. So the listing sorted the table every time, which is
7
+ -- the shape `028_annotations.sql` names for its own three tables and `029` for
8
+ -- the ledger: a reader that orders by a column is a reader that wants it
9
+ -- indexed.
10
+ --
11
+ -- `track_id` is in the index and not only in the query because SQLite has to
12
+ -- break a tie by something, and a tie broken by a column the index does not
13
+ -- carry is a rowid walk per equal pair.
14
+ CREATE INDEX bookmark_changed ON bookmark (changed_at DESC, track_id);
@@ -0,0 +1,33 @@
1
+ -- What the collection holds that is not a record, and who said so.
2
+ --
3
+ -- The contract asks for a junk filter that hides what is not music from the
4
+ -- default view and **never deletes anything** (requirements:47 §11). Two halves,
5
+ -- stored apart because they have different owners:
6
+ --
7
+ -- 1. `album.junk_reason` is *derived*, like every other column classify
8
+ -- writes: it is re-computed from the folder on every pass, so it follows
9
+ -- the disk and a rescan can change its mind. NULL means "a record".
10
+ -- 2. `junk_mark` is *said*, and only a person says it. It is what the
11
+ -- contract calls the allow/block edit, and it outlives everything: it is
12
+ -- keyed on the path and not on the album row, so a folder that vanishes
13
+ -- and comes back is still marked, and a mark for a folder that is gone is
14
+ -- kept rather than swept — a statement about a path does not stop being
15
+ -- true because the disk is unavailable.
16
+ --
17
+ -- The two are not equal partners: classify reads the marks and lets them win
18
+ -- over its own reading, which is why `source` is on the row. The rule the
19
+ -- project keeps for every derived field (Q15, brainstorm:190) is that the
20
+ -- *owner* of a field decides and never "whoever wrote last" — here the owner is
21
+ -- the person, and the scan is the default they override.
22
+
23
+ -- Why an album is not a record, or NULL when it is one. Written by classify.
24
+ ALTER TABLE album ADD COLUMN junk_reason TEXT;
25
+
26
+ CREATE TABLE junk_mark (
27
+ root_id INTEGER NOT NULL,
28
+ rel_path TEXT NOT NULL,
29
+ verdict TEXT NOT NULL CHECK (verdict IN ('junk', 'trust')),
30
+ note TEXT,
31
+ marked_at TEXT NOT NULL,
32
+ PRIMARY KEY (root_id, rel_path)
33
+ );
@@ -0,0 +1,38 @@
1
+ -- The keys a client may present instead of a password, and the one that is not here.
2
+ --
3
+ -- The OpenSubsonic extension `apiKeyAuthentication` is not a parameter, it is a
4
+ -- pair of promises: a key that arrives is a whole credential, and a server that
5
+ -- accepts one **must** offer a way to see the keys it is accepting and to take
6
+ -- one back. This project honoured the first half from the beginning — the
7
+ -- parameter worked, once `auth.ts` stopped demanding `u` beside it — and had no
8
+ -- way at all to do the second: the key lived in a variable in `start.cmd`,
9
+ -- readable only by reading that file on the server, and revocable only by
10
+ -- editing it and restarting the daemon (task:2915).
11
+ --
12
+ -- So this is the registry that makes the second half true. A key added here can
13
+ -- be listed and revoked while the server is running, which is what taking a key
14
+ -- back from a device somebody lost has to mean.
15
+ --
16
+ -- **The environment's key is deliberately not in this table.** `FUNOTEKA_APIKEY`
17
+ -- stays exactly what it was — checked first, always valid — and it is the
18
+ -- credential that must survive a database. A key whose only copy lives in the
19
+ -- database cannot recover that database, and a bootstrap credential that
20
+ -- `revoke` could silently undo on the next restart would be worse than none. The
21
+ -- CLI reports it as what it is and says where it is revoked.
22
+ --
23
+ -- A revoked row is kept rather than deleted. Its `secret` stays under the unique
24
+ -- index, so a key that was taken back cannot be added again by accident and
25
+ -- quietly become valid — and "when was this taken back" is a question the
26
+ -- registry should be able to answer, which a deleted row cannot.
27
+ CREATE TABLE api_key (
28
+ id INTEGER PRIMARY KEY,
29
+ -- What the person called it: "the tablet", "Symfonium on the phone". A key
30
+ -- nobody can tell from another is a key nobody dares revoke.
31
+ label TEXT NOT NULL,
32
+ secret TEXT NOT NULL,
33
+ created_at TEXT NOT NULL,
34
+ revoked_at TEXT
35
+ );
36
+
37
+ -- Unique across revoked rows too — see the note above.
38
+ CREATE UNIQUE INDEX api_key_secret ON api_key (secret);
@@ -0,0 +1,88 @@
1
+ -- The eight tags a song listing asks for, as one row a file.
2
+ --
3
+ -- A listing builds a `Child` per song, and eight of that payload's fields come
4
+ -- from `file_tag`: the genre, the track artist, the two advisories, and the four
5
+ -- ReplayGain numbers. **A page of five hundred songs costs 10.04 ms reading them
6
+ -- the obvious way — one correlated subquery per name, which is what the API did
7
+ -- — and 6.37 ms reading them off this row**, measured 2026-09-16 on a copy of the
8
+ -- live collection, the two statements interleaved in one run (task:2925).
9
+ --
10
+ -- **That shape is not the defect, and the measurement says so.** A correlated
11
+ -- scalar subquery here is one index seek through
12
+ -- `sqlite_autoindex_file_tag_1 (file_id, name, position)`, with `ORDER BY
13
+ -- position LIMIT 1` free, and an absent name costs a seek that finds nothing.
14
+ -- Every alternative was measured and every one is worse: the same eight as one
15
+ -- `json_group_object` is no faster and answers differently; a `LEFT JOIN` to a
16
+ -- derived pivot — the shape `ALBUM_GENRES` uses — cannot be restricted to the
17
+ -- page, so it materialises over the whole tag table and multiplies the page's
18
+ -- cost by an order of magnitude (51 ms against 11.29 in the run that measured
19
+ -- the alternatives); a covering index `(file_id, name, position, value)` is 13
20
+ -- times worse forced (the planner will not choose it: it gives up the free
21
+ -- ordering); and `WITHOUT ROWID` is 5 times worse. What is left is that eight
22
+ -- seeks per song are eight seeks per song.
23
+ --
24
+ -- So the values move out of the tag table, and a file's row is joined the way
25
+ -- `audio_probe` already is — one primary-key lookup on a table of one row per
26
+ -- file. The join is not quite free: a page with no tag columns at all is 6.56 ms
27
+ -- in the run that put the eight subqueries at 11.29.
28
+ --
29
+ -- **The rule is the API's own, copied exactly**, because these columns are what
30
+ -- a `Child` is shown: the *first value by position* of each name, trimmed of the
31
+ -- whitespace a tag can hide in, with an empty result treated as no value at all
32
+ -- so that a second line falls through to a first. `TRIM` is spelled with its
33
+ -- character set and not with SQLite's one-argument form, which removes spaces
34
+ -- and leaves a tab — the same trap `meta.ts` spells out beside `trimmed`.
35
+ --
36
+ -- Written by the tags stage from this migration onward (`tags/first.ts` holds
37
+ -- the live statement; this is the frozen backfill), so a file whose tags are
38
+ -- read again gets its row rewritten in the same transaction that rewrote the
39
+ -- tags. A file that has never been read has no row, and the join answers null —
40
+ -- which is what the subquery answered too.
41
+ CREATE TABLE file_tag_first (
42
+ file_id INTEGER PRIMARY KEY REFERENCES file(id) ON DELETE CASCADE,
43
+ genre TEXT,
44
+ track_artist TEXT,
45
+ advisory_itunes TEXT,
46
+ advisory_mp4 TEXT,
47
+ rg_track_gain TEXT,
48
+ rg_album_gain TEXT,
49
+ rg_track_peak TEXT,
50
+ rg_album_peak TEXT
51
+ );
52
+
53
+ -- The collection as it stands, once. From here the stage keeps it.
54
+ INSERT INTO file_tag_first (
55
+ file_id, genre, track_artist, advisory_itunes, advisory_mp4,
56
+ rg_track_gain, rg_album_gain, rg_track_peak, rg_album_peak
57
+ )
58
+ SELECT file_id,
59
+ MAX(CASE WHEN name = 'genre' THEN value END),
60
+ MAX(CASE WHEN name = 'artist' THEN value END),
61
+ MAX(CASE WHEN name = 'itunesadvisory' THEN value END),
62
+ MAX(CASE WHEN name = 'rtng' THEN value END),
63
+ MAX(CASE WHEN name = 'replaygain_track_gain' THEN value END),
64
+ MAX(CASE WHEN name = 'replaygain_album_gain' THEN value END),
65
+ MAX(CASE WHEN name = 'replaygain_track_peak' THEN value END),
66
+ MAX(CASE WHEN name = 'replaygain_album_peak' THEN value END)
67
+ FROM (
68
+ SELECT ft.file_id,
69
+ ft.name,
70
+ TRIM(ft.value, ' ' || CHAR(9) || CHAR(10) || CHAR(13) || CHAR(160) || CHAR(12288)) AS value,
71
+ ROW_NUMBER() OVER (PARTITION BY ft.file_id, ft.name ORDER BY ft.position) AS rn
72
+ FROM file_tag ft
73
+ WHERE ft.name IN ('genre', 'artist', 'itunesadvisory', 'rtng',
74
+ 'replaygain_track_gain', 'replaygain_album_gain',
75
+ 'replaygain_track_peak', 'replaygain_album_peak')
76
+ AND TRIM(ft.value, ' ' || CHAR(9) || CHAR(10) || CHAR(13) || CHAR(160) || CHAR(12288)) <> ''
77
+ )
78
+ WHERE rn = 1
79
+ GROUP BY file_id;
80
+
81
+ -- The `MAX` above is exact rather than a choice: `rn = 1` leaves one row per
82
+ -- (file, name), so the aggregate has nothing to choose between. That is the
83
+ -- whole reason for the window function — the cheaper-looking `MIN(position)`
84
+ -- beside a bare `value` reads the value out of whichever row the aggregate
85
+ -- happened to keep, which SQLite guarantees only while the subquery holds
86
+ -- exactly one aggregate. `albumGenres` in `meta.ts` declines that form at
87
+ -- length; this declines it for the same reason and pays the same few
88
+ -- milliseconds for it, once.
@@ -0,0 +1,31 @@
1
+ -- What a mutation answered, kept by the key the caller gave it.
2
+ --
3
+ -- A control surface is called by an agent or a script over a network, and a
4
+ -- network gives two answers that look alike from the caller's side: the request
5
+ -- that was never received, and the answer that was lost on the way back. A
6
+ -- caller that retries cannot tell them apart, and for `config set` a retry is
7
+ -- harmless while for a rotation it is not. An `Idempotency-Key` header is how
8
+ -- the caller says "this is the same request", and this table is where the first
9
+ -- answer is kept so the second one can be given it instead of doing the work
10
+ -- twice.
11
+ --
12
+ -- In the database rather than in memory, and that is not a detail: `POST
13
+ -- /restart` exists, it is in this same surface, and a server that had just
14
+ -- restarted would have forgotten every key it had ever been given — which is
15
+ -- exactly the moment a client retries.
16
+ --
17
+ -- The body is stored as the text that was sent, with its status, because a
18
+ -- replay has to be indistinguishable from the answer it repeats: a caller that
19
+ -- got a different body the second time would be right to conclude the operation
20
+ -- ran twice.
21
+ CREATE TABLE admin_idempotency (
22
+ key TEXT PRIMARY KEY,
23
+ method TEXT NOT NULL,
24
+ path TEXT NOT NULL,
25
+ status INTEGER NOT NULL,
26
+ body TEXT NOT NULL,
27
+ created_at TEXT NOT NULL
28
+ );
29
+
30
+ -- Pruning is by age, and age is the only thing it is ever asked for.
31
+ CREATE INDEX admin_idempotency_age ON admin_idempotency (created_at);
@@ -0,0 +1,24 @@
1
+ -- The cue that plays an audio file, found by that file.
2
+ --
3
+ -- `cue.audio_file_id` is what every reader of a cue track joins on: the
4
+ -- inventory's performer column, its "audio files with no track or cue" counter,
5
+ -- and the artist stage's own binding of a cue to the file it came from. The
6
+ -- table carried only its own primary key, so each of those questions was a full
7
+ -- scan of `cue` — and one of them is asked once per track.
8
+ --
9
+ -- Measured on a 472-album collection, 5054 tracks against 302 cues: the
10
+ -- inventory's performer subquery cost 120.7 ms of the dump's 171.0 ms, because
11
+ -- every track read all 302 cues. With this index the same subquery is 28.4 ms
12
+ -- and the whole dump 72.7 ms.
13
+ --
14
+ -- **The review that found this attributed the cost to the wrong thing**, and the
15
+ -- correction is worth keeping: `GET /inventory` ran 1416 statements (three per
16
+ -- album) and the dump was read as "73% statement executions". Executing the same
17
+ -- three statements once for the whole collection — which is a real improvement
18
+ -- and is kept — bought only about 20%. The executions were expensive for the
19
+ -- rows they read, not for their number, and the rows were expensive because this
20
+ -- index was missing.
21
+ --
22
+ -- Nothing about the answers changes: this is an index, not a redefinition, so no
23
+ -- reader's output moves by a byte.
24
+ CREATE INDEX idx_cue_audio_file ON cue (audio_file_id);
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The tables, in the order they may be emptied.
3
+ *
4
+ * `album` before `release`, because an album's `release_id` names a release and
5
+ * a release emptied first would leave the albums of that box behind. The rest
6
+ * is the order the two stages have always emptied their tables in, and it is
7
+ * free to be arbitrary: nothing constrains `file` and `folder`, which are
8
+ * joined by path rather than by key.
9
+ */
10
+ const DERIVED = [
11
+ {
12
+ table: 'file',
13
+ lateStamp: false,
14
+ detach: [
15
+ { action: 'delete', from: 'track', column: 'file_id' },
16
+ { action: 'null', from: 'cue', column: 'audio_file_id' },
17
+ ],
18
+ },
19
+ { table: 'folder', lateStamp: true, detach: [] },
20
+ { table: 'scan_state', lateStamp: false, detach: [] },
21
+ { table: 'album', lateStamp: true, detach: [] },
22
+ {
23
+ table: 'release',
24
+ lateStamp: true,
25
+ // Belt and braces, and said plainly rather than dressed up: nothing reaches
26
+ // this today. Every album that outlives the sweep above was re-derived from
27
+ // a surviving folder on this run, and re-deriving an album writes its
28
+ // `release_id` itself — so no surviving album can still name a doomed
29
+ // release. Removing this changes no test. It stays because the foreign key
30
+ // is declared without a cascade, and the cost of being wrong about the
31
+ // reachability is a run that fails on its last step.
32
+ detach: [{ action: 'null', from: 'album', column: 'release_id' }],
33
+ },
34
+ ];
35
+ /** What a walk leaves behind, and what the scan takes away when it is gone. */
36
+ export const SWEPT_BY_SCAN = ['file', 'folder', 'scan_state'];
37
+ /** What classification derives from the folders the walk left standing. */
38
+ export const SWEPT_BY_CLASSIFY = ['album', 'release'];
39
+ /** `root_id = ? AND <the stamp is not this run's>`, bound in that order. */
40
+ function vanished(derived) {
41
+ return derived.lateStamp
42
+ ? 'root_id = ? AND (last_seen_run_id IS NULL OR last_seen_run_id <> ?)'
43
+ : 'root_id = ? AND last_seen_run_id <> ?';
44
+ }
45
+ /**
46
+ * A statement releasing one reference, scoped by the rows that are going.
47
+ *
48
+ * The table and the column are this module's own literals — never a caller's
49
+ * string and never a name off disk — which is what makes interpolating them
50
+ * here the same kind of statement the sweep was written as before.
51
+ */
52
+ function releaseSql(detach, derived) {
53
+ const doomed = `(SELECT id FROM ${derived.table} WHERE ${vanished(derived)})`;
54
+ return detach.action === 'delete'
55
+ ? `DELETE FROM ${detach.from} WHERE ${detach.column} IN ${doomed}`
56
+ : `UPDATE ${detach.from} SET ${detach.column} = NULL WHERE ${detach.column} IN ${doomed}`;
57
+ }
58
+ export function prepareSweep(db, tables) {
59
+ const owned = DERIVED.filter((derived) => tables.includes(derived.table));
60
+ const steps = owned.map((derived) => ({
61
+ markSeen: db.prepare(`UPDATE ${derived.table} SET last_seen_run_id = ?
62
+ WHERE root_id = ? AND (rel_path = ? OR substr(rel_path, 1, ?) = ?)`),
63
+ // What a row of this table says before a held path is stamped, so the stamp
64
+ // can be handed back afterwards. Read and written with the same matcher as
65
+ // `markSeen`, so the two cannot come to disagree about which rows are which.
66
+ // Keyed by the natural key rather than by `id`, which `folder` and
67
+ // `scan_state` do not have: what every table here shares is `root_id` and
68
+ // `rel_path`, which is why the matcher is spelled that way in the first place.
69
+ remember: db.prepare(`SELECT rel_path, last_seen_run_id AS stamp FROM ${derived.table}
70
+ WHERE root_id = ? AND (rel_path = ? OR substr(rel_path, 1, ?) = ?)`),
71
+ restore: db.prepare(`UPDATE ${derived.table} SET last_seen_run_id = ? WHERE root_id = ? AND rel_path = ?`),
72
+ detach: derived.detach.map((detach) => db.prepare(releaseSql(detach, derived))),
73
+ drop: db.prepare(`DELETE FROM ${derived.table} WHERE ${vanished(derived)}`),
74
+ }));
75
+ return {
76
+ run(rootId, runId, kept = [], held = []) {
77
+ for (const step of steps) {
78
+ // Before this table's rows are judged, not after: a row under a path the
79
+ // walk could not enter has to say it was seen, or the delete below
80
+ // reads it as gone. Each table is stamped before its own delete, which
81
+ // is all the ordering the amnesty needs — nothing else reads the stamp
82
+ // of another table mid-sweep.
83
+ for (const path of kept) {
84
+ const below = `${path}/`;
85
+ step.markSeen.run(runId, rootId, path, below.length, below);
86
+ }
87
+ // A held path is stamped for the same reason and unstamped for the
88
+ // opposite one: it has not vanished, and this run did not look at it.
89
+ // What survives the delete is the row; what must not survive is the
90
+ // claim, because that claim is the scope every stage selects its work
91
+ // by. Restored after the delete rather than instead of it — a row whose
92
+ // stamp says some older run is a row this delete would take.
93
+ const wasSaid = [];
94
+ for (const path of held) {
95
+ const below = `${path}/`;
96
+ for (const row of step.remember.all(rootId, path, below.length, below)) {
97
+ wasSaid.push(row);
98
+ }
99
+ step.markSeen.run(runId, rootId, path, below.length, below);
100
+ }
101
+ for (const statement of step.detach)
102
+ statement.run(rootId, runId);
103
+ step.drop.run(rootId, runId);
104
+ for (const row of wasSaid)
105
+ step.restore.run(row.stamp, rootId, row.rel_path);
106
+ }
107
+ },
108
+ };
109
+ }