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,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
+ }