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,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which of a folder's pictures is the cover.
|
|
3
|
+
*
|
|
4
|
+
* A rip carries more than one image — the front, the back, the disc, the
|
|
5
|
+
* booklet, a matrix photograph — and the protocol asks for "the cover art",
|
|
6
|
+
* singular. So one of them has to be chosen, and the choice has to be a
|
|
7
|
+
* function of what the folder holds rather than of the order the filesystem
|
|
8
|
+
* happened to hand the walk.
|
|
9
|
+
*
|
|
10
|
+
* The words below are not a guess about what rips are called in general. They
|
|
11
|
+
* are what this collection actually uses: across the scanned sample the names
|
|
12
|
+
* that mean "this is the front" are `front` and `cover`, and beside them sit
|
|
13
|
+
* `back`, `cd`, `cd matrix`, `booklet 1`, `obi`, `jap. booklet`, `text`, `box`
|
|
14
|
+
* and the plain numbered scans — none of which may win. A picture whose name
|
|
15
|
+
* says nothing (`001.jpg`) is not wrong to serve, but it may not displace one
|
|
16
|
+
* that says "front".
|
|
17
|
+
*
|
|
18
|
+
* Ranking is by the first word of the name and nothing else, which is what
|
|
19
|
+
* makes `front cover.jpg` and `front.jpg` the same answer while `cover back.jpg`
|
|
20
|
+
* — a name that would defeat a rule reading the whole stem — is not a case this
|
|
21
|
+
* collection produces. Adding words here is cheap; adding a rule is not.
|
|
22
|
+
*/
|
|
23
|
+
const FRONT_WORDS = ['front', 'cover', 'folder', 'album'];
|
|
24
|
+
/**
|
|
25
|
+
* The picture to serve for a folder, or nothing when it holds none.
|
|
26
|
+
*
|
|
27
|
+
* `pictures` is expected in path order — that is what `picturesInFolder` orders
|
|
28
|
+
* by, and the fallback leans on it: a folder with no telling name is read from
|
|
29
|
+
* its first picture, which is a decision anyone can reproduce by listing the
|
|
30
|
+
* folder rather than one that depends on how the disk was walked.
|
|
31
|
+
*/
|
|
32
|
+
export function pickCover(pictures) {
|
|
33
|
+
let chosen;
|
|
34
|
+
let chosenRank = FRONT_WORDS.length;
|
|
35
|
+
for (const picture of pictures) {
|
|
36
|
+
const rank = FRONT_WORDS.indexOf(firstWord(picture.rel_path));
|
|
37
|
+
if (rank !== -1 && rank < chosenRank) {
|
|
38
|
+
chosen = picture;
|
|
39
|
+
chosenRank = rank;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return chosen ?? pictures[0];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The word a picture's name starts with — `front` for `front cover.jpg`,
|
|
46
|
+
* `cd` for `cd matrix.jpg`, `001` for `001.jpg`.
|
|
47
|
+
*
|
|
48
|
+
* The extension is dropped before the split so that a file named `cover.mp3.jpg`
|
|
49
|
+
* is not read as a name about mp3, and the split is on the separators a file
|
|
50
|
+
* name actually uses, which leaves `front_cover.jpg` and `front-cover.jpg`
|
|
51
|
+
* saying the same thing as `front cover.jpg`.
|
|
52
|
+
*/
|
|
53
|
+
function firstWord(relPath) {
|
|
54
|
+
const name = relPath.slice(relPath.lastIndexOf('/') + 1);
|
|
55
|
+
const dot = name.lastIndexOf('.');
|
|
56
|
+
const stem = dot === -1 ? name : name.slice(0, dot);
|
|
57
|
+
return stem.toLowerCase().split(/[^\p{L}\p{N}]+/u)[0] ?? '';
|
|
58
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { closeSync, openSync, readSync } from 'node:fs';
|
|
2
|
+
import { oggPicture } from "../tags/ogg.js";
|
|
3
|
+
import { pictureInComment } from "../tags/vorbis-comment.js";
|
|
4
|
+
/**
|
|
5
|
+
* The cover a file carries when its bytes are not a range of it.
|
|
6
|
+
*
|
|
7
|
+
* Everything else in this project records *where* a picture is and serves that
|
|
8
|
+
* range (`TagPicture`, and `db/migrations/013_cover_art.sql`), because a copy
|
|
9
|
+
* would be gigabytes of something already on the disk and would go stale the
|
|
10
|
+
* moment somebody edited the file's tags. A `METADATA_BLOCK_PICTURE` comment
|
|
11
|
+
* breaks the premise rather than the rule: it is base64 of a picture block
|
|
12
|
+
* sitting in a packet the lacing rule has scattered across pages, so there is no
|
|
13
|
+
* range whose bytes are the image. What the scan writes down is therefore a
|
|
14
|
+
* range *worth reading* — `TagPicture.indirect` — and this is the other half:
|
|
15
|
+
* read it, and derive the picture again.
|
|
16
|
+
*
|
|
17
|
+
* Nothing is copied into the meta layer: what is stored is where to look, and the
|
|
18
|
+
* picture is derived from those bytes again on each request. A file whose comment
|
|
19
|
+
* was rewritten to the same length therefore serves the new picture without a
|
|
20
|
+
* rescan. One that has grown past the recorded region serves none, because the
|
|
21
|
+
* packet is cut at the old end — a stale row rather than a missing file, and the
|
|
22
|
+
* next scan writes it again.
|
|
23
|
+
*
|
|
24
|
+
* The cost is paid where it can be afforded. A cover is asked for once per album
|
|
25
|
+
* per client, and a client caches what it gets, while the scan walks everything
|
|
26
|
+
* — so this runs rarely over a region of a couple of hundred kilobytes, and the
|
|
27
|
+
* scan runs always over the whole file.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* The largest region this will read, in bytes.
|
|
31
|
+
*
|
|
32
|
+
* A comment packet is a few hundred kilobytes in every real file, and the region
|
|
33
|
+
* ends where that packet does. The bound is here because this runs on a request
|
|
34
|
+
* rather than during a scan: a file whose comment packet ran for a gigabyte
|
|
35
|
+
* would otherwise have a client ask the server to allocate a gigabyte, and the
|
|
36
|
+
* answer for such a file is that it has no cover this will serve — which draws
|
|
37
|
+
* the client's placeholder, exactly as a record with no art does.
|
|
38
|
+
*
|
|
39
|
+
* Sized against what the scan can actually record rather than picked round. The
|
|
40
|
+
* region runs from the start of the file to the end of the page the comment
|
|
41
|
+
* finishes on, and the reader assembles no packet beyond
|
|
42
|
+
* `MAX_HEADER_PACKET_BYTES` (16 MiB, `src/tags/ogg.ts`). The identification
|
|
43
|
+
* packet sharing those pages is tens of bytes, so the worst region a scan can
|
|
44
|
+
* write is a shade over 16 MiB once page headers are counted. This bound is
|
|
45
|
+
* larger than that on purpose: refusing a row the scan wrote is the safe
|
|
46
|
+
* direction, but a bound that disagrees with its own writer is a defect waiting
|
|
47
|
+
* to be reported as one.
|
|
48
|
+
*/
|
|
49
|
+
const MAX_REGION_BYTES = 64 * 1024 * 1024;
|
|
50
|
+
/**
|
|
51
|
+
* Read `[offset, offset + length)` of a file, or null when it cannot be read.
|
|
52
|
+
*
|
|
53
|
+
* Read in a loop because a single `readSync` is allowed to return less than was
|
|
54
|
+
* asked for, and a partial region would be parsed as a truncated file rather
|
|
55
|
+
* than reported as an unreadable one.
|
|
56
|
+
*/
|
|
57
|
+
function readRegion(path, offset, length) {
|
|
58
|
+
if (offset < 0 || length <= 0 || length > MAX_REGION_BYTES)
|
|
59
|
+
return null;
|
|
60
|
+
// `openSync` is inside the `try`. A file that has gone is one of the cases this
|
|
61
|
+
// answers `null` for, and opening it outside made that promise unreachable: the
|
|
62
|
+
// error escaped and the route answered `Internal error` instead of saying the
|
|
63
|
+
// picture could not be read.
|
|
64
|
+
let fd;
|
|
65
|
+
try {
|
|
66
|
+
fd = openSync(path, 'r');
|
|
67
|
+
const buffer = Buffer.alloc(length);
|
|
68
|
+
let filled = 0;
|
|
69
|
+
while (filled < length) {
|
|
70
|
+
const got = readSync(fd, buffer, filled, length - filled, offset + filled);
|
|
71
|
+
if (got <= 0)
|
|
72
|
+
break;
|
|
73
|
+
filled += got;
|
|
74
|
+
}
|
|
75
|
+
return buffer.subarray(0, filled);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// A file that has gone, or one this process may not open. A cover is not
|
|
79
|
+
// worth an exception: the client is told there is none.
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
if (fd !== undefined)
|
|
84
|
+
closeSync(fd);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The picture in that region, or null when there is not one.
|
|
89
|
+
*
|
|
90
|
+
* Which reader to ask is decided by the container, and the container is a
|
|
91
|
+
* verdict the scan already reached and wrote down (`file.tags_container`) rather
|
|
92
|
+
* than something to work out again from four magic bytes — the same reason the
|
|
93
|
+
* probe's answer is stored rather than re-derived at serving time.
|
|
94
|
+
*
|
|
95
|
+
* Two containers can be indirect and both do the same thing by different roads.
|
|
96
|
+
* An Ogg region is pages, and the picture has to be reassembled out of the
|
|
97
|
+
* comment packet they carry. A FLAC region is the comment block itself, already
|
|
98
|
+
* contiguous, and is read directly. Nothing else in this project has a picture
|
|
99
|
+
* that is not a range: MP4 `covr` and ID3v2 `APIC` both name the image's own
|
|
100
|
+
* bytes, which is why neither appears here.
|
|
101
|
+
*/
|
|
102
|
+
export function pictureInRegion(path, container, offset, length) {
|
|
103
|
+
const region = readRegion(path, offset, length);
|
|
104
|
+
if (region === null)
|
|
105
|
+
return null;
|
|
106
|
+
if (container === 'ogg')
|
|
107
|
+
return oggPicture(region);
|
|
108
|
+
if (container === 'flac')
|
|
109
|
+
return pictureInComment(region, 0, region.length);
|
|
110
|
+
return null;
|
|
111
|
+
}
|