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,304 @@
|
|
|
1
|
+
import { createReadStream, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { pickCover } from "../cover/pick.js";
|
|
4
|
+
import { pictureInRegion } from "../cover/picture.js";
|
|
5
|
+
import { parseId, required } from "./browse.js";
|
|
6
|
+
import { ApiError, ERROR } from "./envelope.js";
|
|
7
|
+
import { album, albumPlacesOfArtist, artist, artistOwnFolders, embeddedCoverInFolder, fileFoldersOfAlbums, folder, picturesInFolder, root, song, } from "./meta.js";
|
|
8
|
+
/**
|
|
9
|
+
* The cover a client shows beside a record.
|
|
10
|
+
*
|
|
11
|
+
* The protocol asks for one image by the id of whatever is on screen — an
|
|
12
|
+
* album, an artist, a song, a folder — so the id is a way of *asking*, not a
|
|
13
|
+
* statement about where the picture is. What this file does is turn each kind
|
|
14
|
+
* of id into the folders worth looking in, in the order worth looking, and hand
|
|
15
|
+
* the first picture found to the client.
|
|
16
|
+
*
|
|
17
|
+
* Only the folder's own pictures are considered. Descending into a subfolder
|
|
18
|
+
* looks tempting — the scans of a box often sit in a `Full scans` or `Artwork`
|
|
19
|
+
* directory — but those directories are where the *back*, the disc and the
|
|
20
|
+
* booklet live, and a rule that reached into one would as often answer with the
|
|
21
|
+
* back of the record as with its front. A folder that keeps its cover nowhere
|
|
22
|
+
* answers "no cover", and the client draws its own placeholder.
|
|
23
|
+
*
|
|
24
|
+
* Nothing is scaled. The protocol allows a `size`, and this server answers with
|
|
25
|
+
* the collection's own file whatever size was asked for: nothing in the runtime
|
|
26
|
+
* can decode an image, and the file the operator scanned is a truer answer than
|
|
27
|
+
* a resize made by guessing. A client that wants a thumbnail makes one.
|
|
28
|
+
*/
|
|
29
|
+
/** What an image file is, so the client knows what it is about to receive. */
|
|
30
|
+
const IMAGE_TYPES = {
|
|
31
|
+
jpg: 'image/jpeg',
|
|
32
|
+
jpeg: 'image/jpeg',
|
|
33
|
+
png: 'image/png',
|
|
34
|
+
gif: 'image/gif',
|
|
35
|
+
bmp: 'image/bmp',
|
|
36
|
+
webp: 'image/webp',
|
|
37
|
+
tif: 'image/tiff',
|
|
38
|
+
tiff: 'image/tiff',
|
|
39
|
+
};
|
|
40
|
+
export function imageContentType(ext) {
|
|
41
|
+
return IMAGE_TYPES[ext] ?? 'application/octet-stream';
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The folders an id asks about, best first.
|
|
45
|
+
*
|
|
46
|
+
* Several, because the meta layer's answer is not always one folder: a song on
|
|
47
|
+
* no album still has the folder it sits in, and an album whose row names a file
|
|
48
|
+
* rather than a directory — what a loose download becomes — keeps its pictures
|
|
49
|
+
* in the folder that file is in. An artist is the one case where the list is
|
|
50
|
+
* genuinely long: it has no art of its own, so every one of its records offers
|
|
51
|
+
* what it has.
|
|
52
|
+
*
|
|
53
|
+
* The order is the caller's own and is kept, but it is not what decides first:
|
|
54
|
+
* these folders are a *set* to be ranked once, so the record that happens to
|
|
55
|
+
* come first does not answer for the artist — a record whose picture is named
|
|
56
|
+
* `front` does, wherever in the list it sits. The order decides only the case
|
|
57
|
+
* no name decides, and `foldersOfAlbums` preserves it for exactly that; a
|
|
58
|
+
* comment here once said the caller must not read the list as ordered at all,
|
|
59
|
+
* which the ranking below contradicts.
|
|
60
|
+
*/
|
|
61
|
+
function candidateFolders(db, id) {
|
|
62
|
+
const parsed = parseId(id);
|
|
63
|
+
if (parsed === undefined)
|
|
64
|
+
return [];
|
|
65
|
+
switch (parsed.kind) {
|
|
66
|
+
case 'al': {
|
|
67
|
+
const row = album(db, parsed.n);
|
|
68
|
+
return row === undefined ? [] : foldersOfAlbums(db, [row]);
|
|
69
|
+
}
|
|
70
|
+
case 'tr': {
|
|
71
|
+
const row = song(db, parsed.n);
|
|
72
|
+
if (row === undefined)
|
|
73
|
+
return [];
|
|
74
|
+
const own = { rootId: row.root_id, relPath: row.folder_rel_path };
|
|
75
|
+
if (row.album_id === null)
|
|
76
|
+
return [own];
|
|
77
|
+
const record = album(db, row.album_id);
|
|
78
|
+
return record === undefined
|
|
79
|
+
? [own]
|
|
80
|
+
: distinct([{ rootId: record.root_id, relPath: record.rel_path }, own]);
|
|
81
|
+
}
|
|
82
|
+
case 'ar': {
|
|
83
|
+
const row = artist(db, parsed.n);
|
|
84
|
+
if (row === undefined)
|
|
85
|
+
return [];
|
|
86
|
+
// Through the same expansion an album gets, and for the same reason: a
|
|
87
|
+
// record whose row names a *file* — a flat rip, a `.ape` beside its cue —
|
|
88
|
+
// keeps its pictures in the folder that file is in, and an artist asking
|
|
89
|
+
// for art is asking about its records either way.
|
|
90
|
+
//
|
|
91
|
+
// Asked for the artist's records *together*, because the answer is a set:
|
|
92
|
+
// the folders of thirty records are one join, where asking each record on
|
|
93
|
+
// its own read that record's whole song list to learn the same thing. And
|
|
94
|
+
// asked for their *places* rather than for the records, because a cover is
|
|
95
|
+
// not about what a record holds — the genre and the running time this
|
|
96
|
+
// route never reads are built for every album in the collection before the
|
|
97
|
+
// artist's are picked out.
|
|
98
|
+
return foldersOfAlbums(db, albumPlacesOfArtist(db, row.id));
|
|
99
|
+
}
|
|
100
|
+
case 'fd': {
|
|
101
|
+
const row = folder(db, parsed.n);
|
|
102
|
+
return row === undefined ? [] : [{ rootId: row.root_id, relPath: row.rel_path }];
|
|
103
|
+
}
|
|
104
|
+
case 'ro': {
|
|
105
|
+
const row = root(db, parsed.n);
|
|
106
|
+
// A root is a folder like any other — `''` is the row that stands for it
|
|
107
|
+
// (`db/migrations/001_init.sql`) — so the pictures an operator dropped in
|
|
108
|
+
// their download folder are reachable the same way a record's are.
|
|
109
|
+
return row === undefined ? [] : [{ rootId: row.id, relPath: '' }];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The folders several records' pictures could be in, best first.
|
|
115
|
+
*
|
|
116
|
+
* The album's row is its folder by the identity rule, and the songs are where
|
|
117
|
+
* that stops being true: a flat rip whose every file became its own album names
|
|
118
|
+
* the file, not a directory (`.ape` beside its cue is the shape this collection
|
|
119
|
+
* has). Both are offered per record, the row's own first, so that a record keyed
|
|
120
|
+
* on a file still finds the folder the file sits in.
|
|
121
|
+
*
|
|
122
|
+
* The records are taken as many at once rather than one at a time, and the
|
|
123
|
+
* caller's own order is what the result is in — a record's row, then its files,
|
|
124
|
+
* then the next record's. That order is the tie-break when no picture's name
|
|
125
|
+
* says which side it is, so it is preserved rather than sorted.
|
|
126
|
+
*/
|
|
127
|
+
function foldersOfAlbums(db, records) {
|
|
128
|
+
const files = fileFoldersOfAlbums(db, records.map((row) => row.id));
|
|
129
|
+
return distinct(records.flatMap((row) => [
|
|
130
|
+
{ rootId: row.root_id, relPath: row.rel_path },
|
|
131
|
+
...(files.get(row.id) ?? []),
|
|
132
|
+
]));
|
|
133
|
+
}
|
|
134
|
+
function distinct(folders) {
|
|
135
|
+
const seen = new Set();
|
|
136
|
+
return folders.filter(({ rootId, relPath }) => {
|
|
137
|
+
const key = `${rootId}\u0000${relPath}`;
|
|
138
|
+
if (seen.has(key))
|
|
139
|
+
return false;
|
|
140
|
+
seen.add(key);
|
|
141
|
+
return true;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/** A picture sitting on disk, as the cover to serve. */
|
|
145
|
+
function fileCover(picture) {
|
|
146
|
+
return { kind: 'file', rootPath: picture.root_path, relPath: picture.rel_path, ext: picture.ext };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* The picture a single folder holds, or nothing when it holds none.
|
|
150
|
+
*
|
|
151
|
+
* One folder's own pictures ranked on their own — the reader `resolve` uses for
|
|
152
|
+
* its first question, which asks a folder named for the artist before it asks
|
|
153
|
+
* anything of the artist's records.
|
|
154
|
+
*/
|
|
155
|
+
function coverInFolder(db, { rootId, relPath }) {
|
|
156
|
+
const chosen = pickCover(picturesInFolder(db, rootId, relPath));
|
|
157
|
+
return chosen === undefined ? undefined : fileCover(chosen);
|
|
158
|
+
}
|
|
159
|
+
function resolve(db, id) {
|
|
160
|
+
// An artist's own folder answers for the artist, and only for the artist.
|
|
161
|
+
//
|
|
162
|
+
// It is asked apart from the ranking below rather than put at the front of
|
|
163
|
+
// its list, because "first among equals" would not decide anything: a folder
|
|
164
|
+
// named for the artist holds a `folder.jpg`, a record's folder holds a
|
|
165
|
+
// `front.jpg`, and `front` outranks `folder` — so an album cover would still
|
|
166
|
+
// have won the tie it was never party to. A picture of the artist is a fact
|
|
167
|
+
// about the artist, and where the collection states one it is the answer.
|
|
168
|
+
const asked = parseId(id);
|
|
169
|
+
if (asked !== undefined && asked.kind === 'ar') {
|
|
170
|
+
for (const where of artistOwnFolders(db, asked.n)) {
|
|
171
|
+
const own = coverInFolder(db, where);
|
|
172
|
+
if (own !== undefined)
|
|
173
|
+
return { picture: own, folders: [where] };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const folders = candidateFolders(db, id);
|
|
177
|
+
// Every folder's pictures as one set, ranked once — not "the first folder
|
|
178
|
+
// holding anything answers".
|
|
179
|
+
//
|
|
180
|
+
// A box keeps its front cover inside each disc and its paperwork in the box
|
|
181
|
+
// folder, and the folder that answers first is the box's: `Кинохроники` has an
|
|
182
|
+
// `info.png` at the top and `front.jpg` one level down, so the sheet about the
|
|
183
|
+
// release was served as its cover. Ranking across the folders lets a name that
|
|
184
|
+
// says `front` win wherever it is, and the folder order still decides when no
|
|
185
|
+
// name says anything — `pickCover` falls back to the first picture it was
|
|
186
|
+
// given, and the record's own folder is first in the list.
|
|
187
|
+
const pictures = folders.flatMap(({ rootId, relPath }) => picturesInFolder(db, rootId, relPath));
|
|
188
|
+
const chosen = pickCover(pictures);
|
|
189
|
+
if (chosen !== undefined) {
|
|
190
|
+
return { picture: fileCover(chosen), folders };
|
|
191
|
+
}
|
|
192
|
+
for (const { rootId, relPath } of folders) {
|
|
193
|
+
const embedded = embeddedCoverInFolder(db, rootId, relPath);
|
|
194
|
+
if (embedded === undefined)
|
|
195
|
+
continue;
|
|
196
|
+
// Two kinds of place, and the row says which. A picture the file carries as
|
|
197
|
+
// its own bytes is a range a client can be sent; one it carries base64'd
|
|
198
|
+
// inside a comment is a range that has to be read and parsed first — see
|
|
199
|
+
// `src/cover/picture.ts`.
|
|
200
|
+
const picture = embedded.kind === 'indirect'
|
|
201
|
+
? {
|
|
202
|
+
kind: 'indirect',
|
|
203
|
+
rootPath: embedded.root_path,
|
|
204
|
+
relPath: embedded.rel_path,
|
|
205
|
+
container: embedded.container,
|
|
206
|
+
offset: embedded.offset,
|
|
207
|
+
length: embedded.length,
|
|
208
|
+
}
|
|
209
|
+
: {
|
|
210
|
+
kind: 'embedded',
|
|
211
|
+
rootPath: embedded.root_path,
|
|
212
|
+
relPath: embedded.rel_path,
|
|
213
|
+
mime: embedded.mime,
|
|
214
|
+
offset: embedded.offset,
|
|
215
|
+
length: embedded.length,
|
|
216
|
+
};
|
|
217
|
+
return { picture, folders };
|
|
218
|
+
}
|
|
219
|
+
return { picture: undefined, folders };
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* The route.
|
|
223
|
+
*
|
|
224
|
+
* Bytes, not an envelope, so it is a `BinaryRoute` like `stream` — but with one
|
|
225
|
+
* difference that matters: it knows the whole answer before the first header is
|
|
226
|
+
* written. An image is a file, and a file's size is known. So a refusal is
|
|
227
|
+
* always still a refusal the client can read, and the response is never a
|
|
228
|
+
* connection that ends without saying why.
|
|
229
|
+
*/
|
|
230
|
+
export async function coverArt(context, request, response) {
|
|
231
|
+
const id = required(context.query, 'id');
|
|
232
|
+
const { picture, folders } = resolve(context.db, id);
|
|
233
|
+
if (picture === undefined) {
|
|
234
|
+
// The folder is named when one was looked in, because "this record has no
|
|
235
|
+
// cover" and "there is no such record" are different things to go and fix,
|
|
236
|
+
// and the message is the only place a caller learns which happened. The
|
|
237
|
+
// folders come from the lookup that just failed rather than a second walk of
|
|
238
|
+
// the same ground.
|
|
239
|
+
const where = folders.map((f) => f.relPath || '/');
|
|
240
|
+
throw new ApiError(ERROR.notFound, where.length === 0
|
|
241
|
+
? `No such id: ${id}`
|
|
242
|
+
: `No cover art for ${id}: ${where.join(', ')} holds no picture`);
|
|
243
|
+
}
|
|
244
|
+
const path = join(picture.rootPath, picture.relPath);
|
|
245
|
+
// The kind of place that is not a range. Its bytes have to be derived before
|
|
246
|
+
// anything can be promised about them — including whether there are any — so
|
|
247
|
+
// this is the one branch that is not a `readStream` over a slice.
|
|
248
|
+
if (picture.kind === 'indirect') {
|
|
249
|
+
const derived = pictureInRegion(path, picture.container, picture.offset, picture.length);
|
|
250
|
+
if (derived === null) {
|
|
251
|
+
// The row said a picture was there and it could not be derived: the file
|
|
252
|
+
// has changed since the scan, or its comment is damaged. Saying so is the
|
|
253
|
+
// same answer as having none, because that is what the client gets — but
|
|
254
|
+
// the message names the file, which is where to go and look.
|
|
255
|
+
throw new ApiError(ERROR.notFound, `No cover art for ${id}: the picture inside ${picture.relPath} could not be read`);
|
|
256
|
+
}
|
|
257
|
+
response.writeHead(200, {
|
|
258
|
+
'content-type': derived.mime,
|
|
259
|
+
'content-length': derived.data.length,
|
|
260
|
+
});
|
|
261
|
+
if (request.method === 'HEAD' || derived.data.length === 0) {
|
|
262
|
+
response.end();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
// Already in memory, so it goes out as it is: a stream over a buffer is a
|
|
266
|
+
// second copy of a picture that was just decoded into the first.
|
|
267
|
+
response.end(derived.data);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
// Where in that file the bytes are: the whole of it for a picture sitting
|
|
271
|
+
// beside the record, and a named range of it for one the record carries.
|
|
272
|
+
const [from, to, mime] = picture.kind === 'file'
|
|
273
|
+
? [0, statSync(path).size - 1, imageContentType(picture.ext)]
|
|
274
|
+
: [picture.offset, picture.offset + picture.length - 1, picture.mime];
|
|
275
|
+
const length = to - from + 1;
|
|
276
|
+
response.writeHead(200, { 'content-type': mime, 'content-length': length });
|
|
277
|
+
// A HEAD asks what a GET would bring. Reading the file to throw the bytes
|
|
278
|
+
// away is the one thing the method exists to avoid.
|
|
279
|
+
if (request.method === 'HEAD' || length <= 0) {
|
|
280
|
+
response.end();
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
await pipeRange(path, from, to, response);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* The picture, as a stream that ends with the response.
|
|
287
|
+
*
|
|
288
|
+
* Bounded by the last byte the header already promised, and that bound is not
|
|
289
|
+
* decoration: `createReadStream(path)` alone reads until the file says it is
|
|
290
|
+
* over, so a picture replaced between the `statSync` above and this read would
|
|
291
|
+
* put more bytes on the wire than `Content-Length` declared and leave the
|
|
292
|
+
* connection out of step for whatever the client sent next. Measured on this
|
|
293
|
+
* repository: with the unbounded form the connection stayed open for three
|
|
294
|
+
* seconds after the last byte, which is how the difference was noticed at all —
|
|
295
|
+
* it looked exactly like a slow server.
|
|
296
|
+
*/
|
|
297
|
+
function pipeRange(path, from, to, response) {
|
|
298
|
+
return new Promise((resolve, reject) => {
|
|
299
|
+
const file = createReadStream(path, { start: from, end: to });
|
|
300
|
+
file.on('error', reject);
|
|
301
|
+
response.on('close', () => file.destroy());
|
|
302
|
+
file.pipe(response).on('finish', resolve).on('error', reject);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { stretchOf } from "../stream/segment.js";
|
|
4
|
+
import { contentType, trackId } from "./browse.js";
|
|
5
|
+
import { ApiError, ERROR } from "./envelope.js";
|
|
6
|
+
import { song } from "./meta.js";
|
|
7
|
+
import { serveBytes, serveSegment } from "./stream.js";
|
|
8
|
+
/**
|
|
9
|
+
* The file itself, for a client that means to keep it.
|
|
10
|
+
*
|
|
11
|
+
* `stream`'s plainer sibling, and the difference is the whole of what this
|
|
12
|
+
* method is for: `stream` answers with something a client can *play* — a cue
|
|
13
|
+
* track cut out of an image, a re-encode of a format the client cannot read —
|
|
14
|
+
* and `download` answers with the original media data, "without transcoding or
|
|
15
|
+
* downsampling", which is what a client needs to put a record on a phone for a
|
|
16
|
+
* train. So there is one path here and no plan: the file the row names, sent as
|
|
17
|
+
* it is.
|
|
18
|
+
*
|
|
19
|
+
* **A song cut from a cue image is sent as its own frames, not as the image.**
|
|
20
|
+
* The first version of this handed back the whole file, on the reasoning that
|
|
21
|
+
* anything else would be "cutting" what the method promises to deliver whole —
|
|
22
|
+
* and that reasoning was wrong, in the way that matters: the saved file played
|
|
23
|
+
* from the top of the disc, so a client that asked for track nine got the wrong
|
|
24
|
+
* song in 510 MB, for 1 789 of this collection's 4 969 tracks (task:2864).
|
|
25
|
+
*
|
|
26
|
+
* Copying a frame range is not a transcode. Nothing is decoded, nothing is
|
|
27
|
+
* resampled, and `stream/segment.ts` calls that path lossless for the same
|
|
28
|
+
* reason `stream` uses it. What this method refuses is ffmpeg — so for the few
|
|
29
|
+
* images whose frames cannot be restated (ape, MP4) the segment is refused with
|
|
30
|
+
* a reason rather than served as something else, and the sender is told to ask
|
|
31
|
+
* `stream` or to take the image deliberately.
|
|
32
|
+
*
|
|
33
|
+
* The bytes and the ranging are `stream.ts`'s — `serveBytes` and `serveSegment`
|
|
34
|
+
* are the same functions the stream route uses, so seeking a download works for
|
|
35
|
+
* the same reason seeking a stream does and cannot drift away from it.
|
|
36
|
+
*/
|
|
37
|
+
export async function download(context, request, response) {
|
|
38
|
+
const id = trackId(context.query);
|
|
39
|
+
const row = song(context.db, id);
|
|
40
|
+
if (row === undefined)
|
|
41
|
+
throw new ApiError(ERROR.notFound, `No such song: ${id}`);
|
|
42
|
+
// The path is the meta layer's own, never the client's — the rule `stream.ts`
|
|
43
|
+
// states, and the reason neither of these can be talked into reading a file
|
|
44
|
+
// the scan never saw.
|
|
45
|
+
const path = join(row.root_path, row.rel_path);
|
|
46
|
+
// Asked here rather than left to `serveBytes`, which would find it out by
|
|
47
|
+
// throwing from `statSync`. The difference is the header below: a refusal is
|
|
48
|
+
// rendered into the same response, and an `attachment` beside "Internal
|
|
49
|
+
// error" is a client saving a refusal under a track's name.
|
|
50
|
+
if (!existsSync(path)) {
|
|
51
|
+
throw new ApiError(ERROR.notFound, `No such file on disk: ${row.rel_path}`);
|
|
52
|
+
}
|
|
53
|
+
// Set before `serveBytes` writes the head, which keeps what was set here:
|
|
54
|
+
// this is the method a client saves with, and a name is what makes a saved
|
|
55
|
+
// file recognisable.
|
|
56
|
+
// A whole file is saved under its own name; a stretch of one is saved under
|
|
57
|
+
// the song's, because that is what it is — `Tool - Undertow.flac` is a file
|
|
58
|
+
// somebody can find again, and `Tool - Undertow [61422-33010-2].flac` is a
|
|
59
|
+
// 510 MB disc they did not ask for.
|
|
60
|
+
response.setHeader('content-disposition', attachmentOf(row.segment_start_ms === null
|
|
61
|
+
? basename(path)
|
|
62
|
+
: `${row.title ?? basename(path)}.${row.ext}`));
|
|
63
|
+
if (row.segment_start_ms !== null) {
|
|
64
|
+
// A song cut from an image is a stretch of it, and the stretch is what this
|
|
65
|
+
// method is asked for. `serveSegment` copies the frames — nothing is decoded
|
|
66
|
+
// and nothing is resampled, so it is the original data and not a transcode
|
|
67
|
+
// — and the one branch it is not allowed here is ffmpeg's, which would make
|
|
68
|
+
// the answer a different file from the one on disk.
|
|
69
|
+
// The stretch is the song's own, and there is no offset here to move it:
|
|
70
|
+
// download has no `timeOffset`, and the file a client asked to keep is the
|
|
71
|
+
// track, not a later part of it.
|
|
72
|
+
const stretch = stretchOf(row, null);
|
|
73
|
+
if (stretch.kind !== 'span') {
|
|
74
|
+
throw new ApiError(ERROR.generic, stretch.kind === 'refused' ? stretch.reason : 'This song is not a stretch of its file');
|
|
75
|
+
}
|
|
76
|
+
await serveSegment(context, row, path, request, response, {
|
|
77
|
+
times: stretch,
|
|
78
|
+
as: 'download',
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
await serveBytes(path, contentType(row.ext), request, response);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* A `Content-Disposition` a header can actually carry.
|
|
86
|
+
*
|
|
87
|
+
* **Two filenames, and the second is not decoration.** A header value is
|
|
88
|
+
* latin-1, and this collection's names are not: a folder called `Кино` would
|
|
89
|
+
* make `setHeader` throw and take the whole request down with it. So the plain
|
|
90
|
+
* `filename` is the name with everything outside ASCII replaced, and
|
|
91
|
+
* `filename*` carries the real one percent-encoded, which is what RFC 5987
|
|
92
|
+
* added and what every client that matters reads. A client that understands
|
|
93
|
+
* only the first gets a file it can still recognise; one that reads the second
|
|
94
|
+
* gets the name the file has.
|
|
95
|
+
*/
|
|
96
|
+
function attachmentOf(name) {
|
|
97
|
+
const safe = name.replace(/[^\x20-\x7e]/gu, '_').replace(/["\\]/gu, '_');
|
|
98
|
+
return `attachment; filename="${safe}"; filename*=UTF-8''${extValue(name)}`;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* A string as RFC 5987's `ext-value`, which `encodeURIComponent` is not.
|
|
102
|
+
*
|
|
103
|
+
* The standard's `attr-char` leaves out `'`, `(`, `)`, `*` and `!` — and
|
|
104
|
+
* `encodeURIComponent` escapes none of the first four. `'` is the one that
|
|
105
|
+
* bites: it is the quote around the charset in `UTF-8''`, so a name like
|
|
106
|
+
* `Guns N' Roses (Live).flac` emitted an encoding whose own separator appears
|
|
107
|
+
* inside it, and a strict client drops the whole parameter and falls back to
|
|
108
|
+
* the ASCII stand-in.
|
|
109
|
+
*/
|
|
110
|
+
function extValue(name) {
|
|
111
|
+
return encodeURIComponent(name).replace(/['()*]/gu, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
112
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Subsonic response envelope, and the two ways it is rendered.
|
|
3
|
+
*
|
|
4
|
+
* Every answer the API gives — a method's payload or an error — travels inside
|
|
5
|
+
* one `subsonic-response` object, and the client decides whether the call
|
|
6
|
+
* worked by reading `status` out of that object rather than by reading the HTTP
|
|
7
|
+
* status line. So the envelope is the one shape every route agrees on, and the
|
|
8
|
+
* one place a route can be wrong about the protocol while being right about its
|
|
9
|
+
* own data. It gets a module of its own for that reason.
|
|
10
|
+
*
|
|
11
|
+
* Rendering is generic, and deliberately so. In the protocol's XML a field is
|
|
12
|
+
* an attribute when it holds a value and a child element when it holds a record,
|
|
13
|
+
* which makes the two formats one answer seen twice — so a route hands over a
|
|
14
|
+
* plain object and neither chooses a format nor knows one is being chosen.
|
|
15
|
+
*/
|
|
16
|
+
/** The protocol version this server speaks. Clients refuse a server they outrank. */
|
|
17
|
+
export const API_VERSION = '1.16.1';
|
|
18
|
+
/**
|
|
19
|
+
* What this server is called, and which of its own versions this is.
|
|
20
|
+
*
|
|
21
|
+
* **Both are required of every answer, and neither is about the answer.**
|
|
22
|
+
* `type` is how a client adapts to what a server calling itself Subsonic
|
|
23
|
+
* actually supports; `serverVersion` is how it knows *to ask again which
|
|
24
|
+
* extensions exist* after a deployment — the spec is explicit that this is what
|
|
25
|
+
* the field is for. `version` above is the API version and answers a third
|
|
26
|
+
* question, so it stands in for neither.
|
|
27
|
+
*
|
|
28
|
+
* Pinned to `package.json` by a test rather than read from it at import: a
|
|
29
|
+
* constant that cannot drift silently is worth more here than a file read in a
|
|
30
|
+
* module every route depends on.
|
|
31
|
+
*/
|
|
32
|
+
export const SERVER_TYPE = 'funoteka';
|
|
33
|
+
export const SERVER_VERSION = '0.1.0';
|
|
34
|
+
/**
|
|
35
|
+
* The fields every answer carries, whatever the answer is.
|
|
36
|
+
*
|
|
37
|
+
* `openSubsonic` is the one that matters most and the least obvious: it is how
|
|
38
|
+
* a client learns that the extension list is worth asking for at all, and a
|
|
39
|
+
* server that answers `getOpenSubsonicExtensions` without it has called a
|
|
40
|
+
* client to a door the client cannot see.
|
|
41
|
+
*/
|
|
42
|
+
function identity() {
|
|
43
|
+
return { type: SERVER_TYPE, serverVersion: SERVER_VERSION, openSubsonic: true };
|
|
44
|
+
}
|
|
45
|
+
/** The one element every answer hangs from, in either format. */
|
|
46
|
+
const ROOT = 'subsonic-response';
|
|
47
|
+
const NAMESPACE = 'http://subsonic.org/restapi';
|
|
48
|
+
/**
|
|
49
|
+
* The protocol's own error codes.
|
|
50
|
+
*
|
|
51
|
+
* Transcribed complete rather than trimmed to the ones routes use today: a code
|
|
52
|
+
* is a promise to the client about what went wrong, and the surest way to keep
|
|
53
|
+
* that promise is to pick from the standard's list instead of inventing a
|
|
54
|
+
* number that means something else to somebody's client.
|
|
55
|
+
*/
|
|
56
|
+
export const ERROR = {
|
|
57
|
+
generic: 0,
|
|
58
|
+
missingParameter: 10,
|
|
59
|
+
clientTooOld: 20,
|
|
60
|
+
serverTooOld: 30,
|
|
61
|
+
wrongCredentials: 40,
|
|
62
|
+
tokenAuthRefused: 41,
|
|
63
|
+
unsupportedAuthMechanism: 42,
|
|
64
|
+
conflictingAuthMechanisms: 43,
|
|
65
|
+
invalidApiKey: 44,
|
|
66
|
+
notAuthorized: 50,
|
|
67
|
+
trialOver: 60,
|
|
68
|
+
notFound: 70,
|
|
69
|
+
};
|
|
70
|
+
export function ok(payload = {}) {
|
|
71
|
+
return { status: 'ok', version: API_VERSION, ...identity(), ...payload };
|
|
72
|
+
}
|
|
73
|
+
export function failed(code, message) {
|
|
74
|
+
return { status: 'failed', version: API_VERSION, ...identity(), error: { code, message } };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* A refusal a route decided on, on its way out.
|
|
78
|
+
*
|
|
79
|
+
* A route answers with the fields of a successful call, so the one thing it
|
|
80
|
+
* cannot do is answer with a failure — and a route is exactly where the reason
|
|
81
|
+
* for a failure is known: which parameter was missing, which id names nothing.
|
|
82
|
+
* Throwing carries the code and the message from where the reason is to the one
|
|
83
|
+
* place that renders envelopes.
|
|
84
|
+
*/
|
|
85
|
+
export class ApiError extends Error {
|
|
86
|
+
code;
|
|
87
|
+
constructor(code, message) {
|
|
88
|
+
super(message);
|
|
89
|
+
this.name = 'ApiError';
|
|
90
|
+
this.code = code;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Anything but an explicit `f=json` is XML, which is what the protocol defaults to. */
|
|
94
|
+
export function parseFormat(value) {
|
|
95
|
+
return value === 'json' ? 'json' : 'xml';
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The envelope, rendered.
|
|
99
|
+
*
|
|
100
|
+
* Both formats wrap it in `subsonic-response` — that name is how a client finds
|
|
101
|
+
* the answer at all, in either — and only the XML carries a namespace, because
|
|
102
|
+
* a namespace is an XML device and the JSON form has nowhere to put one.
|
|
103
|
+
*/
|
|
104
|
+
export function render(envelope, format) {
|
|
105
|
+
return format === 'json'
|
|
106
|
+
? {
|
|
107
|
+
contentType: 'application/json; charset=utf-8',
|
|
108
|
+
body: JSON.stringify({ [ROOT]: envelope }),
|
|
109
|
+
}
|
|
110
|
+
: { contentType: 'application/xml; charset=utf-8', body: toXml(envelope) };
|
|
111
|
+
}
|
|
112
|
+
function toXml(envelope) {
|
|
113
|
+
const fields = [['xmlns', NAMESPACE], ...Object.entries(envelope)];
|
|
114
|
+
return element(ROOT, fields);
|
|
115
|
+
}
|
|
116
|
+
function element(name, fields) {
|
|
117
|
+
const attributes = scalarFields(fields)
|
|
118
|
+
.map(([field, value]) => ` ${field}="${escape(String(value))}"`)
|
|
119
|
+
.join('');
|
|
120
|
+
const children = recordFields(fields)
|
|
121
|
+
.map(([field, value]) =>
|
|
122
|
+
// An array is the same field repeated. A record standing alone is one
|
|
123
|
+
// such field. A bare value in an array has no field to carry it, so the
|
|
124
|
+
// element holds the value itself — which is how a protocol that writes
|
|
125
|
+
// lyrics as element text is written.
|
|
126
|
+
asArray(value)
|
|
127
|
+
.map((item) => isRecord(item)
|
|
128
|
+
? element(field, Object.entries(item))
|
|
129
|
+
: `<${field}>${escape(String(item))}</${field}>`)
|
|
130
|
+
.join(''))
|
|
131
|
+
.join('');
|
|
132
|
+
return children === ''
|
|
133
|
+
? `<${name}${attributes}/>`
|
|
134
|
+
: `<${name}${attributes}>${children}</${name}>`;
|
|
135
|
+
}
|
|
136
|
+
/** The fields that hold a value, in the order they were given. Absent ones are dropped. */
|
|
137
|
+
function scalarFields(fields) {
|
|
138
|
+
return fields.filter(([, value]) => !isRecord(value) && !Array.isArray(value) && value != null);
|
|
139
|
+
}
|
|
140
|
+
function recordFields(fields) {
|
|
141
|
+
return fields.filter(([, value]) => (isRecord(value) || Array.isArray(value)) && value != null);
|
|
142
|
+
}
|
|
143
|
+
function isRecord(value) {
|
|
144
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
145
|
+
}
|
|
146
|
+
function asArray(value) {
|
|
147
|
+
return Array.isArray(value) ? value : [value];
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* `&` first, or the ampersands an escape introduces would be escaped again.
|
|
151
|
+
*
|
|
152
|
+
* This is not decoration: the error message quotes the method name the client
|
|
153
|
+
* asked for, and that name comes off the wire. Unescaped, it would be a way to
|
|
154
|
+
* put arbitrary elements into a document the client parses as XML.
|
|
155
|
+
*/
|
|
156
|
+
function escape(value) {
|
|
157
|
+
return value
|
|
158
|
+
.replace(/&/g, '&')
|
|
159
|
+
.replace(/</g, '<')
|
|
160
|
+
.replace(/>/g, '>')
|
|
161
|
+
.replace(/"/g, '"');
|
|
162
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The extensions this server implements, each with the endpoint that carries it.
|
|
3
|
+
*
|
|
4
|
+
* `transcoding` was deliberately absent until it was built, and the entry below
|
|
5
|
+
* is what that discipline looks like once it holds: the two endpoints exist, so
|
|
6
|
+
* the name does. A client that read the name before the code would call
|
|
7
|
+
* `getTranscodeDecision` and get "unknown method" from a server that had just
|
|
8
|
+
* told it the method existed — and nothing in the protocol lets it ask again or
|
|
9
|
+
* tell it whose fault that was.
|
|
10
|
+
*/
|
|
11
|
+
const EXTENSIONS = [
|
|
12
|
+
// getPlayQueueByIndex / savePlayQueueByIndex — the same queue, addressed by
|
|
13
|
+
// position rather than by id, because an id cannot say which of two identical
|
|
14
|
+
// entries is playing.
|
|
15
|
+
{ name: 'indexBasedQueue', versions: [1] },
|
|
16
|
+
// reportPlayback — what a player says about a song it has just played, which
|
|
17
|
+
// is the history the server did not witness.
|
|
18
|
+
{ name: 'playbackReport', versions: [1] },
|
|
19
|
+
// The `timeOffset` parameter on `stream` — `Transcode Offset`: start the
|
|
20
|
+
// answer later than the song does.
|
|
21
|
+
{ name: 'transcodeOffset', versions: [1] },
|
|
22
|
+
// getTranscodeDecision / getTranscodeStream — a client states what it can play
|
|
23
|
+
// and is told what this server would do with a song, instead of naming a
|
|
24
|
+
// format and a ceiling and hoping. Over http only: the client's HLS profiles
|
|
25
|
+
// are skipped and answered with `canTranscode: false`, because this server
|
|
26
|
+
// produces no HLS (task:2896).
|
|
27
|
+
{ name: 'transcoding', versions: [1] },
|
|
28
|
+
// `apiKey` as a whole credential, and the pair of promises the extension is:
|
|
29
|
+
// a key that arrives alone is accepted, and a server that accepts one offers
|
|
30
|
+
// a way to see the keys it holds and to take one back. The first half worked
|
|
31
|
+
// from the beginning and the second did not exist, which is why the name was
|
|
32
|
+
// withheld: the key lived in a variable in `start.cmd` and revoking it meant
|
|
33
|
+
// editing that file and restarting the daemon (task:2915).
|
|
34
|
+
//
|
|
35
|
+
// It is declared now because both halves are true — the registry and the two
|
|
36
|
+
// CLI verbs are `keys.ts` and `cli/keys.ts`. The environment's key is outside
|
|
37
|
+
// the registry on purpose and `list` says so; see the migration.
|
|
38
|
+
{ name: 'apiKeyAuthentication', versions: [1] },
|
|
39
|
+
];
|
|
40
|
+
/** The answer to `getOpenSubsonicExtensions`, and the whole of it. */
|
|
41
|
+
export function openSubsonicExtensions() {
|
|
42
|
+
return { openSubsonicExtensions: EXTENSIONS.map(({ name, versions }) => ({ name, versions })) };
|
|
43
|
+
}
|