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,116 @@
1
+ import { dirname, isAbsolute, resolve } from 'node:path';
2
+ /**
3
+ * The `.m3u` files lying in the collection.
4
+ *
5
+ * A playlist file is two very different things depending on what is in it, and
6
+ * the difference is not in its name or its size — it is in where its entries
7
+ * point. A `.m3u` written beside an album, listing that album's tracks, says
8
+ * nothing the folder tree does not already say: it is *redundant*, and offering
9
+ * it as a playlist would put every record in the library into a client's
10
+ * playlist list twice. One whose entries come from several folders is a list
11
+ * somebody made on purpose, and that is the kind worth importing.
12
+ *
13
+ * This module decides that and nothing else — it reads text, not the meta
14
+ * layer, and knows nothing about tracks. Matching entries to songs is the
15
+ * stage's business (`playlist/import.ts`, run by `run.ts` after `cues`),
16
+ * because it is the stage that has a database.
17
+ *
18
+ * Nothing here reads a file: the bytes are decoded by `text/encoding.ts` and
19
+ * handed over as a string, so a playlist in CP1251 is the same problem as an
20
+ * `.nfo` in CP1251 and is solved in the same place.
21
+ */
22
+ /**
23
+ * The entries of a playlist file, in the order it states them.
24
+ *
25
+ * Three dialects arrive in the same collection and all three are handled:
26
+ * a bare list of paths, the extended form where every path is preceded by
27
+ * `#EXTINF:` lines, and the same with the paths quoted. Comments and directives
28
+ * start with `#` and are dropped — an `#EXTINF` line carries a duration and a
29
+ * title, and neither is used: the file says *which* songs and in what order,
30
+ * and what each song is called is a fact about the song.
31
+ *
32
+ * A byte-order mark leading the file is dropped with them. It is three bytes of
33
+ * `EF BB BF` that a Windows editor wrote, and left in place it becomes the
34
+ * first character of the first path — a file that cannot be found, in a list
35
+ * where every other entry resolves.
36
+ */
37
+ export function parseEntries(text) {
38
+ return text
39
+ .replace(/^\uFEFF/, '')
40
+ .split(/\r?\n/)
41
+ .map((line) => line.trim())
42
+ .filter((line) => line !== '' && !line.startsWith('#'))
43
+ .map((line) => unquote(line))
44
+ .filter((line) => line !== '');
45
+ }
46
+ /** A path a playlist wrote the way a person would — sometimes in quotes. */
47
+ function unquote(line) {
48
+ const quoted = /^"(.*)"$/.exec(line) ?? /^'(.*)'$/.exec(line);
49
+ return (quoted?.[1] ?? line).trim();
50
+ }
51
+ /**
52
+ * What an entry points at, as a path — or nothing, when it points out of the
53
+ * filesystem altogether.
54
+ *
55
+ * A playlist may name a stream (`http://…`), and no folder will ever hold one.
56
+ * Those entries are dropped here rather than resolved into a nonsense path: the
57
+ * question this module answers is about *folders*, and a URL has none.
58
+ */
59
+ export function resolveEntry(entry, folder) {
60
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(entry))
61
+ return null;
62
+ // A Windows path may be written with either separator, and both arrive: an
63
+ // absolute `C:\Music\…` from a ripping tool and a relative `01 - x.flac` from
64
+ // a person. `resolve` handles the second and understands both separators of
65
+ // the first on Windows; on other platforms an absolute Windows path is not a
66
+ // path, and the entry resolves relative to the folder like any other string.
67
+ return resolve(isAbsolute(entry) ? entry : folder, entry);
68
+ }
69
+ /**
70
+ * How the folders of a playlist compare.
71
+ *
72
+ * Case-folded on Windows and not elsewhere, because that is what the two
73
+ * filesystems do: `C:\Music\A` and `c:\music\a` are one folder there and two
74
+ * paths anywhere else. Getting this wrong in the other direction would call a
75
+ * curated list redundant — the mistake this classification exists to avoid —
76
+ * so it follows the platform rather than a preference.
77
+ */
78
+ function folderKey(path) {
79
+ return process.platform === 'win32' ? path.toLowerCase() : path;
80
+ }
81
+ /**
82
+ * What a playlist file is: the album it sits beside, or a list of its own.
83
+ *
84
+ * One folder — or none — is redundant. That covers the ordinary case (a `.m3u`
85
+ * beside an album, listing that album) and the degenerate ones (an empty file,
86
+ * a file listing only one song): none of them is a list a client should be
87
+ * offered, and importing them would fill the playlist list with the library
88
+ * itself.
89
+ *
90
+ * More than one folder is curated. This is deliberately a question about
91
+ * *folders* and not about albums or artists: a playlist of one artist's records
92
+ * is exactly the case worth importing, and so is a mix, while a playlist of one
93
+ * album from two folders is a record this collection filed in two places and
94
+ * still says nothing new.
95
+ *
96
+ * Named `readingOf` and not `classify`, which in this project is a stage: the
97
+ * folder classifier that decides what an *album* is. Two questions a word apart
98
+ * and nothing else in common.
99
+ */
100
+ export function readingOf(entries, folder) {
101
+ const folders = new Set();
102
+ const paths = [];
103
+ for (const entry of entries) {
104
+ const path = resolveEntry(entry, folder);
105
+ if (path === null)
106
+ continue;
107
+ paths.push(path);
108
+ folders.add(folderKey(dirname(path)));
109
+ }
110
+ return {
111
+ verdict: folders.size > 1 ? 'curated' : 'redundant',
112
+ entries: entries.length,
113
+ folders: folders.size,
114
+ paths,
115
+ };
116
+ }
@@ -0,0 +1,260 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { basename, dirname, join, relative, sep } from 'node:path';
3
+ import { withTransaction } from "../db/index.js";
4
+ import { clearIssues } from "../db/issue.js";
5
+ import { ledgerEntry, moved } from "../db/ledger.js";
6
+ import { decodeText } from "../text/encoding.js";
7
+ import { basenameOf, stemOf } from "../util/names.js";
8
+ import { parseEntries, readingOf } from "./files.js";
9
+ import * as store from "./store.js";
10
+ /**
11
+ * The playlist files the collection carries, read and weighed.
12
+ *
13
+ * A `.m3u` is either the album it sits beside or a list somebody made, and
14
+ * `files.ts` decides which — this stage is where that decision meets the meta
15
+ * layer. The two outcomes are handled very differently on purpose:
16
+ *
17
+ * - **redundant** (every entry in one folder) is *ignored*, and an issue says
18
+ * so. The folder tree already offers that music; importing it would list
19
+ * every album in the library a second time, under a name a client shows
20
+ * beside real playlists.
21
+ * - **curated** becomes a playlist, found by the file it came from rather
22
+ * than by its name, so reading the same file again updates that playlist
23
+ * instead of making another one. The row keeps the file it came from, which
24
+ * is also what tells the API that this playlist is the file's and not the
25
+ * listener's (`playlist/store.ts`).
26
+ *
27
+ * A file that stops being curated takes its playlist with it, and so does one
28
+ * that stops naming anything this library holds: the list is the file's, and a
29
+ * row left behind by a file that no longer asks for it is a list the collection
30
+ * does not have. So does a file that is deleted, through the cascade on
31
+ * `playlist.source_file_id`.
32
+ *
33
+ * **What is measured on this collection:** of 27 playlist files, all 27 name
34
+ * songs from one folder each and not one reaches outside the folder it sits in.
35
+ * The curated branch is therefore the one this library never exercises — which
36
+ * is why it is the branch the tests do.
37
+ */
38
+ /** This stage's name in `issue.stage`. Bound to the insert and to the clear. */
39
+ const STAGE = 'playlists';
40
+ /**
41
+ * Read the collection's playlist files, and import the ones that are lists.
42
+ *
43
+ * Runs after `cues`, and that is not a preference: an imported playlist names
44
+ * songs, and songs exist only once the cue stage has written them.
45
+ *
46
+ * The whole pass is one transaction, like every other stage that writes: it
47
+ * reads a file, asks several questions of the meta layer and writes what it
48
+ * concluded, and a scan that fell over halfway through should leave none of it
49
+ * behind. Measured on the live meta layer, a hundred writes outside a
50
+ * transaction cost 4452 ms against 74 ms inside one — the stage that reads
51
+ * every playlist in a collection is not the place to pay that again.
52
+ */
53
+ export function applyPlaylists(db, deps = {}) {
54
+ const counters = {
55
+ files: 0,
56
+ filesRead: 0,
57
+ redundant: 0,
58
+ curated: 0,
59
+ imported: 0,
60
+ unchanged: 0,
61
+ entriesMissing: 0,
62
+ unreadable: 0,
63
+ issues: 0,
64
+ };
65
+ const latest = db.prepare('SELECT MAX(id) AS id FROM scan_run').get();
66
+ if (latest.id === null)
67
+ return counters;
68
+ const readBytes = deps.readBytes ?? ((absPath) => readFileSync(absPath));
69
+ // Every playlist file of this run is taken up on every run, and its *bytes*
70
+ // are read only when there are none kept or the ledger says they moved — the
71
+ // same rule the tag stage follows for the record's documentation, and the same
72
+ // table: `sidecar_text` holds what the bytes said, so a playlist in CP1251 is
73
+ // decoded once.
74
+ //
75
+ // Taken up every time on purpose, and that is not the same question. A `.m3u`
76
+ // that did not change still describes a *library* that may have: a song it
77
+ // names can be deleted, and the playlist imported from it should lose that
78
+ // song. Skipping the files the ledger calls unmoved would leave the playlist
79
+ // holding what the collection no longer has, which is the one thing an
80
+ // imported list must never do. Re-reading the text is what the cache saves;
81
+ // the verdict is reached again from it, and nothing is written unless it
82
+ // changed (see `sameSongs`).
83
+ const due = db
84
+ .prepare(`SELECT f.id, f.rel_path, f.root_id, r.path AS root_path, f.ext,
85
+ st.text AS cached_text, ${moved('ss')} AS moved
86
+ FROM file f
87
+ JOIN root r ON r.id = f.root_id
88
+ LEFT JOIN scan_state ss ON ${ledgerEntry('f', 'ss')}
89
+ LEFT JOIN sidecar_text st ON st.file_id = f.id
90
+ WHERE f.kind = 'playlist' AND f.last_seen_run_id = ?
91
+ ORDER BY f.root_id, f.rel_path`)
92
+ .all(latest.id);
93
+ const upsertText = db.prepare(`INSERT INTO sidecar_text (file_id, text) VALUES (?, ?)
94
+ ON CONFLICT (file_id) DO UPDATE SET text = excluded.text`);
95
+ const stampEncoding = db.prepare('UPDATE file SET encoding = ?, encoding_confidence = ? WHERE id = ?');
96
+ // A path may be spelled with either separator, and the meta layer holds the
97
+ // forward one (`scan/walk.ts` normalises it there). Case is folded on Windows
98
+ // and not elsewhere, because that is what the two filesystems do — a list that
99
+ // says `01.FLAC` names the file on one and not on the other.
100
+ const samePath = process.platform === 'win32' ? 'COLLATE NOCASE' : '';
101
+ const fileIdOf = db.prepare(`SELECT f.id AS id FROM file f WHERE f.root_id = ? AND f.rel_path = ? ${samePath}`);
102
+ const songsOfFile = db.prepare(`SELECT t.id AS id FROM track t JOIN file f ON f.id = t.file_id
103
+ WHERE f.root_id = ? AND f.rel_path = ? ${samePath}
104
+ ORDER BY t.ordinal, t.id`);
105
+ const nameTaken = db.prepare(`SELECT 1 AS taken FROM playlist
106
+ WHERE name = ? AND (source_file_id IS NULL OR source_file_id <> ?)`);
107
+ const isTaken = (name, fileId) => nameTaken.get(name, fileId) !== undefined;
108
+ const insertIssue = db.prepare(`INSERT INTO issue (scan_run_id, stage, root_id, rel_path, kind, severity, detail)
109
+ VALUES (?, ?, ?, ?, ?, ?, ?)`);
110
+ const complain = (row, kind, severity, detail) => {
111
+ insertIssue.run(latest.id, STAGE, row.root_id, row.rel_path, kind, severity, detail);
112
+ counters.issues += 1;
113
+ };
114
+ withTransaction(db, () => {
115
+ for (const row of due) {
116
+ clearIssues(db, STAGE, { rootId: row.root_id, relPath: row.rel_path });
117
+ // The one format this does not read. `.pls` is a different document — an
118
+ // INI with numbered `FileN=` keys — and pretending otherwise would be
119
+ // worse than saying so: a `.pls` read as an `.m3u` yields no entries at
120
+ // all, and "redundant" would be the answer to a question nobody asked.
121
+ if (row.ext !== 'm3u' && row.ext !== 'm3u8') {
122
+ complain(row, 'playlist-format-not-read', 'info', `.${row.ext} is not read`);
123
+ continue;
124
+ }
125
+ let text = row.cached_text;
126
+ if (text === null || row.moved === 1) {
127
+ try {
128
+ const decoded = decodeText(readBytes(join(row.root_path, row.rel_path)));
129
+ text = decoded.text;
130
+ counters.filesRead += 1;
131
+ upsertText.run(row.id, text);
132
+ stampEncoding.run(decoded.encoding, decoded.confidence, row.id);
133
+ if (decoded.confidence < 1) {
134
+ complain(row, 'playlist-encoding-guessed', 'info', decoded.basis ?? `${decoded.encoding} was inferred`);
135
+ }
136
+ }
137
+ catch (err) {
138
+ counters.unreadable += 1;
139
+ complain(row, 'playlist-unreadable', 'warn', err.message);
140
+ continue;
141
+ }
142
+ }
143
+ // Counted here rather than at the top of the loop: a `.pls` was taken up
144
+ // and refused, and a file that would not open was not read at all — the
145
+ // report says "read", and this is the number that keeps that word true.
146
+ counters.files += 1;
147
+ const folder = dirname(join(row.root_path, row.rel_path));
148
+ const entries = parseEntries(text);
149
+ const reading = readingOf(entries, folder);
150
+ // Both verdicts need this: a redundant list is only *the folder* if its
151
+ // entries are the folder's files, and one carried from another machine
152
+ // names nothing here — calling that "already offered by the folder" would
153
+ // be a false account of a list that was quietly dropped.
154
+ const songs = [];
155
+ let missing = 0;
156
+ for (const path of reading.paths) {
157
+ const rel = within(row.root_path, path);
158
+ if (rel === null) {
159
+ missing += 1;
160
+ continue;
161
+ }
162
+ const found = fileIdOf.get(row.root_id, rel);
163
+ if (found === undefined) {
164
+ missing += 1;
165
+ continue;
166
+ }
167
+ if (reading.verdict === 'curated') {
168
+ for (const song of songsOfFile.all(row.root_id, rel))
169
+ songs.push(song.id);
170
+ }
171
+ }
172
+ if (reading.verdict === 'redundant') {
173
+ store.removeBySourceFile(db, row.id);
174
+ counters.redundant += 1;
175
+ complain(row, 'playlist-redundant', 'info', missing > 0
176
+ ? `${reading.entries} entries in ${reading.folders} folder${reading.folders === 1 ? '' : 's'}, ${missing} of them naming nothing here — the folder already offers this`
177
+ : `${reading.entries} entries in ${reading.folders} folder${reading.folders === 1 ? '' : 's'} — the folder already offers this`);
178
+ continue;
179
+ }
180
+ counters.curated += 1;
181
+ counters.entriesMissing += missing;
182
+ if (missing > 0) {
183
+ complain(row, 'playlist-entries-missing', 'warn', `${missing} of ${entries.length} entries name no song in this library`);
184
+ }
185
+ if (songs.length === 0) {
186
+ // Curated by where it points, but nothing in it lands here — a list made
187
+ // on another machine, or one whose music was never scanned. An empty
188
+ // playlist would be a promise this library cannot keep, and one left over
189
+ // from a previous reading of this file would be a list it no longer says.
190
+ store.removeBySourceFile(db, row.id);
191
+ complain(row, 'playlist-nothing-imported', 'warn', `no entry of ${entries.length} names a song here`);
192
+ continue;
193
+ }
194
+ const name = nameFor(row, isTaken);
195
+ const existing = store.bySourceFile(db, row.id);
196
+ if (existing === undefined) {
197
+ store.create(db, name, songs, row.id);
198
+ counters.imported += 1;
199
+ }
200
+ else if (existing.name !== name || !sameSongs(db, existing.id, songs)) {
201
+ store.replace(db, existing.id, name, songs);
202
+ counters.imported += 1;
203
+ }
204
+ else {
205
+ // The file says what the playlist already says. Writing it again would
206
+ // move `changed_at`, and a client that syncs by that stamp would re-read
207
+ // a playlist because a scan ran — the same reasoning as `store.edit`'s
208
+ // silence when nothing changes.
209
+ counters.unchanged += 1;
210
+ }
211
+ complain(row, 'playlist-imported', 'info', `${songs.length} songs from ${reading.folders} folders`);
212
+ }
213
+ });
214
+ return counters;
215
+ }
216
+ /** Whether a playlist already holds exactly these songs, in this order. */
217
+ function sameSongs(db, playlistId, songs) {
218
+ const held = store.entriesOf(db, playlistId);
219
+ return held.length === songs.length && held.every((id, at) => id === songs.at(at));
220
+ }
221
+ /**
222
+ * What to call an imported playlist.
223
+ *
224
+ * The file's own name, and the folder it sits in when that name is already
225
+ * taken by another playlist. Two `a.m3u` in two folders are two lists, and a
226
+ * sidebar showing `a` twice is a sidebar where one of them cannot be chosen —
227
+ * the same argument the project settled for records whose names collide
228
+ * ([[wiki:3602]]). The name is not the identity here either: the row is found
229
+ * by its file, and this is only what a person reads.
230
+ */
231
+ function nameFor(row, isTaken) {
232
+ const stem = stemOf(basename(row.rel_path));
233
+ if (!isTaken(stem, row.id))
234
+ return stem;
235
+ const parent = basenameOf(dirname(row.rel_path));
236
+ return parent === '' ? stem : `${stem} (${parent})`;
237
+ }
238
+ /**
239
+ * Where a path lies inside the root, in the spelling the meta layer uses — or
240
+ * nothing, when it lies outside it.
241
+ *
242
+ * Outside is not a failure of the list: a curated playlist made elsewhere may
243
+ * name music this library does not have, and those entries are counted and
244
+ * reported rather than resolved into a path that cannot exist. `..odd.flac`
245
+ * inside the root is a file, though, and is not confused with `../odd.flac` —
246
+ * hence the separator rather than a bare prefix test.
247
+ *
248
+ * The collection has more than one root, and this stage asks about one at a
249
+ * time: a list may name music that lives under *another* root of the same
250
+ * collection, and that entry is reported as unmatched. Doing better means
251
+ * resolving against every root, which is a change to make when a collection
252
+ * actually has a `.m3u` that reaches across its own roots — measured on this
253
+ * one, none does.
254
+ */
255
+ function within(rootPath, absPath) {
256
+ const rel = relative(rootPath, absPath);
257
+ if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || /^[a-z]:/i.test(rel))
258
+ return null;
259
+ return rel.split('\\').join('/');
260
+ }
@@ -0,0 +1,240 @@
1
+ import { withTransaction } from "../db/index.js";
2
+ /**
3
+ * Playlists, and who owns which of them.
4
+ *
5
+ * Two kinds of row live in the same two tables and they have two different
6
+ * authors, which is the whole of what a reader has to know before touching one:
7
+ *
8
+ * - **The listener's**, written by the API: a statement about the music that
9
+ * lives nowhere on disk. Nothing else writes these.
10
+ * - **A file's**, imported by the playlist stage from a `.m3u` in the
11
+ * collection (`playlist/import.ts`): the row is a reading of that file, and
12
+ * the file is what the list *is*. A scan rewrites it from the file, and the
13
+ * API refuses to edit or delete it — see below.
14
+ *
15
+ * `source_file_id` is the field that tells them apart: `NULL` is the listener's
16
+ * and a file id is that file's. It is not decoration, and the two rules that
17
+ * hang off it are the reason this module has a doc comment at all:
18
+ *
19
+ * 1. An imported row is rewritten on every scan that re-reads its file, so an
20
+ * edit made through the API would survive exactly until the next scan. The
21
+ * API therefore does not accept one, and says so (`api/playlist.ts`).
22
+ * 2. The row is the file's, so the file's removal takes it — through the
23
+ * cascade, and through the stage when a file stops being a list.
24
+ *
25
+ * What this module does *not* read is the collection. A playlist's songs are
26
+ * read in `api/meta.ts` with every other song, by the same select, so a song in
27
+ * a playlist is the same song as everywhere else — and the question this module
28
+ * has about the collection, whether a track exists, is asked there too.
29
+ *
30
+ * Order is the whole of the domain here. `position` is a sequence laid out from
31
+ * zero by every write, because the protocol addresses entries by index —
32
+ * `songIndexToRemove` is a position — so every mutation below finishes by
33
+ * writing the order it left behind rather than trusting the arithmetic of the
34
+ * one before it. A song dropped by the cascade leaves its number unused until
35
+ * the next write; nothing reads the number as a position, only as an order.
36
+ */
37
+ /**
38
+ * The number the next playlist gets.
39
+ *
40
+ * Read and moved on in the same transaction as the insert that uses it, so two
41
+ * callers cannot be handed the same number. See migration 025 for why the
42
+ * number comes from a table of its own rather than from the rows it numbers.
43
+ */
44
+ function nextId(db) {
45
+ const row = db.prepare('SELECT next FROM playlist_sequence WHERE only_row = 1').get();
46
+ db.prepare('UPDATE playlist_sequence SET next = ? WHERE only_row = 1').run(row.next + 1);
47
+ return row.next;
48
+ }
49
+ /**
50
+ * When the playlist last moved, touched by every mutation that changes it.
51
+ *
52
+ * Reads and writes share the value: `changed_at` is written by the same
53
+ * statement that writes the change, so a client syncing by it can never see a
54
+ * change without the stamp that announces it.
55
+ */
56
+ const TOUCH = 'changed_at = ?';
57
+ /**
58
+ * Everything a playlist is, with the numbers and the picture a client draws
59
+ * beside it.
60
+ *
61
+ * Counted over the entries rather than over distinct tracks: a song twice in one
62
+ * playlist is two entries and two plays, and `songCount` is what the client
63
+ * shows beside the length of the list.
64
+ *
65
+ * The picture is the first entry's song, and it is taken by a correlated
66
+ * subquery — which this project has paid for once already (`ALBUM_GENRES` in
67
+ * `api/meta.ts`) and which is allowed here for the reason it was wrong there.
68
+ * The question is `MIN(position)` for one playlist, and the primary key of
69
+ * `playlist_track` is `(playlist_id, position)`: the answer is the first row of
70
+ * an index range, not a scan. Measured on 15 000 entries, a form that grouped
71
+ * every playlist's entries before the `WHERE` could narrow anything cost
72
+ * 5137 µs per call — against this one, which reads one index range. A playlist
73
+ * with no entries matches nothing and is handed no picture.
74
+ */
75
+ const PLAYLIST_SELECT = `
76
+ SELECT p.id, p.name, p.comment, p.source_file_id, p.public, p.created_at, p.changed_at,
77
+ COUNT(pt.track_id) AS song_count,
78
+ SUM(t.duration_ms) AS duration_ms,
79
+ ft.album_id AS cover_album_id,
80
+ ft.id AS cover_track_id
81
+ FROM playlist p
82
+ LEFT JOIN playlist_track pt ON pt.playlist_id = p.id
83
+ LEFT JOIN track t ON t.id = pt.track_id
84
+ LEFT JOIN playlist_track fp ON fp.playlist_id = p.id
85
+ AND fp.position = (SELECT MIN(first_pt.position) FROM playlist_track first_pt
86
+ WHERE first_pt.playlist_id = p.id)
87
+ LEFT JOIN track ft ON ft.id = fp.track_id
88
+ `;
89
+ export function playlists(db) {
90
+ return db
91
+ .prepare(`${PLAYLIST_SELECT}
92
+ GROUP BY p.id
93
+ ORDER BY p.name COLLATE NOCASE, p.id`)
94
+ .all();
95
+ }
96
+ export function playlist(db, id) {
97
+ return db
98
+ .prepare(`${PLAYLIST_SELECT} WHERE p.id = ? GROUP BY p.id`)
99
+ .get(id);
100
+ }
101
+ /**
102
+ * The entries of a playlist, in the order it holds them.
103
+ *
104
+ * The ids alone, because what a caller does with them is decide what to keep:
105
+ * this is read by the mutations below, which reason about the list as a
106
+ * sequence of tracks, and by nothing that renders.
107
+ */
108
+ export function entriesOf(db, playlistId) {
109
+ return db
110
+ .prepare('SELECT track_id FROM playlist_track WHERE playlist_id = ? ORDER BY position')
111
+ .all(playlistId).map((row) => row.track_id);
112
+ }
113
+ /**
114
+ * Write a playlist's entries as the order given.
115
+ *
116
+ * The one place positions are assigned, and it assigns all of them: every
117
+ * mutation above ends here with the list it decided on, so no caller has to be
118
+ * right about gaps, shifts or the arithmetic of a removal. Rewriting the whole
119
+ * list is cheaper than proving an incremental update left no hole, and the
120
+ * protocol sets no ceiling on how long a playlist may be — measured, a hundred
121
+ * entries cost 74 ms inside the transaction that carries them.
122
+ */
123
+ function writeEntries(db, playlistId, tracks) {
124
+ db.prepare('DELETE FROM playlist_track WHERE playlist_id = ?').run(playlistId);
125
+ const insert = db.prepare('INSERT INTO playlist_track (playlist_id, position, track_id) VALUES (?, ?, ?)');
126
+ tracks.forEach((trackId, position) => insert.run(playlistId, position, trackId));
127
+ }
128
+ /**
129
+ * A new playlist holding the songs named.
130
+ *
131
+ * An id that names no track never reaches here: `createPlaylist` refuses the
132
+ * whole call when one of its `songId`s is not a song, and refusing is the point
133
+ * — a playlist silently shorter than the request is a playlist the client
134
+ * cannot tell from one it sent correctly, and the missing song would only show
135
+ * up when somebody played the list through.
136
+ *
137
+ * The timestamps are the same instant: a playlist that was just made has not
138
+ * been changed since it was made.
139
+ */
140
+ export function create(db, name, tracks, sourceFileId = null) {
141
+ const at = new Date().toISOString();
142
+ return withTransaction(db, () => {
143
+ const id = nextId(db);
144
+ db.prepare(`INSERT INTO playlist (id, name, comment, source_file_id, created_at, changed_at)
145
+ VALUES (?, ?, NULL, ?, ?, ?)`).run(id, name, sourceFileId, at, at);
146
+ writeEntries(db, id, tracks);
147
+ return written(db, id);
148
+ });
149
+ }
150
+ /**
151
+ * The row a write just left, read back.
152
+ *
153
+ * Every mutation that answers with the playlist answers with what is in the
154
+ * table rather than with what it meant to put there — so the counts, the
155
+ * timestamps and the entries in the answer are the ones a later `getPlaylist`
156
+ * would give, and cannot be a calculation that disagreed with the write.
157
+ */
158
+ function written(db, id) {
159
+ const row = playlist(db, id);
160
+ if (row === undefined)
161
+ throw new Error(`playlist ${id} is not there after it was written`);
162
+ return row;
163
+ }
164
+ /**
165
+ * Rename a playlist and, when songs are given, make them its contents.
166
+ *
167
+ * This is `createPlaylist` handed an id that already exists, which the protocol
168
+ * makes the same call — see `api/playlist.ts` for why this server replaces the
169
+ * songs and for the one case where it does not.
170
+ *
171
+ * `tracks` is `undefined` when the client named no songs at all, and that is not
172
+ * the same as naming none: a call that renames a playlist carries no song list,
173
+ * and treating its silence as "empty the playlist" would throw away everything
174
+ * the listener put in it while answering `ok`. A client that means to empty a
175
+ * playlist says so — it sends no song it wants kept.
176
+ */
177
+ export function replace(db, id, name, tracks) {
178
+ return withTransaction(db, () => {
179
+ db.prepare(`UPDATE playlist SET name = ?, ${TOUCH} WHERE id = ?`).run(name, new Date().toISOString(), id);
180
+ if (tracks !== undefined)
181
+ writeEntries(db, id, tracks);
182
+ return written(db, id);
183
+ });
184
+ }
185
+ export function edit(db, id, edit) {
186
+ const adds = edit.add !== undefined && edit.add.length > 0;
187
+ const removes = edit.remove !== undefined && edit.remove.length > 0;
188
+ const fields = edit.name !== undefined || edit.comment !== undefined || edit.public !== undefined;
189
+ if (!adds && !removes && !fields)
190
+ return;
191
+ withTransaction(db, () => {
192
+ let tracks = entriesOf(db, id);
193
+ if (removes) {
194
+ const doomed = new Set(edit.remove);
195
+ tracks = tracks.filter((_, position) => !doomed.has(position));
196
+ }
197
+ if (adds)
198
+ tracks = [...tracks, ...(edit.add ?? [])];
199
+ const sets = [];
200
+ const args = [];
201
+ if (edit.name !== undefined) {
202
+ sets.push('name = ?');
203
+ args.push(edit.name);
204
+ }
205
+ if (edit.comment !== undefined) {
206
+ sets.push('comment = ?');
207
+ args.push(edit.comment);
208
+ }
209
+ if (edit.public !== undefined) {
210
+ sets.push('public = ?');
211
+ args.push(edit.public ? 1 : 0);
212
+ }
213
+ sets.push(TOUCH);
214
+ args.push(new Date().toISOString(), id);
215
+ db.prepare(`UPDATE playlist SET ${sets.join(', ')} WHERE id = ?`).run(...args);
216
+ if (adds || removes)
217
+ writeEntries(db, id, tracks);
218
+ });
219
+ }
220
+ /** A playlist and its entries, gone. The songs themselves are untouched. */
221
+ export function remove(db, id) {
222
+ db.prepare('DELETE FROM playlist WHERE id = ?').run(id);
223
+ }
224
+ /**
225
+ * The playlist a file asked for, if this server has imported it.
226
+ *
227
+ * How an import stays one playlist: the file is what the list *is*, so the row
228
+ * it produced is found by the file rather than by its name — a scan that reads
229
+ * the same file again updates that row instead of making a second list with the
230
+ * same name, and a third on the run after that.
231
+ */
232
+ export function bySourceFile(db, fileId) {
233
+ return db
234
+ .prepare(`${PLAYLIST_SELECT} WHERE p.source_file_id = ? GROUP BY p.id`)
235
+ .get(fileId);
236
+ }
237
+ /** The playlist a file asked for, gone — the file stopped asking for one. */
238
+ export function removeBySourceFile(db, fileId) {
239
+ db.prepare('DELETE FROM playlist WHERE source_file_id = ?').run(fileId);
240
+ }