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,624 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { statSync } from 'node:fs';
|
|
3
|
+
import { probeAsync } from "../probe/ffprobe.js";
|
|
4
|
+
import { HIDDEN } from "../util/child.js";
|
|
5
|
+
import { flacSegment } from "./flac.js";
|
|
6
|
+
import { mpegSegment } from "./mpeg.js";
|
|
7
|
+
import { restatedSize } from "./rewrite.js";
|
|
8
|
+
/**
|
|
9
|
+
* How many bytes a segment comes to.
|
|
10
|
+
*
|
|
11
|
+
* The range, the prefix in front of it, and what restating the frames adds or
|
|
12
|
+
* takes away — a number written in fewer bytes than the image's makes the served
|
|
13
|
+
* stream shorter than the bytes it was cut from. The footers are two bytes for
|
|
14
|
+
* two bytes and change nothing.
|
|
15
|
+
*/
|
|
16
|
+
export function segmentSize(segment) {
|
|
17
|
+
return segment.prefix.length + (segment.to - segment.from) + restatedSize(segment.frames);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The piece of a segment a client asked for, as what the route sends.
|
|
21
|
+
*
|
|
22
|
+
* A range on a cue track is a range of the *answer*, not of the image: the
|
|
23
|
+
* answer begins with a rebuilt header that exists in no file, and each frame
|
|
24
|
+
* after it is a header written out, a body copied, and a footer computed. So
|
|
25
|
+
* the offsets a client seeks by are answered by building the same stream and
|
|
26
|
+
* passing over what comes before — the prefix is a slice of itself, and the
|
|
27
|
+
* rest is a window onto the frames.
|
|
28
|
+
*/
|
|
29
|
+
export function windowOf(segment, start, end) {
|
|
30
|
+
const intoPrefix = Math.min(Math.max(start, 0), segment.prefix.length);
|
|
31
|
+
const prefixTo = Math.min(Math.max(end + 1, intoPrefix), segment.prefix.length);
|
|
32
|
+
const prefix = segment.prefix.subarray(intoPrefix, prefixTo);
|
|
33
|
+
return {
|
|
34
|
+
prefix,
|
|
35
|
+
skip: Math.max(0, start - segment.prefix.length),
|
|
36
|
+
limit: end - start + 1 - prefix.length,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** The formats whose segments are cut from the container by hand. */
|
|
40
|
+
const NATIVE = new Set(['flac', 'mp3']);
|
|
41
|
+
/**
|
|
42
|
+
* Formats the layer of delivery handles, and the ones it does so with ffmpeg.
|
|
43
|
+
*
|
|
44
|
+
* Monkey's Audio is here for the same reason the MP4 containers are: the audio
|
|
45
|
+
* cannot be cut out by hand — the frames are not self-delimiting the way mp3's
|
|
46
|
+
* are, and nothing in the stream states a length — and ffmpeg decodes it, so a
|
|
47
|
+
* cue image of it is re-encoded instead of being refused. Two albums of the
|
|
48
|
+
* collection are ape images, and a refusal they can read is still no music.
|
|
49
|
+
*/
|
|
50
|
+
const THROUGH_FFMPEG = new Set(['m4a', 'mp4', 'aac', 'alac', 'ape']);
|
|
51
|
+
/**
|
|
52
|
+
* Codecs no browser decodes.
|
|
53
|
+
*
|
|
54
|
+
* The collection holds more than the web does: ALAC is Apple's and no browser
|
|
55
|
+
* decodes it, Monkey's Audio none at all, and both are lossless — which is what
|
|
56
|
+
* makes re-encoding them cheap to justify. The names are ffprobe's own, which is
|
|
57
|
+
* where the meta layer's `codec` comes from.
|
|
58
|
+
*/
|
|
59
|
+
const UNPLAYABLE_CODECS = new Set([
|
|
60
|
+
'alac',
|
|
61
|
+
'ape',
|
|
62
|
+
'wavpack',
|
|
63
|
+
'wma',
|
|
64
|
+
'wmav1',
|
|
65
|
+
'wmav2',
|
|
66
|
+
'wmavoice',
|
|
67
|
+
'tta',
|
|
68
|
+
'shorten',
|
|
69
|
+
'dsd_lsbf',
|
|
70
|
+
'dsd_msbf',
|
|
71
|
+
'dsd_lsbf_planar',
|
|
72
|
+
'dsd_msbf_planar',
|
|
73
|
+
]);
|
|
74
|
+
/** Codecs a browser plays, named the way ffprobe names them. */
|
|
75
|
+
const PLAYABLE_CODECS = new Set([
|
|
76
|
+
'flac',
|
|
77
|
+
'mp3',
|
|
78
|
+
'mp3float',
|
|
79
|
+
'aac',
|
|
80
|
+
'aac_latm',
|
|
81
|
+
'opus',
|
|
82
|
+
'vorbis',
|
|
83
|
+
'pcm_s16le',
|
|
84
|
+
'pcm_s24le',
|
|
85
|
+
'pcm_s32le',
|
|
86
|
+
'pcm_u8',
|
|
87
|
+
'pcm_f32le',
|
|
88
|
+
'pcm_s16be',
|
|
89
|
+
'pcm_s24be',
|
|
90
|
+
'pcm_mulaw',
|
|
91
|
+
'pcm_alaw',
|
|
92
|
+
]);
|
|
93
|
+
/** Containers a browser opens. Anything else is re-encoded, whatever is inside. */
|
|
94
|
+
const PLAYABLE_CONTAINERS = new Set(['flac', 'mp3', 'm4a', 'm4b', 'mp4', 'aac', 'ogg', 'oga', 'opus', 'wav', 'weba']);
|
|
95
|
+
/**
|
|
96
|
+
* Containers that can hold a codec no browser plays, whatever they are named.
|
|
97
|
+
*
|
|
98
|
+
* `.m4a` is one container whether it holds AAC or Apple's ALAC, so the name is
|
|
99
|
+
* the one thing that cannot answer for it — the file has to be read.
|
|
100
|
+
*/
|
|
101
|
+
const AMBIGUOUS_CONTAINERS = new Set(['m4a', 'm4b', 'mp4', 'mov']);
|
|
102
|
+
/**
|
|
103
|
+
* Whether a client can play these bytes as they are.
|
|
104
|
+
*
|
|
105
|
+
* A browser needs both things at once: a container it opens and a codec inside
|
|
106
|
+
* it that it decodes. Neither answer stands in for the other — Monkey's Audio
|
|
107
|
+
* is a box nothing opens whatever is inside it, and ALAC sits in the same `.m4a`
|
|
108
|
+
* that AAC does.
|
|
109
|
+
*
|
|
110
|
+
* A codec this has never heard of is not a codec: the probe is optional, and an
|
|
111
|
+
* older scan of this collection wrote the *container's* name where the codec
|
|
112
|
+
* belongs (`mp4` for AAC, `id3v2` for mp3). Those files play, so an unknown name
|
|
113
|
+
* leaves the container to answer — see `codecOf`, which reads the file when the
|
|
114
|
+
* container is one that could be hiding something.
|
|
115
|
+
*/
|
|
116
|
+
export function playable(codec, ext) {
|
|
117
|
+
if (!PLAYABLE_CONTAINERS.has(ext))
|
|
118
|
+
return false;
|
|
119
|
+
return !(codec !== null && UNPLAYABLE_CODECS.has(codec));
|
|
120
|
+
}
|
|
121
|
+
/** What has been read of which file, kept until the file itself moves. */
|
|
122
|
+
const readings = new Map();
|
|
123
|
+
/**
|
|
124
|
+
* The readings being taken right now, so two callers wait on one ffprobe.
|
|
125
|
+
*
|
|
126
|
+
* `readings` answers the *second* question about a file and not the first: two
|
|
127
|
+
* requests that arrive before either probe has returned both find nothing kept
|
|
128
|
+
* and both spawn ffprobe. It was two, and it is a number that multiplies —
|
|
129
|
+
* `getTranscodeDecision` asks this of every song a client asks a decision about,
|
|
130
|
+
* and a client asking about a library asks about a library. One process per file
|
|
131
|
+
* in flight is the bound this keeps; there is no bound across files, which is
|
|
132
|
+
* named and measured in task:2910 rather than guessed at here.
|
|
133
|
+
*/
|
|
134
|
+
const probing = new Map();
|
|
135
|
+
/**
|
|
136
|
+
* The codec of a file that is about to be sent.
|
|
137
|
+
*
|
|
138
|
+
* The meta layer answers for it when it named a codec this knows — either one a
|
|
139
|
+
* browser plays or one it does not — because that reading is the scan's own and
|
|
140
|
+
* was taken from the same bytes. When it named something else, and the container
|
|
141
|
+
* is one that could be hiding an unplayable codec, the file is read here: a
|
|
142
|
+
* `.m4a` whose codec nobody has established is exactly the case this exists for,
|
|
143
|
+
* and sending ALAC to a browser is a download it sits silent through.
|
|
144
|
+
*
|
|
145
|
+
* The reading is kept per file until its size or modification time moves — the
|
|
146
|
+
* same guard the frame index uses — so a re-encode is noticed rather than
|
|
147
|
+
* answered from the reading of the file it replaced.
|
|
148
|
+
*
|
|
149
|
+
* Awaited, because this stands on the hot path of every `stream` of such a file
|
|
150
|
+
* and the reading is a whole ffprobe process: asked synchronously it held this
|
|
151
|
+
* single-threaded server for 90 ms apiece (measured, task:2898).
|
|
152
|
+
*/
|
|
153
|
+
export async function codecOf(path, ext, known) {
|
|
154
|
+
if (known !== null && (UNPLAYABLE_CODECS.has(known) || PLAYABLE_CODECS.has(known)))
|
|
155
|
+
return known;
|
|
156
|
+
if (!AMBIGUOUS_CONTAINERS.has(ext))
|
|
157
|
+
return known;
|
|
158
|
+
const stat = statSync(path);
|
|
159
|
+
const kept = readings.get(path);
|
|
160
|
+
if (kept !== undefined && kept.size === stat.size && kept.mtimeMs === stat.mtimeMs) {
|
|
161
|
+
return kept.codec ?? known;
|
|
162
|
+
}
|
|
163
|
+
const already = probing.get(path);
|
|
164
|
+
if (already !== undefined)
|
|
165
|
+
return (await already) ?? known;
|
|
166
|
+
const work = probeAsync(path)
|
|
167
|
+
.then((probe) => {
|
|
168
|
+
readings.set(path, { size: stat.size, mtimeMs: stat.mtimeMs, codec: probe.codec });
|
|
169
|
+
return probe.codec;
|
|
170
|
+
})
|
|
171
|
+
.finally(() => probing.delete(path));
|
|
172
|
+
probing.set(path, work);
|
|
173
|
+
return (await work) ?? known;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Whether the binary is there, asked once per process.
|
|
177
|
+
*
|
|
178
|
+
* Asked before any byte is written, never after: a stream that discovers
|
|
179
|
+
* half-way through that it cannot be made has already told the client it could.
|
|
180
|
+
* The probe costs one short-lived process and the answer cannot change under a
|
|
181
|
+
* running server, which is why it is kept.
|
|
182
|
+
*/
|
|
183
|
+
const ready = new Map();
|
|
184
|
+
export function ffmpegWorks(binary) {
|
|
185
|
+
const known = ready.get(binary);
|
|
186
|
+
if (known !== undefined)
|
|
187
|
+
return known;
|
|
188
|
+
let works = false;
|
|
189
|
+
try {
|
|
190
|
+
execFileSync(binary, ['-version'], {
|
|
191
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
192
|
+
timeout: 10_000,
|
|
193
|
+
...HIDDEN,
|
|
194
|
+
});
|
|
195
|
+
works = true;
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
works = false;
|
|
199
|
+
}
|
|
200
|
+
ready.set(binary, works);
|
|
201
|
+
return works;
|
|
202
|
+
}
|
|
203
|
+
/** See `Stretch`. */
|
|
204
|
+
export function stretchOf(song, offsetMs) {
|
|
205
|
+
if (song.segment_start_ms === null) {
|
|
206
|
+
if (offsetMs === null)
|
|
207
|
+
return { kind: 'file' };
|
|
208
|
+
if (song.duration_ms === null) {
|
|
209
|
+
return {
|
|
210
|
+
kind: 'refused',
|
|
211
|
+
reason: 'This song has no measured length, so there is nothing to start it later from',
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
if (offsetMs >= song.duration_ms) {
|
|
215
|
+
return {
|
|
216
|
+
kind: 'refused',
|
|
217
|
+
reason: `timeOffset ${offsetMs / 1000} is at or past the end of a song of ${song.duration_ms / 1000} seconds`,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
return { kind: 'span', startMs: offsetMs, endMs: song.duration_ms };
|
|
221
|
+
}
|
|
222
|
+
// The closing track of a disc has no following index to end it, and its
|
|
223
|
+
// length comes from the measurement the probe took rather than from the cue.
|
|
224
|
+
const endMs = song.segment_end_ms ?? song.segment_start_ms + (song.duration_ms ?? 0);
|
|
225
|
+
const startMs = song.segment_start_ms + (offsetMs ?? 0);
|
|
226
|
+
if (endMs <= startMs) {
|
|
227
|
+
return {
|
|
228
|
+
kind: 'refused',
|
|
229
|
+
reason: offsetMs === null
|
|
230
|
+
? 'This segment has no length to serve'
|
|
231
|
+
: `timeOffset ${offsetMs / 1000} is at or past the end of this track`,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
return { kind: 'span', startMs, endMs };
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* What to do about one track that is a segment of `path`.
|
|
238
|
+
*
|
|
239
|
+
* `startMs` and `endMs` are the segment's own times, which the cue stage settled
|
|
240
|
+
* and the meta layer holds — or, when a client asked to start later, those times
|
|
241
|
+
* moved, which is `stretchOf`'s to work out.
|
|
242
|
+
*/
|
|
243
|
+
export async function planSegment(input) {
|
|
244
|
+
const { path, ext, startMs, endMs, tags } = input;
|
|
245
|
+
const seconds = (ms) => (ms / 1000).toFixed(3);
|
|
246
|
+
if (NATIVE.has(ext)) {
|
|
247
|
+
const segment = ext === 'flac'
|
|
248
|
+
? await flacSegment(path, startMs, endMs, tags)
|
|
249
|
+
: await mpegSegment(path, startMs, endMs, tags);
|
|
250
|
+
if (segment === null) {
|
|
251
|
+
return {
|
|
252
|
+
kind: 'unavailable',
|
|
253
|
+
reason: `The ${ext} stream could not be walked, so this segment cannot be cut from it`,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
return { kind: 'bytes', segment };
|
|
257
|
+
}
|
|
258
|
+
if (THROUGH_FFMPEG.has(ext)) {
|
|
259
|
+
// No target: a segment of an image is re-encoded only when nothing else can
|
|
260
|
+
// be done with it, and what it is re-encoded into is the lossless default —
|
|
261
|
+
// a cue image of ALAC or Monkey's Audio decoded and written back out without
|
|
262
|
+
// losing anything. A client that named a format of its own goes through
|
|
263
|
+
// `planTarget` instead, which is where the ceiling and the container live.
|
|
264
|
+
return planOf(reencode(path, { startMs, endMs }, null, tags));
|
|
265
|
+
}
|
|
266
|
+
return { kind: 'unavailable', reason: `A segment of a .${ext} file cannot be served` };
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* The formats this server will make, and nothing else.
|
|
270
|
+
*
|
|
271
|
+
* **A closed list, and the refusal names it.** The protocol lets a client ask
|
|
272
|
+
* for any format string at all, and a server that accepted one it could not make
|
|
273
|
+
* would either fail inside ffmpeg with a message no client can act on or, worse,
|
|
274
|
+
* hand back something else. So an unknown name is refused with the list of what
|
|
275
|
+
* is on offer — the same shape `getOpenSubsonicExtensions` will take when it
|
|
276
|
+
* says which extensions exist (task:2866).
|
|
277
|
+
*/
|
|
278
|
+
const TARGETS = {
|
|
279
|
+
mp3: { codec: 'libmp3lame', container: 'mp3', contentType: 'audio/mpeg', lossless: false },
|
|
280
|
+
opus: { codec: 'libopus', container: 'ogg', contentType: 'audio/ogg', lossless: false },
|
|
281
|
+
ogg: { codec: 'libvorbis', container: 'ogg', contentType: 'audio/ogg', lossless: false },
|
|
282
|
+
aac: { codec: 'aac', container: 'mp4', contentType: 'audio/mp4', lossless: false },
|
|
283
|
+
flac: { codec: 'flac', container: 'flac', contentType: 'audio/flac', lossless: true },
|
|
284
|
+
wav: { codec: 'pcm_s16le', container: 'wav', contentType: 'audio/wav', lossless: true },
|
|
285
|
+
};
|
|
286
|
+
/** The one the server falls back to on its own, and the only one it ever chose. */
|
|
287
|
+
const LOSSLESS_FORMAT = 'flac';
|
|
288
|
+
const LOSSLESS = TARGETS[LOSSLESS_FORMAT];
|
|
289
|
+
/**
|
|
290
|
+
* The lossy default when a ceiling is named and no format is.
|
|
291
|
+
*
|
|
292
|
+
* The protocol says a ceiling is "an attempt to limit the bitrate" and names no
|
|
293
|
+
* format to limit it *to* — and a lossless file cannot be limited without
|
|
294
|
+
* leaving lossless. So a ceiling alone is a request for the lossy format every
|
|
295
|
+
* client can play.
|
|
296
|
+
*/
|
|
297
|
+
export const DEFAULT_LOSSY = 'mp3';
|
|
298
|
+
/** What this server can be asked to make, for a refusal that says so. */
|
|
299
|
+
export function formatsOffered() {
|
|
300
|
+
return Object.keys(TARGETS);
|
|
301
|
+
}
|
|
302
|
+
/** Whether a target keeps every sample. */
|
|
303
|
+
export function isLossless(format) {
|
|
304
|
+
return TARGETS[format]?.lossless === true;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* The codecs that *are* a format, as a probe spells them.
|
|
308
|
+
*
|
|
309
|
+
* Asked so that a file already holding what a client asked for is not decoded
|
|
310
|
+
* and encoded again for nothing. What a file is called is not the question:
|
|
311
|
+
* `.m4a` is not `mp4` to a client, and the codec is what it is asking about.
|
|
312
|
+
*
|
|
313
|
+
* The PCM names are all here because a wav is a wav at any width — answering
|
|
314
|
+
* `format=wav` for a 24-bit file by re-encoding it to the 16-bit one this server
|
|
315
|
+
* would write throws bits away to no purpose.
|
|
316
|
+
*/
|
|
317
|
+
const CODECS_OF = {
|
|
318
|
+
mp3: ['mp3', 'mp3float'],
|
|
319
|
+
opus: ['opus'],
|
|
320
|
+
ogg: ['vorbis'],
|
|
321
|
+
aac: ['aac', 'aac_latm'],
|
|
322
|
+
flac: ['flac'],
|
|
323
|
+
wav: [
|
|
324
|
+
'pcm_s16le',
|
|
325
|
+
'pcm_s24le',
|
|
326
|
+
'pcm_s32le',
|
|
327
|
+
'pcm_u8',
|
|
328
|
+
'pcm_f32le',
|
|
329
|
+
'pcm_s16be',
|
|
330
|
+
'pcm_s24be',
|
|
331
|
+
'pcm_mulaw',
|
|
332
|
+
'pcm_alaw',
|
|
333
|
+
],
|
|
334
|
+
};
|
|
335
|
+
/** Whether a file already holds the codec a format names. */
|
|
336
|
+
export function codecIs(format, codec) {
|
|
337
|
+
if (codec === null)
|
|
338
|
+
return false;
|
|
339
|
+
return CODECS_OF[format]?.includes(codec) === true;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Containers that are one container under more than one name.
|
|
343
|
+
*
|
|
344
|
+
* A client names the container, and the name a file carries is not the thing it
|
|
345
|
+
* is: `.m4a` and `.m4b` are ISO base media whatever they are called, and `.oga`
|
|
346
|
+
* and `.opus` are Ogg. Only the names that differ are here.
|
|
347
|
+
*/
|
|
348
|
+
const CONTAINER_CLASS = {
|
|
349
|
+
m4a: 'mp4',
|
|
350
|
+
m4b: 'mp4',
|
|
351
|
+
mov: 'mp4',
|
|
352
|
+
oga: 'ogg',
|
|
353
|
+
opus: 'ogg',
|
|
354
|
+
};
|
|
355
|
+
/**
|
|
356
|
+
* The container a name means, where two names are one container.
|
|
357
|
+
*
|
|
358
|
+
* Asked by everything that compares a container a client named with a container
|
|
359
|
+
* this server holds or writes — a file's extension, a `format` parameter, or a
|
|
360
|
+
* profile in the `transcoding` extension's `ClientInfo`. Exported because
|
|
361
|
+
* `SourceStream` reports a container to a client in this vocabulary too: the
|
|
362
|
+
* `.m4a` on disk is an `mp4` in the protocol, and a client matching its own
|
|
363
|
+
* direct-play profile against `m4a` would match nothing.
|
|
364
|
+
*/
|
|
365
|
+
export function containerOf(name) {
|
|
366
|
+
return CONTAINER_CLASS[name] ?? name;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Whether a file is held in the container a format names.
|
|
370
|
+
*
|
|
371
|
+
* The pair to `codecIs`, and never a substitute for it — see `alreadyIs`.
|
|
372
|
+
*/
|
|
373
|
+
export function containerIs(format, ext) {
|
|
374
|
+
const named = TARGETS[format]?.container;
|
|
375
|
+
if (named === undefined)
|
|
376
|
+
return false;
|
|
377
|
+
return containerOf(named) === containerOf(ext);
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* The format this server would make of a stream a client named, or nothing.
|
|
381
|
+
*
|
|
382
|
+
* A client's transcoding profile names a container and a codec in the protocol's
|
|
383
|
+
* vocabulary; this server makes six things and no others. The question is both
|
|
384
|
+
* halves at once — a container this server writes holding a codec it writes —
|
|
385
|
+
* which is the same pair `alreadyIs` asks from the other direction, so it is
|
|
386
|
+
* asked with the same two predicates rather than a second table.
|
|
387
|
+
*/
|
|
388
|
+
export function formatFor(container, codec) {
|
|
389
|
+
for (const format of Object.keys(TARGETS)) {
|
|
390
|
+
if (containerIs(format, container) && codecIs(format, codec))
|
|
391
|
+
return format;
|
|
392
|
+
}
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* What this server's answer would be, in the protocol's own words.
|
|
397
|
+
*
|
|
398
|
+
* `TARGETS` names ffmpeg's encoders and the containers ffmpeg writes; a client
|
|
399
|
+
* reads `StreamDetails` in the protocol's vocabulary, where the codec of an ogg
|
|
400
|
+
* is `vorbis` and not `libvorbis`, and where what an `.m4a` is written in is
|
|
401
|
+
* `mp4`. Two vocabularies, so the translation lives in one place — the same
|
|
402
|
+
* reason `metadata` sits beside `tags/encode.ts` instead of being guessed at
|
|
403
|
+
* each call.
|
|
404
|
+
*
|
|
405
|
+
* The codec is the *first* name `CODECS_OF` lists, and that is the one ffmpeg
|
|
406
|
+
* itself would write: the rest of each list is the other spellings a probe may
|
|
407
|
+
* report for the same codec in a file that already holds it.
|
|
408
|
+
*/
|
|
409
|
+
export function streamOf(format) {
|
|
410
|
+
const made = TARGETS[format];
|
|
411
|
+
const codecs = CODECS_OF[format];
|
|
412
|
+
if (made === undefined || codecs === undefined)
|
|
413
|
+
return null;
|
|
414
|
+
// The transport is not here, and deliberately: this module knows what ffmpeg
|
|
415
|
+
// writes, and the protocol's one transport is a fact about the API. It is
|
|
416
|
+
// named once, where the protocol is spoken — `HTTP` in `transcode.ts`.
|
|
417
|
+
return { container: made.container, codec: codecs[0] };
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Whether a stretch of this container is cut out by hand, or by ffmpeg.
|
|
421
|
+
*
|
|
422
|
+
* The question `stream` settles before it can call a cue track playable at all:
|
|
423
|
+
* a stretch of FLAC or mp3 is the file's own frames restated, and a stretch of
|
|
424
|
+
* anything else has to be decoded and written again — which is a transcode, and
|
|
425
|
+
* so not a thing a client can be told it will play as it is.
|
|
426
|
+
*/
|
|
427
|
+
export function cutByHand(ext) {
|
|
428
|
+
return NATIVE.has(ext);
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Whether the file already is what the client asked for.
|
|
432
|
+
*
|
|
433
|
+
* **Both questions, and both have to be yes.** The codec has to be the one the
|
|
434
|
+
* format names *and* the container has to be the one it would be written in.
|
|
435
|
+
* Either alone is a lie to a client: an `.ogg` holding vorbis is not the
|
|
436
|
+
* `format=opus` answer though its container is right, and a `.mp4` holding ALAC
|
|
437
|
+
* is not the `format=aac` answer *though its container is right too* — answering
|
|
438
|
+
* on the container alone handed a client that had said it decodes AAC the ALAC
|
|
439
|
+
* it could not, which is exactly the class of lie `playable` exists to prevent
|
|
440
|
+
* (measured, task:2865).
|
|
441
|
+
*
|
|
442
|
+
* A file whose bitrate nobody measured cannot be shown to be under a ceiling, so
|
|
443
|
+
* it is re-encoded: the safe answer is the one that obeys.
|
|
444
|
+
*
|
|
445
|
+
* **The channel cap is asked here too, and it is the one question this function
|
|
446
|
+
* cannot answer by looking at the format.** It is an agreement between the
|
|
447
|
+
* client and the *file* rather than between the client and the container: a
|
|
448
|
+
* six-channel FLAC asked for as `format=flac` is trivially the format that was
|
|
449
|
+
* named, and it is not what a client that decodes two channels asked for.
|
|
450
|
+
* Without this the shortcut below would hand that client the six channels the
|
|
451
|
+
* decision had just promised to fold down — one decision with two answers,
|
|
452
|
+
* which is the class `Plan.made` and `keyOf` exist to close, and which was found
|
|
453
|
+
* by the standards axis rather than by this suite (task:2896).
|
|
454
|
+
*
|
|
455
|
+
* A count nobody measured does *not* block, and that is the opposite of the
|
|
456
|
+
* ceiling above for a reason that was measured the hard way: a cap handed to
|
|
457
|
+
* ffmpeg is a number of channels to **produce**, so treating an unmeasured file
|
|
458
|
+
* as "not shown to be within the cap" made a stereo mp3 come out as a 52 MB 5.1
|
|
459
|
+
* FLAC against a client that had only said "no more than six" (task:2896). A cap
|
|
460
|
+
* is a limit on a file known to exceed it; the rest go as they are.
|
|
461
|
+
*
|
|
462
|
+
* `channels` is required rather than optional for the reason `keyOf`'s `made`
|
|
463
|
+
* is: a caller that forgot it would silently get the old, wrong answer.
|
|
464
|
+
*/
|
|
465
|
+
export function alreadyIs(target, file) {
|
|
466
|
+
if (!codecIs(target.format, file.codec))
|
|
467
|
+
return false;
|
|
468
|
+
if (!containerIs(target.format, file.ext))
|
|
469
|
+
return false;
|
|
470
|
+
if (target.maxChannels != null && file.channels !== null && file.channels > target.maxChannels) {
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
if (target.maxBitRate === null)
|
|
474
|
+
return true;
|
|
475
|
+
if (isLossless(target.format))
|
|
476
|
+
return true;
|
|
477
|
+
return file.bitrate !== null && file.bitrate <= target.maxBitRate * 1000;
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* The song's tags in ffmpeg's own spelling.
|
|
481
|
+
*
|
|
482
|
+
* Lowercase names and no underscores, which is what ffmpeg's metadata keys are —
|
|
483
|
+
* `album_artist` for the album artist, `track` for the number — and the reason
|
|
484
|
+
* the mapping is written out rather than derived from `tags/encode.ts`'s table:
|
|
485
|
+
* two writers, two vocabularies, and a translation is one place for the
|
|
486
|
+
* difference to live instead of a guess at each call.
|
|
487
|
+
*/
|
|
488
|
+
function metadata(tags) {
|
|
489
|
+
const wanted = [
|
|
490
|
+
['title', tags.title],
|
|
491
|
+
['artist', tags.artist],
|
|
492
|
+
['album_artist', tags.albumArtist],
|
|
493
|
+
['album', tags.album],
|
|
494
|
+
['track', tags.trackNumber],
|
|
495
|
+
['disc', tags.discNumber],
|
|
496
|
+
['date', tags.date],
|
|
497
|
+
['genre', tags.genre],
|
|
498
|
+
];
|
|
499
|
+
return Object.fromEntries(wanted
|
|
500
|
+
.filter((entry) => entry[1] !== null && entry[1] !== '')
|
|
501
|
+
.map(([name, value]) => [name, String(value)]));
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* The plan a re-encode is, whichever question asked for it.
|
|
505
|
+
*
|
|
506
|
+
* Three callers and one shape, so it is written once. The plan's fields are the
|
|
507
|
+
* re-encode's own, and a field added to either would otherwise have to be added
|
|
508
|
+
* in three places that are easy to keep two of — the shape this module has
|
|
509
|
+
* already been bitten by once, when `made` gained its third component.
|
|
510
|
+
*/
|
|
511
|
+
function planOf(made) {
|
|
512
|
+
return {
|
|
513
|
+
kind: 'transcode',
|
|
514
|
+
args: made.args,
|
|
515
|
+
contentType: made.contentType,
|
|
516
|
+
made: made.made,
|
|
517
|
+
container: made.container,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
/** A plan that makes the format a client named, whatever the file holds. */
|
|
521
|
+
export function planTarget(input) {
|
|
522
|
+
return planOf(reencode(input.path, input.times, input.target, input.tags));
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* What to do about a whole file a client cannot play.
|
|
526
|
+
*
|
|
527
|
+
* The same answer a segment of an MP4 or ape image gets, without the times: the
|
|
528
|
+
* file is decoded and written out as FLAC. Lossless in, lossless out — ALAC and
|
|
529
|
+
* Monkey's Audio are both lossless, so re-encoding costs processor time and
|
|
530
|
+
* gives back exactly the audio that was asked for, and a lossy transcode would
|
|
531
|
+
* be a quality decision the collection never asked anyone to make.
|
|
532
|
+
*
|
|
533
|
+
* A range cannot be honoured on the answer, since its length is not known until
|
|
534
|
+
* ffmpeg has produced it; a client that seeks re-asks for the song.
|
|
535
|
+
*/
|
|
536
|
+
export function planWholeFile(input) {
|
|
537
|
+
const { path, target = null, times = null, tags } = input;
|
|
538
|
+
return planOf(reencode(path, times, target, tags));
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* The arguments that make the format that was asked for out of whatever is there.
|
|
542
|
+
*
|
|
543
|
+
* Six possible answers and not one: `wanted` is the format a client named, or
|
|
544
|
+
* the lossless default when it named none. The header here used to promise FLAC
|
|
545
|
+
* and nothing else, which stopped being true the moment `format` arrived — and a
|
|
546
|
+
* comment that promises what the code no longer does weighs as much as a bug in
|
|
547
|
+
* this project (task:2865).
|
|
548
|
+
*
|
|
549
|
+
* `-ss` and `-t` are input options, so ffmpeg seeks before decoding rather than
|
|
550
|
+
* decoding from the start and discarding; `-map 0:a:0` keeps the audio of a file
|
|
551
|
+
* that may also carry a video stream, and `-vn` makes sure of it.
|
|
552
|
+
*/
|
|
553
|
+
function reencode(path, times, wanted, tags) {
|
|
554
|
+
const seconds = (ms) => (ms / 1000).toFixed(3);
|
|
555
|
+
// What the answer *is* — which is not everything that was asked for, and the
|
|
556
|
+
// difference is the point. A re-encode a client named as `format=flac` and one
|
|
557
|
+
// the server fell back to with nothing asked are the same arguments and the
|
|
558
|
+
// same bytes — and two spellings of one identity stored the *same*
|
|
559
|
+
// 68 863 444-byte answer twice, 785 ms apart, under two names (measured,
|
|
560
|
+
// task:2865).
|
|
561
|
+
//
|
|
562
|
+
// The ceiling is dropped from it for a lossless target for the same reason it
|
|
563
|
+
// is dropped from the arguments below: it never reaches ffmpeg, so it is not
|
|
564
|
+
// part of the answer. `flac:128` and `flac:none` were two names for one
|
|
565
|
+
// answer. The channel limit is *not* dropped — it changes the bytes — and it
|
|
566
|
+
// is part of the identity even when it is absent, so that one answer has one
|
|
567
|
+
// spelling rather than two.
|
|
568
|
+
const identity = wanted ?? { format: LOSSLESS_FORMAT, maxBitRate: null, maxChannels: null };
|
|
569
|
+
const made = wanted === null ? LOSSLESS : TARGETS[wanted.format];
|
|
570
|
+
const line = [
|
|
571
|
+
'-v',
|
|
572
|
+
'error',
|
|
573
|
+
'-nostdin',
|
|
574
|
+
...(times === null
|
|
575
|
+
? []
|
|
576
|
+
: ['-ss', seconds(times.startMs), '-t', seconds(Math.max(0, times.endMs - times.startMs))]),
|
|
577
|
+
'-i',
|
|
578
|
+
path,
|
|
579
|
+
'-map',
|
|
580
|
+
'0:a:0',
|
|
581
|
+
'-vn',
|
|
582
|
+
'-c:a',
|
|
583
|
+
made.codec,
|
|
584
|
+
// A ceiling on a lossless target would be a request to throw audio away
|
|
585
|
+
// while calling the answer lossless, so it is dropped where it cannot mean
|
|
586
|
+
// anything — and FLAC has no bitrate to cap in the first place.
|
|
587
|
+
...(wanted === null || wanted.maxBitRate === null || made.lossless
|
|
588
|
+
? []
|
|
589
|
+
: ['-b:a', `${wanted.maxBitRate}k`]),
|
|
590
|
+
// The channel limit, by contrast, is obeyed whatever the target is. It comes
|
|
591
|
+
// from the `transcoding` extension, where a client states what it can
|
|
592
|
+
// decode: one that cannot decode six channels cannot play six channels
|
|
593
|
+
// whether they arrive as mp3 or as FLAC, and answering a lossless format
|
|
594
|
+
// with them anyway would be a promise about the client's hardware.
|
|
595
|
+
...(wanted?.maxChannels == null ? [] : ['-ac', String(wanted.maxChannels)]),
|
|
596
|
+
// What the answer says about itself. ffmpeg copies no metadata unless it is
|
|
597
|
+
// asked to (`-map 0:a:0` maps the audio and nothing else), so without these
|
|
598
|
+
// a re-encoded song arrives with no tags — the same defect the hand-cut
|
|
599
|
+
// segments had, in the branch that decodes (task:2895).
|
|
600
|
+
//
|
|
601
|
+
// `-map_metadata -1` first, and it is not redundant: `-metadata` *adds* to
|
|
602
|
+
// whatever the input carried, and the input here can be a cue image whose
|
|
603
|
+
// own tags name the disc rather than the track. Clearing first is what makes
|
|
604
|
+
// the file state the song and only the song.
|
|
605
|
+
...['-map_metadata', '-1'],
|
|
606
|
+
...Object.entries(metadata(tags)).flatMap(([name, value]) => ['-metadata', `${name}=${value}`]),
|
|
607
|
+
// **ID3v2.3, where ffmpeg would write 2.4.** The tags are the same either
|
|
608
|
+
// way, and the readers are not: 2.3 is understood by both generations of
|
|
609
|
+
// them, 2.4 only by the modern ones — a reader that knows 2.3 alone sees
|
|
610
|
+
// every tag except the year, which lives in `TDRC` in 2.4 and in `TYER` in
|
|
611
|
+
// 2.3. There is nothing in these tags that 2.4 says better, so the version
|
|
612
|
+
// that everybody reads is the one to write. Asked of ffmpeg only where it
|
|
613
|
+
// means something: the option belongs to the mp3 muxer.
|
|
614
|
+
...(made.container === 'mp3' ? ['-id3v2_version', '3'] : []),
|
|
615
|
+
'-f',
|
|
616
|
+
made.container,
|
|
617
|
+
];
|
|
618
|
+
return {
|
|
619
|
+
args: (target) => [...line, target],
|
|
620
|
+
contentType: made.contentType,
|
|
621
|
+
made: `${identity.format}:${made.lossless ? 'none' : (identity.maxBitRate ?? 'none')}:${identity.maxChannels ?? 'none'}`,
|
|
622
|
+
container: made.container,
|
|
623
|
+
};
|
|
624
|
+
}
|