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,179 @@
|
|
|
1
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
2
|
+
import { HIDDEN } from "../util/child.js";
|
|
3
|
+
function describe(err) {
|
|
4
|
+
if (err !== null && typeof err === 'object') {
|
|
5
|
+
const { stderr, message } = err;
|
|
6
|
+
const text = stderr === undefined ? '' : String(stderr).trim();
|
|
7
|
+
if (text !== '')
|
|
8
|
+
return text.split('\n')[0] ?? text;
|
|
9
|
+
if (message !== undefined)
|
|
10
|
+
return message;
|
|
11
|
+
}
|
|
12
|
+
return String(err);
|
|
13
|
+
}
|
|
14
|
+
function toInt(value) {
|
|
15
|
+
if (value === undefined)
|
|
16
|
+
return null;
|
|
17
|
+
const parsed = Number.parseInt(value, 10);
|
|
18
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Which reading of a file a stored probe row belongs to.
|
|
22
|
+
*
|
|
23
|
+
* A row is an answer about a file *as read by a particular method*, and the two
|
|
24
|
+
* cannot be told apart once stored: this collection's `codec` column was filled
|
|
25
|
+
* by a method that wrote the *container's* name where a codec belongs (`mp4` for
|
|
26
|
+
* AAC, `id3v2` for mp3) and nothing in a row said so, so two thousand files
|
|
27
|
+
* carried a reading that was not one. Raising this number is how a change of
|
|
28
|
+
* method is announced — every row below it is no longer an answer, and the next
|
|
29
|
+
* scan takes those files again.
|
|
30
|
+
*
|
|
31
|
+
* **2 — the reader that seeds a row states more about the file than it used to.**
|
|
32
|
+
* The MPEG frame walk now reports the sample rate and the channel count beside
|
|
33
|
+
* the length (task:2910), so a row seeded by the reader carries four facts where
|
|
34
|
+
* it carried two. The stored row is not the same answer, which is precisely what
|
|
35
|
+
* this number exists to say.
|
|
36
|
+
*
|
|
37
|
+
* It has to be this number and not `TAGS_METHOD` alone, and the first attempt at
|
|
38
|
+
* this change proved it: re-reading a file is not enough, because the seed
|
|
39
|
+
* defers to a row that already succeeded — `seedProbe`'s guard is
|
|
40
|
+
* `probe_ok = 0 OR stale OR probe_method <> ?`, and a row sitting at method 1 on
|
|
41
|
+
* an unmoved file passes none of the three. Rescanning the collection with
|
|
42
|
+
* `TAGS_METHOD` bumped moved all 3350 audio files to the new reader and changed
|
|
43
|
+
* **not one** of the 728 channel counts, because the reader's answer never
|
|
44
|
+
* reached the row it was meant to correct.
|
|
45
|
+
*
|
|
46
|
+
* **3 — the MP4 reader names a codec**, which is the same kind of change as 2
|
|
47
|
+
* and lands in the same place: a seeded row now carries `aac` or `alac` where
|
|
48
|
+
* it carried null, and a row already sitting at method 2 on an unmoved file
|
|
49
|
+
* passes none of the seed's three conditions. Without this the 1425 `.m4a`
|
|
50
|
+
* would keep their empty codec and the decision path would keep spawning
|
|
51
|
+
* ffprobe for every one of them (task:2910).
|
|
52
|
+
*
|
|
53
|
+
* **4 — that reader states the channel count and the sample rate as well**, out
|
|
54
|
+
* of the same header it was already standing on. An `.m4a` row that carried a
|
|
55
|
+
* codec and nothing else now carries what `alreadyIs` needs to answer a
|
|
56
|
+
* client's `maxAudioChannels`, so the file is served rather than transcoded
|
|
57
|
+
* whole. Same reasoning as 2 and 3, and the number moves for the same reason:
|
|
58
|
+
* what the reader states has moved, and a row on an unmoved file cannot be
|
|
59
|
+
* told otherwise.
|
|
60
|
+
*/
|
|
61
|
+
export const PROBE_METHOD = 4;
|
|
62
|
+
/**
|
|
63
|
+
* Ask ffprobe about one file.
|
|
64
|
+
*
|
|
65
|
+
* Nothing here is required to work: a duration the file's own bytes did not
|
|
66
|
+
* state, and the codec inside a container whose name does not say (a `.m4a` is
|
|
67
|
+
* AAC or ALAC and only the file knows), are the two questions asked of it. A
|
|
68
|
+
* failure is data, not an exception — the caller records `ok: false` and
|
|
69
|
+
* carries on, and the closing track of a cue split simply has no end. That is
|
|
70
|
+
* why this never throws.
|
|
71
|
+
*/
|
|
72
|
+
/** What the question is: the codec and the length, and nothing else. */
|
|
73
|
+
function probeArgs(absPath) {
|
|
74
|
+
return [
|
|
75
|
+
'-v',
|
|
76
|
+
'error',
|
|
77
|
+
'-select_streams',
|
|
78
|
+
'a:0',
|
|
79
|
+
'-show_entries',
|
|
80
|
+
'format=duration,bit_rate:stream=codec_name,sample_rate,channels',
|
|
81
|
+
'-of',
|
|
82
|
+
'json',
|
|
83
|
+
absPath,
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
/** A file ffprobe was never asked about, which is a fact like any other. */
|
|
87
|
+
function unasked(err) {
|
|
88
|
+
return {
|
|
89
|
+
durationMs: null,
|
|
90
|
+
codec: null,
|
|
91
|
+
sampleRate: null,
|
|
92
|
+
channels: null,
|
|
93
|
+
bitrate: null,
|
|
94
|
+
ok: false,
|
|
95
|
+
err: describe(err),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The same question, asked without holding the thread that asked it.
|
|
100
|
+
*
|
|
101
|
+
* `codecOf` needs this one: it stands on the hot path of `stream`, for the files
|
|
102
|
+
* whose container tells a client nothing about what is inside — a `.m4a` is AAC
|
|
103
|
+
* or ALAC and only the file knows. Asked synchronously it cost the daemon 90 ms
|
|
104
|
+
* of answering nobody, per file, for 1422 files of this collection (measured,
|
|
105
|
+
* task:2898). The scan, which is not answering anybody, keeps the sync one.
|
|
106
|
+
*
|
|
107
|
+
* Like `probeFile` this never rejects: a failure to ask is `ok: false` and a
|
|
108
|
+
* sentence, because the callers treat it as data.
|
|
109
|
+
*/
|
|
110
|
+
export function probeAsync(absPath, options = {}) {
|
|
111
|
+
const bin = options.ffprobePath ?? 'ffprobe';
|
|
112
|
+
return new Promise((resolve) => {
|
|
113
|
+
execFile(bin, probeArgs(absPath), {
|
|
114
|
+
encoding: 'utf8',
|
|
115
|
+
timeout: options.timeoutMs ?? 30_000,
|
|
116
|
+
...HIDDEN,
|
|
117
|
+
}, (err, stdout) => {
|
|
118
|
+
resolve(err === null ? parseProbe(stdout) : unasked(err));
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
export function probeFile(absPath, options = {}) {
|
|
123
|
+
const bin = options.ffprobePath ?? 'ffprobe';
|
|
124
|
+
let stdout;
|
|
125
|
+
try {
|
|
126
|
+
stdout = execFileSync(bin, probeArgs(absPath), {
|
|
127
|
+
encoding: 'utf8',
|
|
128
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
129
|
+
timeout: options.timeoutMs ?? 30_000,
|
|
130
|
+
...HIDDEN,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
return unasked(err);
|
|
135
|
+
}
|
|
136
|
+
return parseProbe(stdout);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* What ffprobe said, as a `Probe`.
|
|
140
|
+
*
|
|
141
|
+
* Shared by both spellings of the call, so that what is known about a file does
|
|
142
|
+
* not depend on which one asked it.
|
|
143
|
+
*/
|
|
144
|
+
function parseProbe(stdout) {
|
|
145
|
+
/** Nothing known, with a reason: every refusal below is this plus a sentence. */
|
|
146
|
+
const nothing = (err) => ({ ...unasked(err), err });
|
|
147
|
+
let parsed;
|
|
148
|
+
try {
|
|
149
|
+
parsed = JSON.parse(stdout);
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
return nothing(`unreadable ffprobe output: ${describe(err)}`);
|
|
153
|
+
}
|
|
154
|
+
const stream = parsed.streams?.[0];
|
|
155
|
+
if (stream === undefined) {
|
|
156
|
+
return nothing('no audio stream found');
|
|
157
|
+
}
|
|
158
|
+
const seconds = parsed.format?.duration === undefined ? null : Number(parsed.format.duration);
|
|
159
|
+
const durationMs = seconds === null || Number.isNaN(seconds) ? null : Math.round(seconds * 1000);
|
|
160
|
+
const channels = stream.channels ?? null;
|
|
161
|
+
const probed = {
|
|
162
|
+
durationMs,
|
|
163
|
+
codec: stream.codec_name ?? null,
|
|
164
|
+
sampleRate: toInt(stream.sample_rate),
|
|
165
|
+
channels,
|
|
166
|
+
bitrate: toInt(parsed.format?.bit_rate),
|
|
167
|
+
ok: false,
|
|
168
|
+
err: null,
|
|
169
|
+
};
|
|
170
|
+
// A stream being present is not enough to call the file readable. A truncated
|
|
171
|
+
// or corrupt file can make ffprobe report a codec while giving no format
|
|
172
|
+
// section at all — exit code 0, a plausible codec name, and a zero sample
|
|
173
|
+
// rate. Since the only thing we need from ffprobe is a usable duration, that
|
|
174
|
+
// is what "ok" means.
|
|
175
|
+
if (durationMs === null || durationMs <= 0 || channels === 0) {
|
|
176
|
+
return { ...probed, err: 'stream is not readable (no usable duration)' };
|
|
177
|
+
}
|
|
178
|
+
return { ...probed, ok: true };
|
|
179
|
+
}
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { applyArtists } from "./artist/apply.js";
|
|
2
|
+
import { classify } from "./classify/classify.js";
|
|
3
|
+
import { numberCollidingRecords } from "./classify/collision-name.js";
|
|
4
|
+
import { nameShelfRecords } from "./classify/shelf-name.js";
|
|
5
|
+
import { applyCues } from "./cue/engine.js";
|
|
6
|
+
import { recordStageFailure } from "./db/issue.js";
|
|
7
|
+
import { scan } from "./scan/scan.js";
|
|
8
|
+
import { SETTLE_MS } from "./scan/settle.js";
|
|
9
|
+
import { applyPlaylists } from "./playlist/import.js";
|
|
10
|
+
import { rebuildSearchIndex } from "./search/index.js";
|
|
11
|
+
import { applyTags } from "./tags/apply.js";
|
|
12
|
+
/**
|
|
13
|
+
* Run the chain over the given roots, and settle the run's status.
|
|
14
|
+
*
|
|
15
|
+
* The order is the one the priority depends on: the classifier names albums
|
|
16
|
+
* from the folder, tags have their say next, and the cue has the last word on
|
|
17
|
+
* what a record is called. The artist stage reads all three.
|
|
18
|
+
*
|
|
19
|
+
* The run id comes from `scan`, and later stages stamp their rows with it — so
|
|
20
|
+
* `scan` is not optional and not reorderable. Everything after it is: each
|
|
21
|
+
* stage derives its own rows from the present and clears them itself, which is
|
|
22
|
+
* what makes a rerun after a failure reach the state a clean run would.
|
|
23
|
+
*/
|
|
24
|
+
export function runStages(db, rootPaths, options = {}) {
|
|
25
|
+
// **The one place the half-write gate is asked for.** `scan` takes the window
|
|
26
|
+
// rather than assuming one, because waiting is a cost and only the caller
|
|
27
|
+
// knows whether it can be paid; this is production's only scan, so this is
|
|
28
|
+
// where the contract's clause becomes true of a real run. See `settle.ts`.
|
|
29
|
+
//
|
|
30
|
+
// The production window unless a caller names another, and the caller that
|
|
31
|
+
// does is a test: `settleMs` is the seam `walk` and `probe` already are, and
|
|
32
|
+
// a suite that pays the window on every fixture it has just written is a
|
|
33
|
+
// suite that measures the gate rather than the stage it asked about.
|
|
34
|
+
const scanCounters = scan(db, rootPaths, {
|
|
35
|
+
walk: options.walk,
|
|
36
|
+
full: options.full,
|
|
37
|
+
settleMs: options.settleMs ?? SETTLE_MS,
|
|
38
|
+
});
|
|
39
|
+
const runId = scanCounters.scanRunId;
|
|
40
|
+
// Which stage is running, so a throw can be recorded against its name. Held
|
|
41
|
+
// in a local rather than threaded through four wrapped calls: the stages
|
|
42
|
+
// answer with different counters, and a table of them would need a union per
|
|
43
|
+
// entry to say what it returns.
|
|
44
|
+
let stage = 'classify';
|
|
45
|
+
try {
|
|
46
|
+
const classified = classify(db);
|
|
47
|
+
stage = 'tags';
|
|
48
|
+
const tags = applyTags(db, { probe: options.probe, readBytes: options.readBytes });
|
|
49
|
+
stage = 'cues';
|
|
50
|
+
const cues = applyCues(db, { probe: options.probe, readBytes: options.readBytes });
|
|
51
|
+
// After `cues` and not before it: an imported playlist names songs, and
|
|
52
|
+
// songs are what the cue stage writes. A `.m3u` read earlier would have no
|
|
53
|
+
// tracks to point at and would import an empty list every time.
|
|
54
|
+
stage = 'playlists';
|
|
55
|
+
const playlists = applyPlaylists(db, { readBytes: options.readBytes });
|
|
56
|
+
stage = 'artists';
|
|
57
|
+
const artists = applyArtists(db);
|
|
58
|
+
// After every stage that names anything, and before the index that copies
|
|
59
|
+
// the names: a record's shelf is the last thing its name gains, because it
|
|
60
|
+
// is the only stage that can read the artist a shelf's name is measured
|
|
61
|
+
// against. See `shelf-name.ts`.
|
|
62
|
+
stage = 'shelves';
|
|
63
|
+
const shelves = nameShelfRecords(db);
|
|
64
|
+
// Last of the naming stages, and it has to be last: it numbers the records
|
|
65
|
+
// that would otherwise read identically, and a qualifier added after it would
|
|
66
|
+
// give the two names apart on its own — leaving the number describing a
|
|
67
|
+
// difference that is no longer the whole of it.
|
|
68
|
+
stage = 'collisions';
|
|
69
|
+
const collisions = numberCollidingRecords(db);
|
|
70
|
+
// Last, and it has to be: the index is a copy of what the stages above
|
|
71
|
+
// settled, so indexing before the cue had its last word on a title would
|
|
72
|
+
// publish the title it overruled.
|
|
73
|
+
stage = 'search';
|
|
74
|
+
const searchIndex = rebuildSearchIndex(db);
|
|
75
|
+
// The planner's statistics are derived from the data as plainly as any row
|
|
76
|
+
// here is, and this run is the only thing that knows the data has changed.
|
|
77
|
+
// Nothing had ever refreshed them, so `sqlite_stat1` did not exist and
|
|
78
|
+
// SQLite guessed — and on this collection it guessed that the cover route's
|
|
79
|
+
// folder lookup should scan every file in the root rather than use the
|
|
80
|
+
// index naming exactly those two columns: measured 12.6 ms against 0.02 ms
|
|
81
|
+
// per call, on a route a client calls once per artist on screen.
|
|
82
|
+
stage = 'planner';
|
|
83
|
+
db.exec('ANALYZE');
|
|
84
|
+
return {
|
|
85
|
+
scan: scanCounters,
|
|
86
|
+
classify: classified,
|
|
87
|
+
tags,
|
|
88
|
+
cues,
|
|
89
|
+
playlists,
|
|
90
|
+
artists,
|
|
91
|
+
shelves,
|
|
92
|
+
collisions,
|
|
93
|
+
search: searchIndex,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
try {
|
|
98
|
+
recordStageFailure(db, runId, stage, err);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// The failure is already on its way to the caller. A trace that cannot be
|
|
102
|
+
// written must not replace the reason it was being written for.
|
|
103
|
+
}
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
// The run's own end, not the walk's.
|
|
108
|
+
//
|
|
109
|
+
// `scan` stamps `finished_at` when its walk is done, which is right for the
|
|
110
|
+
// walk and wrong for the row it is written on: the chain then spends most of
|
|
111
|
+
// the run — classify, tags, cues, artists, the index — after that stamp, so
|
|
112
|
+
// a twelve-root run recorded 0.238 s against 38.8 s of work. Nothing could
|
|
113
|
+
// measure a run's duration from these columns (task:2756).
|
|
114
|
+
//
|
|
115
|
+
// `status` is left where `scan` put it: this says when the run ended, not
|
|
116
|
+
// whether it succeeded, and the failure path above has already written what
|
|
117
|
+
// went wrong.
|
|
118
|
+
db.prepare('UPDATE scan_run SET finished_at = ? WHERE id = ?').run(new Date().toISOString(), runId);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { extname } from 'node:path';
|
|
2
|
+
/** Formats the collection matrix calls out: FLAC / APE / WAV / ALAC(m4a) / MP3 / TTA / AIFF. */
|
|
3
|
+
const AUDIO = new Set([
|
|
4
|
+
'flac',
|
|
5
|
+
'ape',
|
|
6
|
+
'wav',
|
|
7
|
+
'm4a', // ALAC and AAC both land here
|
|
8
|
+
'mp3',
|
|
9
|
+
'tta',
|
|
10
|
+
'aiff',
|
|
11
|
+
'aif',
|
|
12
|
+
'aifc',
|
|
13
|
+
'alac',
|
|
14
|
+
// No `mp4`: it is a video container, and audio-only material uses `m4a`
|
|
15
|
+
// above. The collection keeps live clips in `.mp4`, and treating the
|
|
16
|
+
// extension as audio filed a video as a track.
|
|
17
|
+
'ogg',
|
|
18
|
+
'oga',
|
|
19
|
+
'opus',
|
|
20
|
+
'wma',
|
|
21
|
+
'wv',
|
|
22
|
+
'mpc',
|
|
23
|
+
'shn',
|
|
24
|
+
'tak',
|
|
25
|
+
'dsf',
|
|
26
|
+
'dff',
|
|
27
|
+
]);
|
|
28
|
+
const IMAGE = new Set(['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tif', 'tiff']);
|
|
29
|
+
const PLAYLIST = new Set(['m3u', 'm3u8', 'pls']);
|
|
30
|
+
export function classifyFile(name) {
|
|
31
|
+
// A leading dot marks a hidden file (`.DS_Store`), not an extension — Node's
|
|
32
|
+
// extname already reports '' for those, which falls through to 'other'.
|
|
33
|
+
const ext = extname(name).toLowerCase().slice(1);
|
|
34
|
+
if (ext === '')
|
|
35
|
+
return 'other';
|
|
36
|
+
if (AUDIO.has(ext))
|
|
37
|
+
return 'audio';
|
|
38
|
+
if (IMAGE.has(ext))
|
|
39
|
+
return 'image';
|
|
40
|
+
if (PLAYLIST.has(ext))
|
|
41
|
+
return 'playlist';
|
|
42
|
+
if (ext === 'cue')
|
|
43
|
+
return 'cue';
|
|
44
|
+
if (ext === 'nfo')
|
|
45
|
+
return 'nfo';
|
|
46
|
+
if (ext === 'log')
|
|
47
|
+
return 'log';
|
|
48
|
+
return 'other';
|
|
49
|
+
}
|