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.
Files changed (162) hide show
  1. package/.env.example +90 -0
  2. package/CHANGELOG.md +104 -0
  3. package/DEPLOY.md +375 -0
  4. package/LICENSE +21 -0
  5. package/README.md +138 -0
  6. package/deploy/answers.schema.json +151 -0
  7. package/deploy/build.mjs +88 -0
  8. package/deploy/check-publishable.mjs +246 -0
  9. package/deploy/smoke.mjs +681 -0
  10. package/deploy/systemd/funoteka.env.example +41 -0
  11. package/deploy/systemd/funoteka.service +77 -0
  12. package/deploy/windows/funoteka-service.xml +58 -0
  13. package/deploy/windows/install-service.ps1 +158 -0
  14. package/dist/annotation/store.js +67 -0
  15. package/dist/api/admin-export.js +227 -0
  16. package/dist/api/admin-guard.js +181 -0
  17. package/dist/api/admin-library.js +113 -0
  18. package/dist/api/admin.js +1162 -0
  19. package/dist/api/annotation.js +193 -0
  20. package/dist/api/artistinfo.js +140 -0
  21. package/dist/api/audit.js +32 -0
  22. package/dist/api/auth.js +145 -0
  23. package/dist/api/bookmark.js +108 -0
  24. package/dist/api/browse.js +1241 -0
  25. package/dist/api/config-file.js +202 -0
  26. package/dist/api/config.js +285 -0
  27. package/dist/api/cover.js +304 -0
  28. package/dist/api/download.js +112 -0
  29. package/dist/api/envelope.js +162 -0
  30. package/dist/api/extensions.js +43 -0
  31. package/dist/api/genre.js +81 -0
  32. package/dist/api/health.js +35 -0
  33. package/dist/api/history.js +374 -0
  34. package/dist/api/idempotency.js +66 -0
  35. package/dist/api/keys.js +90 -0
  36. package/dist/api/log-file.js +65 -0
  37. package/dist/api/meta.js +1410 -0
  38. package/dist/api/playlist.js +311 -0
  39. package/dist/api/rescan.js +196 -0
  40. package/dist/api/roots.js +161 -0
  41. package/dist/api/router.js +133 -0
  42. package/dist/api/scanner.js +163 -0
  43. package/dist/api/search.js +82 -0
  44. package/dist/api/server.js +643 -0
  45. package/dist/api/settings.js +67 -0
  46. package/dist/api/stream.js +442 -0
  47. package/dist/api/stubs.js +183 -0
  48. package/dist/api/transcode.js +678 -0
  49. package/dist/api/user.js +73 -0
  50. package/dist/api/virtual.js +362 -0
  51. package/dist/api/visibility.js +21 -0
  52. package/dist/artist/apply.js +752 -0
  53. package/dist/artist/credit.js +95 -0
  54. package/dist/artist/folder.js +65 -0
  55. package/dist/artist/name.js +205 -0
  56. package/dist/artist/nfo.js +101 -0
  57. package/dist/artist/translit.js +79 -0
  58. package/dist/bookmark/store.js +54 -0
  59. package/dist/classify/classify.js +405 -0
  60. package/dist/classify/collision-name.js +109 -0
  61. package/dist/classify/folder-name.js +466 -0
  62. package/dist/classify/roles.js +277 -0
  63. package/dist/classify/shelf-name.js +178 -0
  64. package/dist/classify/tree.js +43 -0
  65. package/dist/cli/args.js +169 -0
  66. package/dist/cli/daemon.js +192 -0
  67. package/dist/cli/entry.js +25 -0
  68. package/dist/cli/keys.js +52 -0
  69. package/dist/cli.js +638 -0
  70. package/dist/cover/pick.js +58 -0
  71. package/dist/cover/picture.js +111 -0
  72. package/dist/cue/engine.js +759 -0
  73. package/dist/cue/match.js +142 -0
  74. package/dist/cue/parse.js +182 -0
  75. package/dist/cue/plan.js +286 -0
  76. package/dist/cue/track-name.js +120 -0
  77. package/dist/db/index.js +163 -0
  78. package/dist/db/issue.js +73 -0
  79. package/dist/db/ledger.js +84 -0
  80. package/dist/db/migrations/001_init.sql +196 -0
  81. package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
  82. package/dist/db/migrations/003_cue_metadata.sql +14 -0
  83. package/dist/db/migrations/004_cue_performer.sql +12 -0
  84. package/dist/db/migrations/005_incremental.sql +23 -0
  85. package/dist/db/migrations/006_tags.sql +57 -0
  86. package/dist/db/migrations/007_artist_credit.sql +35 -0
  87. package/dist/db/migrations/008_issue_stage.sql +39 -0
  88. package/dist/db/migrations/009_cue_title.sql +20 -0
  89. package/dist/db/migrations/010_duration_source.sql +31 -0
  90. package/dist/db/migrations/011_track_title_source.sql +28 -0
  91. package/dist/db/migrations/012_file_container.sql +25 -0
  92. package/dist/db/migrations/013_cover_art.sql +28 -0
  93. package/dist/db/migrations/014_album_year.sql +17 -0
  94. package/dist/db/migrations/015_probe_method.sql +14 -0
  95. package/dist/db/migrations/016_tags_method.sql +20 -0
  96. package/dist/db/migrations/017_cover_indirect.sql +23 -0
  97. package/dist/db/migrations/018_release_year.sql +14 -0
  98. package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
  99. package/dist/db/migrations/020_analyze_after_index.sql +16 -0
  100. package/dist/db/migrations/021_cue_index_files.sql +15 -0
  101. package/dist/db/migrations/022_sidecar_text.sql +19 -0
  102. package/dist/db/migrations/023_track_credit.sql +23 -0
  103. package/dist/db/migrations/024_playlists.sql +63 -0
  104. package/dist/db/migrations/025_playlist_sequence.sql +27 -0
  105. package/dist/db/migrations/026_playlist_source_file.sql +19 -0
  106. package/dist/db/migrations/027_lookup_indexes.sql +30 -0
  107. package/dist/db/migrations/028_annotations.sql +47 -0
  108. package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
  109. package/dist/db/migrations/030_history.sql +108 -0
  110. package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
  111. package/dist/db/migrations/032_bookmarks.sql +27 -0
  112. package/dist/db/migrations/033_bookmark_order.sql +14 -0
  113. package/dist/db/migrations/034_junk.sql +33 -0
  114. package/dist/db/migrations/035_api_key.sql +38 -0
  115. package/dist/db/migrations/036_file_tag_first.sql +88 -0
  116. package/dist/db/migrations/037_admin_idempotency.sql +31 -0
  117. package/dist/db/migrations/038_cue_audio_index.sql +24 -0
  118. package/dist/db/sweep.js +109 -0
  119. package/dist/history/store.js +151 -0
  120. package/dist/inventory/inventory.js +294 -0
  121. package/dist/junk/marks.js +143 -0
  122. package/dist/junk/rule.js +93 -0
  123. package/dist/mcp/client.js +48 -0
  124. package/dist/mcp/server.js +175 -0
  125. package/dist/mcp/tools.js +348 -0
  126. package/dist/playlist/files.js +116 -0
  127. package/dist/playlist/import.js +260 -0
  128. package/dist/playlist/store.js +240 -0
  129. package/dist/probe/ffprobe.js +179 -0
  130. package/dist/run.js +120 -0
  131. package/dist/scan/kinds.js +49 -0
  132. package/dist/scan/scan.js +503 -0
  133. package/dist/scan/schedule.js +132 -0
  134. package/dist/scan/settle.js +172 -0
  135. package/dist/scan/walk.js +132 -0
  136. package/dist/search/index.js +41 -0
  137. package/dist/search/query.js +28 -0
  138. package/dist/stream/flac.js +541 -0
  139. package/dist/stream/mpeg.js +179 -0
  140. package/dist/stream/recode.js +393 -0
  141. package/dist/stream/rewrite.js +163 -0
  142. package/dist/stream/segment.js +624 -0
  143. package/dist/tags/apply.js +703 -0
  144. package/dist/tags/encode.js +164 -0
  145. package/dist/tags/first.js +130 -0
  146. package/dist/tags/flac.js +196 -0
  147. package/dist/tags/genres.js +123 -0
  148. package/dist/tags/id3v1.js +209 -0
  149. package/dist/tags/id3v2.js +754 -0
  150. package/dist/tags/mp4.js +534 -0
  151. package/dist/tags/mpeg.js +355 -0
  152. package/dist/tags/ogg.js +463 -0
  153. package/dist/tags/picture.js +71 -0
  154. package/dist/tags/read.js +269 -0
  155. package/dist/tags/types.js +51 -0
  156. package/dist/tags/vorbis-comment.js +155 -0
  157. package/dist/text/encoding.js +384 -0
  158. package/dist/text/site-name.js +37 -0
  159. package/dist/util/child.js +21 -0
  160. package/dist/util/names.js +97 -0
  161. package/funoteka.json.example +27 -0
  162. 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
+ }