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,116 @@
|
|
|
1
|
+
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
2
|
+
/**
|
|
3
|
+
* The `.m3u` files lying in the collection.
|
|
4
|
+
*
|
|
5
|
+
* A playlist file is two very different things depending on what is in it, and
|
|
6
|
+
* the difference is not in its name or its size — it is in where its entries
|
|
7
|
+
* point. A `.m3u` written beside an album, listing that album's tracks, says
|
|
8
|
+
* nothing the folder tree does not already say: it is *redundant*, and offering
|
|
9
|
+
* it as a playlist would put every record in the library into a client's
|
|
10
|
+
* playlist list twice. One whose entries come from several folders is a list
|
|
11
|
+
* somebody made on purpose, and that is the kind worth importing.
|
|
12
|
+
*
|
|
13
|
+
* This module decides that and nothing else — it reads text, not the meta
|
|
14
|
+
* layer, and knows nothing about tracks. Matching entries to songs is the
|
|
15
|
+
* stage's business (`playlist/import.ts`, run by `run.ts` after `cues`),
|
|
16
|
+
* because it is the stage that has a database.
|
|
17
|
+
*
|
|
18
|
+
* Nothing here reads a file: the bytes are decoded by `text/encoding.ts` and
|
|
19
|
+
* handed over as a string, so a playlist in CP1251 is the same problem as an
|
|
20
|
+
* `.nfo` in CP1251 and is solved in the same place.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* The entries of a playlist file, in the order it states them.
|
|
24
|
+
*
|
|
25
|
+
* Three dialects arrive in the same collection and all three are handled:
|
|
26
|
+
* a bare list of paths, the extended form where every path is preceded by
|
|
27
|
+
* `#EXTINF:` lines, and the same with the paths quoted. Comments and directives
|
|
28
|
+
* start with `#` and are dropped — an `#EXTINF` line carries a duration and a
|
|
29
|
+
* title, and neither is used: the file says *which* songs and in what order,
|
|
30
|
+
* and what each song is called is a fact about the song.
|
|
31
|
+
*
|
|
32
|
+
* A byte-order mark leading the file is dropped with them. It is three bytes of
|
|
33
|
+
* `EF BB BF` that a Windows editor wrote, and left in place it becomes the
|
|
34
|
+
* first character of the first path — a file that cannot be found, in a list
|
|
35
|
+
* where every other entry resolves.
|
|
36
|
+
*/
|
|
37
|
+
export function parseEntries(text) {
|
|
38
|
+
return text
|
|
39
|
+
.replace(/^\uFEFF/, '')
|
|
40
|
+
.split(/\r?\n/)
|
|
41
|
+
.map((line) => line.trim())
|
|
42
|
+
.filter((line) => line !== '' && !line.startsWith('#'))
|
|
43
|
+
.map((line) => unquote(line))
|
|
44
|
+
.filter((line) => line !== '');
|
|
45
|
+
}
|
|
46
|
+
/** A path a playlist wrote the way a person would — sometimes in quotes. */
|
|
47
|
+
function unquote(line) {
|
|
48
|
+
const quoted = /^"(.*)"$/.exec(line) ?? /^'(.*)'$/.exec(line);
|
|
49
|
+
return (quoted?.[1] ?? line).trim();
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* What an entry points at, as a path — or nothing, when it points out of the
|
|
53
|
+
* filesystem altogether.
|
|
54
|
+
*
|
|
55
|
+
* A playlist may name a stream (`http://…`), and no folder will ever hold one.
|
|
56
|
+
* Those entries are dropped here rather than resolved into a nonsense path: the
|
|
57
|
+
* question this module answers is about *folders*, and a URL has none.
|
|
58
|
+
*/
|
|
59
|
+
export function resolveEntry(entry, folder) {
|
|
60
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(entry))
|
|
61
|
+
return null;
|
|
62
|
+
// A Windows path may be written with either separator, and both arrive: an
|
|
63
|
+
// absolute `C:\Music\…` from a ripping tool and a relative `01 - x.flac` from
|
|
64
|
+
// a person. `resolve` handles the second and understands both separators of
|
|
65
|
+
// the first on Windows; on other platforms an absolute Windows path is not a
|
|
66
|
+
// path, and the entry resolves relative to the folder like any other string.
|
|
67
|
+
return resolve(isAbsolute(entry) ? entry : folder, entry);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* How the folders of a playlist compare.
|
|
71
|
+
*
|
|
72
|
+
* Case-folded on Windows and not elsewhere, because that is what the two
|
|
73
|
+
* filesystems do: `C:\Music\A` and `c:\music\a` are one folder there and two
|
|
74
|
+
* paths anywhere else. Getting this wrong in the other direction would call a
|
|
75
|
+
* curated list redundant — the mistake this classification exists to avoid —
|
|
76
|
+
* so it follows the platform rather than a preference.
|
|
77
|
+
*/
|
|
78
|
+
function folderKey(path) {
|
|
79
|
+
return process.platform === 'win32' ? path.toLowerCase() : path;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* What a playlist file is: the album it sits beside, or a list of its own.
|
|
83
|
+
*
|
|
84
|
+
* One folder — or none — is redundant. That covers the ordinary case (a `.m3u`
|
|
85
|
+
* beside an album, listing that album) and the degenerate ones (an empty file,
|
|
86
|
+
* a file listing only one song): none of them is a list a client should be
|
|
87
|
+
* offered, and importing them would fill the playlist list with the library
|
|
88
|
+
* itself.
|
|
89
|
+
*
|
|
90
|
+
* More than one folder is curated. This is deliberately a question about
|
|
91
|
+
* *folders* and not about albums or artists: a playlist of one artist's records
|
|
92
|
+
* is exactly the case worth importing, and so is a mix, while a playlist of one
|
|
93
|
+
* album from two folders is a record this collection filed in two places and
|
|
94
|
+
* still says nothing new.
|
|
95
|
+
*
|
|
96
|
+
* Named `readingOf` and not `classify`, which in this project is a stage: the
|
|
97
|
+
* folder classifier that decides what an *album* is. Two questions a word apart
|
|
98
|
+
* and nothing else in common.
|
|
99
|
+
*/
|
|
100
|
+
export function readingOf(entries, folder) {
|
|
101
|
+
const folders = new Set();
|
|
102
|
+
const paths = [];
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
const path = resolveEntry(entry, folder);
|
|
105
|
+
if (path === null)
|
|
106
|
+
continue;
|
|
107
|
+
paths.push(path);
|
|
108
|
+
folders.add(folderKey(dirname(path)));
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
verdict: folders.size > 1 ? 'curated' : 'redundant',
|
|
112
|
+
entries: entries.length,
|
|
113
|
+
folders: folders.size,
|
|
114
|
+
paths,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join, relative, sep } from 'node:path';
|
|
3
|
+
import { withTransaction } from "../db/index.js";
|
|
4
|
+
import { clearIssues } from "../db/issue.js";
|
|
5
|
+
import { ledgerEntry, moved } from "../db/ledger.js";
|
|
6
|
+
import { decodeText } from "../text/encoding.js";
|
|
7
|
+
import { basenameOf, stemOf } from "../util/names.js";
|
|
8
|
+
import { parseEntries, readingOf } from "./files.js";
|
|
9
|
+
import * as store from "./store.js";
|
|
10
|
+
/**
|
|
11
|
+
* The playlist files the collection carries, read and weighed.
|
|
12
|
+
*
|
|
13
|
+
* A `.m3u` is either the album it sits beside or a list somebody made, and
|
|
14
|
+
* `files.ts` decides which — this stage is where that decision meets the meta
|
|
15
|
+
* layer. The two outcomes are handled very differently on purpose:
|
|
16
|
+
*
|
|
17
|
+
* - **redundant** (every entry in one folder) is *ignored*, and an issue says
|
|
18
|
+
* so. The folder tree already offers that music; importing it would list
|
|
19
|
+
* every album in the library a second time, under a name a client shows
|
|
20
|
+
* beside real playlists.
|
|
21
|
+
* - **curated** becomes a playlist, found by the file it came from rather
|
|
22
|
+
* than by its name, so reading the same file again updates that playlist
|
|
23
|
+
* instead of making another one. The row keeps the file it came from, which
|
|
24
|
+
* is also what tells the API that this playlist is the file's and not the
|
|
25
|
+
* listener's (`playlist/store.ts`).
|
|
26
|
+
*
|
|
27
|
+
* A file that stops being curated takes its playlist with it, and so does one
|
|
28
|
+
* that stops naming anything this library holds: the list is the file's, and a
|
|
29
|
+
* row left behind by a file that no longer asks for it is a list the collection
|
|
30
|
+
* does not have. So does a file that is deleted, through the cascade on
|
|
31
|
+
* `playlist.source_file_id`.
|
|
32
|
+
*
|
|
33
|
+
* **What is measured on this collection:** of 27 playlist files, all 27 name
|
|
34
|
+
* songs from one folder each and not one reaches outside the folder it sits in.
|
|
35
|
+
* The curated branch is therefore the one this library never exercises — which
|
|
36
|
+
* is why it is the branch the tests do.
|
|
37
|
+
*/
|
|
38
|
+
/** This stage's name in `issue.stage`. Bound to the insert and to the clear. */
|
|
39
|
+
const STAGE = 'playlists';
|
|
40
|
+
/**
|
|
41
|
+
* Read the collection's playlist files, and import the ones that are lists.
|
|
42
|
+
*
|
|
43
|
+
* Runs after `cues`, and that is not a preference: an imported playlist names
|
|
44
|
+
* songs, and songs exist only once the cue stage has written them.
|
|
45
|
+
*
|
|
46
|
+
* The whole pass is one transaction, like every other stage that writes: it
|
|
47
|
+
* reads a file, asks several questions of the meta layer and writes what it
|
|
48
|
+
* concluded, and a scan that fell over halfway through should leave none of it
|
|
49
|
+
* behind. Measured on the live meta layer, a hundred writes outside a
|
|
50
|
+
* transaction cost 4452 ms against 74 ms inside one — the stage that reads
|
|
51
|
+
* every playlist in a collection is not the place to pay that again.
|
|
52
|
+
*/
|
|
53
|
+
export function applyPlaylists(db, deps = {}) {
|
|
54
|
+
const counters = {
|
|
55
|
+
files: 0,
|
|
56
|
+
filesRead: 0,
|
|
57
|
+
redundant: 0,
|
|
58
|
+
curated: 0,
|
|
59
|
+
imported: 0,
|
|
60
|
+
unchanged: 0,
|
|
61
|
+
entriesMissing: 0,
|
|
62
|
+
unreadable: 0,
|
|
63
|
+
issues: 0,
|
|
64
|
+
};
|
|
65
|
+
const latest = db.prepare('SELECT MAX(id) AS id FROM scan_run').get();
|
|
66
|
+
if (latest.id === null)
|
|
67
|
+
return counters;
|
|
68
|
+
const readBytes = deps.readBytes ?? ((absPath) => readFileSync(absPath));
|
|
69
|
+
// Every playlist file of this run is taken up on every run, and its *bytes*
|
|
70
|
+
// are read only when there are none kept or the ledger says they moved — the
|
|
71
|
+
// same rule the tag stage follows for the record's documentation, and the same
|
|
72
|
+
// table: `sidecar_text` holds what the bytes said, so a playlist in CP1251 is
|
|
73
|
+
// decoded once.
|
|
74
|
+
//
|
|
75
|
+
// Taken up every time on purpose, and that is not the same question. A `.m3u`
|
|
76
|
+
// that did not change still describes a *library* that may have: a song it
|
|
77
|
+
// names can be deleted, and the playlist imported from it should lose that
|
|
78
|
+
// song. Skipping the files the ledger calls unmoved would leave the playlist
|
|
79
|
+
// holding what the collection no longer has, which is the one thing an
|
|
80
|
+
// imported list must never do. Re-reading the text is what the cache saves;
|
|
81
|
+
// the verdict is reached again from it, and nothing is written unless it
|
|
82
|
+
// changed (see `sameSongs`).
|
|
83
|
+
const due = db
|
|
84
|
+
.prepare(`SELECT f.id, f.rel_path, f.root_id, r.path AS root_path, f.ext,
|
|
85
|
+
st.text AS cached_text, ${moved('ss')} AS moved
|
|
86
|
+
FROM file f
|
|
87
|
+
JOIN root r ON r.id = f.root_id
|
|
88
|
+
LEFT JOIN scan_state ss ON ${ledgerEntry('f', 'ss')}
|
|
89
|
+
LEFT JOIN sidecar_text st ON st.file_id = f.id
|
|
90
|
+
WHERE f.kind = 'playlist' AND f.last_seen_run_id = ?
|
|
91
|
+
ORDER BY f.root_id, f.rel_path`)
|
|
92
|
+
.all(latest.id);
|
|
93
|
+
const upsertText = db.prepare(`INSERT INTO sidecar_text (file_id, text) VALUES (?, ?)
|
|
94
|
+
ON CONFLICT (file_id) DO UPDATE SET text = excluded.text`);
|
|
95
|
+
const stampEncoding = db.prepare('UPDATE file SET encoding = ?, encoding_confidence = ? WHERE id = ?');
|
|
96
|
+
// A path may be spelled with either separator, and the meta layer holds the
|
|
97
|
+
// forward one (`scan/walk.ts` normalises it there). Case is folded on Windows
|
|
98
|
+
// and not elsewhere, because that is what the two filesystems do — a list that
|
|
99
|
+
// says `01.FLAC` names the file on one and not on the other.
|
|
100
|
+
const samePath = process.platform === 'win32' ? 'COLLATE NOCASE' : '';
|
|
101
|
+
const fileIdOf = db.prepare(`SELECT f.id AS id FROM file f WHERE f.root_id = ? AND f.rel_path = ? ${samePath}`);
|
|
102
|
+
const songsOfFile = db.prepare(`SELECT t.id AS id FROM track t JOIN file f ON f.id = t.file_id
|
|
103
|
+
WHERE f.root_id = ? AND f.rel_path = ? ${samePath}
|
|
104
|
+
ORDER BY t.ordinal, t.id`);
|
|
105
|
+
const nameTaken = db.prepare(`SELECT 1 AS taken FROM playlist
|
|
106
|
+
WHERE name = ? AND (source_file_id IS NULL OR source_file_id <> ?)`);
|
|
107
|
+
const isTaken = (name, fileId) => nameTaken.get(name, fileId) !== undefined;
|
|
108
|
+
const insertIssue = db.prepare(`INSERT INTO issue (scan_run_id, stage, root_id, rel_path, kind, severity, detail)
|
|
109
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
|
110
|
+
const complain = (row, kind, severity, detail) => {
|
|
111
|
+
insertIssue.run(latest.id, STAGE, row.root_id, row.rel_path, kind, severity, detail);
|
|
112
|
+
counters.issues += 1;
|
|
113
|
+
};
|
|
114
|
+
withTransaction(db, () => {
|
|
115
|
+
for (const row of due) {
|
|
116
|
+
clearIssues(db, STAGE, { rootId: row.root_id, relPath: row.rel_path });
|
|
117
|
+
// The one format this does not read. `.pls` is a different document — an
|
|
118
|
+
// INI with numbered `FileN=` keys — and pretending otherwise would be
|
|
119
|
+
// worse than saying so: a `.pls` read as an `.m3u` yields no entries at
|
|
120
|
+
// all, and "redundant" would be the answer to a question nobody asked.
|
|
121
|
+
if (row.ext !== 'm3u' && row.ext !== 'm3u8') {
|
|
122
|
+
complain(row, 'playlist-format-not-read', 'info', `.${row.ext} is not read`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
let text = row.cached_text;
|
|
126
|
+
if (text === null || row.moved === 1) {
|
|
127
|
+
try {
|
|
128
|
+
const decoded = decodeText(readBytes(join(row.root_path, row.rel_path)));
|
|
129
|
+
text = decoded.text;
|
|
130
|
+
counters.filesRead += 1;
|
|
131
|
+
upsertText.run(row.id, text);
|
|
132
|
+
stampEncoding.run(decoded.encoding, decoded.confidence, row.id);
|
|
133
|
+
if (decoded.confidence < 1) {
|
|
134
|
+
complain(row, 'playlist-encoding-guessed', 'info', decoded.basis ?? `${decoded.encoding} was inferred`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
counters.unreadable += 1;
|
|
139
|
+
complain(row, 'playlist-unreadable', 'warn', err.message);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Counted here rather than at the top of the loop: a `.pls` was taken up
|
|
144
|
+
// and refused, and a file that would not open was not read at all — the
|
|
145
|
+
// report says "read", and this is the number that keeps that word true.
|
|
146
|
+
counters.files += 1;
|
|
147
|
+
const folder = dirname(join(row.root_path, row.rel_path));
|
|
148
|
+
const entries = parseEntries(text);
|
|
149
|
+
const reading = readingOf(entries, folder);
|
|
150
|
+
// Both verdicts need this: a redundant list is only *the folder* if its
|
|
151
|
+
// entries are the folder's files, and one carried from another machine
|
|
152
|
+
// names nothing here — calling that "already offered by the folder" would
|
|
153
|
+
// be a false account of a list that was quietly dropped.
|
|
154
|
+
const songs = [];
|
|
155
|
+
let missing = 0;
|
|
156
|
+
for (const path of reading.paths) {
|
|
157
|
+
const rel = within(row.root_path, path);
|
|
158
|
+
if (rel === null) {
|
|
159
|
+
missing += 1;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const found = fileIdOf.get(row.root_id, rel);
|
|
163
|
+
if (found === undefined) {
|
|
164
|
+
missing += 1;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (reading.verdict === 'curated') {
|
|
168
|
+
for (const song of songsOfFile.all(row.root_id, rel))
|
|
169
|
+
songs.push(song.id);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (reading.verdict === 'redundant') {
|
|
173
|
+
store.removeBySourceFile(db, row.id);
|
|
174
|
+
counters.redundant += 1;
|
|
175
|
+
complain(row, 'playlist-redundant', 'info', missing > 0
|
|
176
|
+
? `${reading.entries} entries in ${reading.folders} folder${reading.folders === 1 ? '' : 's'}, ${missing} of them naming nothing here — the folder already offers this`
|
|
177
|
+
: `${reading.entries} entries in ${reading.folders} folder${reading.folders === 1 ? '' : 's'} — the folder already offers this`);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
counters.curated += 1;
|
|
181
|
+
counters.entriesMissing += missing;
|
|
182
|
+
if (missing > 0) {
|
|
183
|
+
complain(row, 'playlist-entries-missing', 'warn', `${missing} of ${entries.length} entries name no song in this library`);
|
|
184
|
+
}
|
|
185
|
+
if (songs.length === 0) {
|
|
186
|
+
// Curated by where it points, but nothing in it lands here — a list made
|
|
187
|
+
// on another machine, or one whose music was never scanned. An empty
|
|
188
|
+
// playlist would be a promise this library cannot keep, and one left over
|
|
189
|
+
// from a previous reading of this file would be a list it no longer says.
|
|
190
|
+
store.removeBySourceFile(db, row.id);
|
|
191
|
+
complain(row, 'playlist-nothing-imported', 'warn', `no entry of ${entries.length} names a song here`);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const name = nameFor(row, isTaken);
|
|
195
|
+
const existing = store.bySourceFile(db, row.id);
|
|
196
|
+
if (existing === undefined) {
|
|
197
|
+
store.create(db, name, songs, row.id);
|
|
198
|
+
counters.imported += 1;
|
|
199
|
+
}
|
|
200
|
+
else if (existing.name !== name || !sameSongs(db, existing.id, songs)) {
|
|
201
|
+
store.replace(db, existing.id, name, songs);
|
|
202
|
+
counters.imported += 1;
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
// The file says what the playlist already says. Writing it again would
|
|
206
|
+
// move `changed_at`, and a client that syncs by that stamp would re-read
|
|
207
|
+
// a playlist because a scan ran — the same reasoning as `store.edit`'s
|
|
208
|
+
// silence when nothing changes.
|
|
209
|
+
counters.unchanged += 1;
|
|
210
|
+
}
|
|
211
|
+
complain(row, 'playlist-imported', 'info', `${songs.length} songs from ${reading.folders} folders`);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
return counters;
|
|
215
|
+
}
|
|
216
|
+
/** Whether a playlist already holds exactly these songs, in this order. */
|
|
217
|
+
function sameSongs(db, playlistId, songs) {
|
|
218
|
+
const held = store.entriesOf(db, playlistId);
|
|
219
|
+
return held.length === songs.length && held.every((id, at) => id === songs.at(at));
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* What to call an imported playlist.
|
|
223
|
+
*
|
|
224
|
+
* The file's own name, and the folder it sits in when that name is already
|
|
225
|
+
* taken by another playlist. Two `a.m3u` in two folders are two lists, and a
|
|
226
|
+
* sidebar showing `a` twice is a sidebar where one of them cannot be chosen —
|
|
227
|
+
* the same argument the project settled for records whose names collide
|
|
228
|
+
* ([[wiki:3602]]). The name is not the identity here either: the row is found
|
|
229
|
+
* by its file, and this is only what a person reads.
|
|
230
|
+
*/
|
|
231
|
+
function nameFor(row, isTaken) {
|
|
232
|
+
const stem = stemOf(basename(row.rel_path));
|
|
233
|
+
if (!isTaken(stem, row.id))
|
|
234
|
+
return stem;
|
|
235
|
+
const parent = basenameOf(dirname(row.rel_path));
|
|
236
|
+
return parent === '' ? stem : `${stem} (${parent})`;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Where a path lies inside the root, in the spelling the meta layer uses — or
|
|
240
|
+
* nothing, when it lies outside it.
|
|
241
|
+
*
|
|
242
|
+
* Outside is not a failure of the list: a curated playlist made elsewhere may
|
|
243
|
+
* name music this library does not have, and those entries are counted and
|
|
244
|
+
* reported rather than resolved into a path that cannot exist. `..odd.flac`
|
|
245
|
+
* inside the root is a file, though, and is not confused with `../odd.flac` —
|
|
246
|
+
* hence the separator rather than a bare prefix test.
|
|
247
|
+
*
|
|
248
|
+
* The collection has more than one root, and this stage asks about one at a
|
|
249
|
+
* time: a list may name music that lives under *another* root of the same
|
|
250
|
+
* collection, and that entry is reported as unmatched. Doing better means
|
|
251
|
+
* resolving against every root, which is a change to make when a collection
|
|
252
|
+
* actually has a `.m3u` that reaches across its own roots — measured on this
|
|
253
|
+
* one, none does.
|
|
254
|
+
*/
|
|
255
|
+
function within(rootPath, absPath) {
|
|
256
|
+
const rel = relative(rootPath, absPath);
|
|
257
|
+
if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || /^[a-z]:/i.test(rel))
|
|
258
|
+
return null;
|
|
259
|
+
return rel.split('\\').join('/');
|
|
260
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { withTransaction } from "../db/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Playlists, and who owns which of them.
|
|
4
|
+
*
|
|
5
|
+
* Two kinds of row live in the same two tables and they have two different
|
|
6
|
+
* authors, which is the whole of what a reader has to know before touching one:
|
|
7
|
+
*
|
|
8
|
+
* - **The listener's**, written by the API: a statement about the music that
|
|
9
|
+
* lives nowhere on disk. Nothing else writes these.
|
|
10
|
+
* - **A file's**, imported by the playlist stage from a `.m3u` in the
|
|
11
|
+
* collection (`playlist/import.ts`): the row is a reading of that file, and
|
|
12
|
+
* the file is what the list *is*. A scan rewrites it from the file, and the
|
|
13
|
+
* API refuses to edit or delete it — see below.
|
|
14
|
+
*
|
|
15
|
+
* `source_file_id` is the field that tells them apart: `NULL` is the listener's
|
|
16
|
+
* and a file id is that file's. It is not decoration, and the two rules that
|
|
17
|
+
* hang off it are the reason this module has a doc comment at all:
|
|
18
|
+
*
|
|
19
|
+
* 1. An imported row is rewritten on every scan that re-reads its file, so an
|
|
20
|
+
* edit made through the API would survive exactly until the next scan. The
|
|
21
|
+
* API therefore does not accept one, and says so (`api/playlist.ts`).
|
|
22
|
+
* 2. The row is the file's, so the file's removal takes it — through the
|
|
23
|
+
* cascade, and through the stage when a file stops being a list.
|
|
24
|
+
*
|
|
25
|
+
* What this module does *not* read is the collection. A playlist's songs are
|
|
26
|
+
* read in `api/meta.ts` with every other song, by the same select, so a song in
|
|
27
|
+
* a playlist is the same song as everywhere else — and the question this module
|
|
28
|
+
* has about the collection, whether a track exists, is asked there too.
|
|
29
|
+
*
|
|
30
|
+
* Order is the whole of the domain here. `position` is a sequence laid out from
|
|
31
|
+
* zero by every write, because the protocol addresses entries by index —
|
|
32
|
+
* `songIndexToRemove` is a position — so every mutation below finishes by
|
|
33
|
+
* writing the order it left behind rather than trusting the arithmetic of the
|
|
34
|
+
* one before it. A song dropped by the cascade leaves its number unused until
|
|
35
|
+
* the next write; nothing reads the number as a position, only as an order.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* The number the next playlist gets.
|
|
39
|
+
*
|
|
40
|
+
* Read and moved on in the same transaction as the insert that uses it, so two
|
|
41
|
+
* callers cannot be handed the same number. See migration 025 for why the
|
|
42
|
+
* number comes from a table of its own rather than from the rows it numbers.
|
|
43
|
+
*/
|
|
44
|
+
function nextId(db) {
|
|
45
|
+
const row = db.prepare('SELECT next FROM playlist_sequence WHERE only_row = 1').get();
|
|
46
|
+
db.prepare('UPDATE playlist_sequence SET next = ? WHERE only_row = 1').run(row.next + 1);
|
|
47
|
+
return row.next;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* When the playlist last moved, touched by every mutation that changes it.
|
|
51
|
+
*
|
|
52
|
+
* Reads and writes share the value: `changed_at` is written by the same
|
|
53
|
+
* statement that writes the change, so a client syncing by it can never see a
|
|
54
|
+
* change without the stamp that announces it.
|
|
55
|
+
*/
|
|
56
|
+
const TOUCH = 'changed_at = ?';
|
|
57
|
+
/**
|
|
58
|
+
* Everything a playlist is, with the numbers and the picture a client draws
|
|
59
|
+
* beside it.
|
|
60
|
+
*
|
|
61
|
+
* Counted over the entries rather than over distinct tracks: a song twice in one
|
|
62
|
+
* playlist is two entries and two plays, and `songCount` is what the client
|
|
63
|
+
* shows beside the length of the list.
|
|
64
|
+
*
|
|
65
|
+
* The picture is the first entry's song, and it is taken by a correlated
|
|
66
|
+
* subquery — which this project has paid for once already (`ALBUM_GENRES` in
|
|
67
|
+
* `api/meta.ts`) and which is allowed here for the reason it was wrong there.
|
|
68
|
+
* The question is `MIN(position)` for one playlist, and the primary key of
|
|
69
|
+
* `playlist_track` is `(playlist_id, position)`: the answer is the first row of
|
|
70
|
+
* an index range, not a scan. Measured on 15 000 entries, a form that grouped
|
|
71
|
+
* every playlist's entries before the `WHERE` could narrow anything cost
|
|
72
|
+
* 5137 µs per call — against this one, which reads one index range. A playlist
|
|
73
|
+
* with no entries matches nothing and is handed no picture.
|
|
74
|
+
*/
|
|
75
|
+
const PLAYLIST_SELECT = `
|
|
76
|
+
SELECT p.id, p.name, p.comment, p.source_file_id, p.public, p.created_at, p.changed_at,
|
|
77
|
+
COUNT(pt.track_id) AS song_count,
|
|
78
|
+
SUM(t.duration_ms) AS duration_ms,
|
|
79
|
+
ft.album_id AS cover_album_id,
|
|
80
|
+
ft.id AS cover_track_id
|
|
81
|
+
FROM playlist p
|
|
82
|
+
LEFT JOIN playlist_track pt ON pt.playlist_id = p.id
|
|
83
|
+
LEFT JOIN track t ON t.id = pt.track_id
|
|
84
|
+
LEFT JOIN playlist_track fp ON fp.playlist_id = p.id
|
|
85
|
+
AND fp.position = (SELECT MIN(first_pt.position) FROM playlist_track first_pt
|
|
86
|
+
WHERE first_pt.playlist_id = p.id)
|
|
87
|
+
LEFT JOIN track ft ON ft.id = fp.track_id
|
|
88
|
+
`;
|
|
89
|
+
export function playlists(db) {
|
|
90
|
+
return db
|
|
91
|
+
.prepare(`${PLAYLIST_SELECT}
|
|
92
|
+
GROUP BY p.id
|
|
93
|
+
ORDER BY p.name COLLATE NOCASE, p.id`)
|
|
94
|
+
.all();
|
|
95
|
+
}
|
|
96
|
+
export function playlist(db, id) {
|
|
97
|
+
return db
|
|
98
|
+
.prepare(`${PLAYLIST_SELECT} WHERE p.id = ? GROUP BY p.id`)
|
|
99
|
+
.get(id);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The entries of a playlist, in the order it holds them.
|
|
103
|
+
*
|
|
104
|
+
* The ids alone, because what a caller does with them is decide what to keep:
|
|
105
|
+
* this is read by the mutations below, which reason about the list as a
|
|
106
|
+
* sequence of tracks, and by nothing that renders.
|
|
107
|
+
*/
|
|
108
|
+
export function entriesOf(db, playlistId) {
|
|
109
|
+
return db
|
|
110
|
+
.prepare('SELECT track_id FROM playlist_track WHERE playlist_id = ? ORDER BY position')
|
|
111
|
+
.all(playlistId).map((row) => row.track_id);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Write a playlist's entries as the order given.
|
|
115
|
+
*
|
|
116
|
+
* The one place positions are assigned, and it assigns all of them: every
|
|
117
|
+
* mutation above ends here with the list it decided on, so no caller has to be
|
|
118
|
+
* right about gaps, shifts or the arithmetic of a removal. Rewriting the whole
|
|
119
|
+
* list is cheaper than proving an incremental update left no hole, and the
|
|
120
|
+
* protocol sets no ceiling on how long a playlist may be — measured, a hundred
|
|
121
|
+
* entries cost 74 ms inside the transaction that carries them.
|
|
122
|
+
*/
|
|
123
|
+
function writeEntries(db, playlistId, tracks) {
|
|
124
|
+
db.prepare('DELETE FROM playlist_track WHERE playlist_id = ?').run(playlistId);
|
|
125
|
+
const insert = db.prepare('INSERT INTO playlist_track (playlist_id, position, track_id) VALUES (?, ?, ?)');
|
|
126
|
+
tracks.forEach((trackId, position) => insert.run(playlistId, position, trackId));
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* A new playlist holding the songs named.
|
|
130
|
+
*
|
|
131
|
+
* An id that names no track never reaches here: `createPlaylist` refuses the
|
|
132
|
+
* whole call when one of its `songId`s is not a song, and refusing is the point
|
|
133
|
+
* — a playlist silently shorter than the request is a playlist the client
|
|
134
|
+
* cannot tell from one it sent correctly, and the missing song would only show
|
|
135
|
+
* up when somebody played the list through.
|
|
136
|
+
*
|
|
137
|
+
* The timestamps are the same instant: a playlist that was just made has not
|
|
138
|
+
* been changed since it was made.
|
|
139
|
+
*/
|
|
140
|
+
export function create(db, name, tracks, sourceFileId = null) {
|
|
141
|
+
const at = new Date().toISOString();
|
|
142
|
+
return withTransaction(db, () => {
|
|
143
|
+
const id = nextId(db);
|
|
144
|
+
db.prepare(`INSERT INTO playlist (id, name, comment, source_file_id, created_at, changed_at)
|
|
145
|
+
VALUES (?, ?, NULL, ?, ?, ?)`).run(id, name, sourceFileId, at, at);
|
|
146
|
+
writeEntries(db, id, tracks);
|
|
147
|
+
return written(db, id);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The row a write just left, read back.
|
|
152
|
+
*
|
|
153
|
+
* Every mutation that answers with the playlist answers with what is in the
|
|
154
|
+
* table rather than with what it meant to put there — so the counts, the
|
|
155
|
+
* timestamps and the entries in the answer are the ones a later `getPlaylist`
|
|
156
|
+
* would give, and cannot be a calculation that disagreed with the write.
|
|
157
|
+
*/
|
|
158
|
+
function written(db, id) {
|
|
159
|
+
const row = playlist(db, id);
|
|
160
|
+
if (row === undefined)
|
|
161
|
+
throw new Error(`playlist ${id} is not there after it was written`);
|
|
162
|
+
return row;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Rename a playlist and, when songs are given, make them its contents.
|
|
166
|
+
*
|
|
167
|
+
* This is `createPlaylist` handed an id that already exists, which the protocol
|
|
168
|
+
* makes the same call — see `api/playlist.ts` for why this server replaces the
|
|
169
|
+
* songs and for the one case where it does not.
|
|
170
|
+
*
|
|
171
|
+
* `tracks` is `undefined` when the client named no songs at all, and that is not
|
|
172
|
+
* the same as naming none: a call that renames a playlist carries no song list,
|
|
173
|
+
* and treating its silence as "empty the playlist" would throw away everything
|
|
174
|
+
* the listener put in it while answering `ok`. A client that means to empty a
|
|
175
|
+
* playlist says so — it sends no song it wants kept.
|
|
176
|
+
*/
|
|
177
|
+
export function replace(db, id, name, tracks) {
|
|
178
|
+
return withTransaction(db, () => {
|
|
179
|
+
db.prepare(`UPDATE playlist SET name = ?, ${TOUCH} WHERE id = ?`).run(name, new Date().toISOString(), id);
|
|
180
|
+
if (tracks !== undefined)
|
|
181
|
+
writeEntries(db, id, tracks);
|
|
182
|
+
return written(db, id);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
export function edit(db, id, edit) {
|
|
186
|
+
const adds = edit.add !== undefined && edit.add.length > 0;
|
|
187
|
+
const removes = edit.remove !== undefined && edit.remove.length > 0;
|
|
188
|
+
const fields = edit.name !== undefined || edit.comment !== undefined || edit.public !== undefined;
|
|
189
|
+
if (!adds && !removes && !fields)
|
|
190
|
+
return;
|
|
191
|
+
withTransaction(db, () => {
|
|
192
|
+
let tracks = entriesOf(db, id);
|
|
193
|
+
if (removes) {
|
|
194
|
+
const doomed = new Set(edit.remove);
|
|
195
|
+
tracks = tracks.filter((_, position) => !doomed.has(position));
|
|
196
|
+
}
|
|
197
|
+
if (adds)
|
|
198
|
+
tracks = [...tracks, ...(edit.add ?? [])];
|
|
199
|
+
const sets = [];
|
|
200
|
+
const args = [];
|
|
201
|
+
if (edit.name !== undefined) {
|
|
202
|
+
sets.push('name = ?');
|
|
203
|
+
args.push(edit.name);
|
|
204
|
+
}
|
|
205
|
+
if (edit.comment !== undefined) {
|
|
206
|
+
sets.push('comment = ?');
|
|
207
|
+
args.push(edit.comment);
|
|
208
|
+
}
|
|
209
|
+
if (edit.public !== undefined) {
|
|
210
|
+
sets.push('public = ?');
|
|
211
|
+
args.push(edit.public ? 1 : 0);
|
|
212
|
+
}
|
|
213
|
+
sets.push(TOUCH);
|
|
214
|
+
args.push(new Date().toISOString(), id);
|
|
215
|
+
db.prepare(`UPDATE playlist SET ${sets.join(', ')} WHERE id = ?`).run(...args);
|
|
216
|
+
if (adds || removes)
|
|
217
|
+
writeEntries(db, id, tracks);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
/** A playlist and its entries, gone. The songs themselves are untouched. */
|
|
221
|
+
export function remove(db, id) {
|
|
222
|
+
db.prepare('DELETE FROM playlist WHERE id = ?').run(id);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* The playlist a file asked for, if this server has imported it.
|
|
226
|
+
*
|
|
227
|
+
* How an import stays one playlist: the file is what the list *is*, so the row
|
|
228
|
+
* it produced is found by the file rather than by its name — a scan that reads
|
|
229
|
+
* the same file again updates that row instead of making a second list with the
|
|
230
|
+
* same name, and a third on the run after that.
|
|
231
|
+
*/
|
|
232
|
+
export function bySourceFile(db, fileId) {
|
|
233
|
+
return db
|
|
234
|
+
.prepare(`${PLAYLIST_SELECT} WHERE p.source_file_id = ? GROUP BY p.id`)
|
|
235
|
+
.get(fileId);
|
|
236
|
+
}
|
|
237
|
+
/** The playlist a file asked for, gone — the file stopped asking for one. */
|
|
238
|
+
export function removeBySourceFile(db, fileId) {
|
|
239
|
+
db.prepare('DELETE FROM playlist WHERE source_file_id = ?').run(fileId);
|
|
240
|
+
}
|