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,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vocabulary: what a setting is called and what shape its value has.
|
|
3
|
+
*
|
|
4
|
+
* One table, because two things need it and neither owns it — the config file,
|
|
5
|
+
* which has to refuse a key it does not know, and the config itself, which has
|
|
6
|
+
* to know which keys exist to say where each one came from. A second copy of
|
|
7
|
+
* this list is how a setting becomes writable by one half and invisible to the
|
|
8
|
+
* other.
|
|
9
|
+
*
|
|
10
|
+
* These are the field names of `ServerConfig` and `AdminConfig`, which are also
|
|
11
|
+
* the names the admin API's `config` route speaks. The environment spells them
|
|
12
|
+
* differently (`FUNOTEKA_DB` for `dbPath`, `FUNOTEKA_CACHE` for `cacheDir`), and
|
|
13
|
+
* that mapping lives where the reading happens.
|
|
14
|
+
*/
|
|
15
|
+
export const SETTINGS = {
|
|
16
|
+
dbPath: 'string',
|
|
17
|
+
host: 'string',
|
|
18
|
+
port: 'number',
|
|
19
|
+
user: 'string',
|
|
20
|
+
password: 'string',
|
|
21
|
+
apiKey: 'string',
|
|
22
|
+
ffmpeg: 'string',
|
|
23
|
+
cacheDir: 'string',
|
|
24
|
+
logFile: 'string',
|
|
25
|
+
logRequests: 'boolean',
|
|
26
|
+
cors: 'boolean',
|
|
27
|
+
showJunk: 'boolean',
|
|
28
|
+
adminPort: 'number',
|
|
29
|
+
adminHost: 'string',
|
|
30
|
+
adminToken: 'string',
|
|
31
|
+
adminAllow: 'string',
|
|
32
|
+
adminTrustProxy: 'boolean',
|
|
33
|
+
adminTlsCert: 'string',
|
|
34
|
+
adminTlsKey: 'string',
|
|
35
|
+
supervised: 'boolean',
|
|
36
|
+
scanInterval: 'number',
|
|
37
|
+
scanQuietFrom: 'number',
|
|
38
|
+
scanQuietTo: 'number',
|
|
39
|
+
scanWatch: 'boolean',
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* The numbers that are ports, and are therefore held to a port's range.
|
|
43
|
+
*
|
|
44
|
+
* Which does not go without saying: this vocabulary now carries numbers that are
|
|
45
|
+
* *not* ports — a scan interval in minutes, an hour of the day — and a reader
|
|
46
|
+
* that held every number to 65535 and called a violation "not a port number"
|
|
47
|
+
* would refuse `scanInterval: 100000` with a sentence about sockets.
|
|
48
|
+
*/
|
|
49
|
+
export const PORT_SETTINGS = new Set(['port', 'adminPort']);
|
|
50
|
+
/**
|
|
51
|
+
* The settings whose value is never handed back, only ever accepted.
|
|
52
|
+
*
|
|
53
|
+
* `config get` reports that they are set and where from, and not what they are:
|
|
54
|
+
* a token read back over HTTP is a token in a shell history, a log line and
|
|
55
|
+
* whatever proxy is in front, and the operator who needs to see it already has
|
|
56
|
+
* the file it is in.
|
|
57
|
+
*/
|
|
58
|
+
export const SECRET_SETTINGS = new Set(['password', 'adminToken', 'apiKey']);
|
|
59
|
+
/**
|
|
60
|
+
* Whether a number can be a port, which is the range a socket accepts.
|
|
61
|
+
*
|
|
62
|
+
* 0 is inside it on purpose: it asks the kernel to pick a free port, which is
|
|
63
|
+
* what a test does and what an operator whose port is taken does.
|
|
64
|
+
*/
|
|
65
|
+
export function isPort(value) {
|
|
66
|
+
return Number.isInteger(value) && value >= 0 && value <= 65535;
|
|
67
|
+
}
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createReadStream, statSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { HIDDEN } from "../util/child.js";
|
|
5
|
+
import { kept, keyOf } from "../stream/recode.js";
|
|
6
|
+
import { restating } from "../stream/rewrite.js";
|
|
7
|
+
import { alreadyIs, codecOf, DEFAULT_LOSSY, ffmpegWorks, formatsOffered, planSegment, planTarget, planWholeFile, playable, segmentSize, stretchOf, windowOf, } from "../stream/segment.js";
|
|
8
|
+
import { contentType, trackId } from "./browse.js";
|
|
9
|
+
import { ApiError, ERROR } from "./envelope.js";
|
|
10
|
+
import { song } from "./meta.js";
|
|
11
|
+
/**
|
|
12
|
+
* The bytes a client plays.
|
|
13
|
+
*
|
|
14
|
+
* The one method that does not answer in the protocol's envelope: `stream`
|
|
15
|
+
* answers with audio, and everything else about the exchange — the status, the
|
|
16
|
+
* headers, the range — is HTTP's business. So it is a route of a different kind
|
|
17
|
+
* (`binaryRoute` in `router.ts`), and the server hands it the response rather
|
|
18
|
+
* than asking it for a payload.
|
|
19
|
+
*
|
|
20
|
+
* Two kinds of song arrive here and they are not alike. A whole file is a file:
|
|
21
|
+
* it is sent, and a range of it is sent, which is how seeking works. A song cut
|
|
22
|
+
* from a cue image is not a file at all — it is a stretch of another one — and
|
|
23
|
+
* `stream/segment.ts` decides how to produce it: by hand for FLAC and mp3, by
|
|
24
|
+
* ffmpeg for everything else.
|
|
25
|
+
*
|
|
26
|
+
* Range is offered for both, and it means the same thing to a client either way:
|
|
27
|
+
* the bytes it asked for. For a whole file that is a range of the file; for a cue
|
|
28
|
+
* track it is a range of the *answer*, which is built rather than copied, so the
|
|
29
|
+
* range is answered by building the same answer and passing over what comes
|
|
30
|
+
* before it. That is what makes the bar move on a track that is not a file.
|
|
31
|
+
*
|
|
32
|
+
* What cannot be ranged is what ffmpeg has yet to produce: a re-encoded song or
|
|
33
|
+
* a cue track of an ape or MP4 image states no length until it has one, so a
|
|
34
|
+
* client that seeks there re-asks for the song from its beginning.
|
|
35
|
+
*/
|
|
36
|
+
export async function stream(context, request, response) {
|
|
37
|
+
await serveSong(context, request, response, {
|
|
38
|
+
id: trackId(context.query),
|
|
39
|
+
asked: () => askedFor(context.query),
|
|
40
|
+
offsetMs: () => offsetOf(context.query),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* One song, in the bytes the caller asked for — the whole of `stream` below the
|
|
45
|
+
* query string.
|
|
46
|
+
*
|
|
47
|
+
* A function of its own because `stream` is no longer the only method that
|
|
48
|
+
* answers with a song's bytes. `getTranscodeStream` — the second half of the
|
|
49
|
+
* `transcoding` extension — answers with the very same thing, having been told
|
|
50
|
+
* *which* transcode to make by a decision it was handed earlier rather than by
|
|
51
|
+
* `format` and `maxBitRate` in a query.
|
|
52
|
+
*
|
|
53
|
+
* **One path and not two.** What a format parameter means, what a cue track is
|
|
54
|
+
* answered with, and which stretch of an image a track is *are* this server's
|
|
55
|
+
* delivery policy, and a second copy of that policy would drift from the first
|
|
56
|
+
* on the first change to either. What that costs is written all over this file:
|
|
57
|
+
* the seam between "which stretch" and "in which format" produced four of the
|
|
58
|
+
* findings of task:2865, and every one of them was two answers to one question.
|
|
59
|
+
*/
|
|
60
|
+
export async function serveSong(context, request, response, wanted) {
|
|
61
|
+
const { id } = wanted;
|
|
62
|
+
const row = song(context.db, id);
|
|
63
|
+
if (row === undefined)
|
|
64
|
+
throw new ApiError(ERROR.notFound, `No such song: ${id}`);
|
|
65
|
+
const asked = wanted.asked();
|
|
66
|
+
const offsetMs = wanted.offsetMs();
|
|
67
|
+
// The path is the meta layer's own, never the client's: the request chose a
|
|
68
|
+
// row, and the row was written by the walk. Nothing here can be talked into
|
|
69
|
+
// reading a file the scan never saw.
|
|
70
|
+
const path = join(row.root_path, row.rel_path);
|
|
71
|
+
// Which stretch of its file this request is about — worked out once, and from
|
|
72
|
+
// the row. A cue track is a stretch of an image whether or not the client
|
|
73
|
+
// knows it is one, and the client's offset moves its near end. Every branch
|
|
74
|
+
// below is about *that stretch* and never about the file it lies in, which is
|
|
75
|
+
// the seam three of the review's findings lived on: the format branch built
|
|
76
|
+
// its plan from the image's path and the client's offset alone, so a cue track
|
|
77
|
+
// asked for in another format was answered with the whole image (task:2865).
|
|
78
|
+
const stretch = stretchOf(row, offsetMs);
|
|
79
|
+
if (stretch.kind === 'refused')
|
|
80
|
+
throw new ApiError(ERROR.generic, stretch.reason);
|
|
81
|
+
const times = stretch.kind === 'span' ? { startMs: stretch.startMs, endMs: stretch.endMs } : null;
|
|
82
|
+
// `format=raw` is the protocol's own way of saying "do not transcode": nothing
|
|
83
|
+
// below is reached for, and not a byte of this answer is decoded. On a cue
|
|
84
|
+
// track of an image only ffmpeg can read that is a refusal a client can act
|
|
85
|
+
// on, and not the re-encode it said not to make. On a whole file the offset is
|
|
86
|
+
// vacuous — there is no transcode to start late — and a client that means to
|
|
87
|
+
// begin part-way into a byte copy says so with a Range header.
|
|
88
|
+
if (asked === 'raw') {
|
|
89
|
+
if (row.segment_start_ms !== null) {
|
|
90
|
+
await serveSegment(context, row, path, request, response, { times, as: 'raw' });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
await serveBytes(path, contentType(row.ext), request, response);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const target = asked;
|
|
97
|
+
// A cue track is a stretch of an image, and it is answered as one. A client
|
|
98
|
+
// that named no format, or the one the image already holds, gets that stretch
|
|
99
|
+
// cut out of the image — by hand where its frames can be restated, by ffmpeg
|
|
100
|
+
// where they cannot. One that named another format gets that format made from
|
|
101
|
+
// the stretch, and never from the whole image.
|
|
102
|
+
if (row.segment_start_ms !== null) {
|
|
103
|
+
if (target === null || alreadyIs(target, row)) {
|
|
104
|
+
await serveSegment(context, row, path, request, response, { times, as: 'stream' });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
await serveReencoded(context, path, row.ext, times, carriedOut(planTarget({ path, target, times, tags: tagsOf(row) })), request, response);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// A whole file is a file: sent as it is when a client can play it and asked
|
|
111
|
+
// for nothing else, or when it already is what was asked for. An offset is
|
|
112
|
+
// neither — a byte copy has no time axis to start later on — so it is built.
|
|
113
|
+
if (offsetMs === null) {
|
|
114
|
+
const asIs = target === null
|
|
115
|
+
? playable(await codecOf(path, row.ext, row.codec), row.ext)
|
|
116
|
+
: alreadyIs(target, row);
|
|
117
|
+
if (asIs) {
|
|
118
|
+
await serveBytes(path, contentType(row.ext), request, response);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
await serveReencoded(context, path, row.ext, times, carriedOut(planWholeFile({ path, ext: row.ext, target, times, tags: tagsOf(row) })), request, response);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The song, as the tags a built file carries.
|
|
126
|
+
*
|
|
127
|
+
* **The song's artist is the file's own where the file names one, and the
|
|
128
|
+
* record's otherwise** — the same rule `songChild` writes into a payload, made
|
|
129
|
+
* once more here because this is the other place the answer leaves the server.
|
|
130
|
+
* A compilation is what it is for: the record is credited `Various Artists`,
|
|
131
|
+
* which is true of the record and false of every track on it, and a cut file
|
|
132
|
+
* that says `ARTIST=Various Artists` has been told something the client was
|
|
133
|
+
* not.
|
|
134
|
+
*
|
|
135
|
+
* Everything else is the record's, because a song has none of its own: the year
|
|
136
|
+
* is the album's, the number is its place on the record, and the genre is the
|
|
137
|
+
* one this file states.
|
|
138
|
+
*/
|
|
139
|
+
function tagsOf(row) {
|
|
140
|
+
return {
|
|
141
|
+
title: row.title,
|
|
142
|
+
artist: row.track_artist ?? row.artist_name,
|
|
143
|
+
albumArtist: row.artist_name,
|
|
144
|
+
album: row.album_title,
|
|
145
|
+
trackNumber: row.ordinal,
|
|
146
|
+
discNumber: row.disc_number,
|
|
147
|
+
date: row.album_year === null ? null : String(row.album_year),
|
|
148
|
+
genre: row.genre,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** A plan that has to be carried out, or the refusal it turned out to be. */
|
|
152
|
+
function carriedOut(plan) {
|
|
153
|
+
if (plan.kind !== 'transcode')
|
|
154
|
+
throw new ApiError(ERROR.generic, plan.reason);
|
|
155
|
+
return plan;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* What the client asked to be sent: a target, `raw`, or nothing.
|
|
159
|
+
*
|
|
160
|
+
* `format` is a *container*, and the protocol lets a client name any string. A
|
|
161
|
+
* name this server cannot make is refused here rather than reaching ffmpeg,
|
|
162
|
+
* where the failure would be a message no client can act on — and the refusal
|
|
163
|
+
* says what *is* on offer, which is the one thing it can do about it.
|
|
164
|
+
*/
|
|
165
|
+
function askedFor(query) {
|
|
166
|
+
const format = query.get('format');
|
|
167
|
+
const ceiling = ceilingOf(query);
|
|
168
|
+
if (format === 'raw')
|
|
169
|
+
return 'raw';
|
|
170
|
+
if (format !== null && format !== '') {
|
|
171
|
+
if (!formatsOffered().includes(format)) {
|
|
172
|
+
throw new ApiError(ERROR.generic, `This server cannot make ${format}; it makes ${formatsOffered().join(', ')}`);
|
|
173
|
+
}
|
|
174
|
+
return { format, maxBitRate: ceiling };
|
|
175
|
+
}
|
|
176
|
+
if (ceiling === null)
|
|
177
|
+
return null;
|
|
178
|
+
// A ceiling and no format: a lossless file cannot be limited without leaving
|
|
179
|
+
// lossless, so this is a request for the lossy default — but only when the
|
|
180
|
+
// file is above it, which is `alreadyIs`'s to decide.
|
|
181
|
+
return { format: DEFAULT_LOSSY, maxBitRate: ceiling };
|
|
182
|
+
}
|
|
183
|
+
/** The `maxBitRate` parameter: kilobits per second, and nought means no ceiling. */
|
|
184
|
+
function ceilingOf(query) {
|
|
185
|
+
const raw = query.get('maxBitRate');
|
|
186
|
+
if (raw === null || raw === '')
|
|
187
|
+
return null;
|
|
188
|
+
const rate = Number(raw);
|
|
189
|
+
if (!Number.isFinite(rate) || rate < 0) {
|
|
190
|
+
throw new ApiError(ERROR.generic, `maxBitRate is not a bitrate: ${raw}`);
|
|
191
|
+
}
|
|
192
|
+
return rate === 0 ? null : rate;
|
|
193
|
+
}
|
|
194
|
+
/** The `timeOffset` parameter, in milliseconds — the protocol counts in seconds. */
|
|
195
|
+
function offsetOf(query) {
|
|
196
|
+
const raw = query.get('timeOffset');
|
|
197
|
+
if (raw === null || raw === '')
|
|
198
|
+
return null;
|
|
199
|
+
const seconds = Number(raw);
|
|
200
|
+
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
201
|
+
throw new ApiError(ERROR.generic, `timeOffset is not an offset: ${raw}`);
|
|
202
|
+
}
|
|
203
|
+
return seconds * 1000;
|
|
204
|
+
}
|
|
205
|
+
export async function serveBytes(path, type, request, response) {
|
|
206
|
+
const size = statSync(path).size;
|
|
207
|
+
const wanted = parseRange(request.headers.range, size);
|
|
208
|
+
if (wanted === 'unsatisfiable') {
|
|
209
|
+
response.writeHead(416, { 'content-range': `bytes */${size}` });
|
|
210
|
+
response.end();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const start = wanted === null ? 0 : wanted.start;
|
|
214
|
+
const end = wanted === null ? size - 1 : wanted.end;
|
|
215
|
+
const length = size === 0 ? 0 : end - start + 1;
|
|
216
|
+
response.writeHead(wanted === null ? 200 : 206, {
|
|
217
|
+
'content-type': type,
|
|
218
|
+
'content-length': length,
|
|
219
|
+
'accept-ranges': 'bytes',
|
|
220
|
+
...(wanted === null ? {} : { 'content-range': `bytes ${start}-${end}/${size}` }),
|
|
221
|
+
});
|
|
222
|
+
// A HEAD asks what a GET would bring, and is owed the headers and nothing
|
|
223
|
+
// else. Reading the file to throw the bytes away would be the one thing the
|
|
224
|
+
// method exists to avoid.
|
|
225
|
+
if (request.method === 'HEAD' || length === 0) {
|
|
226
|
+
response.end();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
await pipeFile(path, start, end, response);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* A song that has to be built rather than copied, built — and then kept.
|
|
233
|
+
*
|
|
234
|
+
* Two things arrive here: a whole file no client plays, and a stretch of an image
|
|
235
|
+
* whose frames cannot be cut by hand. What either is re-encoded *into* is what
|
|
236
|
+
* was asked for — the format a client named, or the lossless default when it
|
|
237
|
+
* named none. ffmpeg produces a stream, and a stream's length is not known until
|
|
238
|
+
* it ends, so the answer is written to a file rather than piped: kept whole, it
|
|
239
|
+
* is seekable like any other and the second listen costs nothing. Where it is
|
|
240
|
+
* kept, and for how long, is `stream/recode.ts`'s to say.
|
|
241
|
+
*
|
|
242
|
+
* **The stretch is part of which answer this is**, and is handed to the cache
|
|
243
|
+
* with it. It is not decoration: two cue tracks of one image asked for in the
|
|
244
|
+
* same format are two different answers, and a key that named only the image
|
|
245
|
+
* made them one (task:2865).
|
|
246
|
+
*
|
|
247
|
+
* ffmpeg is asked for only when there is nothing to answer with, so a re-encoded
|
|
248
|
+
* song already in the cache plays on a machine that has no ffmpeg at all.
|
|
249
|
+
*
|
|
250
|
+
* A range cannot be honoured on the *first* play — the length is not known until
|
|
251
|
+
* the file is written, and the file is written before the first byte goes out.
|
|
252
|
+
* That wait is the price of the bar moving at all; it is paid once per song.
|
|
253
|
+
*/
|
|
254
|
+
async function serveReencoded(context, source, ext, times, plan, request, response) {
|
|
255
|
+
const binary = context.config.ffmpeg;
|
|
256
|
+
let produced;
|
|
257
|
+
try {
|
|
258
|
+
produced = await kept({
|
|
259
|
+
dir: context.config.cacheDir,
|
|
260
|
+
// The stretch is part of the key and has to be: two cue tracks of one
|
|
261
|
+
// image asked for in the same format are two different answers, and a key
|
|
262
|
+
// that named only the image called them one — so the first track
|
|
263
|
+
// requested became the answer for every other track of that disc, and
|
|
264
|
+
// stayed it (measured, task:2865).
|
|
265
|
+
key: keyOf({ source, ...(times ?? {}), made: plan.made }),
|
|
266
|
+
// What is kept is named by what it holds. It used to be `.flac` whatever
|
|
267
|
+
// was inside, which was true while FLAC was the only thing ever produced
|
|
268
|
+
// and is a file lying about itself now that a client can ask for mp3.
|
|
269
|
+
extension: `.${plan.container}`,
|
|
270
|
+
produce: async (target) => {
|
|
271
|
+
if (!ffmpegWorks(binary)) {
|
|
272
|
+
throw new Error(`This song is a .${ext} file no browser plays, and ffmpeg — which re-encodes one — is not installed`);
|
|
273
|
+
}
|
|
274
|
+
await encode(binary, plan.args(target));
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
// A refusal a client can read beats a stream that stops: ffmpeg says why it
|
|
280
|
+
// would not produce the answer, and that sentence is the whole of what
|
|
281
|
+
// anyone downstream could know.
|
|
282
|
+
throw new ApiError(ERROR.generic, error instanceof Error ? error.message : String(error));
|
|
283
|
+
}
|
|
284
|
+
await serveBytes(produced, plan.contentType, request, response);
|
|
285
|
+
}
|
|
286
|
+
/** ffmpeg, writing the file it was told to write, or the reason it did not. */
|
|
287
|
+
function encode(binary, args) {
|
|
288
|
+
return new Promise((resolve, reject) => {
|
|
289
|
+
const child = spawn(binary, args, { stdio: ['ignore', 'ignore', 'pipe'], ...HIDDEN });
|
|
290
|
+
let complaint = '';
|
|
291
|
+
child.stderr?.on('data', (chunk) => {
|
|
292
|
+
if (complaint.length < 4096)
|
|
293
|
+
complaint += chunk.toString();
|
|
294
|
+
});
|
|
295
|
+
child.on('error', reject);
|
|
296
|
+
child.on('close', (code) => {
|
|
297
|
+
if (code === 0)
|
|
298
|
+
resolve();
|
|
299
|
+
else
|
|
300
|
+
reject(new Error(`ffmpeg exited ${code}: ${complaint.trim()}`));
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* A cue track: a stretch of an image, served as a track.
|
|
306
|
+
*
|
|
307
|
+
* Everything that could refuse happens before the first header is written,
|
|
308
|
+
* because a refusal after that is not a refusal — the client has already been
|
|
309
|
+
* told the answer is audio.
|
|
310
|
+
*/
|
|
311
|
+
export async function serveSegment(context, row, path, request, response, { times, as = 'stream', }) {
|
|
312
|
+
// A stretch a caller could not bound is a refusal and not an answer, and the
|
|
313
|
+
// caller is the one that knows why — `stretchOf` says it in its own words.
|
|
314
|
+
if (times === null)
|
|
315
|
+
throw new ApiError(ERROR.generic, 'This segment has no length to serve');
|
|
316
|
+
const { startMs, endMs } = times;
|
|
317
|
+
const plan = await planSegment({
|
|
318
|
+
path,
|
|
319
|
+
ext: row.ext,
|
|
320
|
+
startMs,
|
|
321
|
+
endMs,
|
|
322
|
+
tags: tagsOf(row),
|
|
323
|
+
});
|
|
324
|
+
if (plan.kind === 'unavailable')
|
|
325
|
+
throw new ApiError(ERROR.generic, plan.reason);
|
|
326
|
+
if (plan.kind === 'transcode') {
|
|
327
|
+
if (as !== 'stream')
|
|
328
|
+
throw new ApiError(ERROR.generic, onlyByReencoding(row, as));
|
|
329
|
+
await serveReencoded(context, path, row.ext, times, plan, request, response);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const { from, to, frames } = plan.segment;
|
|
333
|
+
const size = segmentSize(plan.segment);
|
|
334
|
+
// A range on a cue track is a range of the *answer*, which is a thing built
|
|
335
|
+
// here rather than a stretch of a file — so it is answered by building the
|
|
336
|
+
// same answer and passing over what comes before. That is what makes seeking
|
|
337
|
+
// work on a track that is not a file, and it is cheap: the frames are in
|
|
338
|
+
// memory and the work is the CRC of each, not a walk from the beginning of a
|
|
339
|
+
// three-hundred-megabyte image.
|
|
340
|
+
const wanted = parseRange(request.headers.range, size);
|
|
341
|
+
if (wanted === 'unsatisfiable') {
|
|
342
|
+
response.writeHead(416, { 'content-range': `bytes */${size}` });
|
|
343
|
+
response.end();
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const start = wanted === null ? 0 : wanted.start;
|
|
347
|
+
const end = wanted === null ? size - 1 : wanted.end;
|
|
348
|
+
const window = windowOf(plan.segment, start, end);
|
|
349
|
+
response.writeHead(wanted === null ? 200 : 206, {
|
|
350
|
+
'content-type': contentType(row.ext),
|
|
351
|
+
'content-length': size === 0 ? 0 : end - start + 1,
|
|
352
|
+
'accept-ranges': 'bytes',
|
|
353
|
+
...(wanted === null ? {} : { 'content-range': `bytes ${start}-${end}/${size}` }),
|
|
354
|
+
});
|
|
355
|
+
if (request.method === 'HEAD') {
|
|
356
|
+
response.end();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (window.prefix.length > 0)
|
|
360
|
+
response.write(window.prefix);
|
|
361
|
+
await pipeFile(path, from, to - 1, response, restating(frames, from, { skip: window.skip, limit: window.limit }));
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Why a stretch of an image only ffmpeg can read is refused to this caller.
|
|
365
|
+
*
|
|
366
|
+
* The sentence has to be the one *this* caller actually made. `download`
|
|
367
|
+
* promises the original media data; `format=raw` is the protocol's "disable
|
|
368
|
+
* transcoding". A refusal that named the other one's promise is a lie to a
|
|
369
|
+
* client — which is the class of thing the whole of this file exists to avoid,
|
|
370
|
+
* and one that arrived here the moment `format=raw` was routed through the
|
|
371
|
+
* branch that already had a message written for download (found live, on a cue
|
|
372
|
+
* track of an `.m4a` image: task:2865).
|
|
373
|
+
*/
|
|
374
|
+
function onlyByReencoding(row, as) {
|
|
375
|
+
const promise = as === 'raw'
|
|
376
|
+
? 'which is the one thing format=raw asked not to do. Ask for a format, or download the image.'
|
|
377
|
+
: 'which is the one thing download promises not to do. Ask stream for it, or download the image.';
|
|
378
|
+
return (`"${row.title ?? row.rel_path}" is a stretch of ${row.rel_path}, and the only way to ` +
|
|
379
|
+
`produce it is to re-encode it — ${promise}`);
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* One range of one file, as a stream that ends with the response.
|
|
383
|
+
*
|
|
384
|
+
* `through` is where a range that is not sent as the file holds it is restated —
|
|
385
|
+
* a FLAC segment's frame headers, whose numbers become the track's own. The
|
|
386
|
+
* substitution needs no more than one pass and no more memory than a chunk, so
|
|
387
|
+
* a segment of a three-hundred-megabyte image is served as it always was.
|
|
388
|
+
*/
|
|
389
|
+
function pipeFile(path, start, end, response, through) {
|
|
390
|
+
return new Promise((resolve, reject) => {
|
|
391
|
+
const file = createReadStream(path, { start, end });
|
|
392
|
+
file.on('error', reject);
|
|
393
|
+
// The response's own end is the other way this finishes: a client that hung
|
|
394
|
+
// up mid-track is not an error worth reporting, but it must not leave the
|
|
395
|
+
// file open.
|
|
396
|
+
response.on('close', () => file.destroy());
|
|
397
|
+
const out = through === undefined ? file : file.pipe(through);
|
|
398
|
+
out.on('error', reject);
|
|
399
|
+
out.pipe(response).on('finish', resolve).on('error', reject);
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* The bytes a client asked for, or that it asked for none, or that it asked for
|
|
404
|
+
* something that is not there.
|
|
405
|
+
*
|
|
406
|
+
* A header this does not understand is treated as no request at all, which the
|
|
407
|
+
* protocol allows: a server may always answer with the whole file, and a range
|
|
408
|
+
* it cannot parse is better served whole than refused. The distinction matters
|
|
409
|
+
* — `unsatisfiable` is a 416 the client can act on, and everything else here is
|
|
410
|
+
* a 200 it can play.
|
|
411
|
+
*/
|
|
412
|
+
function parseRange(header, size) {
|
|
413
|
+
if (header === undefined)
|
|
414
|
+
return null;
|
|
415
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
|
416
|
+
if (match === null)
|
|
417
|
+
return null;
|
|
418
|
+
const from = match[1] ?? '';
|
|
419
|
+
const to = match[2] ?? '';
|
|
420
|
+
if (from === '' && to === '')
|
|
421
|
+
return null;
|
|
422
|
+
// `bytes=-500` is the last five hundred bytes, not the first: a suffix range
|
|
423
|
+
// measures back from the end, so both of its ends come from the file's size
|
|
424
|
+
// and neither is the number that was written. Reading that number as the end
|
|
425
|
+
// — the obvious thing, and the thing this did first — produced a range whose
|
|
426
|
+
// end was before its start, a negative length, and a connection closed under
|
|
427
|
+
// a client that had done nothing wrong.
|
|
428
|
+
if (from === '') {
|
|
429
|
+
const suffix = Number(to);
|
|
430
|
+
if (suffix === 0)
|
|
431
|
+
return 'unsatisfiable';
|
|
432
|
+
return { start: Math.max(size - suffix, 0), end: size - 1 };
|
|
433
|
+
}
|
|
434
|
+
const start = Number(from);
|
|
435
|
+
if (start >= size)
|
|
436
|
+
return 'unsatisfiable';
|
|
437
|
+
// An end before the start is not a range at all. Serving the whole file is
|
|
438
|
+
// the answer the protocol prescribes for one, and is what a client that wrote
|
|
439
|
+
// it by accident can still play.
|
|
440
|
+
const end = to === '' ? size - 1 : Math.min(Number(to), size - 1);
|
|
441
|
+
return end < start ? null : { start, end };
|
|
442
|
+
}
|