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,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The title a file's own name states, for a track nothing else names.
|
|
3
|
+
*
|
|
4
|
+
* A collection is not all tags. One of its parts arrives folder-organized with
|
|
5
|
+
* a `readme` and nothing else (requirements:39 §1, the Kroogi sample), and the
|
|
6
|
+
* name of the file is then the only witness left. The contract's chain is cue →
|
|
7
|
+
* tag → *this*: a document about the album speaks with authority, a tag speaks
|
|
8
|
+
* for its own file, and a file name is the weakest of the three — which is why
|
|
9
|
+
* it is asked last and never overrides either.
|
|
10
|
+
*
|
|
11
|
+
* ## What a name carries that is not the track
|
|
12
|
+
*
|
|
13
|
+
* Three things, and each is dropped only on evidence:
|
|
14
|
+
*
|
|
15
|
+
* - **The track number.** `01-`, `01. `, `01 - ` — a leading count, never part
|
|
16
|
+
* of a title. Dropped on sight, because nothing else in a file name looks
|
|
17
|
+
* like it: no title in these collections opens with two digits and a dot.
|
|
18
|
+
* - **The artist.** The scene writes `Artist - Title` in front of every track,
|
|
19
|
+
* and a split record gives each track a *different* one, so the album's own
|
|
20
|
+
* artist cannot find these. The album's folder **name** can: it lists every
|
|
21
|
+
* player on the record, and a field whose words all appear there is the
|
|
22
|
+
* album's, not the track's.
|
|
23
|
+
* - **The release group.** `-kroogi` glued to the tail is the same class as
|
|
24
|
+
* `-Kroogi.com` in the folder name — where the rip came from, not what the
|
|
25
|
+
* record is.
|
|
26
|
+
*
|
|
27
|
+
* Two rules were narrowed by measuring the rule against the collection's 850
|
|
28
|
+
* audio files (88 fields dropped, all of them credits: `Cock E.S.P.` ×45,
|
|
29
|
+
* `Emil Hagstrom` ×8, `Suffering Bastard` ×7, …), and both narrowings answer a
|
|
30
|
+
* real file:
|
|
31
|
+
*
|
|
32
|
+
* - **Only a *leading* field is a field.** `Кино - Спасём мир (MASHCD-148)`
|
|
33
|
+
* sits in `1986 ● Концерт «Спасём мир» …`, and the folder agrees with its
|
|
34
|
+
* tail word for word. Dropping a trailing ` - ` field because the folder
|
|
35
|
+
* agrees leaves `Кино` as the title of a song called `Кино - Спасём мир`:
|
|
36
|
+
* an album named after the piece it holds is the ordinary case, not the
|
|
37
|
+
* exception, so the tail of a name is never a field.
|
|
38
|
+
* - **The scene's own marks need the scene's written separator.** `_` for a
|
|
39
|
+
* space and a glued `-site` tail are conventions of one layout, and `_-_`
|
|
40
|
+
* is where that layout declares itself — the same "written, not incidental"
|
|
41
|
+
* test `SCENE` in `classify/folder-name.ts` rests on. Without it,
|
|
42
|
+
* `Amp-Destroyer` is a hyphen in a title, and `my_cool_track` keeps its
|
|
43
|
+
* underscore.
|
|
44
|
+
*
|
|
45
|
+
* Nothing is invented. A name written in lower case stays in lower case: that
|
|
46
|
+
* is what the file says, and a title with its capitals belongs to a source that
|
|
47
|
+
* has them (a tag, or the `readme` tracklist — [[task:2710]]).
|
|
48
|
+
*/
|
|
49
|
+
import { stemOf } from "../util/names.js";
|
|
50
|
+
/** `01-`, `01.`, `01 - ` — a leading count and the separator after it. */
|
|
51
|
+
const TRACK_NUMBER = /^\d{1,2}\s*[.\-_)\]]\s*/;
|
|
52
|
+
/** The separator the scene layout writes between artist and title. */
|
|
53
|
+
const SCENE_SEPARATOR = '_-_';
|
|
54
|
+
/**
|
|
55
|
+
* A word glued to the tail by a dash — `archangelsk-kroogi`.
|
|
56
|
+
*
|
|
57
|
+
* The lookbehind is the whole of the rule: a dash with a space in front of it
|
|
58
|
+
* opens a field, and a field is never the release group it was measured against.
|
|
59
|
+
* Letters, digits, marks and dots only, so `(MASHCD-148)` — a bracket and a
|
|
60
|
+
* number — cannot be read as one.
|
|
61
|
+
*/
|
|
62
|
+
const GLUED_TAIL = /(?<=\S)-([\p{L}\p{N}\p{M}.]+)$/u;
|
|
63
|
+
/**
|
|
64
|
+
* The words a name is made of, folded for comparison.
|
|
65
|
+
*
|
|
66
|
+
* Unicode-aware for the same reason `artistName` is: `Кино` has to survive, and
|
|
67
|
+
* `\p{M}` matters because a tree ripped on macOS stores `Björk` decomposed.
|
|
68
|
+
*/
|
|
69
|
+
function words(text) {
|
|
70
|
+
return text
|
|
71
|
+
.toLowerCase()
|
|
72
|
+
.replace(/[^\p{L}\p{N}\p{M}]+/gu, ' ')
|
|
73
|
+
.trim()
|
|
74
|
+
.split(' ')
|
|
75
|
+
.filter((word) => word !== '');
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The title this file's name states.
|
|
79
|
+
*
|
|
80
|
+
* `albumName` is the album's own folder name — the whole of the external
|
|
81
|
+
* evidence, and null when nothing supplies one. Without it the file still
|
|
82
|
+
* speaks; it is simply not corroborated, so no field of it is claimed as the
|
|
83
|
+
* album's.
|
|
84
|
+
*/
|
|
85
|
+
export function titleFromFileName(fileName, albumName) {
|
|
86
|
+
const stem = stemOf(fileName);
|
|
87
|
+
// Both of the scene's marks stand or fall with its separator, so it is read
|
|
88
|
+
// once, before the folding that loses it.
|
|
89
|
+
const scene = stem.includes(SCENE_SEPARATOR);
|
|
90
|
+
const rest = scene ? stem.replace(/_/g, ' ') : stem;
|
|
91
|
+
const evidence = new Set(words(albumName ?? ''));
|
|
92
|
+
const stated = (field) => {
|
|
93
|
+
const found = words(field);
|
|
94
|
+
return found.length > 0 && found.every((word) => evidence.has(word));
|
|
95
|
+
};
|
|
96
|
+
let text = rest.trim();
|
|
97
|
+
// The number goes first: it is the one mark that is proof in itself, and the
|
|
98
|
+
// fields below are read on what is left of the name.
|
|
99
|
+
const number = TRACK_NUMBER.exec(text);
|
|
100
|
+
if (number !== null && number[0].length < text.length)
|
|
101
|
+
text = text.slice(number[0].length).trim();
|
|
102
|
+
// Never the whole name: a file called `01.mp3` states a number and nothing
|
|
103
|
+
// else, and eating it would leave no title at all.
|
|
104
|
+
let fields = text.split(/\s+-\s+/);
|
|
105
|
+
while (fields.length > 1 && stated(fields[0] ?? '') && fields.slice(1).join(' - ').trim() !== '') {
|
|
106
|
+
fields = fields.slice(1);
|
|
107
|
+
}
|
|
108
|
+
text = fields.join(' - ');
|
|
109
|
+
// The scene's tail: the dash is glued to the word, so it never opened a field.
|
|
110
|
+
const glued = scene ? GLUED_TAIL.exec(text) : null;
|
|
111
|
+
if (glued !== null && stated(glued[1] ?? '') && text.slice(0, glued.index).trim() !== '') {
|
|
112
|
+
text = text.slice(0, glued.index).trim();
|
|
113
|
+
}
|
|
114
|
+
// The underscore fold is a rewrite of somebody's name, so the spacing it
|
|
115
|
+
// leaves behind is this module's to tidy. Nothing else is collapsed: a double
|
|
116
|
+
// space in a name nobody folded is what was written.
|
|
117
|
+
if (scene)
|
|
118
|
+
text = text.replace(/\s+/g, ' ');
|
|
119
|
+
return text === '' ? stem : text;
|
|
120
|
+
}
|
package/dist/db/index.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Access to the meta layer.
|
|
6
|
+
*
|
|
7
|
+
* This module is the single seam between the scanner and SQLite: everything
|
|
8
|
+
* else talks to it through `DatabaseSync`, so swapping the driver (say, to
|
|
9
|
+
* better-sqlite3) stays a one-file change. We use the built-in `node:sqlite`
|
|
10
|
+
* because it ships with Node 24 already compiled with FTS5, which keeps the
|
|
11
|
+
* project dependency-free.
|
|
12
|
+
*/
|
|
13
|
+
const MIGRATIONS_DIR = join(import.meta.dirname, 'migrations');
|
|
14
|
+
export const SCHEMA_VERSION = 38;
|
|
15
|
+
/**
|
|
16
|
+
* Where the meta layer lives when nobody says otherwise.
|
|
17
|
+
*
|
|
18
|
+
* The scanner and the server both open it, so the name is the meta layer's own
|
|
19
|
+
* and not either command's: a server started with no arguments has to find the
|
|
20
|
+
* database the scan wrote, or the two halves of the project would need telling
|
|
21
|
+
* about each other.
|
|
22
|
+
*/
|
|
23
|
+
export const DEFAULT_DB = 'funoteka.db';
|
|
24
|
+
/** Migrations are `NNN_name.sql`; the leading number is the target version. */
|
|
25
|
+
function migrations() {
|
|
26
|
+
return readdirSync(MIGRATIONS_DIR)
|
|
27
|
+
.filter((name) => name.endsWith('.sql'))
|
|
28
|
+
.map((name) => ({ version: Number.parseInt(name.slice(0, 3), 10), path: join(MIGRATIONS_DIR, name) }))
|
|
29
|
+
.filter((m) => Number.isInteger(m.version))
|
|
30
|
+
.sort((a, b) => a.version - b.version);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Bring the database up to `SCHEMA_VERSION`.
|
|
34
|
+
*
|
|
35
|
+
* Idempotent by design: every scan opens the database, so running this against
|
|
36
|
+
* an already-migrated file has to be a no-op rather than an error.
|
|
37
|
+
*/
|
|
38
|
+
export function migrate(db) {
|
|
39
|
+
let current = db.prepare('PRAGMA user_version').get().user_version;
|
|
40
|
+
for (const migration of migrations()) {
|
|
41
|
+
if (migration.version <= current)
|
|
42
|
+
continue;
|
|
43
|
+
// `IMMEDIATE`, like every other transaction in this project — the rule is
|
|
44
|
+
// stated below `withTransaction`, and this one broke it. It was survivable
|
|
45
|
+
// only by accident: each file here happens to write first (020 opens with a
|
|
46
|
+
// bare `ANALYZE`), and a deferred transaction whose *first* statement is a
|
|
47
|
+
// write does ask the busy handler. A later migration that reads before it
|
|
48
|
+
// writes would have been refused at once instead — and this runs inside
|
|
49
|
+
// `openDb`, before any caller has had a chance to raise the timeout.
|
|
50
|
+
db.exec('BEGIN IMMEDIATE');
|
|
51
|
+
try {
|
|
52
|
+
db.exec(readFileSync(migration.path, 'utf8'));
|
|
53
|
+
db.exec(`PRAGMA user_version = ${migration.version}`);
|
|
54
|
+
db.exec('COMMIT');
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
try {
|
|
58
|
+
db.exec('ROLLBACK');
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Already unwound; the original error is what matters.
|
|
62
|
+
}
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
current = migration.version;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Open the meta layer, creating and migrating it if needed.
|
|
70
|
+
*
|
|
71
|
+
* Pass ':memory:' for an ephemeral database (tests). On disk we use WAL so a
|
|
72
|
+
* scan can write while the API reads.
|
|
73
|
+
*/
|
|
74
|
+
export function openDb(path) {
|
|
75
|
+
const db = new DatabaseSync(path);
|
|
76
|
+
// Per-connection, and required for the ON DELETE CASCADE rules to fire.
|
|
77
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
78
|
+
if (path !== ':memory:')
|
|
79
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
80
|
+
// How long a writer waits for the other writer.
|
|
81
|
+
//
|
|
82
|
+
// Two things write this file: the scan, which is a process of its own, and the
|
|
83
|
+
// API, which writes playlists. SQLite's default answer to the second arriving
|
|
84
|
+
// while the first holds the lock is to give up at once, and "at once" is about
|
|
85
|
+
// 20 ms: measured on two connections to one file, the save failed with
|
|
86
|
+
// "database is locked" while the other held the lock.
|
|
87
|
+
//
|
|
88
|
+
// **Short, because the wait is served by blocking the thread.** `node:sqlite`
|
|
89
|
+
// is synchronous, so this sleep is not a sleep this server can take while
|
|
90
|
+
// answering anybody else: measured with the second writer holding the lock for
|
|
91
|
+
// seven seconds, a `ping` sent during the wait came back after 5133 ms — the
|
|
92
|
+
// whole daemon, stopped, for one client's save.
|
|
93
|
+
//
|
|
94
|
+
// A quarter of a second is set against how long the scan now holds the lock,
|
|
95
|
+
// which is no longer a root: `scan.ts` writes in batches and lets go between
|
|
96
|
+
// them — measured on the live collection, one batch holds the write lock **12
|
|
97
|
+
// ms at the median and 96 ms at its worst**. So a save that meets a scan waits
|
|
98
|
+
// out a single batch and gets in, which is what this number was always meant
|
|
99
|
+
// to buy and could not buy while a scan held the lock for its whole run.
|
|
100
|
+
//
|
|
101
|
+
// The scan's own connection is the other half of this, and takes ten seconds
|
|
102
|
+
// (`cli.ts`): there the wait holds up nobody, because a scan answers nobody.
|
|
103
|
+
// What is left owed here is the length of the *stages* — `cues` still writes
|
|
104
|
+
// one transaction of about two seconds, and a save arriving inside it is
|
|
105
|
+
// refused (task:2880).
|
|
106
|
+
db.exec('PRAGMA busy_timeout = 250');
|
|
107
|
+
migrate(db);
|
|
108
|
+
return db;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* A mutation, as one transaction.
|
|
112
|
+
*
|
|
113
|
+
* **Measured, and it is the whole of why this exists.** On a copy of the live
|
|
114
|
+
* meta layer, a hundred entries written one statement at a time — which is what
|
|
115
|
+
* SQLite does without a transaction, each commit a write to disk — took
|
|
116
|
+
* **4452 ms**. The same hundred inside one transaction took **74 ms**, and a
|
|
117
|
+
* single statement costs about 26 ms on its own. A server that answers every
|
|
118
|
+
* request in one thread cannot spend four seconds of that on one client's save.
|
|
119
|
+
*
|
|
120
|
+
* The figures come from the live database and not from the suite on purpose: on
|
|
121
|
+
* an empty database a commit costs about nothing, so both forms measure under a
|
|
122
|
+
* millisecond there and a test cannot see the difference this makes. What it
|
|
123
|
+
* costs is paid in proportion to a database that exists.
|
|
124
|
+
*
|
|
125
|
+
* **`IMMEDIATE`, and the word is load-bearing.** A plain `BEGIN` is deferred:
|
|
126
|
+
* the transaction takes its read snapshot first and only tries to become a
|
|
127
|
+
* writer at the first write — and a write that has to *upgrade* a snapshot when
|
|
128
|
+
* somebody else holds the lock is refused at once, without consulting the busy
|
|
129
|
+
* handler `PRAGMA busy_timeout` installs. Measured against a second connection
|
|
130
|
+
* holding the lock: deferred `BEGIN` failed in 2.6–17 ms, while `BEGIN
|
|
131
|
+
* IMMEDIATE` (which asks for the lock up front, where waiting is allowed) and a
|
|
132
|
+
* bare `DELETE` both waited as configured. The writers here read before they
|
|
133
|
+
* write — a counter, a list of entries — so a deferred begin would refuse every
|
|
134
|
+
* save made while a scan was running, which is the one moment this arrangement
|
|
135
|
+
* exists for.
|
|
136
|
+
*
|
|
137
|
+
* Nested calls join the outer transaction rather than opening a second: SQLite
|
|
138
|
+
* has no nested `BEGIN`, and a stage that fell over halfway through should leave
|
|
139
|
+
* nothing of itself behind.
|
|
140
|
+
*/
|
|
141
|
+
export function withTransaction(db, work) {
|
|
142
|
+
if (db.isTransaction)
|
|
143
|
+
return work();
|
|
144
|
+
db.exec('BEGIN IMMEDIATE');
|
|
145
|
+
try {
|
|
146
|
+
const result = work();
|
|
147
|
+
db.exec('COMMIT');
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
// Guarded, and the guard is the point: whatever threw may have unwound the
|
|
152
|
+
// transaction already (`SQLITE_FULL`, a constraint that aborts), and a
|
|
153
|
+
// `ROLLBACK` with nothing to roll back throws its own error — which would
|
|
154
|
+
// replace the reason the caller needs with a complaint about the clean-up.
|
|
155
|
+
try {
|
|
156
|
+
db.exec('ROLLBACK');
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// Already unwound; the original error is what matters.
|
|
160
|
+
}
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
}
|
package/dist/db/issue.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clear the issues a stage owns, so it can write the current run's.
|
|
3
|
+
*
|
|
4
|
+
* A stage that *derives* its rows must call this or it grows without bound:
|
|
5
|
+
* every reader scopes by run, so an earlier run's rows are read by nobody and
|
|
6
|
+
* removed by nobody. A collection rescanned daily grew one run's worth per day.
|
|
7
|
+
*
|
|
8
|
+
* A stage that records *events* must not. A healed root path and a twin whose
|
|
9
|
+
* rows were dropped happen once, and an unchanged root raises nothing on the
|
|
10
|
+
* next run — clearing would not replace those rows with an equal report, it
|
|
11
|
+
* would delete the only record that the event happened.
|
|
12
|
+
*
|
|
13
|
+
* Which of the two a row is decides the scope, and there are three:
|
|
14
|
+
*
|
|
15
|
+
* - the whole stage, for `cues` and `artists`, which walk every root on every
|
|
16
|
+
* run and re-derive all of it;
|
|
17
|
+
* - one kind, for `scan`, which does both: its root-settlement issues are
|
|
18
|
+
* events, while `walk_skipped` is re-read off the filesystem every time —
|
|
19
|
+
* the symlink it could not follow is still there next run;
|
|
20
|
+
* - one file, for `tags` and `playlists`, which re-read only the files the
|
|
21
|
+
* ledger reports changed or never read. Their rows describe the last
|
|
22
|
+
* attempt on that file, so the replacement is per file — and a file left
|
|
23
|
+
* unstamped on purpose (a permission error is transient, so the next scan
|
|
24
|
+
* retries) is reported again each run, which is exactly what a per-file
|
|
25
|
+
* clear keeps bounded.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Write a stage failure down, and say the run it belongs to is not ok.
|
|
29
|
+
*
|
|
30
|
+
* `scan_run.status` is a property of the whole run, and `scan` settles it when
|
|
31
|
+
* its own walk is done — which is right for the walk and wrong for the run: the
|
|
32
|
+
* stages after it can throw, and each rolls back only its own transaction, so
|
|
33
|
+
* the database is left holding some stages and not others under a row that says
|
|
34
|
+
* `ok`. A reader of that dump sees a healthy run over albums with no tracks, and
|
|
35
|
+
* cannot tell it from a collection that really is that empty. The status is
|
|
36
|
+
* settled here instead, and the failure is written down as well as raised:
|
|
37
|
+
* stderr is gone by the time anyone opens the database.
|
|
38
|
+
*
|
|
39
|
+
* `scan` is the other caller, and the reason this cannot simply live in the run:
|
|
40
|
+
* it throws before its transaction too — a statement that will not compile, a
|
|
41
|
+
* disk that will not read — and the run row is inserted before that transaction,
|
|
42
|
+
* so no rollback reaches it. The run id is what `scan` returns, so the caller
|
|
43
|
+
* cannot file the failure either. Left unwritten, the database says the scan is
|
|
44
|
+
* still going, indefinitely.
|
|
45
|
+
*
|
|
46
|
+
* The row is an *event*, not a derivation — nothing re-reads a stage failure off
|
|
47
|
+
* the filesystem — so no stage clears it wholesale. It describes one attempt,
|
|
48
|
+
* and it is scoped to the run that made it, which is what every reader scopes
|
|
49
|
+
* by. A later run that succeeds supersedes it in the only sense that matters.
|
|
50
|
+
*/
|
|
51
|
+
export function recordStageFailure(db, runId, stage, err) {
|
|
52
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
53
|
+
db.prepare(`INSERT INTO issue (scan_run_id, stage, root_id, rel_path, kind, severity, detail)
|
|
54
|
+
VALUES (?, ?, NULL, NULL, ?, 'warn', ?)`).run(runId, stage, `${stage}-failed`, `${stage} threw: ${message}`);
|
|
55
|
+
db.prepare('UPDATE scan_run SET status = ? WHERE id = ?').run('failed', runId);
|
|
56
|
+
}
|
|
57
|
+
export function clearIssues(db, stage, scope = {}) {
|
|
58
|
+
const conditions = ['stage = ?'];
|
|
59
|
+
const values = [stage];
|
|
60
|
+
if (scope.kind !== undefined) {
|
|
61
|
+
conditions.push('kind = ?');
|
|
62
|
+
values.push(scope.kind);
|
|
63
|
+
}
|
|
64
|
+
if (scope.rootId !== undefined) {
|
|
65
|
+
conditions.push('root_id = ?');
|
|
66
|
+
values.push(scope.rootId);
|
|
67
|
+
}
|
|
68
|
+
if (scope.relPath !== undefined) {
|
|
69
|
+
conditions.push('rel_path = ?');
|
|
70
|
+
values.push(scope.relPath);
|
|
71
|
+
}
|
|
72
|
+
db.prepare(`DELETE FROM issue WHERE ${conditions.join(' AND ')}`).run(...values);
|
|
73
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ledger: what the last walk observed of each file, and what that is worth.
|
|
3
|
+
*
|
|
4
|
+
* `scan_state` is written by the scan and read by stages that own nothing of
|
|
5
|
+
* it. Tags asks which files need reading, the cue stage whether a stored
|
|
6
|
+
* measurement is still good, the dump whether a run found anything unmoved —
|
|
7
|
+
* and each of them used to join the table and decide for itself what `changed`
|
|
8
|
+
* and the run stamp meant, which made one rule with four copies (task:2683).
|
|
9
|
+
* The key, the verdict, and the currency of a verdict live here; a reader asks
|
|
10
|
+
* for the fragment it needs by the alias it gave the ledger.
|
|
11
|
+
*
|
|
12
|
+
* The sweep of the same table is the other reading of the same stamp, and lives
|
|
13
|
+
* in `sweep.ts`: this module asks what a row says about a file that is still
|
|
14
|
+
* there, that one asks what to do with a row whose file is not.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Join a file to the ledger entry describing it.
|
|
18
|
+
*
|
|
19
|
+
* A file and its entry are keyed alike — the root and the relative path — and
|
|
20
|
+
* that pair is the whole of the relationship, so it is written once and asked
|
|
21
|
+
* for by alias. Whether the caller writes `JOIN` or `LEFT JOIN` stays the
|
|
22
|
+
* caller's business: a file the walk has not seen twice has no entry at all,
|
|
23
|
+
* and whether that disqualifies it depends on the question being asked.
|
|
24
|
+
*/
|
|
25
|
+
export function ledgerEntry(file, ledger) {
|
|
26
|
+
return `${ledger}.root_id = ${file}.root_id AND ${ledger}.rel_path = ${file}.rel_path`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The recorded verdict that the filesystem reported the file exactly as the
|
|
30
|
+
* previous observation left it.
|
|
31
|
+
*
|
|
32
|
+
* Read, never recomputed. The comparison against the filesystem happened once,
|
|
33
|
+
* in the walk, where the size and the mtime were already in hand — `movedSince`
|
|
34
|
+
* is that comparison, and a reader that made it again would be stat-ing a file
|
|
35
|
+
* the walk has just walked. False for a file with no entry: nothing was
|
|
36
|
+
* observed, so nothing is known to be unchanged.
|
|
37
|
+
*/
|
|
38
|
+
export function unmoved(ledger) {
|
|
39
|
+
return `${ledger}.changed = 0`;
|
|
40
|
+
}
|
|
41
|
+
/** The recorded verdict that the file moved since the previous observation. */
|
|
42
|
+
export function moved(ledger) {
|
|
43
|
+
return `${ledger}.changed = 1`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The currency of a verdict: the entry was written by the run that last walked
|
|
47
|
+
* this file's root.
|
|
48
|
+
*
|
|
49
|
+
* A verdict is only worth anything while the run that wrote it is the run that
|
|
50
|
+
* last walked *that root*: naming one root in an invocation says nothing about
|
|
51
|
+
* another, and a verdict from a run that never saw the file would be worse than
|
|
52
|
+
* none. Asking for this is the caller's call — a stored whole-file duration is
|
|
53
|
+
* forgiven a stale verdict, the closing bound of a cue split is not.
|
|
54
|
+
*/
|
|
55
|
+
export function current(ledger) {
|
|
56
|
+
const earlier = `earlier_${ledger}`;
|
|
57
|
+
return (`${ledger}.last_seen_run_id = ` +
|
|
58
|
+
`(SELECT MAX(${earlier}.last_seen_run_id) FROM scan_state ${earlier} ` +
|
|
59
|
+
`WHERE ${earlier}.root_id = ${ledger}.root_id)`);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Did the file move since the ledger recorded it?
|
|
63
|
+
*
|
|
64
|
+
* The comparison behind `changed`, and the only place it is made. It is then
|
|
65
|
+
* *stored* rather than left to readers, because the walk has already read the
|
|
66
|
+
* filesystem's answer and every later stage would have to read it again to
|
|
67
|
+
* reach the same verdict — which is the whole cost the ledger exists to avoid.
|
|
68
|
+
* A file the ledger holds no observation of has moved as far as anyone knows.
|
|
69
|
+
*/
|
|
70
|
+
export function movedSince(previous, observed) {
|
|
71
|
+
return previous === undefined || previous.size !== observed.size || previous.mtime_ms !== observed.mtimeMs;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* How many files the given run found exactly as the previous one left them.
|
|
75
|
+
*
|
|
76
|
+
* Scoped by the run named, not by the root: this answers "what did this run
|
|
77
|
+
* report", which is what a dump of that run is about.
|
|
78
|
+
*/
|
|
79
|
+
export function unchangedCount(db, runId) {
|
|
80
|
+
const row = db
|
|
81
|
+
.prepare(`SELECT COUNT(*) AS n FROM scan_state WHERE last_seen_run_id = ? AND ${unmoved('scan_state')}`)
|
|
82
|
+
.get(runId);
|
|
83
|
+
return row.n;
|
|
84
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
-- Meta layer (v1) — the classified model the API will read from.
|
|
2
|
+
--
|
|
3
|
+
-- Two rules shape this schema and both come straight from the contract
|
|
4
|
+
-- (requirements:39):
|
|
5
|
+
--
|
|
6
|
+
-- 1. Album identity is the PATH (root-qualified). Nothing is ever merged by
|
|
7
|
+
-- tags or content, so pressings of the same album, or a copy of one album
|
|
8
|
+
-- sitting in two roots, are distinct rows.
|
|
9
|
+
-- 2. The filesystem is raw truth. Every row here is derived from it, and the
|
|
10
|
+
-- collection files themselves are never mutated — this layer is an overlay.
|
|
11
|
+
|
|
12
|
+
-- Scan bookkeeping -----------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
CREATE TABLE scan_run (
|
|
15
|
+
id INTEGER PRIMARY KEY,
|
|
16
|
+
started_at TEXT NOT NULL,
|
|
17
|
+
finished_at TEXT,
|
|
18
|
+
status TEXT NOT NULL, -- running | ok | failed
|
|
19
|
+
roots_json TEXT NOT NULL -- JSON array of the configured root paths
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
-- Sources --------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
CREATE TABLE root (
|
|
25
|
+
id INTEGER PRIMARY KEY,
|
|
26
|
+
path TEXT NOT NULL UNIQUE, -- absolute, as configured by the user
|
|
27
|
+
alias TEXT,
|
|
28
|
+
created_at TEXT NOT NULL
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
-- Folders, with the classification role the classifier assigns (album /
|
|
32
|
+
-- category / box / disc). NULL until the classifier has run over this folder.
|
|
33
|
+
CREATE TABLE folder (
|
|
34
|
+
id INTEGER PRIMARY KEY,
|
|
35
|
+
root_id INTEGER NOT NULL REFERENCES root(id) ON DELETE CASCADE,
|
|
36
|
+
rel_path TEXT NOT NULL, -- '' is the root itself
|
|
37
|
+
parent_rel_path TEXT,
|
|
38
|
+
role TEXT,
|
|
39
|
+
UNIQUE (root_id, rel_path)
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
-- Every file met on disk. Audio files are the primary truth; the rest are
|
|
43
|
+
-- sidecars (cue/nfo/log), artwork, playlists, or noise.
|
|
44
|
+
CREATE TABLE file (
|
|
45
|
+
id INTEGER PRIMARY KEY,
|
|
46
|
+
root_id INTEGER NOT NULL REFERENCES root(id) ON DELETE CASCADE,
|
|
47
|
+
rel_path TEXT NOT NULL,
|
|
48
|
+
folder_rel_path TEXT NOT NULL,
|
|
49
|
+
name TEXT NOT NULL,
|
|
50
|
+
kind TEXT NOT NULL, -- audio | cue | image | nfo | log | playlist | other
|
|
51
|
+
ext TEXT NOT NULL,
|
|
52
|
+
size INTEGER NOT NULL,
|
|
53
|
+
mtime_ms INTEGER NOT NULL,
|
|
54
|
+
first_seen_run_id INTEGER REFERENCES scan_run(id),
|
|
55
|
+
last_seen_run_id INTEGER REFERENCES scan_run(id),
|
|
56
|
+
UNIQUE (root_id, rel_path)
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
CREATE INDEX idx_file_kind ON file (root_id, kind);
|
|
60
|
+
CREATE INDEX idx_file_folder ON file (root_id, folder_rel_path);
|
|
61
|
+
|
|
62
|
+
-- ffprobe results. `probe_ok = 0` with a `probe_err` is a legitimate state:
|
|
63
|
+
-- ffprobe is optional, and a missing probe must degrade, not abort the scan.
|
|
64
|
+
CREATE TABLE audio_probe (
|
|
65
|
+
file_id INTEGER PRIMARY KEY REFERENCES file(id) ON DELETE CASCADE,
|
|
66
|
+
duration_ms INTEGER,
|
|
67
|
+
codec TEXT,
|
|
68
|
+
sample_rate INTEGER,
|
|
69
|
+
channels INTEGER,
|
|
70
|
+
bitrate INTEGER,
|
|
71
|
+
probe_ok INTEGER NOT NULL DEFAULT 0,
|
|
72
|
+
probe_err TEXT
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
-- Artists. `name_key` is the normalised merge key (Cure / The Cure / Cure, The
|
|
76
|
+
-- collapse to one). It stays UNIQUE, so one row still cannot be two artists at
|
|
77
|
+
-- once — but a homonym no longer has to share a key: the second artist folder to
|
|
78
|
+
-- claim a name gets `nirvana#2`, which cannot collide with a bare key because a
|
|
79
|
+
-- key carrying `#` has no letter or digit in it (`qualifiedKey`, src/artist).
|
|
80
|
+
--
|
|
81
|
+
-- `ambiguous = 1` therefore records that a row's identity is a *guess*, whichever
|
|
82
|
+
-- way it was reached: a key that merged spellings which fold differently, or a
|
|
83
|
+
-- key split apart because two artist folders both claimed it ("разные арт-папки
|
|
84
|
+
-- = разные артисты", wiki:3498 §3). An artist that won its name outright is the
|
|
85
|
+
-- only kind left unflagged; `artist-homonym` issues name what a split threw
|
|
86
|
+
-- together, and `ambiguous` alone never says which of the two happened.
|
|
87
|
+
CREATE TABLE artist (
|
|
88
|
+
id INTEGER PRIMARY KEY,
|
|
89
|
+
name TEXT NOT NULL,
|
|
90
|
+
name_key TEXT NOT NULL UNIQUE,
|
|
91
|
+
sort_key TEXT,
|
|
92
|
+
ambiguous INTEGER NOT NULL DEFAULT 0
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
-- A box set: one release spanning several discs, which also projects each disc
|
|
96
|
+
-- as its own album row. Discs show up two ways — as CD subfolders, or, when the
|
|
97
|
+
-- rip is flat, as one image+cue pair per disc.
|
|
98
|
+
CREATE TABLE release (
|
|
99
|
+
id INTEGER PRIMARY KEY,
|
|
100
|
+
root_id INTEGER NOT NULL REFERENCES root(id) ON DELETE CASCADE,
|
|
101
|
+
rel_path TEXT NOT NULL,
|
|
102
|
+
title TEXT,
|
|
103
|
+
artist_id INTEGER REFERENCES artist(id),
|
|
104
|
+
UNIQUE (root_id, rel_path)
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
CREATE TABLE album (
|
|
108
|
+
id INTEGER PRIMARY KEY,
|
|
109
|
+
root_id INTEGER NOT NULL REFERENCES root(id) ON DELETE CASCADE,
|
|
110
|
+
rel_path TEXT NOT NULL, -- identity — see rule 1 above
|
|
111
|
+
title TEXT,
|
|
112
|
+
artist_id INTEGER REFERENCES artist(id),
|
|
113
|
+
release_id INTEGER REFERENCES release(id),
|
|
114
|
+
disc_number INTEGER,
|
|
115
|
+
UNIQUE (root_id, rel_path)
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
CREATE INDEX idx_album_release ON album (release_id);
|
|
119
|
+
|
|
120
|
+
-- Cue documents. `audio_file_id` is resolved by matching the audio files that
|
|
121
|
+
-- are actually present, NOT by the cue's FILE tag — real rips carry a stale tag
|
|
122
|
+
-- (a .m4a declared as WAVE). NULL means no audio in the folder matched.
|
|
123
|
+
CREATE TABLE cue (
|
|
124
|
+
id INTEGER PRIMARY KEY,
|
|
125
|
+
file_id INTEGER NOT NULL UNIQUE REFERENCES file(id) ON DELETE CASCADE,
|
|
126
|
+
encoding TEXT,
|
|
127
|
+
encoding_confidence REAL,
|
|
128
|
+
audio_file_id INTEGER REFERENCES file(id)
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
-- Raw cue TRACK entries, in file order. Times are milliseconds; `index00_ms` is
|
|
132
|
+
-- the pregap and may legitimately be absent.
|
|
133
|
+
CREATE TABLE cue_track (
|
|
134
|
+
id INTEGER PRIMARY KEY,
|
|
135
|
+
cue_id INTEGER NOT NULL REFERENCES cue(id) ON DELETE CASCADE,
|
|
136
|
+
ordinal INTEGER NOT NULL,
|
|
137
|
+
title TEXT,
|
|
138
|
+
performer TEXT,
|
|
139
|
+
index00_ms INTEGER,
|
|
140
|
+
index01_ms INTEGER NOT NULL,
|
|
141
|
+
UNIQUE (cue_id, ordinal)
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
-- Playable units. A whole-file track leaves the segment NULL; a cue-split track
|
|
145
|
+
-- points at the image file and carries a time segment. Note that the *last*
|
|
146
|
+
-- split track has no following index to bound it — its end comes from the audio
|
|
147
|
+
-- file's real duration, which is why audio_probe exists.
|
|
148
|
+
CREATE TABLE track (
|
|
149
|
+
id INTEGER PRIMARY KEY,
|
|
150
|
+
album_id INTEGER REFERENCES album(id) ON DELETE CASCADE,
|
|
151
|
+
artist_id INTEGER REFERENCES artist(id),
|
|
152
|
+
ordinal INTEGER NOT NULL,
|
|
153
|
+
title TEXT,
|
|
154
|
+
file_id INTEGER NOT NULL REFERENCES file(id),
|
|
155
|
+
segment_start_ms INTEGER,
|
|
156
|
+
segment_end_ms INTEGER,
|
|
157
|
+
duration_ms INTEGER,
|
|
158
|
+
UNIQUE (album_id, ordinal)
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
CREATE INDEX idx_track_file ON track (file_id);
|
|
162
|
+
|
|
163
|
+
-- Incremental rescan bookkeeping: a file whose size and mtime are unchanged is
|
|
164
|
+
-- not re-read.
|
|
165
|
+
CREATE TABLE scan_state (
|
|
166
|
+
root_id INTEGER NOT NULL REFERENCES root(id) ON DELETE CASCADE,
|
|
167
|
+
rel_path TEXT NOT NULL,
|
|
168
|
+
size INTEGER NOT NULL,
|
|
169
|
+
mtime_ms INTEGER NOT NULL,
|
|
170
|
+
last_seen_run_id INTEGER NOT NULL REFERENCES scan_run(id),
|
|
171
|
+
PRIMARY KEY (root_id, rel_path)
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
-- "What the scanner did not understand." Every guess, skip and unmatched cue
|
|
175
|
+
-- lands here so a scan can never lose information silently.
|
|
176
|
+
CREATE TABLE issue (
|
|
177
|
+
id INTEGER PRIMARY KEY,
|
|
178
|
+
scan_run_id INTEGER REFERENCES scan_run(id) ON DELETE CASCADE,
|
|
179
|
+
root_id INTEGER REFERENCES root(id),
|
|
180
|
+
rel_path TEXT,
|
|
181
|
+
kind TEXT NOT NULL,
|
|
182
|
+
severity TEXT NOT NULL DEFAULT 'warn',
|
|
183
|
+
detail TEXT
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
CREATE INDEX idx_issue_run ON issue (scan_run_id, kind);
|
|
187
|
+
|
|
188
|
+
-- Search layer (spec §2) ----------------------------------------------------
|
|
189
|
+
-- Populated by a later stage; declared here so the meta layer is complete.
|
|
190
|
+
|
|
191
|
+
CREATE VIRTUAL TABLE track_fts USING fts5 (
|
|
192
|
+
title,
|
|
193
|
+
artist,
|
|
194
|
+
album,
|
|
195
|
+
tokenize = 'unicode61 remove_diacritics 2'
|
|
196
|
+
);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- Two things a cue sheet can legally do, neither of which the first schema
|
|
2
|
+
-- could store. Both crashed a scan outright rather than being reported.
|
|
3
|
+
--
|
|
4
|
+
-- 1. Declare a TRACK with no INDEX 01. The parser reports this honestly as a
|
|
5
|
+
-- null index, and the planner already flags it — but `index01_ms` was
|
|
6
|
+
-- NOT NULL, so the insert threw and took the whole run down with it.
|
|
7
|
+
--
|
|
8
|
+
-- 2. Span several files, restarting TRACK numbering at each FILE. The matrix
|
|
9
|
+
-- calls this out explicitly ("FILE CD1.flac" + "FILE CD2.flac") and the
|
|
10
|
+
-- parser records the file index, but the old UNIQUE (cue_id, ordinal)
|
|
11
|
+
-- saw the second FILE's TRACK 01 as a duplicate and threw.
|
|
12
|
+
--
|
|
13
|
+
-- SQLite cannot alter a constraint, so the table is rebuilt.
|
|
14
|
+
|
|
15
|
+
CREATE TABLE cue_track_new (
|
|
16
|
+
id INTEGER PRIMARY KEY,
|
|
17
|
+
cue_id INTEGER NOT NULL REFERENCES cue(id) ON DELETE CASCADE,
|
|
18
|
+
-- Which FILE of the cue this track belongs to; 0 for the common single-file
|
|
19
|
+
-- case, so numbering restarts legitimately per file.
|
|
20
|
+
file_index INTEGER NOT NULL DEFAULT 0,
|
|
21
|
+
ordinal INTEGER NOT NULL,
|
|
22
|
+
title TEXT,
|
|
23
|
+
performer TEXT,
|
|
24
|
+
index00_ms INTEGER,
|
|
25
|
+
-- Nullable: a declared track may have no index. Callers report it.
|
|
26
|
+
index01_ms INTEGER,
|
|
27
|
+
UNIQUE (cue_id, file_index, ordinal)
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
INSERT INTO cue_track_new (id, cue_id, file_index, ordinal, title, performer, index00_ms, index01_ms)
|
|
31
|
+
SELECT id, cue_id, 0, ordinal, title, performer, index00_ms, index01_ms FROM cue_track;
|
|
32
|
+
|
|
33
|
+
DROP TABLE cue_track;
|
|
34
|
+
|
|
35
|
+
ALTER TABLE cue_track_new RENAME TO cue_track;
|