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,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An artist credit, read as a list.
|
|
3
|
+
*
|
|
4
|
+
* A collaboration is not one artist with punctuation in the name, and the
|
|
5
|
+
* collection says so constantly: `Cock E.S.P. + Thirdorgan`, `Merzbow & Cock
|
|
6
|
+
* E.S.P.`, `Aube / Cock E.S.P.` spread across 20 of the 24 Cock E.S.P folders.
|
|
7
|
+
* Reading them as a single name loses the collaboration; reading them as a list
|
|
8
|
+
* and throwing the string away loses the album when the reading is wrong.
|
|
9
|
+
*
|
|
10
|
+
* So neither is lost. Each entry carries the exact phrase that joined it to the
|
|
11
|
+
* entry before, which means the list rebuilds into the original string
|
|
12
|
+
* character for character. A split is a *reading* of a credit rather than a
|
|
13
|
+
* replacement for it, and an operator who disagrees with one can see exactly
|
|
14
|
+
* what was done and undo it.
|
|
15
|
+
*
|
|
16
|
+
* That is what makes "catch maximally" safe. And it needs to be, because two of
|
|
17
|
+
* these rules are provably wrong and will stay wrong:
|
|
18
|
+
*
|
|
19
|
+
* - `Smell & Quim` is one artist. The rules split it. Nothing available to a
|
|
20
|
+
* deterministic core can tell it apart from `Merzbow & Cock E.S.P.`, and
|
|
21
|
+
* §2 of the design spec puts machine inference outside the core — so the
|
|
22
|
+
* honest answer is to split it and be seen doing so.
|
|
23
|
+
* - A **word** token matched anywhere rather than as a whole word is not a
|
|
24
|
+
* near-miss, it is a disaster: `Extreme Noise Terror` becomes `E` +
|
|
25
|
+
* `treme Noise Terror` the moment `x` is a bare token, and `The Nihilist
|
|
26
|
+
* Spasm Band` becomes `The Nihilist Spasm B` + `and`. Both were measured on
|
|
27
|
+
* the real sample before this rule was written.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* Symbols join wherever they appear, with whatever whitespace surrounds them.
|
|
31
|
+
*
|
|
32
|
+
* Unambiguous as characters: nobody spells an artist with a `;`. `&` is the
|
|
33
|
+
* risky one (`Smell & Quim`) and there is no way around that except to keep the
|
|
34
|
+
* original string on the row.
|
|
35
|
+
*/
|
|
36
|
+
const SYMBOLS = '&+/;×';
|
|
37
|
+
/**
|
|
38
|
+
* Words join only when they stand alone — whitespace on both sides.
|
|
39
|
+
*
|
|
40
|
+
* The rule the measurement bought. `x` and `and` are far too common *inside*
|
|
41
|
+
* names to match as bare substrings, and `x` counts only in lower case:
|
|
42
|
+
* `Malcolm X` is a name, `Malcolm X and Xzibit` is a collaboration.
|
|
43
|
+
*/
|
|
44
|
+
const WORDS = ['featuring', 'feat\\.', 'ft\\.', 'vs\\.?', 'and', 'with', 'meets', 'x'];
|
|
45
|
+
/**
|
|
46
|
+
* One pattern, two alternatives: a symbol with the whitespace around it, or a
|
|
47
|
+
* whole word with the whitespace that isolates it.
|
|
48
|
+
*
|
|
49
|
+
* The whitespace is captured *into* the match deliberately — it belongs to the
|
|
50
|
+
* phrase that joined the two artists, and dropping it is how a credit stops
|
|
51
|
+
* rebuilding into itself. Both branches therefore take the *run*, `\s+` and
|
|
52
|
+
* `\s*`, and not a single character: `A and B` has two spaces either side of
|
|
53
|
+
* the word, and a pattern that took one of each rebuilt it as `A and B` — which
|
|
54
|
+
* is what the module's promise of a character-for-character round trip turned
|
|
55
|
+
* out not to cover (task:2756).
|
|
56
|
+
*/
|
|
57
|
+
const JOINER = new RegExp(`\\s*([${SYMBOLS}])\\s*|\\s+(?:${WORDS.join('|')})\\s+`, 'g');
|
|
58
|
+
/**
|
|
59
|
+
* Read a credit string as a list of artists and the phrases between them.
|
|
60
|
+
*
|
|
61
|
+
* Never throws and never loses its input: for every string with content on both
|
|
62
|
+
* sides of a joiner, `splitCredit(raw).map((e) => e.joinPhrase + e.name)
|
|
63
|
+
* .join('')` returns `raw`. Outer whitespace is the one exception, and
|
|
64
|
+
* deliberately so — a name with a leading space sorts wrongly forever.
|
|
65
|
+
*/
|
|
66
|
+
export function splitCredit(raw) {
|
|
67
|
+
const text = raw.trim();
|
|
68
|
+
const entries = [];
|
|
69
|
+
let at = 0;
|
|
70
|
+
// The phrase leading to the entry about to be pushed; empty before the first.
|
|
71
|
+
let phrase = '';
|
|
72
|
+
JOINER.lastIndex = 0;
|
|
73
|
+
for (let match = JOINER.exec(text); match !== null; match = JOINER.exec(text)) {
|
|
74
|
+
const start = match.index;
|
|
75
|
+
const after = start + match[0].length;
|
|
76
|
+
const name = text.slice(at, start).trim();
|
|
77
|
+
// A joiner with nothing before it (`& x`) or nothing after it (`a +`) is
|
|
78
|
+
// not joining two artists — it is part of one name. Splitting there would
|
|
79
|
+
// produce an empty name and break the round-trip on precisely the
|
|
80
|
+
// malformed input that most needs to survive intact.
|
|
81
|
+
if (name === '' || text.slice(after).trim() === '')
|
|
82
|
+
continue;
|
|
83
|
+
entries.push({ name, joinPhrase: phrase });
|
|
84
|
+
phrase = match[0];
|
|
85
|
+
at = after;
|
|
86
|
+
}
|
|
87
|
+
if (entries.length === 0) {
|
|
88
|
+
// Either the whole thing is one name, or it is empty.
|
|
89
|
+
return text === '' ? [] : [{ name: text, joinPhrase: '' }];
|
|
90
|
+
}
|
|
91
|
+
const tail = text.slice(at).trim();
|
|
92
|
+
if (tail !== '')
|
|
93
|
+
entries.push({ name: tail, joinPhrase: phrase });
|
|
94
|
+
return entries;
|
|
95
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { artistName } from "./name.js";
|
|
2
|
+
import { rootBasenameOf } from "../util/names.js";
|
|
3
|
+
/**
|
|
4
|
+
* The artist folder above `albumRelPath`, or null when there is none.
|
|
5
|
+
*
|
|
6
|
+
* Null is the ordinary answer, not a failure: most albums in a loose collection
|
|
7
|
+
* have no artist folder at all, and a null folder is simply not evidence. The
|
|
8
|
+
* caller must not read it as "a different artist" — see `applyArtists`, where a
|
|
9
|
+
* key splits only when two or more *real* folders disagree.
|
|
10
|
+
*/
|
|
11
|
+
export function artistFolderOf(input) {
|
|
12
|
+
const { albumRelPath, rootPath, key } = input;
|
|
13
|
+
// An empty key means "not a name" (see `artistName`), and it would match the
|
|
14
|
+
// first folder whose own name is also not a name — `The`, say — inventing an
|
|
15
|
+
// artist folder out of nothing. The caller already refuses empty keys; this is
|
|
16
|
+
// the guard that keeps the refusal from having to be repeated.
|
|
17
|
+
if (key === '')
|
|
18
|
+
return null;
|
|
19
|
+
// An album that *is* the root has no folder above it. The root is its own
|
|
20
|
+
// folder here, and a record's folder is never its artist's.
|
|
21
|
+
if (albumRelPath === '')
|
|
22
|
+
return null;
|
|
23
|
+
// The root first: it is the outermost folder there is, and `Downloads\Кино`
|
|
24
|
+
// is a real artist folder (wiki:3519). Its name has to be read
|
|
25
|
+
// separator-agnostically — on Windows the stored path is backslashed.
|
|
26
|
+
const rootName = rootBasenameOf(rootPath);
|
|
27
|
+
if (artistName(rootName).key === key) {
|
|
28
|
+
return { id: `${rootPath}:`, rootPath, relPath: '' };
|
|
29
|
+
}
|
|
30
|
+
const parts = albumRelPath.split('/');
|
|
31
|
+
// Shallowest first. Bounding at `i < parts.length` is what keeps the album's
|
|
32
|
+
// own folder — the last segment — out of the running: the deepest candidate
|
|
33
|
+
// is its parent.
|
|
34
|
+
for (let i = 1; i < parts.length; i += 1) {
|
|
35
|
+
const name = parts[i - 1] ?? '';
|
|
36
|
+
if (artistName(name).key === key) {
|
|
37
|
+
const relPath = parts.slice(0, i).join('/');
|
|
38
|
+
return { id: `${rootPath}:${relPath}`, rootPath, relPath };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The merge key for the `index`-th artist folder of one name.
|
|
45
|
+
*
|
|
46
|
+
* The first folder keeps the bare key — for the overwhelming majority of
|
|
47
|
+
* artists there is only one folder anyway, and `nirvana` is a better key to read
|
|
48
|
+
* in a dump than `nirvana#1`. The rest are numbered in the order the caller
|
|
49
|
+
* sorted them, so a folder added ahead of an existing one takes the bare key and
|
|
50
|
+
* the one behind it moves to the next number. Nothing corrupts when that
|
|
51
|
+
* happens — albums and credits are re-resolved through the new keys every run —
|
|
52
|
+
* but a reader who knew a row as `nirvana#2` last time may find it elsewhere.
|
|
53
|
+
*
|
|
54
|
+
* A suffixed key can never collide with a bare one, though not for the obvious
|
|
55
|
+
* reason. `#` *does* survive into a key: `artistName` falls back to the raw
|
|
56
|
+
* lowercased name for a name with no letter or digit in it, so an artist called
|
|
57
|
+
* `#` keys to `#`. The invariant is narrower and holds regardless — a key
|
|
58
|
+
* containing `#` has no letter or digit anywhere in it (that is the only route
|
|
59
|
+
* to the fallback), while a suffixed key always ends in one. A name reading
|
|
60
|
+
* `nirvana#2` folds to `nirvana 2`, and `##2` folds to `2`, so neither can
|
|
61
|
+
* produce a suffixed key. The two sets are disjoint by construction.
|
|
62
|
+
*/
|
|
63
|
+
export function qualifiedKey(key, index) {
|
|
64
|
+
return index === 0 ? key : `${key}#${index + 1}`;
|
|
65
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One artist, however many ways the collection spells them.
|
|
3
|
+
*
|
|
4
|
+
* `The Cure`, `Cure, The` and `cure` are one artist, and a library that lists
|
|
5
|
+
* them three times has failed at the only thing it promised. So names are
|
|
6
|
+
* reduced to a key, and the key is what merges.
|
|
7
|
+
*
|
|
8
|
+
* But merging is a claim, and some claims are guesses. The module therefore
|
|
9
|
+
* answers at *two* levels, and the gap between them is the point:
|
|
10
|
+
*
|
|
11
|
+
* - `folded` normalises only what is obviously the same record — case,
|
|
12
|
+
* punctuation, whitespace, the definite article, and the accents a tag
|
|
13
|
+
* loses on its way through a ripper.
|
|
14
|
+
* - `key` goes further and also drops a disambiguator, because `Nirvana (UK)`
|
|
15
|
+
* and `Nirvana` would otherwise never merge.
|
|
16
|
+
*
|
|
17
|
+
* Two names that fold to the same thing are the same artist, plainly. Two that
|
|
18
|
+
* fold *differently* and still share a key were merged by discarding something
|
|
19
|
+
* a human wrote on purpose — and that is exactly the merge worth flagging
|
|
20
|
+
* rather than performing in silence (requirements:39 §6).
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* `’` for `'` and the typographic dashes for `-`.
|
|
24
|
+
*
|
|
25
|
+
* Rippers write all of these, and a key that treats `N’Ko` and `N'Ko` as two
|
|
26
|
+
* artists is not doing its job. Whitespace is collapsed here rather than at
|
|
27
|
+
* each use, since every path below assumes single spaces.
|
|
28
|
+
*/
|
|
29
|
+
function tidy(raw) {
|
|
30
|
+
return raw
|
|
31
|
+
.replace(/[‘’ʼ]/g, "'")
|
|
32
|
+
.replace(/[“”]/g, '"')
|
|
33
|
+
.replace(/[–—]/g, '-')
|
|
34
|
+
.replace(/\s+/g, ' ')
|
|
35
|
+
.trim();
|
|
36
|
+
}
|
|
37
|
+
const LEADING_ARTICLE = /^the\s+/i;
|
|
38
|
+
const TRAILING_ARTICLE = /,\s*the$/i;
|
|
39
|
+
/** A name that is nothing but the article — not an artist, and not a key. */
|
|
40
|
+
const ONLY_ARTICLE = /^the$/i;
|
|
41
|
+
/**
|
|
42
|
+
* Remove the definite article, from whichever end a ripper put it.
|
|
43
|
+
*
|
|
44
|
+
* Only `the`: it is the one article that gets inverted in practice, and
|
|
45
|
+
* stripping `a`/`an` would merge `A Perfect Circle` with a hypothetical
|
|
46
|
+
* `Perfect Circle` on no evidence at all.
|
|
47
|
+
*/
|
|
48
|
+
function dropArticle(name) {
|
|
49
|
+
if (ONLY_ARTICLE.test(name))
|
|
50
|
+
return '';
|
|
51
|
+
if (LEADING_ARTICLE.test(name))
|
|
52
|
+
return name.replace(LEADING_ARTICLE, '');
|
|
53
|
+
if (TRAILING_ARTICLE.test(name))
|
|
54
|
+
return name.replace(TRAILING_ARTICLE, '');
|
|
55
|
+
return name;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Letters, digits and combining marks survive; everything else becomes a
|
|
59
|
+
* separator.
|
|
60
|
+
*
|
|
61
|
+
* Unicode-aware on purpose — `Кино` has to come out the other side intact, and
|
|
62
|
+
* `\w` would not manage it. `\p{M}` survives *here* so that a mark stays part
|
|
63
|
+
* of its letter instead of becoming a separator: `foldName` below is where it
|
|
64
|
+
* is dropped, deliberately, and a mark turned into a space first would leave
|
|
65
|
+
* `bj rk` behind and merge nothing.
|
|
66
|
+
*/
|
|
67
|
+
function stripPunctuation(name) {
|
|
68
|
+
return name.replace(/[^\p{L}\p{N}\p{M}]+/gu, ' ').trim();
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* A dot inside a word belongs to the word, not between two of them.
|
|
72
|
+
*
|
|
73
|
+
* `Cock E.S.P.` and `Cock Esp` are one act, and this collection holds both
|
|
74
|
+
* spellings — twelve albums under the first, two under the second, and no flag
|
|
75
|
+
* between them, because the fold turned the acronym's dots into spaces and left
|
|
76
|
+
* `cock e s p` standing beside `cock esp`. An initialism is written with its
|
|
77
|
+
* dots in some places and without them in others; dropping them is the same
|
|
78
|
+
* class of normalisation as case, and it is what makes the two one key.
|
|
79
|
+
*
|
|
80
|
+
* Only a dot with something other than a space after it. `Dr. Dre` and
|
|
81
|
+
* `St. Vincent` keep the space their dot was standing in, and a dot at the end
|
|
82
|
+
* of a name is turned into a separator by the fold below either way.
|
|
83
|
+
*/
|
|
84
|
+
function closeAcronymDots(name) {
|
|
85
|
+
return name.replace(/\.(?=\S)/g, '');
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The key a name folds to, with its accents gone.
|
|
89
|
+
*
|
|
90
|
+
* `Röyksopp` and `Royksopp` are one artist, and so are `Sigur Rós` and `Sigur
|
|
91
|
+
* Ros` — measured over the live collection, where those two pairs are the only
|
|
92
|
+
* names this merges and nothing else in it moves.
|
|
93
|
+
*
|
|
94
|
+
* The claim is about the collection rather than about Unicode: an unaccented
|
|
95
|
+
* spelling is nearly always a tag that lost its accent, not a name written
|
|
96
|
+
* differently on purpose. Nobody types `Royksopp` deliberately, and the
|
|
97
|
+
* opposite case — a collector who meant the plain spelling and got the accented
|
|
98
|
+
* one — costs a letter rather than a record.
|
|
99
|
+
*
|
|
100
|
+
* Decomposing before the marks come off is what keeps the two writings of one
|
|
101
|
+
* letter together: `ö` is a single code point in a tag and `o` plus U+0308 in a
|
|
102
|
+
* tree ripped on macOS, and both have to fold the same way. It is also what
|
|
103
|
+
* lets `Bjork`, carrying no mark at all, join them.
|
|
104
|
+
*
|
|
105
|
+
* What it costs: a mark that carries meaning goes with the rest. Cyrillic loses
|
|
106
|
+
* `й` to `и` and `ё` to `е`, so two names differing only there — and they are
|
|
107
|
+
* two names — would fold together. Nothing in this collection does, and the
|
|
108
|
+
* alternative is a library that lists `Röyksopp` twice.
|
|
109
|
+
*/
|
|
110
|
+
function foldName(name) {
|
|
111
|
+
const folded = stripPunctuation(closeAcronymDots(dropArticle(name).toLowerCase()));
|
|
112
|
+
return folded.normalize('NFD').replace(/\p{M}/gu, '');
|
|
113
|
+
}
|
|
114
|
+
/** `(UK)`, `[US]` — a human marking which of several same-named artists this is. */
|
|
115
|
+
const BRACKETED = /[([][^)\]]*[)\]]/g;
|
|
116
|
+
function stripDisambiguators(name) {
|
|
117
|
+
const found = [];
|
|
118
|
+
// Whitespace is collapsed, not merely left behind. Removing `(UK)` from
|
|
119
|
+
// `Cure, The (UK)` otherwise leaves a trailing space, and the article
|
|
120
|
+
// pattern — anchored at the end — stops matching, so the name keys as
|
|
121
|
+
// `cure the` while `Cure, The` keys as `cure`. One artist, two rows, which is
|
|
122
|
+
// the whole failure this module exists to prevent.
|
|
123
|
+
const cleaned = name
|
|
124
|
+
.replace(BRACKETED, (match) => {
|
|
125
|
+
const inner = match.slice(1, -1).trim();
|
|
126
|
+
if (inner !== '')
|
|
127
|
+
found.push(inner);
|
|
128
|
+
return ' ';
|
|
129
|
+
})
|
|
130
|
+
.replace(/\s+/g, ' ')
|
|
131
|
+
.trim();
|
|
132
|
+
// A name written *entirely* in brackets is the name, and the bracket has
|
|
133
|
+
// nothing left to qualify. `[LINKIN PARK]` is how a file states the artist —
|
|
134
|
+
// not a qualifier of one — and stripping it anyway left an empty name, which
|
|
135
|
+
// the key reads as "not a name". So one act the collection writes two ways
|
|
136
|
+
// became two identities, one of them nameless: thirteen records of
|
|
137
|
+
// `Linkin Park Japan CD` state `[LINKIN PARK]` on some tracks and
|
|
138
|
+
// `Linkin Park` on the rest, the stage found no name every track states, and
|
|
139
|
+
// they carried no album artist at all (task:2837).
|
|
140
|
+
if (cleaned === '' && found.length > 0)
|
|
141
|
+
return { name: found[0], stripped: null };
|
|
142
|
+
return { name: cleaned, stripped: found.length === 0 ? null : found.join('; ') };
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Sort key: `The Cure` files under C.
|
|
146
|
+
*
|
|
147
|
+
* A name already written inverted (`Cure, The`) is left as it is — inverting
|
|
148
|
+
* it again would produce `The, Cure, The`.
|
|
149
|
+
*/
|
|
150
|
+
function sortKeyOf(name) {
|
|
151
|
+
if (!LEADING_ARTICLE.test(name))
|
|
152
|
+
return name;
|
|
153
|
+
return `${name.replace(LEADING_ARTICLE, '')}, The`;
|
|
154
|
+
}
|
|
155
|
+
/** Anything that could be a word. `The` has it; `!!!` does not. */
|
|
156
|
+
function hasWordChar(name) {
|
|
157
|
+
return /[\p{L}\p{N}]/u.test(name);
|
|
158
|
+
}
|
|
159
|
+
export function artistName(raw) {
|
|
160
|
+
const name = tidy(raw);
|
|
161
|
+
const disambiguated = stripDisambiguators(name);
|
|
162
|
+
// An empty key means "not a name", and folding alone cannot tell the two
|
|
163
|
+
// cases apart: `The` folds to nothing because it is not a name, while `!!!`
|
|
164
|
+
// and `∆` fold to nothing because they are names written in symbols. Both
|
|
165
|
+
// are real artists, and giving them one shared empty key would merge every
|
|
166
|
+
// symbol-only act into a single nonexistent row.
|
|
167
|
+
const foldedDisambiguated = foldName(disambiguated.name);
|
|
168
|
+
const key = foldedDisambiguated !== '' || hasWordChar(name) ? foldedDisambiguated : name.toLowerCase();
|
|
169
|
+
return {
|
|
170
|
+
name,
|
|
171
|
+
folded: foldName(name),
|
|
172
|
+
key,
|
|
173
|
+
sortKey: sortKeyOf(name),
|
|
174
|
+
stripped: disambiguated.stripped,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The artist a folder's name belongs to, or null.
|
|
179
|
+
*
|
|
180
|
+
* The rule `shelf-name.ts` spells out, read the other way round. There a shelf's
|
|
181
|
+
* name is the artist's name *plus* something and the something is subtracted to
|
|
182
|
+
* name the record; here the same fact gathers the artist — a folder whose name
|
|
183
|
+
* opens with an artist's key is that artist's.
|
|
184
|
+
*
|
|
185
|
+
* The longest key wins, not the first: `Röyksopp Discography` opens with
|
|
186
|
+
* `Röyksopp`, and a shorter artist called `Röy` would otherwise take it.
|
|
187
|
+
*
|
|
188
|
+
* A separator has to follow, or `S` claims `Slipknot`. The same guard
|
|
189
|
+
* `shelf-name.ts` puts on the subtraction, for the same reason and from the
|
|
190
|
+
* same measurement.
|
|
191
|
+
*/
|
|
192
|
+
export function artistOwning(folderName, keys) {
|
|
193
|
+
const folderKey = artistName(folderName).key;
|
|
194
|
+
if (folderKey === '')
|
|
195
|
+
return null;
|
|
196
|
+
let best = null;
|
|
197
|
+
for (const key of keys) {
|
|
198
|
+
if (key === '')
|
|
199
|
+
continue;
|
|
200
|
+
const fits = folderKey === key || folderKey.startsWith(`${key} `);
|
|
201
|
+
if (fits && (best === null || key.length > best.length))
|
|
202
|
+
best = key;
|
|
203
|
+
}
|
|
204
|
+
return best;
|
|
205
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an artist's `.nfo` says about them.
|
|
3
|
+
*
|
|
4
|
+
* The collection keeps a note about an artist in the folder named for them —
|
|
5
|
+
* `The Cure/artist.nfo`, written by Jellyfin — and it is the only offline
|
|
6
|
+
* source of a biography this project has. The scan records `.nfo` files as
|
|
7
|
+
* files and never parses them: their text is not in the meta layer, so it is
|
|
8
|
+
* read out of the document at the point a client asks, which is what makes this
|
|
9
|
+
* a reader rather than a column.
|
|
10
|
+
*
|
|
11
|
+
* It is not an XML parser and does not pretend to be. The documents this
|
|
12
|
+
* collection holds are the ones Jellyfin writes, and this reads the two things
|
|
13
|
+
* about them that matter: a tag's text, and the five entities XML defines. A
|
|
14
|
+
* document that needed more than that would need a parser, and adding one
|
|
15
|
+
* before there is a document to justify it would be guessing at the shape of
|
|
16
|
+
* the problem.
|
|
17
|
+
*/
|
|
18
|
+
/** The text a document holds between one tag's opening and its closing. */
|
|
19
|
+
function elementText(xml, tag) {
|
|
20
|
+
const open = `<${tag}>`;
|
|
21
|
+
const close = `</${tag}>`;
|
|
22
|
+
const from = xml.indexOf(open);
|
|
23
|
+
if (from === -1)
|
|
24
|
+
return null;
|
|
25
|
+
const to = xml.indexOf(close, from + open.length);
|
|
26
|
+
if (to === -1)
|
|
27
|
+
return null;
|
|
28
|
+
return xml.slice(from + open.length, to);
|
|
29
|
+
}
|
|
30
|
+
/** The last code point Unicode has, which is also the last XML allows. */
|
|
31
|
+
const LAST_CODE_POINT = 0x10ffff;
|
|
32
|
+
/** The surrogate range: a pair's halves, never a character on its own. */
|
|
33
|
+
const SURROGATES = { from: 0xd800, to: 0xdfff };
|
|
34
|
+
/**
|
|
35
|
+
* Whether a number names a character XML has — which is not every number
|
|
36
|
+
* Unicode does.
|
|
37
|
+
*
|
|
38
|
+
* `Char` is what the format permits in a document, and it excludes the
|
|
39
|
+
* surrogates (each is half of a pair) and everything past the last plane.
|
|
40
|
+
* `String.fromCodePoint` refuses only the second of those, so the first is
|
|
41
|
+
* checked here rather than left to it: a lone surrogate that reached the XML
|
|
42
|
+
* envelope would be a document no reader could parse.
|
|
43
|
+
*/
|
|
44
|
+
function isXmlChar(point) {
|
|
45
|
+
if (!Number.isInteger(point) || point < 0 || point > LAST_CODE_POINT)
|
|
46
|
+
return false;
|
|
47
|
+
return point < SURROGATES.from || point > SURROGATES.to;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The text with its entities resolved — `Siouxsie & the Banshees` read as
|
|
51
|
+
* a client should show it.
|
|
52
|
+
*
|
|
53
|
+
* Named and numeric alike, because a document may spell an ampersand either
|
|
54
|
+
* way and both mean the same thing to a reader. Anything that is not an entity
|
|
55
|
+
* this reader can resolve is left exactly as it was written, and that includes
|
|
56
|
+
* a numeric one naming no XML character: `�` is not a character, so
|
|
57
|
+
* there is nothing to resolve it to, and a bare `&` is not this reader's to
|
|
58
|
+
* fix. Leaving the text is also what keeps this total — a note read off disk
|
|
59
|
+
* is somebody else's file, and a malformed one must not be able to throw out
|
|
60
|
+
* of a pure reader that answers with text.
|
|
61
|
+
*/
|
|
62
|
+
function unescapeXml(text) {
|
|
63
|
+
return text.replace(/&(?:#(\d+)|#x([0-9a-fA-F]+)|(amp|lt|gt|quot|apos));/g, (whole, dec, hex, named) => {
|
|
64
|
+
if (dec !== undefined || hex !== undefined) {
|
|
65
|
+
const point = dec !== undefined ? Number(dec) : parseInt(hex ?? '', 16);
|
|
66
|
+
return isXmlChar(point) ? String.fromCodePoint(point) : whole;
|
|
67
|
+
}
|
|
68
|
+
switch (named) {
|
|
69
|
+
case 'amp':
|
|
70
|
+
return '&';
|
|
71
|
+
case 'lt':
|
|
72
|
+
return '<';
|
|
73
|
+
case 'gt':
|
|
74
|
+
return '>';
|
|
75
|
+
case 'quot':
|
|
76
|
+
return '"';
|
|
77
|
+
case 'apos':
|
|
78
|
+
return "'";
|
|
79
|
+
default:
|
|
80
|
+
return whole;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The artist's biography, or nothing when the document does not hold one.
|
|
86
|
+
*
|
|
87
|
+
* A document with no `<biography>` has no biography — an `<outline>` beside it
|
|
88
|
+
* is a summary and is not the same claim, so it is not offered in its place.
|
|
89
|
+
* Whitespace around the text is the document's layout rather than its content
|
|
90
|
+
* and goes; the lines inside it are the author's and stay.
|
|
91
|
+
*
|
|
92
|
+
* Empty is nothing. A caller that has to check for an empty string before
|
|
93
|
+
* showing it is a caller doing this reader's job.
|
|
94
|
+
*/
|
|
95
|
+
export function biographyOf(xml) {
|
|
96
|
+
const raw = elementText(xml, 'biography');
|
|
97
|
+
if (raw === null)
|
|
98
|
+
return null;
|
|
99
|
+
const text = unescapeXml(raw).trim();
|
|
100
|
+
return text === '' ? null : text;
|
|
101
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Latin spellings a Cyrillic artist key may be written as.
|
|
3
|
+
*
|
|
4
|
+
* This table never merges anything. It exists so that two rows which are one
|
|
5
|
+
* name in two alphabets can be *named* to a reader (task:2675); the decision
|
|
6
|
+
* that they are the same artist is left to a human or to v1.5's MBID, because
|
|
7
|
+
* nothing on disk states it.
|
|
8
|
+
*
|
|
9
|
+
* The reason it cannot be a merge rule is the reason it is a list of schemes
|
|
10
|
+
* rather than one table. A `name_key` folds spellings that differ only in
|
|
11
|
+
* decoration — case, articles, punctuation — and folding is idempotent and
|
|
12
|
+
* reversible enough to trust. Cyrillic-to-Latin is not a fold: it is a mapping
|
|
13
|
+
* between scripts, it is many-to-many in both directions (`Кино` → `Kino`,
|
|
14
|
+
* `Kino` → `Кино` or `Кіно`), and the schemes disagree with one another
|
|
15
|
+
* (ГОСТ / BGN / ISO render `х` as `kh` or `h`, `я` as `ya` or `a`). A rule that
|
|
16
|
+
* asserted the identity would be choosing a scheme, and a wrong choice is
|
|
17
|
+
* silent: two bands that merely sound alike become one row, and no consumer
|
|
18
|
+
* downstream can see that it happened. Reporting a wrong pair costs a line.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* What rippers, Discogs and MusicBrainz actually write — the BGN/PCGN side of
|
|
22
|
+
* each disagreement. `х` is `kh`, `я` is `ya`.
|
|
23
|
+
*/
|
|
24
|
+
const PRACTICAL = {
|
|
25
|
+
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z',
|
|
26
|
+
и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r',
|
|
27
|
+
с: 's', т: 't', у: 'u', ф: 'f', х: 'kh', ц: 'ts', ч: 'ch', ш: 'sh',
|
|
28
|
+
щ: 'shch', ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya',
|
|
29
|
+
і: 'i', ї: 'i', є: 'e', ґ: 'g',
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The simplified side: one letter per letter, as an ASCII keyboard or a
|
|
33
|
+
* country-agnostic database tends to write it. It differs from `PRACTICAL` on
|
|
34
|
+
* `й` (`i`), `х` (`h`), `ц` (`c`), `щ` (`sh`), `ю` (`u`) and `я` (`a`).
|
|
35
|
+
*/
|
|
36
|
+
const SIMPLIFIED = {
|
|
37
|
+
...PRACTICAL,
|
|
38
|
+
й: 'i', х: 'h', ц: 'c', щ: 'sh', ю: 'u', я: 'a',
|
|
39
|
+
};
|
|
40
|
+
const SCHEMES = [PRACTICAL, SIMPLIFIED];
|
|
41
|
+
/**
|
|
42
|
+
* The property escape rather than a hand-written range: `Ѐ-ӿ` is the main
|
|
43
|
+
* block only, and would read the Cyrillic Supplement (U+0500–U+052F) as a name
|
|
44
|
+
* that is already Latin. `name.ts` asks the same question the same way.
|
|
45
|
+
*/
|
|
46
|
+
const CYRILLIC = /\p{Script=Cyrillic}/u;
|
|
47
|
+
/** Whether a key holds a Cyrillic letter at all. */
|
|
48
|
+
export function hasCyrillic(key) {
|
|
49
|
+
return CYRILLIC.test(key);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Every Latin key the schemes would write this one as, deduplicated. Empty when
|
|
53
|
+
* the key holds no Cyrillic letter — a Latin key is not a candidate for being
|
|
54
|
+
* the Latin side of a pair.
|
|
55
|
+
*
|
|
56
|
+
* The key is lowercased on entry: the schemes are written for lowercase
|
|
57
|
+
* Cyrillic, and a caller holding a display name rather than a key would
|
|
58
|
+
* otherwise get an answer that is still half Cyrillic (`Ха` → `Хa`).
|
|
59
|
+
*
|
|
60
|
+
* Characters no scheme maps — spaces, digits, the `#2` a split homonym carries
|
|
61
|
+
* — pass through unchanged, so `кино` and `кино#2` stay distinguishable. What a
|
|
62
|
+
* caller does with a qualified key is its own decision: the index is the row's
|
|
63
|
+
* position within *its own* key group, so `кино#2` and `kino#2` are second
|
|
64
|
+
* folders of two independently sorted groups and nothing makes them the same
|
|
65
|
+
* folder. Pairing them would assert a correspondence that does not exist.
|
|
66
|
+
*/
|
|
67
|
+
export function latinSpellingsOf(key) {
|
|
68
|
+
const lowered = key.toLowerCase();
|
|
69
|
+
if (!hasCyrillic(lowered))
|
|
70
|
+
return [];
|
|
71
|
+
const spellings = new Set();
|
|
72
|
+
for (const scheme of SCHEMES) {
|
|
73
|
+
let latin = '';
|
|
74
|
+
for (const letter of lowered)
|
|
75
|
+
latin += scheme[letter] ?? letter;
|
|
76
|
+
spellings.add(latin);
|
|
77
|
+
}
|
|
78
|
+
return [...spellings];
|
|
79
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every bookmark, most recently moved first.
|
|
3
|
+
*
|
|
4
|
+
* Ordered by when it was last touched rather than by the song: a list of where
|
|
5
|
+
* somebody stopped is a list of the things they are in the middle of, and the
|
|
6
|
+
* one they are furthest into is the one they last listened to.
|
|
7
|
+
*/
|
|
8
|
+
export function bookmarks(db) {
|
|
9
|
+
return db
|
|
10
|
+
.prepare(`SELECT track_id AS trackId,
|
|
11
|
+
position_ms AS positionMs,
|
|
12
|
+
comment AS comment,
|
|
13
|
+
created_at AS createdAt,
|
|
14
|
+
changed_at AS changedAt
|
|
15
|
+
FROM bookmark
|
|
16
|
+
ORDER BY changed_at DESC, track_id`)
|
|
17
|
+
.all();
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Put the mark on a song, or move the one that is there.
|
|
21
|
+
*
|
|
22
|
+
* `created_at` survives a second write and `changed_at` does not: moving a
|
|
23
|
+
* bookmark is not making one, and a client that shows "added" beside a list of
|
|
24
|
+
* bookmarks would be told something untrue about every mark its user has
|
|
25
|
+
* adjusted.
|
|
26
|
+
*
|
|
27
|
+
* **The comment is written as given, and a write that names none clears the
|
|
28
|
+
* one that was there** — which is the protocol's rule for this method and not
|
|
29
|
+
* the one playlists follow. `updatePlaylist` treats a missing song list as
|
|
30
|
+
* silence because a client renaming a playlist means to keep its songs; here
|
|
31
|
+
* the protocol says the bookmark "will be overwritten", and a client that
|
|
32
|
+
* updates the position and drops the comment has said what the mark now is.
|
|
33
|
+
* The difference is worth the sentence because the other rule is the one this
|
|
34
|
+
* project would reach for by habit.
|
|
35
|
+
*/
|
|
36
|
+
export function bookmark(db, trackId, positionMs, comment, at) {
|
|
37
|
+
db.prepare(`INSERT INTO bookmark (track_id, position_ms, comment, created_at, changed_at)
|
|
38
|
+
VALUES (?, ?, ?, ?, ?)
|
|
39
|
+
ON CONFLICT (track_id) DO UPDATE SET
|
|
40
|
+
position_ms = excluded.position_ms,
|
|
41
|
+
comment = excluded.comment,
|
|
42
|
+
changed_at = excluded.changed_at`).run(trackId, positionMs, comment, at, at);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Take the mark off a song. Nothing to take off is not an error.
|
|
46
|
+
*
|
|
47
|
+
* One statement and no transaction around it, which is `playlist/store.ts`'s
|
|
48
|
+
* `remove` as well: a lone `DELETE` is its own transaction, and a `withTransaction`
|
|
49
|
+
* here would be a wrapper that says something is being kept together when
|
|
50
|
+
* nothing is.
|
|
51
|
+
*/
|
|
52
|
+
export function unbookmark(db, trackId) {
|
|
53
|
+
db.prepare('DELETE FROM bookmark WHERE track_id = ?').run(trackId);
|
|
54
|
+
}
|