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,277 @@
|
|
|
1
|
+
import { classifyFile } from "../scan/kinds.js";
|
|
2
|
+
import { compareNatural, stemOf } from "../util/names.js";
|
|
3
|
+
/**
|
|
4
|
+
* What a cue and the image it describes have in common, so that they pair.
|
|
5
|
+
*
|
|
6
|
+
* One convention, written two ways, both of them in the collection: ASOT ships
|
|
7
|
+
* `… - Pulse.cue` beside `… - Pulse.mp3`, and the Maschina Kino rips ship
|
|
8
|
+
* `… CD1.flac.cue` beside `… CD1.flac`. The first names its disc by stem. The
|
|
9
|
+
* second leaves the audio's own extension on, and `stemOf` — which strips
|
|
10
|
+
* exactly one extension, deliberately — reads that as a disc called
|
|
11
|
+
* `… CD1.flac`, which shares a name with nothing. On MASHCD290 that cost every
|
|
12
|
+
* disc past the first: the folder read as one album of two images, and 30
|
|
13
|
+
* declared tracks became 2 (task:2708).
|
|
14
|
+
*
|
|
15
|
+
* So the extension comes off twice, and the second time only when what is left
|
|
16
|
+
* is audio. `X.log.cue` is a cue about a log, not a disc called `X.log`, and
|
|
17
|
+
* `Opiate FLAC.cue` — the rip that kept two cues and one image — is a name of
|
|
18
|
+
* its own rather than a partner for `Opiate.flac`.
|
|
19
|
+
*
|
|
20
|
+
* Exported because there are two places that pair a cue with its image, and
|
|
21
|
+
* they were reading the evidence differently: this module's flat-disc detector
|
|
22
|
+
* read names from the walk, and `cue/engine.ts` read them from the database.
|
|
23
|
+
* One of them understanding `X.flac.cue` and the other not is how MASHCD290
|
|
24
|
+
* became a release of two discs of which only the first had tracks (task:2708).
|
|
25
|
+
*/
|
|
26
|
+
export function pairKey(file) {
|
|
27
|
+
const named = stemOf(file.name);
|
|
28
|
+
const audioRemoved = classifyFile(named) === 'audio' ? stemOf(named) : named;
|
|
29
|
+
return audioRemoved.toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A multi-disc release laid out flat: one image and one cue per disc, side by
|
|
33
|
+
* side in a single folder, no subfolders at all.
|
|
34
|
+
*
|
|
35
|
+
* They pair by name — an image and a cue that name the same disc are one disc
|
|
36
|
+
* (`pairKey` above has the two writings this has to read). Requiring exactly
|
|
37
|
+
* one cue and one audio per name keeps a normal album out of this: `FLAC.cue` +
|
|
38
|
+
* `WAV.cue` + `Opiate.flac` keys as `flac`, `wav`, `opiate`, none of them with
|
|
39
|
+
* both, so it stays an ordinary album.
|
|
40
|
+
*
|
|
41
|
+
* Ordered the way a person would read the disc numbers, not lexicographically.
|
|
42
|
+
*/
|
|
43
|
+
export function flatDiscPairs(files) {
|
|
44
|
+
const byStem = new Map();
|
|
45
|
+
for (const file of files) {
|
|
46
|
+
if (file.kind !== 'cue' && file.kind !== 'audio')
|
|
47
|
+
continue;
|
|
48
|
+
const stem = pairKey(file);
|
|
49
|
+
const bucket = byStem.get(stem) ?? { cues: [], audio: [] };
|
|
50
|
+
(file.kind === 'cue' ? bucket.cues : bucket.audio).push(file);
|
|
51
|
+
byStem.set(stem, bucket);
|
|
52
|
+
}
|
|
53
|
+
const pairs = [];
|
|
54
|
+
for (const bucket of byStem.values()) {
|
|
55
|
+
const cue = bucket.cues[0];
|
|
56
|
+
const audio = bucket.audio[0];
|
|
57
|
+
if (bucket.cues.length === 1 && bucket.audio.length === 1 && cue !== undefined && audio !== undefined) {
|
|
58
|
+
pairs.push({ cue, audio });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return pairs.sort((a, b) => compareNatural(a.audio.name, b.audio.name));
|
|
62
|
+
}
|
|
63
|
+
/** `CD1`, `CD 1`, `Disc 03`, `d2` — a folder whose whole name is a disc number. */
|
|
64
|
+
const DISC_NAME = /^(?:cd|disc|disk|d)\s*[._-]?\s*(\d{1,2})$/i;
|
|
65
|
+
/**
|
|
66
|
+
* Does this folder name say it is a disc?
|
|
67
|
+
*
|
|
68
|
+
* Two shapes say yes, and both are real. A folder may be nothing *but* the
|
|
69
|
+
* number — `CD1`, `d2` — which is `DISC_NAME`, anchored, because loose it would
|
|
70
|
+
* read the `d1` of `1971 - d1` as a disc. Or it may lead with the number and
|
|
71
|
+
* then name the record: `CD1 ● Группа крови`, how Maschina Records lays out a
|
|
72
|
+
* Kino box. Not a whole name any more, but a disc just as plainly — and the
|
|
73
|
+
* anchored read called nine of the ten Kino boxes plain category folders.
|
|
74
|
+
*
|
|
75
|
+
* `discMarker` below already read that shape in file names and cue titles; only
|
|
76
|
+
* the folder question was still anchored. A box folder must keep answering no:
|
|
77
|
+
* `…, MKK821CD, 3CD)` is a release, and both `\b` guards hold there — in
|
|
78
|
+
* `MKK821CD` the `cd` follows a letter, and `3CD)` has no digits after it.
|
|
79
|
+
*/
|
|
80
|
+
export function isDiscName(name) {
|
|
81
|
+
const text = name.trim();
|
|
82
|
+
return DISC_NAME.test(text) || discMarker(text) !== null;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* A folder named like a box set.
|
|
86
|
+
*
|
|
87
|
+
* Structural detection is preferred and runs first — `CD1`, `CD2`, or several
|
|
88
|
+
* image+cue pairs, are far stronger evidence than a word. This only catches
|
|
89
|
+
* what structure misses: a box whose discs are named after their albums
|
|
90
|
+
* (`The Wall`, `Animals`) rather than numbered. Their disc numbers then come
|
|
91
|
+
* from order, since the names carry none.
|
|
92
|
+
*
|
|
93
|
+
* There is deliberately no `Albums -> category` predicate. A folder holding
|
|
94
|
+
* only albums is already a category structurally, so the name would add a
|
|
95
|
+
* second rule that can never disagree with the first.
|
|
96
|
+
*/
|
|
97
|
+
const BOX_NAME = /^(?:box|box[ _-]?set|set)$/i;
|
|
98
|
+
export function isBoxName(name) {
|
|
99
|
+
return BOX_NAME.test(name.trim());
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* How many discs a folder name says it holds, or null when it says nothing.
|
|
103
|
+
*
|
|
104
|
+
* `The Cure - Assemblage - 1991 (12CD FLAC)` states twelve and `(2CD)` two, and
|
|
105
|
+
* that is the only evidence some boxes give: their discs are named after the
|
|
106
|
+
* albums on them (`01 - Three Imaginary Boys (1979)`), not after their numbers,
|
|
107
|
+
* so nothing structural marks them as discs at all.
|
|
108
|
+
*
|
|
109
|
+
* The digits have to stand on their own, the way a disc number does and for the
|
|
110
|
+
* same reason. `MKK821CD` is a catalogue number, and reading `21CD` out of its
|
|
111
|
+
* middle would make a box of a folder that never claimed one — so neither a
|
|
112
|
+
* letter nor a digit may sit in front of the run.
|
|
113
|
+
*/
|
|
114
|
+
const STATED_DISCS = /(?<![\p{L}\p{N}])(\d+)\s*cd\b/iu;
|
|
115
|
+
export function statedDiscCount(name) {
|
|
116
|
+
const digits = STATED_DISCS.exec(name.trim())?.[1];
|
|
117
|
+
return digits === undefined ? null : Number.parseInt(digits, 10);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The number a disc folder states, or null when it states none.
|
|
121
|
+
*
|
|
122
|
+
* Anchored form first, then the marker — so `CD1 ● Альбом` numbers itself
|
|
123
|
+
* instead of waiting on where it sits. A stated number outranks an ordinal, the
|
|
124
|
+
* same way it does for a flat pair in `classify.ts`; a disc named after its
|
|
125
|
+
* album states nothing and still falls to its position.
|
|
126
|
+
*/
|
|
127
|
+
export function discNumber(name) {
|
|
128
|
+
const text = name.trim();
|
|
129
|
+
const anchored = DISC_NAME.exec(text)?.[1];
|
|
130
|
+
if (anchored !== undefined)
|
|
131
|
+
return Number.parseInt(anchored, 10);
|
|
132
|
+
return discMarker(text);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* `Disc 1`, `[Disc 2]`, `(Disc 03)` — a marker written *anywhere* in a name.
|
|
136
|
+
*
|
|
137
|
+
* `DISC_NAME` above reads a folder called `CD1` and nothing else, which is the
|
|
138
|
+
* right question for a folder. Everything else names a disc differently: the
|
|
139
|
+
* marker sits in the middle, inside brackets, after the record's own name —
|
|
140
|
+
* `Pink Floyd - The Wall [Disc 1].ape`, or a cue's `TITLE "The Wall [Disc 1]"`.
|
|
141
|
+
* Requiring it at the start found nothing, and both discs of the Wall took their
|
|
142
|
+
* number from sort order instead, which happened to put the second disc first.
|
|
143
|
+
*
|
|
144
|
+
* No bare `d`, unlike `DISC_NAME`: anchored to a whole folder name `d2` is
|
|
145
|
+
* unambiguous, but loose in a name it would match any word ending in `d` that
|
|
146
|
+
* runs into a digit.
|
|
147
|
+
*
|
|
148
|
+
* The number has to stand on its own, and the lookahead is what makes it. A
|
|
149
|
+
* disc number is one number; a catalogue number is a run of them, and the head
|
|
150
|
+
* of a run is not a marker. `MEL CD 60 00842` is Melodiya's — the 842nd release
|
|
151
|
+
* of the 60 series — and reading `CD 60` out of it turned the twenty
|
|
152
|
+
* independent CDs of one series folder into twenty discs of a single box
|
|
153
|
+
* ([[task:2713]]) while giving every one of them `disc_number = 60`
|
|
154
|
+
* ([[task:2715]]). A name that follows the number costs nothing: `CD 2 - Disk
|
|
155
|
+
* Union Bonus` and `The Wall [1994 Remaster](Disc 2)` are still discs.
|
|
156
|
+
*/
|
|
157
|
+
const DISC_MARKER = /\b(?:disc|disk|cd)\s*[._-]?\s*(\d{1,2})\b(?!\s*\d)/i;
|
|
158
|
+
export function discMarker(name) {
|
|
159
|
+
const digits = DISC_MARKER.exec(name.trim())?.[1];
|
|
160
|
+
return digits === undefined ? null : Number.parseInt(digits, 10);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Where the marker sits in a name, or `-1` when the name states none.
|
|
164
|
+
*
|
|
165
|
+
* The index rather than the number, because what a caller wants it for is
|
|
166
|
+
* everything *in front* of the marker: two folders that agree up to there name
|
|
167
|
+
* one set, whatever their catalogue numbers say after it.
|
|
168
|
+
*/
|
|
169
|
+
export function discMarkerIndex(name) {
|
|
170
|
+
return DISC_MARKER.exec(name.trim())?.index ?? -1;
|
|
171
|
+
}
|
|
172
|
+
function hasAudio(node) {
|
|
173
|
+
return node.files.some((file) => file.kind === 'audio');
|
|
174
|
+
}
|
|
175
|
+
function hasAudioDeep(node) {
|
|
176
|
+
return hasAudio(node) || node.children.some(hasAudioDeep);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Assign a role to every node, children first.
|
|
180
|
+
*
|
|
181
|
+
* Two rules are worth stating outright, because both were chosen to avoid
|
|
182
|
+
* inventing structure that is not there:
|
|
183
|
+
*
|
|
184
|
+
* - A folder needs *at least two* audio-bearing CD folders beneath it to be a
|
|
185
|
+
* box. One `CD1` is a folder name, not a release, and treating it as one
|
|
186
|
+
* would split albums that are perfectly whole.
|
|
187
|
+
* - Only disc folders that actually contain audio count. An empty `CD2`
|
|
188
|
+
* left over from a bad rip must not turn its parent into a box.
|
|
189
|
+
* - **Every** audio-bearing child has to be a disc — two is necessary and not
|
|
190
|
+
* sufficient. A shelf of albums that happens to hold one two-disc set is not
|
|
191
|
+
* a box: treating it as one made the artist's folder a release and every
|
|
192
|
+
* album in it a disc, and sixteen records collapsed into two. The check
|
|
193
|
+
* below is where that is decided, and the cost of getting it wrong is
|
|
194
|
+
* invisible until a client is shown records rather than album rows.
|
|
195
|
+
*
|
|
196
|
+
* Audio in a folder wins over anything below it: the files are the truth.
|
|
197
|
+
*/
|
|
198
|
+
export function assignRoles(node) {
|
|
199
|
+
for (const child of node.children)
|
|
200
|
+
assignRoles(child);
|
|
201
|
+
// A multi-disc release with no subfolders: several image+cue pairs sitting
|
|
202
|
+
// together. Without this the folder reads as one album holding a few
|
|
203
|
+
// unrelated files, and every disc past the first loses its tracks entirely.
|
|
204
|
+
if (flatDiscPairs(node.files).length >= 2) {
|
|
205
|
+
node.role = 'box';
|
|
206
|
+
return node.role;
|
|
207
|
+
}
|
|
208
|
+
// Direct audio only. A `CD1` that merely *contains* an album folder is a
|
|
209
|
+
// wrapper, not a disc: promoting it would give a disc album with no tracks of
|
|
210
|
+
// its own, and leave the real album as one more row underneath it.
|
|
211
|
+
//
|
|
212
|
+
// **Every audio-bearing child has to be a disc**, not merely two of them. A
|
|
213
|
+
// shelf of albums that happens to hold one two-disc set is the case that
|
|
214
|
+
// decides it: `Slipknot AAC 320` holds eight records, two of which are named
|
|
215
|
+
// `2014 - .5 The Gray Chapter - CD 1 [JP - WPCR-16130]` and `… CD 2 …`. With
|
|
216
|
+
// the count alone the artist's folder became a box, all eight became *discs*
|
|
217
|
+
// of it, and — once the API started answering with records rather than album
|
|
218
|
+
// rows — sixteen records collapsed into two albums called `Slipknot AAC 320`
|
|
219
|
+
// and `Slipknot ALAC`. Measured on the live collection; the operator saw the
|
|
220
|
+
// lost releases before the cause.
|
|
221
|
+
//
|
|
222
|
+
// A real box is unharmed: its audio-bearing children are exactly its discs,
|
|
223
|
+
// and an `Artwork` or `Scans` folder beside them bears none.
|
|
224
|
+
const discChildren = node.children.filter((child) => isDiscName(child.name) && hasAudio(child));
|
|
225
|
+
const albumsAlongside = node.children.filter((child) => hasAudio(child) && !isDiscName(child.name));
|
|
226
|
+
if (discChildren.length >= 2 && albumsAlongside.length === 0) {
|
|
227
|
+
for (const child of discChildren)
|
|
228
|
+
child.role = 'disc';
|
|
229
|
+
node.role = 'box';
|
|
230
|
+
return node.role;
|
|
231
|
+
}
|
|
232
|
+
// Structural detection saw only "a folder of albums". If the folder is named
|
|
233
|
+
// like a box and holds several, it is one — the discs just are not numbered.
|
|
234
|
+
// Direct audio again: promoting a wrapper folder to a disc would leave the
|
|
235
|
+
// disc empty and the real album stranded a level below it.
|
|
236
|
+
const playableChildren = node.children.filter(hasAudio);
|
|
237
|
+
if (isBoxName(node.name) && playableChildren.length >= 2) {
|
|
238
|
+
for (const child of playableChildren)
|
|
239
|
+
child.role = 'disc';
|
|
240
|
+
node.role = 'box';
|
|
241
|
+
return node.role;
|
|
242
|
+
}
|
|
243
|
+
// A box that states how many discs it holds but does not number them.
|
|
244
|
+
//
|
|
245
|
+
// `The Cure - Assemblage - 1991 (12CD FLAC)` lays its twelve out as
|
|
246
|
+
// `01 - Three Imaginary Boys (1979)`, `02 - Boys Don't Cry (1980)`, … — no
|
|
247
|
+
// marker on any of them, so the structural rule above reads twelve albums in
|
|
248
|
+
// a folder and answers `category`. The box then does not exist as a record,
|
|
249
|
+
// and every disc is shown as one of its own with the box's name appended to
|
|
250
|
+
// it: nine records named `X (Assemblage - 1991 (12CD FLAC))`.
|
|
251
|
+
//
|
|
252
|
+
// The count is the collector's own statement, and it is *checked* rather than
|
|
253
|
+
// trusted — a folder holding a different number of records than it claims is
|
|
254
|
+
// not a box. That check is the whole safety of the rule: without it, this
|
|
255
|
+
// says "the name mentions CDs", and a shelf of rips under a folder that
|
|
256
|
+
// happens to say `2CD` becomes a release whose every record is a disc of it.
|
|
257
|
+
const stated = statedDiscCount(node.name);
|
|
258
|
+
if (stated !== null && stated >= 2) {
|
|
259
|
+
const records = node.children.filter(hasAudio);
|
|
260
|
+
if (records.length === stated && records.every((child) => !isDiscName(child.name))) {
|
|
261
|
+
for (const child of records)
|
|
262
|
+
child.role = 'disc';
|
|
263
|
+
node.role = 'box';
|
|
264
|
+
return node.role;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (hasAudio(node)) {
|
|
268
|
+
node.role = 'album';
|
|
269
|
+
return node.role;
|
|
270
|
+
}
|
|
271
|
+
if (node.children.some(hasAudioDeep)) {
|
|
272
|
+
node.role = 'category';
|
|
273
|
+
return node.role;
|
|
274
|
+
}
|
|
275
|
+
node.role = 'empty';
|
|
276
|
+
return node.role;
|
|
277
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { withTransaction } from "../db/index.js";
|
|
2
|
+
import { basenameOf, folderOf } from "../util/names.js";
|
|
3
|
+
/**
|
|
4
|
+
* Say which shelf a record came from — in the record's name, and last.
|
|
5
|
+
*
|
|
6
|
+
* A collector who keeps two rips of one album (`Slipknot AAC 320`,
|
|
7
|
+
* `Slipknot ALAC`) has two records that agree about everything a client is
|
|
8
|
+
* shown: the same title, the same artist, the same year. The only thing
|
|
9
|
+
* separating them is the folder they sit in, and a folder is not a name. So
|
|
10
|
+
* `We Are Not Your Kind` appeared three times in the operator's library with
|
|
11
|
+
* nothing to choose between them.
|
|
12
|
+
*
|
|
13
|
+
* **Why this runs last, and not in `classify`.** Two reasons, and the second is
|
|
14
|
+
* the one that decided it:
|
|
15
|
+
*
|
|
16
|
+
* - The qualifier is the shelf's name *minus the artist's*, and the artist is
|
|
17
|
+
* something only the artist stage knows. `classify` writes no artists at
|
|
18
|
+
* all, deliberately, so a rule there had to guess the name from a sibling
|
|
19
|
+
* folder — a heuristic that named another artist's folder as this one's
|
|
20
|
+
* (`Nirvana (UK)` beside `Nirvana` gave `(UK)`) and could not see the
|
|
21
|
+
* ordinary `Artist/Shelf/CD1` layout at all. Here the artist is a fact.
|
|
22
|
+
* - It must not replace the name, only extend it. The name a record is shown
|
|
23
|
+
* by is the best one three stages could agree on — the folder's, improved
|
|
24
|
+
* by a tag, overruled by a cue — and a qualifier written earlier would
|
|
25
|
+
* either be thrown away by a later stage or force the *folder's* spelling
|
|
26
|
+
* on every shelf album. Measured: the shelves' albums carry tag names the
|
|
27
|
+
* folders do not (`Vol. 3: (The Subliminal Verses)` against `Vol. 3 (The
|
|
28
|
+
* Subliminal Verses)`), and forcing the folder's would have cost the
|
|
29
|
+
* collection every colon it has.
|
|
30
|
+
*
|
|
31
|
+
* Running after every stage that names anything means nothing can overrule it,
|
|
32
|
+
* and it is re-derived every run like every other name here: the stages above
|
|
33
|
+
* rewrite titles from the folder each time, so what arrives here is a fresh
|
|
34
|
+
* name and not one this stage wrote a minute ago.
|
|
35
|
+
*
|
|
36
|
+
* **It appends to the record, not to the disc.** An album that belongs to a
|
|
37
|
+
* release is a disc of it, and the record is what a client is shown — the
|
|
38
|
+
* release's title carries the qualifier, and the disc keeps the name its own
|
|
39
|
+
* folder states.
|
|
40
|
+
*/
|
|
41
|
+
export function nameShelfRecords(db) {
|
|
42
|
+
const counters = { shelves: 0, named: 0 };
|
|
43
|
+
// Which folders are shelves. Read once: the albums below are looked up by
|
|
44
|
+
// their parent's path, and a path is only a shelf in the root it belongs to.
|
|
45
|
+
const roles = new Map();
|
|
46
|
+
const folderRows = db
|
|
47
|
+
.prepare('SELECT root_id, rel_path, role FROM folder')
|
|
48
|
+
.all();
|
|
49
|
+
for (const row of folderRows)
|
|
50
|
+
roles.set(shelfKey(row.root_id, row.rel_path), row.role);
|
|
51
|
+
const seen = new Set();
|
|
52
|
+
const renameAlbum = db.prepare('UPDATE album SET title = ?, title_source = ? WHERE id = ?');
|
|
53
|
+
const renameRelease = db.prepare('UPDATE release SET title = ?, title_source = ? WHERE id = ?');
|
|
54
|
+
const name = (rootId, shelfPath, artist) => {
|
|
55
|
+
if (roles.get(shelfKey(rootId, shelfPath)) !== 'category')
|
|
56
|
+
return null;
|
|
57
|
+
const qualifier = qualifierOf(basenameOf(shelfPath), artist);
|
|
58
|
+
if (qualifier === null)
|
|
59
|
+
return null;
|
|
60
|
+
seen.add(shelfKey(rootId, shelfPath));
|
|
61
|
+
return qualifier;
|
|
62
|
+
};
|
|
63
|
+
// The records that are albums: a shelf's own albums, and not its discs.
|
|
64
|
+
const albums = db
|
|
65
|
+
.prepare(`SELECT a.id, a.root_id, a.rel_path, a.title, ar.name AS artist
|
|
66
|
+
FROM album a
|
|
67
|
+
LEFT JOIN artist ar ON ar.id = a.artist_id
|
|
68
|
+
WHERE a.release_id IS NULL`)
|
|
69
|
+
.all();
|
|
70
|
+
// One transaction for both passes. Without it every rename below is its own
|
|
71
|
+
// commit, and a commit is a disk flush: measured on the live collection, this
|
|
72
|
+
// stage cost **902 ms** committing each and **13 ms** with the transaction
|
|
73
|
+
// (task:2883). `db/index.ts` has the rule.
|
|
74
|
+
withTransaction(db, () => {
|
|
75
|
+
for (const row of albums) {
|
|
76
|
+
const qualifier = name(row.root_id, folderOf(row.rel_path), row.artist);
|
|
77
|
+
if (qualifier === null)
|
|
78
|
+
continue;
|
|
79
|
+
const title = qualified(row.title ?? '', qualifier);
|
|
80
|
+
if (title === row.title)
|
|
81
|
+
continue;
|
|
82
|
+
renameAlbum.run(title, 'shelf', row.id);
|
|
83
|
+
counters.named += 1;
|
|
84
|
+
}
|
|
85
|
+
// And the records that are releases: a shelf's disc set is one of these, and
|
|
86
|
+
// its title is what a client is shown for the whole group.
|
|
87
|
+
const releases = db
|
|
88
|
+
.prepare(`SELECT r.id, r.root_id, r.rel_path, r.title, ar.name AS artist
|
|
89
|
+
FROM release r
|
|
90
|
+
LEFT JOIN artist ar ON ar.id = r.artist_id`)
|
|
91
|
+
.all();
|
|
92
|
+
for (const row of releases) {
|
|
93
|
+
// A release's own artist is set by the artist stage from the credit its
|
|
94
|
+
// discs agree on; when it has none — a compilation whose files name nobody
|
|
95
|
+
// — any artist among its discs answers, because the shelf's name is what
|
|
96
|
+
// is being subtracted and that is the same for all of them.
|
|
97
|
+
const artist = row.artist ??
|
|
98
|
+
(db
|
|
99
|
+
.prepare(`SELECT ar.name AS name FROM album a
|
|
100
|
+
JOIN artist ar ON ar.id = a.artist_id
|
|
101
|
+
WHERE a.release_id = ? LIMIT 1`)
|
|
102
|
+
.get(row.id)?.name ??
|
|
103
|
+
null);
|
|
104
|
+
// The shelf *is* the release's folder — that is what the classifier keys
|
|
105
|
+
// it by — where an album's shelf is the folder above it.
|
|
106
|
+
const qualifier = name(row.root_id, row.rel_path, artist);
|
|
107
|
+
if (qualifier === null)
|
|
108
|
+
continue;
|
|
109
|
+
const title = qualified(row.title ?? '', qualifier);
|
|
110
|
+
if (title === row.title)
|
|
111
|
+
continue;
|
|
112
|
+
renameRelease.run(title, 'shelf', row.id);
|
|
113
|
+
counters.named += 1;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
counters.shelves = seen.size;
|
|
117
|
+
return counters;
|
|
118
|
+
}
|
|
119
|
+
/** A folder is only the one meant when its root is, so the root is in the key. */
|
|
120
|
+
function shelfKey(rootId, relPath) {
|
|
121
|
+
return `${rootId}\u0000${relPath}`;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* What stands between an artist's name and the shelf's own word.
|
|
125
|
+
*
|
|
126
|
+
* The bullet is here for the same reason the dash is: this collection writes
|
|
127
|
+
* `Кино ● Каталог Maschina Records`, and the bullet is a separator in exactly
|
|
128
|
+
* the place a space would be. Left out of the class it became part of the note,
|
|
129
|
+
* and every record under that shelf was named `… (● Каталог Maschina Records)` —
|
|
130
|
+
* the separator carried into the name it was separating.
|
|
131
|
+
*/
|
|
132
|
+
const SEPARATOR = /^[\s\-_.●•]/;
|
|
133
|
+
const SEPARATOR_ALL = /^[\s\-_.●•]+/;
|
|
134
|
+
/**
|
|
135
|
+
* What a shelf's name says beyond the artist it holds, or null.
|
|
136
|
+
*
|
|
137
|
+
* `Slipknot AAC 320` and `Slipknot ALAC` hold different rips of the same
|
|
138
|
+
* records, and what tells them apart is `AAC 320` and `ALAC` — the artist's own
|
|
139
|
+
* name is not part of the difference, and repeating it in every name is noise.
|
|
140
|
+
*
|
|
141
|
+
* A separator has to follow the artist's name, or `S` claims `Slipknot` and
|
|
142
|
+
* `node-extract` claims `node-extract2` — both measured on the live root, as
|
|
143
|
+
* `lipknot AAC 320` and `2`. Null when nothing is left over, which is the
|
|
144
|
+
* answer for an artist's own folder: it has nothing to add to itself.
|
|
145
|
+
*/
|
|
146
|
+
function qualifierOf(shelfName, artistName) {
|
|
147
|
+
const shelf = shelfName.trim();
|
|
148
|
+
const artist = (artistName ?? '').trim();
|
|
149
|
+
if (artist === '' || !shelf.startsWith(artist))
|
|
150
|
+
return null;
|
|
151
|
+
const rest = shelf.slice(artist.length);
|
|
152
|
+
if (!SEPARATOR.test(rest))
|
|
153
|
+
return null;
|
|
154
|
+
const spare = rest.replace(SEPARATOR_ALL, '').trim();
|
|
155
|
+
return spare === '' ? null : spare;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The name with the qualifier on the end, and never on twice.
|
|
159
|
+
*
|
|
160
|
+
* The stages above rewrite every title from the folder each run, so what
|
|
161
|
+
* arrives here normally carries no qualifier — but a root the walk did not see
|
|
162
|
+
* keeps the rows it had, this stage's among them, and a name that collected a
|
|
163
|
+
* second `(AAC 320)` on every run would be a name nobody could read.
|
|
164
|
+
*
|
|
165
|
+
* The empty case is the last line's, and it is a guard rather than a path:
|
|
166
|
+
* `base` is empty exactly when the title *is* the suffix — a leading space, the
|
|
167
|
+
* qualifier, and its brackets, and nothing else. No stage writes a name like
|
|
168
|
+
* that, and none of the 426 records of the live collection carries one: the only
|
|
169
|
+
* title that is nothing but a bracket is `( )`, which has no leading space and
|
|
170
|
+
* is no qualifier (measured 2026-09-13, task:2843). It is kept because the title
|
|
171
|
+
* here can be a *tag's* — the one name this stage does not derive itself — and
|
|
172
|
+
* a guard costs a line where a missing one costs a name.
|
|
173
|
+
*/
|
|
174
|
+
function qualified(title, qualifier) {
|
|
175
|
+
const suffix = ` (${qualifier})`;
|
|
176
|
+
const base = title.endsWith(suffix) ? title.slice(0, -suffix.length) : title;
|
|
177
|
+
return base === '' ? `(${qualifier})` : `${base}${suffix}`;
|
|
178
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
function byRelPath(a, b) {
|
|
2
|
+
return a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0;
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Rebuild the folder hierarchy from the flat inventory a scan produced.
|
|
6
|
+
*
|
|
7
|
+
* The root is a virtual node: it exists even when no folder row describes it,
|
|
8
|
+
* because scanning a folder that *is* an album is normal, and its files have to
|
|
9
|
+
* land somewhere. A file naming a folder that the walk never reported gets that
|
|
10
|
+
* folder synthesised rather than dropped.
|
|
11
|
+
*/
|
|
12
|
+
export function buildTree(folders, files) {
|
|
13
|
+
const root = { relPath: '', name: '', files: [], children: [], role: null };
|
|
14
|
+
const index = new Map([['', root]]);
|
|
15
|
+
const ensure = (relPath) => {
|
|
16
|
+
const known = index.get(relPath);
|
|
17
|
+
if (known)
|
|
18
|
+
return known;
|
|
19
|
+
const cut = relPath.lastIndexOf('/');
|
|
20
|
+
const parentRelPath = cut === -1 ? '' : relPath.slice(0, cut);
|
|
21
|
+
const created = {
|
|
22
|
+
relPath,
|
|
23
|
+
name: relPath.slice(cut + 1),
|
|
24
|
+
files: [],
|
|
25
|
+
children: [],
|
|
26
|
+
role: null,
|
|
27
|
+
};
|
|
28
|
+
ensure(parentRelPath).children.push(created);
|
|
29
|
+
index.set(relPath, created);
|
|
30
|
+
return created;
|
|
31
|
+
};
|
|
32
|
+
for (const folder of folders)
|
|
33
|
+
ensure(folder);
|
|
34
|
+
for (const file of files)
|
|
35
|
+
ensure(file.folderRelPath).files.push(file);
|
|
36
|
+
const sortDeep = (node) => {
|
|
37
|
+
node.children.sort(byRelPath);
|
|
38
|
+
for (const child of node.children)
|
|
39
|
+
sortDeep(child);
|
|
40
|
+
};
|
|
41
|
+
sortDeep(root);
|
|
42
|
+
return root;
|
|
43
|
+
}
|
package/dist/cli/args.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
/** Usage errors share a code so scripts can tell them from a failed scan. */
|
|
3
|
+
const USAGE_ERROR = 2;
|
|
4
|
+
const COMMANDS = new Set([
|
|
5
|
+
'scan',
|
|
6
|
+
'inventory',
|
|
7
|
+
'serve',
|
|
8
|
+
'stop',
|
|
9
|
+
'status',
|
|
10
|
+
'junk',
|
|
11
|
+
'keys',
|
|
12
|
+
'mcp',
|
|
13
|
+
]);
|
|
14
|
+
/** What `junk` can be asked to do, and the verdict each verb writes. */
|
|
15
|
+
const JUNK_ACTIONS = new Set(['list', 'block', 'allow']);
|
|
16
|
+
/** What `keys` can be asked to do. `list` takes nothing; the other two take one name. */
|
|
17
|
+
const KEYS_ACTIONS = new Set(['list', 'add', 'revoke']);
|
|
18
|
+
export function resolveCommand(argv) {
|
|
19
|
+
let values;
|
|
20
|
+
let positionals;
|
|
21
|
+
try {
|
|
22
|
+
({ values, positionals } = parseArgs({
|
|
23
|
+
args: argv,
|
|
24
|
+
allowPositionals: true,
|
|
25
|
+
options: {
|
|
26
|
+
db: { type: 'string' },
|
|
27
|
+
help: { type: 'boolean', short: 'h' },
|
|
28
|
+
host: { type: 'string' },
|
|
29
|
+
port: { type: 'string' },
|
|
30
|
+
daemon: { type: 'boolean' },
|
|
31
|
+
note: { type: 'string' },
|
|
32
|
+
full: { type: 'boolean' },
|
|
33
|
+
url: { type: 'string' },
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
return { kind: 'error', message: err.message, code: USAGE_ERROR };
|
|
39
|
+
}
|
|
40
|
+
if (values.help)
|
|
41
|
+
return { kind: 'help' };
|
|
42
|
+
const dbPath = values.db;
|
|
43
|
+
const [command, ...roots] = positionals;
|
|
44
|
+
if (command === undefined)
|
|
45
|
+
return { kind: 'error', message: 'no command given', code: USAGE_ERROR };
|
|
46
|
+
if (!COMMANDS.has(command)) {
|
|
47
|
+
return { kind: 'error', message: `unknown command "${command}"`, code: USAGE_ERROR };
|
|
48
|
+
}
|
|
49
|
+
if (command === 'serve') {
|
|
50
|
+
if (roots.length > 0) {
|
|
51
|
+
return { kind: 'error', message: 'serve takes no roots', code: USAGE_ERROR };
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
kind: 'serve',
|
|
55
|
+
daemon: values.daemon === true,
|
|
56
|
+
dbPath: values.db,
|
|
57
|
+
host: values.host,
|
|
58
|
+
port: values.port,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (command === 'mcp') {
|
|
62
|
+
if (roots.length > 0) {
|
|
63
|
+
return { kind: 'error', message: 'mcp takes no arguments', code: USAGE_ERROR };
|
|
64
|
+
}
|
|
65
|
+
return { kind: 'mcp', url: values.url };
|
|
66
|
+
}
|
|
67
|
+
if (command === 'stop' || command === 'status') {
|
|
68
|
+
if (roots.length > 0) {
|
|
69
|
+
return { kind: 'error', message: `${command} takes no roots`, code: USAGE_ERROR };
|
|
70
|
+
}
|
|
71
|
+
if (values.daemon === true) {
|
|
72
|
+
return { kind: 'error', message: `${command} takes no --daemon`, code: USAGE_ERROR };
|
|
73
|
+
}
|
|
74
|
+
return { kind: command, dbPath: values.db };
|
|
75
|
+
}
|
|
76
|
+
// Where to listen is the server's question alone. A flag accepted and ignored
|
|
77
|
+
// is worse than one refused: the operator would believe they had moved it.
|
|
78
|
+
const misplaced = [
|
|
79
|
+
...(values.host === undefined ? [] : ['--host']),
|
|
80
|
+
...(values.port === undefined ? [] : ['--port']),
|
|
81
|
+
...(values.daemon === true ? ['--daemon'] : []),
|
|
82
|
+
// `--note` belongs to `junk` alone, and it is refused here rather than
|
|
83
|
+
// there because this is where every command that does not take it is
|
|
84
|
+
// checked. A flag accepted and ignored is worse than one refused: the
|
|
85
|
+
// operator would believe the note had been kept.
|
|
86
|
+
...(values.note === undefined || command === 'junk' ? [] : ['--note']),
|
|
87
|
+
// `--full` belongs to `scan` alone, and it is refused elsewhere rather than
|
|
88
|
+
// there: the operator who put it on `inventory` would otherwise believe they
|
|
89
|
+
// had asked for a re-read.
|
|
90
|
+
...(values.full === true && command !== 'scan' ? ['--full'] : []),
|
|
91
|
+
...(values.url === undefined || command === 'mcp' ? [] : ['--url']),
|
|
92
|
+
];
|
|
93
|
+
if (misplaced.length > 0) {
|
|
94
|
+
return {
|
|
95
|
+
kind: 'error',
|
|
96
|
+
message: `${command} takes no ${misplaced.join(' or ')}`,
|
|
97
|
+
code: USAGE_ERROR,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (command === 'inventory') {
|
|
101
|
+
if (roots.length > 0) {
|
|
102
|
+
return { kind: 'error', message: 'inventory takes no roots', code: USAGE_ERROR };
|
|
103
|
+
}
|
|
104
|
+
return { kind: 'inventory', dbPath };
|
|
105
|
+
}
|
|
106
|
+
if (command === 'keys') {
|
|
107
|
+
const [action = 'list', ...rest] = roots;
|
|
108
|
+
if (!KEYS_ACTIONS.has(action)) {
|
|
109
|
+
return {
|
|
110
|
+
kind: 'error',
|
|
111
|
+
message: `keys does not take "${action}" (one of: list, add, revoke)`,
|
|
112
|
+
code: USAGE_ERROR,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (action === 'list') {
|
|
116
|
+
if (rest.length > 0) {
|
|
117
|
+
return { kind: 'error', message: 'keys list takes nothing', code: USAGE_ERROR };
|
|
118
|
+
}
|
|
119
|
+
return { kind: 'keys', action: 'list', dbPath };
|
|
120
|
+
}
|
|
121
|
+
if (rest.length !== 1 || rest[0] === '') {
|
|
122
|
+
return {
|
|
123
|
+
kind: 'error',
|
|
124
|
+
message: action === 'add'
|
|
125
|
+
? 'keys add needs one label — what the key is for, or whose it is'
|
|
126
|
+
: 'keys revoke needs one id or label',
|
|
127
|
+
code: USAGE_ERROR,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return { kind: 'keys', action: action, what: rest[0], dbPath };
|
|
131
|
+
}
|
|
132
|
+
if (command === 'junk') {
|
|
133
|
+
const [action = 'list', ...rest] = roots;
|
|
134
|
+
if (!JUNK_ACTIONS.has(action)) {
|
|
135
|
+
return {
|
|
136
|
+
kind: 'error',
|
|
137
|
+
message: `junk does not take "${action}" (one of: list, block, allow)`,
|
|
138
|
+
code: USAGE_ERROR,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (action === 'list') {
|
|
142
|
+
if (rest.length > 0) {
|
|
143
|
+
return { kind: 'error', message: 'junk list takes no paths', code: USAGE_ERROR };
|
|
144
|
+
}
|
|
145
|
+
return { kind: 'junk', action: 'list', dbPath };
|
|
146
|
+
}
|
|
147
|
+
if (rest.length !== 1) {
|
|
148
|
+
return {
|
|
149
|
+
kind: 'error',
|
|
150
|
+
message: `junk ${action} needs one path`,
|
|
151
|
+
code: USAGE_ERROR,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (values.note !== undefined && values.note === '') {
|
|
155
|
+
return { kind: 'error', message: '--note needs a value', code: USAGE_ERROR };
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
kind: 'junk',
|
|
159
|
+
action: action,
|
|
160
|
+
path: rest[0],
|
|
161
|
+
note: values.note,
|
|
162
|
+
dbPath,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (roots.length === 0) {
|
|
166
|
+
return { kind: 'error', message: 'scan needs at least one root', code: USAGE_ERROR };
|
|
167
|
+
}
|
|
168
|
+
return { kind: 'scan', roots, dbPath, full: values.full === true };
|
|
169
|
+
}
|