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.
- package/.env.example +90 -0
- package/CHANGELOG.md +104 -0
- package/DEPLOY.md +375 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/deploy/answers.schema.json +151 -0
- package/deploy/build.mjs +88 -0
- package/deploy/check-publishable.mjs +246 -0
- package/deploy/smoke.mjs +681 -0
- package/deploy/systemd/funoteka.env.example +41 -0
- package/deploy/systemd/funoteka.service +77 -0
- package/deploy/windows/funoteka-service.xml +58 -0
- package/deploy/windows/install-service.ps1 +158 -0
- package/dist/annotation/store.js +67 -0
- package/dist/api/admin-export.js +227 -0
- package/dist/api/admin-guard.js +181 -0
- package/dist/api/admin-library.js +113 -0
- package/dist/api/admin.js +1162 -0
- package/dist/api/annotation.js +193 -0
- package/dist/api/artistinfo.js +140 -0
- package/dist/api/audit.js +32 -0
- package/dist/api/auth.js +145 -0
- package/dist/api/bookmark.js +108 -0
- package/dist/api/browse.js +1241 -0
- package/dist/api/config-file.js +202 -0
- package/dist/api/config.js +285 -0
- package/dist/api/cover.js +304 -0
- package/dist/api/download.js +112 -0
- package/dist/api/envelope.js +162 -0
- package/dist/api/extensions.js +43 -0
- package/dist/api/genre.js +81 -0
- package/dist/api/health.js +35 -0
- package/dist/api/history.js +374 -0
- package/dist/api/idempotency.js +66 -0
- package/dist/api/keys.js +90 -0
- package/dist/api/log-file.js +65 -0
- package/dist/api/meta.js +1410 -0
- package/dist/api/playlist.js +311 -0
- package/dist/api/rescan.js +196 -0
- package/dist/api/roots.js +161 -0
- package/dist/api/router.js +133 -0
- package/dist/api/scanner.js +163 -0
- package/dist/api/search.js +82 -0
- package/dist/api/server.js +643 -0
- package/dist/api/settings.js +67 -0
- package/dist/api/stream.js +442 -0
- package/dist/api/stubs.js +183 -0
- package/dist/api/transcode.js +678 -0
- package/dist/api/user.js +73 -0
- package/dist/api/virtual.js +362 -0
- package/dist/api/visibility.js +21 -0
- package/dist/artist/apply.js +752 -0
- package/dist/artist/credit.js +95 -0
- package/dist/artist/folder.js +65 -0
- package/dist/artist/name.js +205 -0
- package/dist/artist/nfo.js +101 -0
- package/dist/artist/translit.js +79 -0
- package/dist/bookmark/store.js +54 -0
- package/dist/classify/classify.js +405 -0
- package/dist/classify/collision-name.js +109 -0
- package/dist/classify/folder-name.js +466 -0
- package/dist/classify/roles.js +277 -0
- package/dist/classify/shelf-name.js +178 -0
- package/dist/classify/tree.js +43 -0
- package/dist/cli/args.js +169 -0
- package/dist/cli/daemon.js +192 -0
- package/dist/cli/entry.js +25 -0
- package/dist/cli/keys.js +52 -0
- package/dist/cli.js +638 -0
- package/dist/cover/pick.js +58 -0
- package/dist/cover/picture.js +111 -0
- package/dist/cue/engine.js +759 -0
- package/dist/cue/match.js +142 -0
- package/dist/cue/parse.js +182 -0
- package/dist/cue/plan.js +286 -0
- package/dist/cue/track-name.js +120 -0
- package/dist/db/index.js +163 -0
- package/dist/db/issue.js +73 -0
- package/dist/db/ledger.js +84 -0
- package/dist/db/migrations/001_init.sql +196 -0
- package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
- package/dist/db/migrations/003_cue_metadata.sql +14 -0
- package/dist/db/migrations/004_cue_performer.sql +12 -0
- package/dist/db/migrations/005_incremental.sql +23 -0
- package/dist/db/migrations/006_tags.sql +57 -0
- package/dist/db/migrations/007_artist_credit.sql +35 -0
- package/dist/db/migrations/008_issue_stage.sql +39 -0
- package/dist/db/migrations/009_cue_title.sql +20 -0
- package/dist/db/migrations/010_duration_source.sql +31 -0
- package/dist/db/migrations/011_track_title_source.sql +28 -0
- package/dist/db/migrations/012_file_container.sql +25 -0
- package/dist/db/migrations/013_cover_art.sql +28 -0
- package/dist/db/migrations/014_album_year.sql +17 -0
- package/dist/db/migrations/015_probe_method.sql +14 -0
- package/dist/db/migrations/016_tags_method.sql +20 -0
- package/dist/db/migrations/017_cover_indirect.sql +23 -0
- package/dist/db/migrations/018_release_year.sql +14 -0
- package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
- package/dist/db/migrations/020_analyze_after_index.sql +16 -0
- package/dist/db/migrations/021_cue_index_files.sql +15 -0
- package/dist/db/migrations/022_sidecar_text.sql +19 -0
- package/dist/db/migrations/023_track_credit.sql +23 -0
- package/dist/db/migrations/024_playlists.sql +63 -0
- package/dist/db/migrations/025_playlist_sequence.sql +27 -0
- package/dist/db/migrations/026_playlist_source_file.sql +19 -0
- package/dist/db/migrations/027_lookup_indexes.sql +30 -0
- package/dist/db/migrations/028_annotations.sql +47 -0
- package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
- package/dist/db/migrations/030_history.sql +108 -0
- package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
- package/dist/db/migrations/032_bookmarks.sql +27 -0
- package/dist/db/migrations/033_bookmark_order.sql +14 -0
- package/dist/db/migrations/034_junk.sql +33 -0
- package/dist/db/migrations/035_api_key.sql +38 -0
- package/dist/db/migrations/036_file_tag_first.sql +88 -0
- package/dist/db/migrations/037_admin_idempotency.sql +31 -0
- package/dist/db/migrations/038_cue_audio_index.sql +24 -0
- package/dist/db/sweep.js +109 -0
- package/dist/history/store.js +151 -0
- package/dist/inventory/inventory.js +294 -0
- package/dist/junk/marks.js +143 -0
- package/dist/junk/rule.js +93 -0
- package/dist/mcp/client.js +48 -0
- package/dist/mcp/server.js +175 -0
- package/dist/mcp/tools.js +348 -0
- package/dist/playlist/files.js +116 -0
- package/dist/playlist/import.js +260 -0
- package/dist/playlist/store.js +240 -0
- package/dist/probe/ffprobe.js +179 -0
- package/dist/run.js +120 -0
- package/dist/scan/kinds.js +49 -0
- package/dist/scan/scan.js +503 -0
- package/dist/scan/schedule.js +132 -0
- package/dist/scan/settle.js +172 -0
- package/dist/scan/walk.js +132 -0
- package/dist/search/index.js +41 -0
- package/dist/search/query.js +28 -0
- package/dist/stream/flac.js +541 -0
- package/dist/stream/mpeg.js +179 -0
- package/dist/stream/recode.js +393 -0
- package/dist/stream/rewrite.js +163 -0
- package/dist/stream/segment.js +624 -0
- package/dist/tags/apply.js +703 -0
- package/dist/tags/encode.js +164 -0
- package/dist/tags/first.js +130 -0
- package/dist/tags/flac.js +196 -0
- package/dist/tags/genres.js +123 -0
- package/dist/tags/id3v1.js +209 -0
- package/dist/tags/id3v2.js +754 -0
- package/dist/tags/mp4.js +534 -0
- package/dist/tags/mpeg.js +355 -0
- package/dist/tags/ogg.js +463 -0
- package/dist/tags/picture.js +71 -0
- package/dist/tags/read.js +269 -0
- package/dist/tags/types.js +51 -0
- package/dist/tags/vorbis-comment.js +155 -0
- package/dist/text/encoding.js +384 -0
- package/dist/text/site-name.js +37 -0
- package/dist/util/child.js +21 -0
- package/dist/util/names.js +97 -0
- package/funoteka.json.example +27 -0
- package/package.json +55 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import * as store from "../playlist/store.js";
|
|
2
|
+
import { ID, parseId, required, seconds, songChild } from "./browse.js";
|
|
3
|
+
import { ApiError, ERROR } from "./envelope.js";
|
|
4
|
+
import { knownTrackIds, songsOfPlaylist } from "./meta.js";
|
|
5
|
+
/**
|
|
6
|
+
* Playlists, in the protocol's shapes.
|
|
7
|
+
*
|
|
8
|
+
* The one part of this API that is not a reading of the collection. A playlist
|
|
9
|
+
* is what the listener said about the music rather than what the music is, and
|
|
10
|
+
* it is the only thing here a client can create — which makes three of these
|
|
11
|
+
* five routes the only routes in the whole API that write (the writes
|
|
12
|
+
* themselves are `playlist/store.ts`).
|
|
13
|
+
*
|
|
14
|
+
* The protocol's own split of that surface is kept as it is: `getPlaylists` is
|
|
15
|
+
* the list a client draws a sidebar from and carries no songs, `getPlaylist` is
|
|
16
|
+
* one playlist with its entries, and `createPlaylist` doubles as the update
|
|
17
|
+
* when it is handed an id — which is the protocol's design and not a shortcut
|
|
18
|
+
* taken here.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* The id a playlist is named by.
|
|
22
|
+
*
|
|
23
|
+
* A `pl:` prefix like every other id this API hands out, and deliberately *not*
|
|
24
|
+
* added to `KINDS` in `browse.ts`: that set is the tree — artists, albums,
|
|
25
|
+
* tracks, folders, roots — and a playlist is not a branch of it. An id from
|
|
26
|
+
* here is therefore refused by `getSong` and `getMusicDirectory` as naming
|
|
27
|
+
* nothing, which is the true answer, while the routes below read it themselves.
|
|
28
|
+
*
|
|
29
|
+
* Digits only, rather than whatever `Number` accepts: `pl:1e3` and `pl:0x10`
|
|
30
|
+
* would otherwise read as ids this server may never have handed out — and the
|
|
31
|
+
* one thing an id from here is allowed to be is one that was handed out. The
|
|
32
|
+
* refusals are the same either way; what the strict reading buys is that they
|
|
33
|
+
* are always true.
|
|
34
|
+
*/
|
|
35
|
+
function playlistIdOf(raw) {
|
|
36
|
+
// **Both separators are read, and this is where that promise was broken
|
|
37
|
+
// first.** `parseId` reads a colon and a hyphen; this compared against the
|
|
38
|
+
// prefix *constant*, so when the constant changed the old spelling stopped
|
|
39
|
+
// being recognised — and a client holding a playlist id from before was told
|
|
40
|
+
// there is no such playlist, while the commit that changed the separator said
|
|
41
|
+
// nothing would be orphaned (found by asking both spellings of a real
|
|
42
|
+
// playlist, task:2896).
|
|
43
|
+
const digits = /^pl[-:]/.test(raw) ? raw.slice(3) : '';
|
|
44
|
+
if (!/^\d+$/.test(digits))
|
|
45
|
+
throw new ApiError(ERROR.notFound, `No such playlist: ${raw}`);
|
|
46
|
+
return Number(digits);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The playlist an id names, or a refusal.
|
|
50
|
+
*
|
|
51
|
+
* Every route but the listing has one of these, and all three spell the
|
|
52
|
+
* protocol's not-found code rather than answering about a playlist that is not
|
|
53
|
+
* there. An id that was never handed out by this server is the same answer as
|
|
54
|
+
* one whose playlist was deleted: the client's question has no subject.
|
|
55
|
+
*/
|
|
56
|
+
function requiredPlaylist(db, raw) {
|
|
57
|
+
const row = store.playlist(db, playlistIdOf(raw));
|
|
58
|
+
if (row === undefined)
|
|
59
|
+
throw new ApiError(ERROR.notFound, `No such playlist: ${raw}`);
|
|
60
|
+
return row;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* A playlist the listener may change — which a file's is not.
|
|
64
|
+
*
|
|
65
|
+
* Refused rather than allowed-and-later-undone, and the refusal is the only
|
|
66
|
+
* honest answer available: the row is re-derived from its `.m3u` on every scan
|
|
67
|
+
* that re-reads the file, so an edit would last until the next scan and a
|
|
68
|
+
* deletion would be undone by the scan after it. A client that lost a rename
|
|
69
|
+
* that way would have no way to tell it from its own bug — and the answer it
|
|
70
|
+
* gets here says which file owns the list, which is also what it takes to
|
|
71
|
+
* change it.
|
|
72
|
+
*
|
|
73
|
+
* The code is the protocol's "not authorized", which is what this is: the
|
|
74
|
+
* caller is authenticated, and this particular list is not theirs to change.
|
|
75
|
+
*/
|
|
76
|
+
function requiredEditable(db, raw) {
|
|
77
|
+
const row = requiredPlaylist(db, raw);
|
|
78
|
+
if (row.source_file_id !== null) {
|
|
79
|
+
throw new ApiError(ERROR.notAuthorized, `This playlist is read from a file in the collection, and a scan rebuilds it: ${raw}`);
|
|
80
|
+
}
|
|
81
|
+
return row;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Every value of a repeated parameter, as song ids.
|
|
85
|
+
*
|
|
86
|
+
* `songId` and `songIdToAdd` arrive the protocol's way — the name repeated once
|
|
87
|
+
* per song — so reading only the first would silently build a playlist one song
|
|
88
|
+
* long. An empty value is dropped rather than refused: `songId=` is a client
|
|
89
|
+
* with nothing to add, which is not an error.
|
|
90
|
+
*
|
|
91
|
+
* The refusal and the existence check below it say the same sentence, and that
|
|
92
|
+
* is on purpose — from a client's side "that is not a song id" and "that song
|
|
93
|
+
* is not here" are one answer about one parameter.
|
|
94
|
+
*/
|
|
95
|
+
function trackIdsIn(query, name) {
|
|
96
|
+
return query
|
|
97
|
+
.getAll(name)
|
|
98
|
+
.filter((raw) => raw !== '')
|
|
99
|
+
.map((raw) => {
|
|
100
|
+
const parsed = parseId(raw);
|
|
101
|
+
if (parsed === undefined || parsed.kind !== 'tr') {
|
|
102
|
+
throw new ApiError(ERROR.notFound, `No such song: ${raw}`);
|
|
103
|
+
}
|
|
104
|
+
return parsed.n;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Songs that exist, or a refusal naming the first that does not.
|
|
109
|
+
*
|
|
110
|
+
* Answered once for the whole call instead of per id, and the refusal is the
|
|
111
|
+
* point: a playlist quietly shorter than the request is one the client cannot
|
|
112
|
+
* tell from the playlist it asked for, and the song that went missing would
|
|
113
|
+
* only surface when somebody played the list through.
|
|
114
|
+
*/
|
|
115
|
+
function requireKnown(db, ids) {
|
|
116
|
+
const known = knownTrackIds(db, ids);
|
|
117
|
+
for (const id of ids) {
|
|
118
|
+
if (!known.has(id))
|
|
119
|
+
throw new ApiError(ERROR.notFound, `No such song: ${ID.track(id)}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Positions in a playlist, as the client sent them.
|
|
124
|
+
*
|
|
125
|
+
* A position is what the client is holding — it reads them out of the answer to
|
|
126
|
+
* `getPlaylist` — so a negative one names nothing. It is refused rather than
|
|
127
|
+
* ignored: a client that computed `-1` has a bug, and the alternative is a
|
|
128
|
+
* playlist that silently kept a song it was told to drop.
|
|
129
|
+
*
|
|
130
|
+
* The code is the generic one, which is what `browse.ts` and `genre.ts` answer
|
|
131
|
+
* with for a parameter that is not the number it should be — a client reading
|
|
132
|
+
* the code learns the same thing from either.
|
|
133
|
+
*/
|
|
134
|
+
function positionsIn(query, name) {
|
|
135
|
+
return query
|
|
136
|
+
.getAll(name)
|
|
137
|
+
.filter((raw) => raw !== '')
|
|
138
|
+
.map((raw) => {
|
|
139
|
+
const n = Number(raw);
|
|
140
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
141
|
+
throw new ApiError(ERROR.generic, `${name} is not a position: ${raw}`);
|
|
142
|
+
}
|
|
143
|
+
return n;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/** The protocol's booleans, which arrive as `true` or `false`. */
|
|
147
|
+
function booleanish(raw) {
|
|
148
|
+
const value = raw.toLowerCase();
|
|
149
|
+
return value === 'true' || value === '1' || value === 'yes';
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* A playlist in the protocol's shape, without its songs.
|
|
153
|
+
*
|
|
154
|
+
* What `getPlaylists` answers with, and the half `getPlaylist` adds `entry` to.
|
|
155
|
+
* The two therefore cannot disagree about a name, a length or a timestamp —
|
|
156
|
+
* they are one object, read once.
|
|
157
|
+
*/
|
|
158
|
+
function asPlaylist(row, config) {
|
|
159
|
+
return {
|
|
160
|
+
id: ID.playlist(row.id),
|
|
161
|
+
name: row.name,
|
|
162
|
+
...(row.comment === null ? {} : { comment: row.comment }),
|
|
163
|
+
// The one account this server has. Kept in the answer because the protocol
|
|
164
|
+
// makes it a field of a playlist and a client draws it beside the name.
|
|
165
|
+
owner: config.user,
|
|
166
|
+
public: row.public === 1,
|
|
167
|
+
// OpenSubsonic's own field, and the protocol's rule for one of its own
|
|
168
|
+
// additions: a server that supports it sends it even when it has nothing to
|
|
169
|
+
// say, so a client can tell "editable" from "this server has never heard of
|
|
170
|
+
// the question".
|
|
171
|
+
//
|
|
172
|
+
// False for the listener's own playlists — the one account here owns those —
|
|
173
|
+
// and true for one read from a `.m3u` in the collection. That is not a
|
|
174
|
+
// restriction invented here: the file is what that list *is*, the stage
|
|
175
|
+
// rewrites the row from it on every scan that re-reads it, and an edit a
|
|
176
|
+
// client was allowed to make would be undone by a scan nobody asked for.
|
|
177
|
+
// A client that is told so can grey the controls out instead of losing work.
|
|
178
|
+
readonly: row.source_file_id !== null,
|
|
179
|
+
songCount: row.song_count,
|
|
180
|
+
duration: seconds(row.duration_ms),
|
|
181
|
+
created: row.created_at,
|
|
182
|
+
changed: row.changed_at,
|
|
183
|
+
// The picture is the first song's, in this answer and in the listing alike
|
|
184
|
+
// — the protocol lets a server name the cover with any id `getCoverArt`
|
|
185
|
+
// answers to, and an id of the playlist's own would need `getCoverArt`
|
|
186
|
+
// taught a second kind of subject to say what this says already. An empty
|
|
187
|
+
// playlist is handed no picture rather than an id for a cover that does not
|
|
188
|
+
// exist.
|
|
189
|
+
...(row.cover_track_id === null
|
|
190
|
+
? {}
|
|
191
|
+
: {
|
|
192
|
+
coverArt: row.cover_album_id === null ? ID.track(row.cover_track_id) : ID.album(row.cover_album_id),
|
|
193
|
+
}),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/** A playlist and its songs, which is what opening one asks for. */
|
|
197
|
+
function withSongs(row, db, config) {
|
|
198
|
+
const id = ID.playlist(row.id);
|
|
199
|
+
return {
|
|
200
|
+
...asPlaylist(row, config),
|
|
201
|
+
entry: songsOfPlaylist(db, row.id).map((song) => songChild(song, id)),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The playlists a client lists, without their songs.
|
|
206
|
+
*
|
|
207
|
+
* `username` is the protocol's filter, and this server has exactly one account:
|
|
208
|
+
* the configured one owns every playlist. A name that is not that one is
|
|
209
|
+
* answered with an empty list rather than an error — the question is "what does
|
|
210
|
+
* this user have", and the true answer is nothing, since no other user exists
|
|
211
|
+
* to have anything.
|
|
212
|
+
*/
|
|
213
|
+
export function getPlaylists(db, config, query) {
|
|
214
|
+
const username = query.get('username');
|
|
215
|
+
const mine = username === null || username === '' || username === config.user;
|
|
216
|
+
return {
|
|
217
|
+
playlists: {
|
|
218
|
+
playlist: (mine ? store.playlists(db) : []).map((row) => asPlaylist(row, config)),
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/** One playlist with its songs, by the id `getPlaylists` handed out. */
|
|
223
|
+
export function getPlaylist(db, config, query) {
|
|
224
|
+
const raw = required(query, 'id');
|
|
225
|
+
return { playlist: withSongs(requiredPlaylist(db, raw), db, config) };
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* A new playlist — or, handed an id, the replacement of an existing one.
|
|
229
|
+
*
|
|
230
|
+
* The protocol folds the two into one method, and the difference is one
|
|
231
|
+
* parameter — but **what an id does to the songs already there, the
|
|
232
|
+
* documentation does not say.** "Creates (or updates) a playlist" is the whole
|
|
233
|
+
* of it, and no client is told which of the two it will get.
|
|
234
|
+
*
|
|
235
|
+
* This server replaces them, because that is what the call means to the client
|
|
236
|
+
* that makes it: a player saving a playlist sends the list it has on screen,
|
|
237
|
+
* and a server that appended instead would make a song impossible to take out
|
|
238
|
+
* of a playlist by the one route that saves one. A client adding a single song
|
|
239
|
+
* has `updatePlaylist` for that, and says so by asking for it.
|
|
240
|
+
*
|
|
241
|
+
* **A call that names no songs at all is not a call that names none.** The
|
|
242
|
+
* protocol's own criterion for this feature tells renaming apart from emptying,
|
|
243
|
+
* and a client that renames sends an id and a name and nothing else; reading
|
|
244
|
+
* its silence as "the list is now empty" would throw the listener's songs away
|
|
245
|
+
* while answering `ok`, and the client would have no way to tell. So the songs
|
|
246
|
+
* are replaced only when the parameter is there, and a client that means to
|
|
247
|
+
* empty a playlist says so — `songId=` is that statement, and so is
|
|
248
|
+
* `updatePlaylist` with every position removed.
|
|
249
|
+
*
|
|
250
|
+
* `name` is required only when there is no id — a playlist with no name cannot
|
|
251
|
+
* be told from another in the sidebar a client draws, and a call that omits it
|
|
252
|
+
* is refused by name rather than given one. With an id it is optional, and a
|
|
253
|
+
* name the client did not send stays where it was.
|
|
254
|
+
*/
|
|
255
|
+
export function createPlaylist(db, config, query) {
|
|
256
|
+
const songs = trackIdsIn(query, 'songId');
|
|
257
|
+
requireKnown(db, songs);
|
|
258
|
+
const rawId = query.get('playlistId');
|
|
259
|
+
if (rawId !== null && rawId !== '') {
|
|
260
|
+
const row = requiredPlaylist(db, rawId);
|
|
261
|
+
const asked = query.get('name');
|
|
262
|
+
const name = asked === null || asked === '' ? row.name : asked;
|
|
263
|
+
// Only when the client said something about the songs — see above.
|
|
264
|
+
const written = store.replace(db, row.id, name, query.has('songId') ? songs : undefined);
|
|
265
|
+
return { playlist: withSongs(written, db, config) };
|
|
266
|
+
}
|
|
267
|
+
const name = required(query, 'name');
|
|
268
|
+
return { playlist: withSongs(store.create(db, name, songs), db, config) };
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* A partial edit: rename, re-comment, add songs, drop songs.
|
|
272
|
+
*
|
|
273
|
+
* The two song arguments are applied with the removals first, because a
|
|
274
|
+
* position names a place in the list the client is holding — see `store.edit`
|
|
275
|
+
* for what the protocol does and does not say about that.
|
|
276
|
+
*
|
|
277
|
+
* A field the client did not send is left alone, and an empty one is left alone
|
|
278
|
+
* too **except for the comment**: a name of nothing is not a name a client can
|
|
279
|
+
* have meant to give, and neither is a visibility of nothing, while an empty
|
|
280
|
+
* comment is exactly how a client takes a comment off a playlist. That one is
|
|
281
|
+
* stored as no comment rather than as a comment that reads empty — the two look
|
|
282
|
+
* the same in a client's sidebar and only one of them is true.
|
|
283
|
+
*/
|
|
284
|
+
export function updatePlaylist(db, config, query) {
|
|
285
|
+
const row = requiredEditable(db, required(query, 'playlistId'));
|
|
286
|
+
const add = trackIdsIn(query, 'songIdToAdd');
|
|
287
|
+
requireKnown(db, add);
|
|
288
|
+
const name = query.get('name');
|
|
289
|
+
const comment = query.get('comment');
|
|
290
|
+
const isPublic = query.get('public');
|
|
291
|
+
store.edit(db, row.id, {
|
|
292
|
+
...(name === null || name === '' ? {} : { name }),
|
|
293
|
+
...(comment === null ? {} : { comment: comment === '' ? null : comment }),
|
|
294
|
+
...(isPublic === null || isPublic === '' ? {} : { public: booleanish(isPublic) }),
|
|
295
|
+
add,
|
|
296
|
+
remove: positionsIn(query, 'songIndexToRemove'),
|
|
297
|
+
});
|
|
298
|
+
return {};
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* A playlist, gone — the songs it named are untouched.
|
|
302
|
+
*
|
|
303
|
+
* A second call for the same id is refused as naming nothing, for the reason
|
|
304
|
+
* every other route refuses one: the client is told its question had no
|
|
305
|
+
* subject, which is truer than an `ok` about a deletion that did not happen.
|
|
306
|
+
*/
|
|
307
|
+
export function deletePlaylist(db, query) {
|
|
308
|
+
const row = requiredEditable(db, required(query, 'id'));
|
|
309
|
+
store.remove(db, row.id);
|
|
310
|
+
return {};
|
|
311
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { watch } from 'node:fs';
|
|
2
|
+
import { dueAt, quiet, staleReadings, watchable } from "../scan/schedule.js";
|
|
3
|
+
import { SETTLE_MS } from "../scan/settle.js";
|
|
4
|
+
/**
|
|
5
|
+
* How often to look at the clock, when the interval does not ask for finer.
|
|
6
|
+
*
|
|
7
|
+
* Five minutes costs two counts and a row, which is nothing — but a tick coarser
|
|
8
|
+
* than the interval would make the interval a lie: a deployment that asked for a
|
|
9
|
+
* scan every minute and got one every five would be a deployment whose setting
|
|
10
|
+
* does not mean what it says. So the tick is a quarter of the interval whenever
|
|
11
|
+
* that is finer, and five minutes otherwise.
|
|
12
|
+
*/
|
|
13
|
+
const TICK_MS = 5 * 60_000;
|
|
14
|
+
export function tickFor(schedule) {
|
|
15
|
+
if (schedule.intervalMinutes <= 0)
|
|
16
|
+
return TICK_MS;
|
|
17
|
+
return Math.max(1_000, Math.min(TICK_MS, (schedule.intervalMinutes * 60_000) / 4));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* How long the disk must be silent before a change is followed by a scan.
|
|
21
|
+
*
|
|
22
|
+
* **This is the watcher's half of the half-write gate, and the number belongs to
|
|
23
|
+
* the other half too.** The interval scan faces the same problem with no events
|
|
24
|
+
* to wait on, so it looks at a recently-written file twice instead — same rule,
|
|
25
|
+
* same window, and the constant lives with that rule in `scan/settle.ts` rather
|
|
26
|
+
* than here. Two numbers for one contract clause would be two rules written down
|
|
27
|
+
* as agreement.
|
|
28
|
+
*
|
|
29
|
+
* Waiting for silence is what the watcher owes and cannot avoid: it is told
|
|
30
|
+
* about an event, not about a file, so the torrent stopping is the only thing it
|
|
31
|
+
* can wait for. Five seconds rather than one, because a copy over the network
|
|
32
|
+
* pauses — and a pause longer than the window is the one case neither path
|
|
33
|
+
* catches, which `settle.ts` states rather than hides.
|
|
34
|
+
*/
|
|
35
|
+
export function rescan(deps) {
|
|
36
|
+
const now = deps.now ?? (() => new Date());
|
|
37
|
+
const tick = deps.tickMs ?? tickFor(deps.schedule);
|
|
38
|
+
const settle = deps.settleMs ?? SETTLE_MS;
|
|
39
|
+
let stopped = false;
|
|
40
|
+
/**
|
|
41
|
+
* Whether the operator has already been told there is nothing to read.
|
|
42
|
+
*
|
|
43
|
+
* Said once rather than on every tick: a server with no roots is a server
|
|
44
|
+
* somebody is about to configure, and a line every five minutes for a week is
|
|
45
|
+
* a log that teaches its reader to skip lines. Said again if roots come and
|
|
46
|
+
* go, because then it is news again.
|
|
47
|
+
*/
|
|
48
|
+
let saidNoRoots = false;
|
|
49
|
+
let timer = null;
|
|
50
|
+
let settling = null;
|
|
51
|
+
const start = (mode, why) => {
|
|
52
|
+
const started = deps.scanner.start(mode);
|
|
53
|
+
deps.log(started.ok
|
|
54
|
+
? `funoteka: scanning (${why})`
|
|
55
|
+
: // Not an error: a scan that is already running is a scan, and the
|
|
56
|
+
// reason this one was asked for is worth saying anyway — it is how
|
|
57
|
+
// somebody finds out their interval is shorter than their scan.
|
|
58
|
+
`funoteka: not scanning (${why}): ${started.reason}`);
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* The roots as they were when the watchers were last reconciled, so that a
|
|
62
|
+
* tick which finds them unchanged does nothing at all.
|
|
63
|
+
*/
|
|
64
|
+
let watched = [];
|
|
65
|
+
const check = () => {
|
|
66
|
+
if (stopped)
|
|
67
|
+
return;
|
|
68
|
+
if (deps.watch) {
|
|
69
|
+
const now = deps.roots();
|
|
70
|
+
if (now.join('\u0000') !== watched.join('\u0000')) {
|
|
71
|
+
watched = now;
|
|
72
|
+
reconcileWatchers();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// A scan already on is the answer to every reason to start one.
|
|
76
|
+
if (deps.scanner.status().running !== null)
|
|
77
|
+
return;
|
|
78
|
+
if (deps.roots().length === 0) {
|
|
79
|
+
if (!saidNoRoots) {
|
|
80
|
+
deps.log('funoteka: nothing to scan — no roots are configured');
|
|
81
|
+
saidNoRoots = true;
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
saidNoRoots = false;
|
|
86
|
+
const stale = staleReadings(deps.db);
|
|
87
|
+
// **The quiet hours hold here too.** They are a rule about when a scan may
|
|
88
|
+
// *start*, and an upgraded deployment whose files are read by an older method
|
|
89
|
+
// is not urgent enough to be the exception: the tick asks again, and when the
|
|
90
|
+
// window closes it scans. The review found this trigger walking past the
|
|
91
|
+
// window that the interval beside it respects.
|
|
92
|
+
if ((stale.tags > 0 || stale.probes > 0) && !quiet(now(), deps.schedule)) {
|
|
93
|
+
start('incremental', `${stale.tags} file(s) and ${stale.probes} probe(s) read by an older method — this build reads them again`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const last = deps.scanner.status().last?.startedAt ?? null;
|
|
97
|
+
const due = dueAt(last, now(), deps.schedule);
|
|
98
|
+
if (due === null)
|
|
99
|
+
return;
|
|
100
|
+
if (due.getTime() > now().getTime())
|
|
101
|
+
return;
|
|
102
|
+
start('incremental', last === null ? 'this library has never been read' : `its interval elapsed at ${due.toISOString()}`);
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Something changed under a root; scan once the disk has stopped changing.
|
|
106
|
+
*
|
|
107
|
+
* The timer is reset by every event, which is the whole of the gate: what is
|
|
108
|
+
* waited for is silence, and a copy that is still running keeps pushing it
|
|
109
|
+
* away.
|
|
110
|
+
*/
|
|
111
|
+
const touched = () => {
|
|
112
|
+
if (settling !== null)
|
|
113
|
+
clearTimeout(settling);
|
|
114
|
+
settling = setTimeout(() => {
|
|
115
|
+
settling = null;
|
|
116
|
+
if (stopped)
|
|
117
|
+
return;
|
|
118
|
+
// Silent when a scan is already on, and deliberately: the change is either
|
|
119
|
+
// picked up by the scan that is running or by the next one, and a line per
|
|
120
|
+
// event would be a log full of a copy that is still going on.
|
|
121
|
+
if (deps.scanner.status().running !== null)
|
|
122
|
+
return;
|
|
123
|
+
if (deps.roots().length === 0)
|
|
124
|
+
return;
|
|
125
|
+
start('incremental', `something changed under a root and the disk has been quiet for ${settle / 1000}s`);
|
|
126
|
+
}, settle);
|
|
127
|
+
settling.unref?.();
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* Watch what is configured *now*.
|
|
131
|
+
*
|
|
132
|
+
* **Called whenever the roots change, and that is the point.** The first
|
|
133
|
+
* version built the watcher set once, when the engine was created — so a shelf
|
|
134
|
+
* added afterwards with `POST /roots` was never watched, while the field beside
|
|
135
|
+
* it promised "a shelf added a minute ago is watched too". A watcher nobody
|
|
136
|
+
* notices is missing is the failure this option is careful about everywhere
|
|
137
|
+
* else, and it was the one place it was not.
|
|
138
|
+
*/
|
|
139
|
+
const watching = new Map();
|
|
140
|
+
const reconcileWatchers = () => {
|
|
141
|
+
const wanted = new Set(deps.roots());
|
|
142
|
+
for (const [root, watcher] of watching) {
|
|
143
|
+
if (wanted.has(root))
|
|
144
|
+
continue;
|
|
145
|
+
watcher.close();
|
|
146
|
+
watching.delete(root);
|
|
147
|
+
deps.log(`funoteka: stopped watching ${root} — it is no longer a root`);
|
|
148
|
+
}
|
|
149
|
+
for (const root of wanted) {
|
|
150
|
+
if (watching.has(root))
|
|
151
|
+
continue;
|
|
152
|
+
const can = watchable(root);
|
|
153
|
+
if (!can.ok) {
|
|
154
|
+
// Said out loud, and the interval carries on: a watcher that is silently
|
|
155
|
+
// deaf is worse than no watcher, because the deployment believes it has
|
|
156
|
+
// one.
|
|
157
|
+
deps.log(`funoteka: not watching ${root} — ${can.why}; the interval still scans`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
// Recursive where the platform has it. Linux does not, and this is the
|
|
162
|
+
// honest handling of that: one line, and the interval is what scans.
|
|
163
|
+
const watcher = watch(root, { recursive: true }, touched);
|
|
164
|
+
watcher.on('error', (err) => {
|
|
165
|
+
deps.log(`funoteka: watching ${root} failed (${err.message}) — the interval still scans`);
|
|
166
|
+
});
|
|
167
|
+
watching.set(root, watcher);
|
|
168
|
+
deps.log(`funoteka: watching ${root} for changes`);
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
deps.log(`funoteka: cannot watch ${root} (${err.message}) — the interval still scans`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
if (deps.watch) {
|
|
176
|
+
watched = deps.roots();
|
|
177
|
+
reconcileWatchers();
|
|
178
|
+
}
|
|
179
|
+
timer = setInterval(check, tick);
|
|
180
|
+
timer.unref?.();
|
|
181
|
+
if (deps.startup !== false)
|
|
182
|
+
check();
|
|
183
|
+
return {
|
|
184
|
+
check,
|
|
185
|
+
stop: () => {
|
|
186
|
+
stopped = true;
|
|
187
|
+
if (timer !== null)
|
|
188
|
+
clearInterval(timer);
|
|
189
|
+
if (settling !== null)
|
|
190
|
+
clearTimeout(settling);
|
|
191
|
+
for (const watcher of watching.values())
|
|
192
|
+
watcher.close();
|
|
193
|
+
watching.clear();
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { rootKey } from "../scan/scan.js";
|
|
4
|
+
import { parseRoots } from "./scanner.js";
|
|
5
|
+
/**
|
|
6
|
+
* Every root, with what is under it.
|
|
7
|
+
*
|
|
8
|
+
* The counts are per root rather than a total, because the question the listing
|
|
9
|
+
* answers is "where did my library come from" — a root with nothing under it is
|
|
10
|
+
* either a shelf that has not been scanned yet or a path that leads somewhere
|
|
11
|
+
* empty, and those want different answers from whoever is reading.
|
|
12
|
+
*/
|
|
13
|
+
export function listRoots(db) {
|
|
14
|
+
const rows = db
|
|
15
|
+
.prepare('SELECT id, path, alias, created_at FROM root ORDER BY path')
|
|
16
|
+
.all();
|
|
17
|
+
const runs = recentRuns(db);
|
|
18
|
+
return rows.map((row) => {
|
|
19
|
+
// Found once: the two fields below are one run read twice, and asking for it
|
|
20
|
+
// twice walked the fifty runs twice for every root.
|
|
21
|
+
const last = lastRunFor(runs, rootKey(row.path));
|
|
22
|
+
return {
|
|
23
|
+
id: row.id,
|
|
24
|
+
path: row.path,
|
|
25
|
+
alias: row.alias,
|
|
26
|
+
createdAt: row.created_at,
|
|
27
|
+
folders: countOf(db, 'folder', row.id),
|
|
28
|
+
files: countOf(db, 'file', row.id, "AND kind = 'audio'"),
|
|
29
|
+
albums: countOf(db, 'album', row.id),
|
|
30
|
+
lastScannedAt: last?.finished_at ?? null,
|
|
31
|
+
lastScanStatus: last?.status ?? null,
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Configure a directory as a root.
|
|
37
|
+
*
|
|
38
|
+
* **It has to be there.** A root naming a directory that does not exist is a
|
|
39
|
+
* deployment that will scan nothing and report success — the classic shape of a
|
|
40
|
+
* mistyped path — so the path is checked while the operator is still looking at
|
|
41
|
+
* the answer. The check is a `stat` and not a walk: what is under it is the
|
|
42
|
+
* scan's business.
|
|
43
|
+
*
|
|
44
|
+
* Adding one that is already a root is not an error and not a second row. It is
|
|
45
|
+
* the same directory, whoever says so — and the answer says it was already
|
|
46
|
+
* there, because an operator who thought they were adding a shelf should know
|
|
47
|
+
* they did not.
|
|
48
|
+
*/
|
|
49
|
+
export function addRoot(db, given) {
|
|
50
|
+
const path = resolve(given);
|
|
51
|
+
let isDirectory = false;
|
|
52
|
+
try {
|
|
53
|
+
isDirectory = statSync(path).isDirectory();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// The sentence below covers both a path that is not there and one that
|
|
57
|
+
// cannot be read, and the second is rarer than the first by a long way.
|
|
58
|
+
isDirectory = false;
|
|
59
|
+
}
|
|
60
|
+
if (!isDirectory)
|
|
61
|
+
throw new Error(`not a directory on this machine: ${path}`);
|
|
62
|
+
const rows = db.prepare('SELECT id, path, alias, created_at FROM root').all();
|
|
63
|
+
const key = rootKey(path);
|
|
64
|
+
const known = rows.find((row) => rootKey(row.path) === key);
|
|
65
|
+
if (known !== undefined) {
|
|
66
|
+
// The row is kept as it is, and the stored spelling is left alone: it is
|
|
67
|
+
// what the operator typed when they configured it, and a listing that
|
|
68
|
+
// silently rewrote it would be reporting a path nobody chose. A scan
|
|
69
|
+
// canonicalises it, which is the place that has the filesystem's answer.
|
|
70
|
+
return { root: view(db, known.id), already: true };
|
|
71
|
+
}
|
|
72
|
+
const inserted = db
|
|
73
|
+
.prepare('INSERT INTO root (path, created_at) VALUES (?, ?)')
|
|
74
|
+
.run(path, new Date().toISOString());
|
|
75
|
+
return { root: view(db, Number(inserted.lastInsertRowid)), already: false };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Stop serving a directory, and take what came from it with it.
|
|
79
|
+
*
|
|
80
|
+
* **This is the destructive verb, and the cascade is deliberate.** Every folder,
|
|
81
|
+
* file, album and track under this root is derived from it — `scan.ts` says so
|
|
82
|
+
* in the schema, `ON DELETE CASCADE` all the way down — so a root that stayed
|
|
83
|
+
* while its rows did would be a library still serving music from a shelf the
|
|
84
|
+
* operator had removed from the deployment. The files on disk are untouched:
|
|
85
|
+
* this is a statement about what this server reads, not about the disk.
|
|
86
|
+
*
|
|
87
|
+
* The answer carries the count, because "removed" and "removed a third of your
|
|
88
|
+
* library" are the same word and not the same event.
|
|
89
|
+
*/
|
|
90
|
+
export function removeRoot(db, given) {
|
|
91
|
+
const key = rootKey(resolve(given));
|
|
92
|
+
const rows = db.prepare('SELECT id, path FROM root').all();
|
|
93
|
+
const doomed = rows.find((row) => rootKey(row.path) === key);
|
|
94
|
+
if (doomed === undefined)
|
|
95
|
+
return null;
|
|
96
|
+
const before = view(db, doomed.id);
|
|
97
|
+
// One transaction, and `IMMEDIATE` like every other write here: a cascade
|
|
98
|
+
// that unwound halfway would leave a root whose rows are some of what it had.
|
|
99
|
+
db.exec('BEGIN IMMEDIATE');
|
|
100
|
+
try {
|
|
101
|
+
db.prepare('DELETE FROM root WHERE id = ?').run(doomed.id);
|
|
102
|
+
db.exec('COMMIT');
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
try {
|
|
106
|
+
db.exec('ROLLBACK');
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Already unwound; the original error is what matters.
|
|
110
|
+
}
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
return { root: before, songs: before.files, albums: before.albums };
|
|
114
|
+
}
|
|
115
|
+
/** One root, counted the way the listing counts them. */
|
|
116
|
+
function view(db, id) {
|
|
117
|
+
const row = db.prepare('SELECT id, path, alias, created_at FROM root WHERE id = ?').get(id);
|
|
118
|
+
const runs = recentRuns(db);
|
|
119
|
+
const last = lastRunFor(runs, rootKey(row.path));
|
|
120
|
+
return {
|
|
121
|
+
id: row.id,
|
|
122
|
+
path: row.path,
|
|
123
|
+
alias: row.alias,
|
|
124
|
+
createdAt: row.created_at,
|
|
125
|
+
folders: countOf(db, 'folder', row.id),
|
|
126
|
+
files: countOf(db, 'file', row.id, "AND kind = 'audio'"),
|
|
127
|
+
albums: countOf(db, 'album', row.id),
|
|
128
|
+
lastScannedAt: last?.finished_at ?? null,
|
|
129
|
+
lastScanStatus: last?.status ?? null,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function countOf(db, table, rootId, extra = '') {
|
|
133
|
+
const row = db
|
|
134
|
+
.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE root_id = ? ${extra}`)
|
|
135
|
+
.get(rootId);
|
|
136
|
+
return row.n;
|
|
137
|
+
}
|
|
138
|
+
function recentRuns(db) {
|
|
139
|
+
const rows = db
|
|
140
|
+
.prepare('SELECT status, finished_at, roots_json FROM scan_run ORDER BY id DESC LIMIT 50')
|
|
141
|
+
.all();
|
|
142
|
+
// The same two or three shelves appear in every one of the fifty runs, so the
|
|
143
|
+
// answer for a path is asked for once and kept.
|
|
144
|
+
const keys = new Map();
|
|
145
|
+
const keyOf = (path) => {
|
|
146
|
+
const known = keys.get(path);
|
|
147
|
+
if (known !== undefined)
|
|
148
|
+
return known;
|
|
149
|
+
const key = rootKey(path);
|
|
150
|
+
keys.set(path, key);
|
|
151
|
+
return key;
|
|
152
|
+
};
|
|
153
|
+
return rows.map((row) => ({
|
|
154
|
+
status: row.status,
|
|
155
|
+
finished_at: row.finished_at,
|
|
156
|
+
keys: parseRoots(row.roots_json).map(keyOf),
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
function lastRunFor(runs, key) {
|
|
160
|
+
return runs.find((run) => run.keys.includes(key));
|
|
161
|
+
}
|