python-musefs 0.0.1__py3-none-any.whl → 1.1.0__py3-none-any.whl
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.
- musefs_common/__init__.py +13 -1
- musefs_common/scan.py +11 -7
- musefs_common/schema.py +164 -137
- musefs_common/store.py +217 -22
- musefs_common/sync.py +44 -15
- python_musefs-1.1.0.dist-info/METADATA +27 -0
- python_musefs-1.1.0.dist-info/RECORD +14 -0
- python_musefs-0.0.1.dist-info/METADATA +0 -73
- python_musefs-0.0.1.dist-info/RECORD +0 -14
- {python_musefs-0.0.1.dist-info → python_musefs-1.1.0.dist-info}/WHEEL +0 -0
- {python_musefs-0.0.1.dist-info → python_musefs-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {python_musefs-0.0.1.dist-info → python_musefs-1.1.0.dist-info}/top_level.txt +0 -0
musefs_common/__init__.py
CHANGED
|
@@ -11,18 +11,24 @@ from .errors import ScanError, SchemaMismatch
|
|
|
11
11
|
from .paths import realpath_key
|
|
12
12
|
from .scan import run_scan
|
|
13
13
|
from .store import (
|
|
14
|
+
TagRow,
|
|
14
15
|
check_schema_version,
|
|
15
16
|
connect,
|
|
17
|
+
delete_tracks,
|
|
18
|
+
merge_tags,
|
|
16
19
|
prune_missing,
|
|
17
20
|
replace_tags,
|
|
18
21
|
replace_track_art,
|
|
19
22
|
sniff_mime,
|
|
23
|
+
tags_for_track,
|
|
20
24
|
track_id_for_path,
|
|
25
|
+
track_ids_by_tag,
|
|
26
|
+
track_ids_for_paths,
|
|
21
27
|
upsert_art,
|
|
22
28
|
)
|
|
23
29
|
from .sync import ArtImage, Record, SyncStats, sync_files, sync_one
|
|
24
30
|
|
|
25
|
-
__version__ = "
|
|
31
|
+
__version__ = "1.1.0"
|
|
26
32
|
|
|
27
33
|
__all__ = [
|
|
28
34
|
"EXPECTED_USER_VERSION",
|
|
@@ -35,7 +41,13 @@ __all__ = [
|
|
|
35
41
|
"connect",
|
|
36
42
|
"check_schema_version",
|
|
37
43
|
"track_id_for_path",
|
|
44
|
+
"track_ids_for_paths",
|
|
45
|
+
"track_ids_by_tag",
|
|
46
|
+
"tags_for_track",
|
|
47
|
+
"TagRow",
|
|
48
|
+
"delete_tracks",
|
|
38
49
|
"prune_missing",
|
|
50
|
+
"merge_tags",
|
|
39
51
|
"replace_tags",
|
|
40
52
|
"upsert_art",
|
|
41
53
|
"replace_track_art",
|
musefs_common/scan.py
CHANGED
|
@@ -4,19 +4,23 @@ import subprocess
|
|
|
4
4
|
from .errors import ScanError
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
def run_scan(binary, db_path, target, *, timeout=None):
|
|
8
|
-
"""Run ``<binary> scan <target...> --db <db_path
|
|
9
|
-
path or an iterable of paths; all targets precede the ``--db``
|
|
10
|
-
scanned under one process (one DB open). Creates the DB if
|
|
11
|
-
the structural columns a plugin can't compute.
|
|
12
|
-
``
|
|
13
|
-
|
|
7
|
+
def run_scan(binary, db_path, target, *, revalidate=False, timeout=None):
|
|
8
|
+
"""Run ``<binary> scan <target...> --db <db_path> [--revalidate]``. ``target``
|
|
9
|
+
is a single path or an iterable of paths; all targets precede the ``--db``
|
|
10
|
+
flag and are scanned under one process (one DB open). Creates the DB if
|
|
11
|
+
absent and fills the structural columns a plugin can't compute. With
|
|
12
|
+
``revalidate``, the scanner re-checks stamps, prunes rows whose backing file
|
|
13
|
+
is gone, and GCs orphaned art. Raises ``ScanError`` (with ``kind`` in
|
|
14
|
+
``"not_found" | "timeout" | "failed"``) on failure; the caller formats its
|
|
15
|
+
own user-facing message from the exception attributes."""
|
|
14
16
|
if isinstance(target, (str, os.PathLike)):
|
|
15
17
|
targets = [target]
|
|
16
18
|
else:
|
|
17
19
|
targets = list(target)
|
|
18
20
|
display = str(targets[0]) if len(targets) == 1 else f"{len(targets)} target(s)"
|
|
19
21
|
argv = [binary, "scan", *(str(t) for t in targets), "--db", str(db_path)]
|
|
22
|
+
if revalidate:
|
|
23
|
+
argv.append("--revalidate")
|
|
20
24
|
try:
|
|
21
25
|
result = subprocess.run(argv, capture_output=True, timeout=timeout)
|
|
22
26
|
except FileNotFoundError as exc:
|
musefs_common/schema.py
CHANGED
|
@@ -5,23 +5,40 @@
|
|
|
5
5
|
SCHEMA_SQL = """\
|
|
6
6
|
-- ── MIGRATION_V1 ──
|
|
7
7
|
CREATE TABLE tracks (
|
|
8
|
-
id
|
|
9
|
-
backing_path
|
|
10
|
-
format
|
|
11
|
-
audio_offset
|
|
12
|
-
audio_length
|
|
13
|
-
backing_size
|
|
14
|
-
|
|
15
|
-
content_version
|
|
16
|
-
updated_at
|
|
8
|
+
id INTEGER PRIMARY KEY,
|
|
9
|
+
backing_path TEXT NOT NULL UNIQUE,
|
|
10
|
+
format TEXT NOT NULL,
|
|
11
|
+
audio_offset INTEGER NOT NULL,
|
|
12
|
+
audio_length INTEGER NOT NULL,
|
|
13
|
+
backing_size INTEGER NOT NULL,
|
|
14
|
+
backing_mtime_ns INTEGER NOT NULL,
|
|
15
|
+
content_version INTEGER NOT NULL DEFAULT 0,
|
|
16
|
+
updated_at INTEGER NOT NULL,
|
|
17
|
+
backing_ctime_ns INTEGER NOT NULL DEFAULT 0 CHECK (backing_ctime_ns >= 0),
|
|
18
|
+
CHECK (format IN ('flac','mp3','m4a','opus','vorbis','oggflac','wav')),
|
|
19
|
+
CHECK (audio_offset >= 0),
|
|
20
|
+
CHECK (audio_length >= 0),
|
|
21
|
+
CHECK (backing_size >= 0),
|
|
22
|
+
CHECK (backing_mtime_ns >= 0),
|
|
23
|
+
CHECK (content_version >= 0),
|
|
24
|
+
CHECK (updated_at >= 0),
|
|
25
|
+
CHECK (audio_offset + audio_length <= backing_size)
|
|
17
26
|
);
|
|
18
27
|
|
|
19
28
|
CREATE TABLE tags (
|
|
20
|
-
track_id
|
|
21
|
-
key
|
|
22
|
-
value
|
|
23
|
-
ordinal
|
|
24
|
-
|
|
29
|
+
track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
|
30
|
+
key TEXT NOT NULL,
|
|
31
|
+
value TEXT NOT NULL,
|
|
32
|
+
ordinal INTEGER NOT NULL DEFAULT 0,
|
|
33
|
+
value_blob BLOB,
|
|
34
|
+
PRIMARY KEY (track_id, key, ordinal),
|
|
35
|
+
CHECK (ordinal >= 0),
|
|
36
|
+
CHECK (value_blob IS NULL OR value = ''),
|
|
37
|
+
CHECK (length(key) <= 256),
|
|
38
|
+
CHECK (length(key) >= 1
|
|
39
|
+
AND key NOT GLOB '*[' || char(1) || '-' || char(31) || ']*'),
|
|
40
|
+
CHECK (length(value) <= 262144),
|
|
41
|
+
CHECK (value_blob IS NULL OR length(value_blob) <= 16711680)
|
|
25
42
|
);
|
|
26
43
|
|
|
27
44
|
CREATE TABLE art (
|
|
@@ -31,7 +48,13 @@ CREATE TABLE art (
|
|
|
31
48
|
width INTEGER,
|
|
32
49
|
height INTEGER,
|
|
33
50
|
byte_len INTEGER NOT NULL,
|
|
34
|
-
data BLOB NOT NULL
|
|
51
|
+
data BLOB NOT NULL,
|
|
52
|
+
CHECK (byte_len = length(data)),
|
|
53
|
+
CHECK (length(sha256) = 64),
|
|
54
|
+
CHECK (width IS NULL OR width >= 0),
|
|
55
|
+
CHECK (height IS NULL OR height >= 0),
|
|
56
|
+
CHECK (length(mime) <= 255),
|
|
57
|
+
CHECK (byte_len <= 16711680)
|
|
35
58
|
);
|
|
36
59
|
|
|
37
60
|
CREATE TABLE track_art (
|
|
@@ -40,9 +63,38 @@ CREATE TABLE track_art (
|
|
|
40
63
|
picture_type INTEGER NOT NULL DEFAULT 3,
|
|
41
64
|
description TEXT NOT NULL DEFAULT '',
|
|
42
65
|
ordinal INTEGER NOT NULL DEFAULT 0,
|
|
43
|
-
PRIMARY KEY (track_id, ordinal)
|
|
66
|
+
PRIMARY KEY (track_id, ordinal),
|
|
67
|
+
CHECK (picture_type BETWEEN 0 AND 20),
|
|
68
|
+
CHECK (ordinal >= 0),
|
|
69
|
+
CHECK (length(description) <= 1024)
|
|
44
70
|
);
|
|
45
71
|
|
|
72
|
+
-- Read-only, derived-from-file structural metadata (FLAC STREAMINFO/SEEKTABLE).
|
|
73
|
+
-- NOT part of the editable `tags` contract: external tools never touch it.
|
|
74
|
+
CREATE TABLE structural_blocks (
|
|
75
|
+
track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
|
76
|
+
kind TEXT NOT NULL,
|
|
77
|
+
ordinal INTEGER NOT NULL DEFAULT 0,
|
|
78
|
+
body BLOB NOT NULL,
|
|
79
|
+
PRIMARY KEY (track_id, kind, ordinal),
|
|
80
|
+
CHECK (kind IN ('STREAMINFO','SEEKTABLE')),
|
|
81
|
+
CHECK (ordinal >= 0),
|
|
82
|
+
CHECK (length(body) <= 16777215)
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
-- Bounded changelog ring for O(changed) refresh. Every metadata edit funnels
|
|
86
|
+
-- through an UPDATE on the tracks row (the tags/track_art triggers), so
|
|
87
|
+
-- triggers on tracks alone capture all writers. Relies on SQLite nested
|
|
88
|
+
-- trigger activation (on by default; distinct from PRAGMA recursive_triggers).
|
|
89
|
+
CREATE TABLE track_changes (
|
|
90
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
91
|
+
track_id INTEGER NOT NULL
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
-- Index the reverse art -> track_art edge so bulk orphan-GC and the art delete
|
|
95
|
+
-- trigger below do not scan the whole join table per deleted row.
|
|
96
|
+
CREATE INDEX track_art_art_id_idx ON track_art(art_id);
|
|
97
|
+
|
|
46
98
|
CREATE TRIGGER tags_ai AFTER INSERT ON tags BEGIN
|
|
47
99
|
UPDATE tracks SET content_version = content_version + 1,
|
|
48
100
|
updated_at = CAST(strftime('%s','now') AS INTEGER)
|
|
@@ -74,33 +126,6 @@ CREATE TRIGGER track_art_ad AFTER DELETE ON track_art BEGIN
|
|
|
74
126
|
updated_at = CAST(strftime('%s','now') AS INTEGER)
|
|
75
127
|
WHERE id = OLD.track_id;
|
|
76
128
|
END;
|
|
77
|
-
PRAGMA user_version = 1;
|
|
78
|
-
|
|
79
|
-
-- ── MIGRATION_V2 ──
|
|
80
|
-
-- Binary tag payloads live alongside text tags. A row is binary iff
|
|
81
|
-
-- value_blob IS NOT NULL; binary rows store '' in value.
|
|
82
|
-
ALTER TABLE tags ADD COLUMN value_blob BLOB;
|
|
83
|
-
|
|
84
|
-
-- Read-only, derived-from-file structural metadata (FLAC STREAMINFO/SEEKTABLE).
|
|
85
|
-
-- NOT part of the editable `tags` contract: external tools never touch it.
|
|
86
|
-
CREATE TABLE structural_blocks (
|
|
87
|
-
track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
|
88
|
-
kind TEXT NOT NULL,
|
|
89
|
-
ordinal INTEGER NOT NULL DEFAULT 0,
|
|
90
|
-
body BLOB NOT NULL,
|
|
91
|
-
PRIMARY KEY (track_id, kind, ordinal)
|
|
92
|
-
);
|
|
93
|
-
PRAGMA user_version = 2;
|
|
94
|
-
|
|
95
|
-
-- ── MIGRATION_V3 ──
|
|
96
|
-
-- Bounded changelog ring for O(changed) refresh. Every metadata edit funnels
|
|
97
|
-
-- through an UPDATE on the tracks row (the V1 tags/track_art triggers), so
|
|
98
|
-
-- triggers on tracks alone capture all writers. Relies on SQLite nested
|
|
99
|
-
-- trigger activation (on by default; distinct from PRAGMA recursive_triggers).
|
|
100
|
-
CREATE TABLE track_changes (
|
|
101
|
-
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
102
|
-
track_id INTEGER NOT NULL
|
|
103
|
-
);
|
|
104
129
|
|
|
105
130
|
CREATE TRIGGER tracks_changelog_ai AFTER INSERT ON tracks BEGIN
|
|
106
131
|
INSERT INTO track_changes (track_id) VALUES (NEW.id);
|
|
@@ -117,40 +142,91 @@ END;
|
|
|
117
142
|
CREATE TRIGGER track_changes_prune AFTER INSERT ON track_changes BEGIN
|
|
118
143
|
DELETE FROM track_changes WHERE seq <= NEW.seq - 8192;
|
|
119
144
|
END;
|
|
120
|
-
PRAGMA user_version = 3;
|
|
121
145
|
|
|
122
|
-
--
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
146
|
+
-- art rows are content-addressed by sha256: once written, their content
|
|
147
|
+
-- columns are immutable. A writer needing different bytes/metadata inserts a
|
|
148
|
+
-- NEW row and relinks via track_art (which bumps content_version through the
|
|
149
|
+
-- track_art triggers). width/height use IS NOT (NULL-safe) because they are
|
|
150
|
+
-- nullable; the NOT NULL columns use <>.
|
|
151
|
+
CREATE TRIGGER art_reject_content_update
|
|
152
|
+
BEFORE UPDATE ON art
|
|
153
|
+
WHEN NEW.data <> OLD.data
|
|
154
|
+
OR NEW.sha256 <> OLD.sha256
|
|
155
|
+
OR NEW.mime <> OLD.mime
|
|
156
|
+
OR NEW.byte_len <> OLD.byte_len
|
|
157
|
+
OR NEW.width IS NOT OLD.width
|
|
158
|
+
OR NEW.height IS NOT OLD.height
|
|
159
|
+
BEGIN
|
|
160
|
+
SELECT RAISE(ABORT,
|
|
161
|
+
'art rows are immutable; insert a new content-addressed row and relink via track_art');
|
|
162
|
+
END;
|
|
127
163
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
164
|
+
-- Deleting an art row that still has track_art references (an orphan an
|
|
165
|
+
-- external writer can produce with foreign_keys OFF) bumps every referencing
|
|
166
|
+
-- track, so the mount rebuilds and serves a clean EIO on the orphan rather
|
|
167
|
+
-- than streaming stale bytes from an old cached layout. Inert on the normal
|
|
168
|
+
-- gc_orphan_art path, where the deleted row has no references.
|
|
169
|
+
CREATE TRIGGER art_ad AFTER DELETE ON art BEGIN
|
|
170
|
+
UPDATE tracks SET content_version = content_version + 1,
|
|
171
|
+
updated_at = CAST(strftime('%s','now') AS INTEGER)
|
|
172
|
+
WHERE id IN (SELECT track_id FROM track_art WHERE art_id = OLD.id);
|
|
173
|
+
END;
|
|
132
174
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
CHECK (content_version >= 0),
|
|
149
|
-
CHECK (updated_at >= 0),
|
|
150
|
-
CHECK (audio_offset + audio_length <= backing_size)
|
|
151
|
-
);
|
|
175
|
+
-- Scanner-owned geometry feeds the synthesized layout, but upsert_track does
|
|
176
|
+
-- not touch content_version. Bump it whenever a geometry column actually
|
|
177
|
+
-- changes, making content_version a true superset of served-byte inputs. The
|
|
178
|
+
-- WHEN guard is false on this trigger's own nested UPDATE (only content_version
|
|
179
|
+
-- changes), so the recursion terminates after exactly one bump.
|
|
180
|
+
CREATE TRIGGER tracks_geometry_au
|
|
181
|
+
AFTER UPDATE ON tracks
|
|
182
|
+
WHEN NEW.format <> OLD.format
|
|
183
|
+
OR NEW.audio_offset <> OLD.audio_offset
|
|
184
|
+
OR NEW.audio_length <> OLD.audio_length
|
|
185
|
+
OR NEW.backing_size <> OLD.backing_size
|
|
186
|
+
OR NEW.backing_mtime_ns <> OLD.backing_mtime_ns
|
|
187
|
+
BEGIN
|
|
188
|
+
UPDATE tracks SET content_version = content_version + 1 WHERE id = NEW.id;
|
|
189
|
+
END;
|
|
152
190
|
|
|
153
|
-
|
|
191
|
+
-- FLAC structural blocks feed synthesized headers and flip the synthesis path
|
|
192
|
+
-- (legacy front-read fallback vs streamed fast path), so a change must bump.
|
|
193
|
+
-- set_structural_blocks is DELETE-then-INSERT (no UPDATE path exists), so these
|
|
194
|
+
-- fire on every rewrite; the resulting over-bump on a byte-identical re-probe
|
|
195
|
+
-- is harmless monotone churn (content_version is compared only for equality).
|
|
196
|
+
CREATE TRIGGER structural_blocks_ai AFTER INSERT ON structural_blocks BEGIN
|
|
197
|
+
UPDATE tracks SET content_version = content_version + 1 WHERE id = NEW.track_id;
|
|
198
|
+
END;
|
|
199
|
+
CREATE TRIGGER structural_blocks_ad AFTER DELETE ON structural_blocks BEGIN
|
|
200
|
+
UPDATE tracks SET content_version = content_version + 1 WHERE id = OLD.track_id;
|
|
201
|
+
END;
|
|
202
|
+
PRAGMA user_version = 1;
|
|
203
|
+
|
|
204
|
+
-- ── MIGRATION_V2 ──
|
|
205
|
+
-- fingerprint/content_hash are scanner-owned content identities. Neither is
|
|
206
|
+
-- UNIQUE and the index is NON-unique BY DESIGN: duplicate-content tracks (same
|
|
207
|
+
-- album in two places, genuine dupes) legitimately share both values, and a
|
|
208
|
+
-- UNIQUE constraint would abort the scan batch on the second copy. Correctness
|
|
209
|
+
-- comes from the refind logic (unique-missing candidate + confirmation), not
|
|
210
|
+
-- from DB uniqueness. Both columns carry a length(x) = 64 CHECK locking them
|
|
211
|
+
-- to SHA-256 hex (Task E2 benchmark locked the hash to SHA-256: under a
|
|
212
|
+
-- realistic SSD/HDD I/O profile the fingerprint adds ~8.6%; the RAM
|
|
213
|
+
-- microbench's higher ratio is an I/O-elimination artifact — see
|
|
214
|
+
-- the benchmarks docs). Hash function is now fixed, so the CHECK is added here.
|
|
215
|
+
ALTER TABLE tracks ADD COLUMN fingerprint TEXT
|
|
216
|
+
CHECK (fingerprint IS NULL OR length(fingerprint) = 64);
|
|
217
|
+
ALTER TABLE tracks ADD COLUMN content_hash TEXT
|
|
218
|
+
CHECK (content_hash IS NULL OR length(content_hash) = 64);
|
|
219
|
+
CREATE INDEX tracks_fingerprint_idx ON tracks(fingerprint);
|
|
220
|
+
|
|
221
|
+
-- Rebuild `tags` with a byte-accurate value cap (#505). SQLite's length() on
|
|
222
|
+
-- TEXT counts characters, so the V1 `CHECK (length(value) <= 262144)` was up to
|
|
223
|
+
-- ~4x looser than the documented 256 KiB byte bound; length(CAST(value AS BLOB))
|
|
224
|
+
-- counts bytes. SQLite cannot alter a CHECK in place, so recreate the table
|
|
225
|
+
-- (V2 is unreleased — this is folded in rather than added as a new migration).
|
|
226
|
+
-- Pre-existing over-cap rows (only reachable on an upgraded store) are dropped:
|
|
227
|
+
-- the read-time guard already counts bytes, so they were unreadable anyway, and
|
|
228
|
+
-- carrying them would abort the rebuild on the new CHECK.
|
|
229
|
+
CREATE TABLE tags_new (
|
|
154
230
|
track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
|
155
231
|
key TEXT NOT NULL,
|
|
156
232
|
value TEXT NOT NULL,
|
|
@@ -158,44 +234,21 @@ CREATE TABLE tags (
|
|
|
158
234
|
value_blob BLOB,
|
|
159
235
|
PRIMARY KEY (track_id, key, ordinal),
|
|
160
236
|
CHECK (ordinal >= 0),
|
|
161
|
-
CHECK (value_blob IS NULL OR value = '')
|
|
237
|
+
CHECK (value_blob IS NULL OR value = ''),
|
|
238
|
+
CHECK (length(key) <= 256),
|
|
239
|
+
CHECK (length(key) >= 1
|
|
240
|
+
AND key NOT GLOB '*[' || char(1) || '-' || char(31) || ']*'),
|
|
241
|
+
CHECK (length(CAST(value AS BLOB)) <= 262144),
|
|
242
|
+
CHECK (value_blob IS NULL OR length(value_blob) <= 16711680)
|
|
162
243
|
);
|
|
244
|
+
INSERT INTO tags_new (track_id, key, value, ordinal, value_blob)
|
|
245
|
+
SELECT track_id, key, value, ordinal, value_blob FROM tags
|
|
246
|
+
WHERE length(CAST(value AS BLOB)) <= 262144;
|
|
247
|
+
DROP TABLE tags;
|
|
248
|
+
ALTER TABLE tags_new RENAME TO tags;
|
|
163
249
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
sha256 TEXT NOT NULL UNIQUE,
|
|
167
|
-
mime TEXT NOT NULL,
|
|
168
|
-
width INTEGER,
|
|
169
|
-
height INTEGER,
|
|
170
|
-
byte_len INTEGER NOT NULL,
|
|
171
|
-
data BLOB NOT NULL,
|
|
172
|
-
CHECK (byte_len = length(data)),
|
|
173
|
-
CHECK (length(sha256) = 64),
|
|
174
|
-
CHECK (width IS NULL OR width >= 0),
|
|
175
|
-
CHECK (height IS NULL OR height >= 0)
|
|
176
|
-
);
|
|
177
|
-
|
|
178
|
-
CREATE TABLE track_art (
|
|
179
|
-
track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
|
180
|
-
art_id INTEGER NOT NULL REFERENCES art(id),
|
|
181
|
-
picture_type INTEGER NOT NULL DEFAULT 3,
|
|
182
|
-
description TEXT NOT NULL DEFAULT '',
|
|
183
|
-
ordinal INTEGER NOT NULL DEFAULT 0,
|
|
184
|
-
PRIMARY KEY (track_id, ordinal),
|
|
185
|
-
CHECK (picture_type BETWEEN 0 AND 20),
|
|
186
|
-
CHECK (ordinal >= 0)
|
|
187
|
-
);
|
|
188
|
-
|
|
189
|
-
INSERT INTO tracks SELECT * FROM _m4_tracks;
|
|
190
|
-
INSERT INTO art SELECT * FROM _m4_art;
|
|
191
|
-
INSERT INTO tags SELECT * FROM _m4_tags;
|
|
192
|
-
INSERT INTO track_art SELECT * FROM _m4_track_art;
|
|
193
|
-
|
|
194
|
-
DROP TABLE _m4_track_art;
|
|
195
|
-
DROP TABLE _m4_tags;
|
|
196
|
-
DROP TABLE _m4_art;
|
|
197
|
-
DROP TABLE _m4_tracks;
|
|
198
|
-
|
|
250
|
+
-- DROP TABLE tags dropped its INSERT/UPDATE/DELETE triggers; recreate them
|
|
251
|
+
-- verbatim so the content_version/updated_at bump contract is unchanged.
|
|
199
252
|
CREATE TRIGGER tags_ai AFTER INSERT ON tags BEGIN
|
|
200
253
|
UPDATE tracks SET content_version = content_version + 1,
|
|
201
254
|
updated_at = CAST(strftime('%s','now') AS INTEGER)
|
|
@@ -211,33 +264,7 @@ CREATE TRIGGER tags_ad AFTER DELETE ON tags BEGIN
|
|
|
211
264
|
updated_at = CAST(strftime('%s','now') AS INTEGER)
|
|
212
265
|
WHERE id = OLD.track_id;
|
|
213
266
|
END;
|
|
214
|
-
|
|
215
|
-
CREATE TRIGGER track_art_ai AFTER INSERT ON track_art BEGIN
|
|
216
|
-
UPDATE tracks SET content_version = content_version + 1,
|
|
217
|
-
updated_at = CAST(strftime('%s','now') AS INTEGER)
|
|
218
|
-
WHERE id = NEW.track_id;
|
|
219
|
-
END;
|
|
220
|
-
CREATE TRIGGER track_art_au AFTER UPDATE ON track_art BEGIN
|
|
221
|
-
UPDATE tracks SET content_version = content_version + 1,
|
|
222
|
-
updated_at = CAST(strftime('%s','now') AS INTEGER)
|
|
223
|
-
WHERE id = NEW.track_id;
|
|
224
|
-
END;
|
|
225
|
-
CREATE TRIGGER track_art_ad AFTER DELETE ON track_art BEGIN
|
|
226
|
-
UPDATE tracks SET content_version = content_version + 1,
|
|
227
|
-
updated_at = CAST(strftime('%s','now') AS INTEGER)
|
|
228
|
-
WHERE id = OLD.track_id;
|
|
229
|
-
END;
|
|
230
|
-
|
|
231
|
-
CREATE TRIGGER tracks_changelog_ai AFTER INSERT ON tracks BEGIN
|
|
232
|
-
INSERT INTO track_changes (track_id) VALUES (NEW.id);
|
|
233
|
-
END;
|
|
234
|
-
CREATE TRIGGER tracks_changelog_au AFTER UPDATE ON tracks BEGIN
|
|
235
|
-
INSERT INTO track_changes (track_id) VALUES (NEW.id);
|
|
236
|
-
END;
|
|
237
|
-
CREATE TRIGGER tracks_changelog_ad AFTER DELETE ON tracks BEGIN
|
|
238
|
-
INSERT INTO track_changes (track_id) VALUES (OLD.id);
|
|
239
|
-
END;
|
|
240
|
-
PRAGMA user_version = 4;
|
|
267
|
+
PRAGMA user_version = 2;
|
|
241
268
|
"""
|
|
242
269
|
|
|
243
|
-
USER_VERSION =
|
|
270
|
+
USER_VERSION = 2
|
musefs_common/store.py
CHANGED
|
@@ -1,10 +1,86 @@
|
|
|
1
|
+
import contextlib
|
|
1
2
|
import hashlib
|
|
2
3
|
import os
|
|
3
4
|
import sqlite3
|
|
5
|
+
from dataclasses import dataclass
|
|
4
6
|
|
|
5
7
|
from .constants import EXPECTED_USER_VERSION
|
|
6
8
|
from .errors import SchemaMismatch
|
|
7
9
|
|
|
10
|
+
# SQLite caps a statement's host parameters (SQLITE_MAX_VARIABLE_NUMBER: 999 on
|
|
11
|
+
# the <3.32 floor). Chunk bulk IN-lists below it so large lookups never trip it.
|
|
12
|
+
_MAX_SQL_VARS = 900
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class TagRow:
|
|
17
|
+
"""One tag row read back from the store: the key, the text value, and the
|
|
18
|
+
raw ``value_blob``. Plugin-owned text tags have ``value_blob is None``;
|
|
19
|
+
scanner-written binary tags have ``value == ""`` and ``value_blob`` bytes."""
|
|
20
|
+
|
|
21
|
+
key: str
|
|
22
|
+
value: str
|
|
23
|
+
value_blob: object = None # bytes | None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# sqlite3.LEGACY_TRANSACTION_CONTROL is 3.12+; it is == -1. Use getattr so this
|
|
27
|
+
# module still imports on the 3.8 floor (where the constant does not exist).
|
|
28
|
+
_LEGACY = getattr(sqlite3, "LEGACY_TRANSACTION_CONTROL", -1)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is_autocommit(conn):
|
|
32
|
+
"""True if the connection auto-commits each statement (no caller-owned
|
|
33
|
+
transaction will be committed for us)."""
|
|
34
|
+
ac = getattr(conn, "autocommit", _LEGACY) # 3.12+ attribute; _LEGACY on <3.12
|
|
35
|
+
if ac is True:
|
|
36
|
+
return True
|
|
37
|
+
if ac is False:
|
|
38
|
+
return False
|
|
39
|
+
return conn.isolation_level is None # legacy transaction control
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _is_legacy(conn):
|
|
43
|
+
"""True if the connection uses legacy transaction control (the <3.12 default
|
|
44
|
+
and the 3.12+ LEGACY_TRANSACTION_CONTROL mode)."""
|
|
45
|
+
return getattr(conn, "autocommit", _LEGACY) == _LEGACY
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@contextlib.contextmanager
|
|
49
|
+
def _savepoint(conn, name):
|
|
50
|
+
"""Make a DELETE+INSERT block atomic regardless of the connection's
|
|
51
|
+
transaction mode. On a caller-managed connection it nests via SAVEPOINT and
|
|
52
|
+
never commits the enclosing transaction; on an autocommit connection the
|
|
53
|
+
outermost call owns a transaction for the block (commit on success, rollback
|
|
54
|
+
on failure). Nested calls only nest -- they never BEGIN or commit -- so a
|
|
55
|
+
sync_one savepoint may wrap these per-function savepoints safely.
|
|
56
|
+
|
|
57
|
+
``name`` must be a hardcoded SQL identifier (it is interpolated into the SQL,
|
|
58
|
+
so never pass caller-controlled text)."""
|
|
59
|
+
autocommit = _is_autocommit(conn)
|
|
60
|
+
owns = not conn.in_transaction # outermost call: it opens & owns the txn
|
|
61
|
+
# Legacy mode never auto-BEGINs before SAVEPOINT, so a savepoint opened as
|
|
62
|
+
# the first statement of a batch would become the outermost transaction and
|
|
63
|
+
# commit durably on RELEASE. Force a nesting BEGIN there. PEP-249 modes
|
|
64
|
+
# auto-begin before any statement, so they need no nudge.
|
|
65
|
+
if owns and _is_legacy(conn):
|
|
66
|
+
conn.execute("BEGIN")
|
|
67
|
+
conn.execute(f"SAVEPOINT {name}")
|
|
68
|
+
try:
|
|
69
|
+
yield
|
|
70
|
+
except BaseException:
|
|
71
|
+
try:
|
|
72
|
+
conn.execute(f"ROLLBACK TO {name}")
|
|
73
|
+
conn.execute(f"RELEASE {name}")
|
|
74
|
+
if owns and autocommit:
|
|
75
|
+
conn.rollback()
|
|
76
|
+
except sqlite3.Error:
|
|
77
|
+
pass # never mask the original exception with a cleanup failure
|
|
78
|
+
raise
|
|
79
|
+
else:
|
|
80
|
+
conn.execute(f"RELEASE {name}")
|
|
81
|
+
if owns and autocommit:
|
|
82
|
+
conn.commit()
|
|
83
|
+
|
|
8
84
|
|
|
9
85
|
def connect(db_path):
|
|
10
86
|
"""Open the musefs DB with a busy timeout and foreign keys enabled."""
|
|
@@ -29,6 +105,77 @@ def track_id_for_path(conn, key):
|
|
|
29
105
|
return row[0] if row else None
|
|
30
106
|
|
|
31
107
|
|
|
108
|
+
def track_ids_for_paths(conn, keys):
|
|
109
|
+
"""Resolve many ``backing_path`` keys to track ids in one pass, returning a
|
|
110
|
+
``{key: id}`` dict that omits keys with no matching track row. The IN-list is
|
|
111
|
+
chunked under SQLite's host-parameter cap so arbitrarily large lookups work
|
|
112
|
+
(the bulk counterpart to ``track_id_for_path``)."""
|
|
113
|
+
# Deduplicate while preserving first-seen order: a key repeated across chunk
|
|
114
|
+
# boundaries would re-fetch its row in a later chunk and trip the duplicate
|
|
115
|
+
# guard below even on a conformant DB.
|
|
116
|
+
keys = list(dict.fromkeys(keys))
|
|
117
|
+
out = {}
|
|
118
|
+
for start in range(0, len(keys), _MAX_SQL_VARS):
|
|
119
|
+
chunk = keys[start : start + _MAX_SQL_VARS]
|
|
120
|
+
placeholders = ",".join("?" for _ in chunk)
|
|
121
|
+
rows = conn.execute(
|
|
122
|
+
f"SELECT backing_path, id FROM tracks WHERE backing_path IN ({placeholders})",
|
|
123
|
+
chunk,
|
|
124
|
+
)
|
|
125
|
+
for backing_path, track_id in rows:
|
|
126
|
+
if backing_path in out:
|
|
127
|
+
# backing_path is UNIQUE in the schema, so a duplicate means a
|
|
128
|
+
# non-conformant DB; collapsing it would silently hide a track
|
|
129
|
+
# from prune (#478). Fail loudly instead.
|
|
130
|
+
raise ValueError(
|
|
131
|
+
f"duplicate backing_path {backing_path!r} in tracks "
|
|
132
|
+
f"(ids {out[backing_path]} and {track_id})"
|
|
133
|
+
)
|
|
134
|
+
out[backing_path] = track_id
|
|
135
|
+
return out
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def track_ids_by_tag(conn, key, value):
|
|
139
|
+
"""Return a list of track ids whose plugin-owned text tag ``(key, value)``
|
|
140
|
+
matches (order unspecified, possibly empty).
|
|
141
|
+
|
|
142
|
+
Scoped to text rows (``value_blob IS NULL``); scanner-written binary tags
|
|
143
|
+
never match. The intent-based counterpart to ``prune_missing``'s
|
|
144
|
+
existence-based scoping: used to map a source's "I deleted this album/artist"
|
|
145
|
+
signal back to the rows it tagged.
|
|
146
|
+
"""
|
|
147
|
+
rows = conn.execute(
|
|
148
|
+
"SELECT track_id FROM tags WHERE key = ? AND value = ? AND value_blob IS NULL",
|
|
149
|
+
(key, value),
|
|
150
|
+
)
|
|
151
|
+
return [track_id for (track_id,) in rows]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def delete_tracks(conn, track_ids):
|
|
155
|
+
"""Unconditionally delete the given track rows; return the count actually
|
|
156
|
+
deleted (an already-gone id contributes 0).
|
|
157
|
+
|
|
158
|
+
The intent-based delete: unlike ``prune_missing`` it does not check on-disk
|
|
159
|
+
existence. ``tags`` and ``track_art`` rows cascade away via the schema's
|
|
160
|
+
``ON DELETE CASCADE`` (``connect`` enables ``foreign_keys = ON``).
|
|
161
|
+
"""
|
|
162
|
+
deleted = 0
|
|
163
|
+
for track_id in track_ids:
|
|
164
|
+
deleted += conn.execute("DELETE FROM tracks WHERE id = ?", (track_id,)).rowcount
|
|
165
|
+
return deleted
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def tags_for_track(conn, track_id):
|
|
169
|
+
"""Read back a track's tag rows as an ordered ``list[TagRow]`` (by key, then
|
|
170
|
+
ordinal). Includes both plugin-owned text tags (``value_blob is None``) and
|
|
171
|
+
scanner-written binary tags (``value == ""``, ``value_blob`` bytes)."""
|
|
172
|
+
rows = conn.execute(
|
|
173
|
+
"SELECT key, value, value_blob FROM tags WHERE track_id = ? ORDER BY key, ordinal",
|
|
174
|
+
(track_id,),
|
|
175
|
+
)
|
|
176
|
+
return [TagRow(key, value, value_blob) for key, value, value_blob in rows]
|
|
177
|
+
|
|
178
|
+
|
|
32
179
|
def prune_missing(conn, track_ids=None):
|
|
33
180
|
"""Delete track rows whose backing file no longer exists on disk.
|
|
34
181
|
|
|
@@ -54,20 +201,63 @@ def prune_missing(conn, track_ids=None):
|
|
|
54
201
|
|
|
55
202
|
def replace_tags(conn, track_id, pairs):
|
|
56
203
|
"""Replace all tags for a track. Duplicate keys get incrementing ordinals
|
|
57
|
-
(mirroring musefs scan ingest).
|
|
204
|
+
(mirroring musefs scan ingest).
|
|
205
|
+
|
|
206
|
+
Atomic via an internal savepoint (see ``_savepoint``), so a crash between the
|
|
207
|
+
DELETE and the INSERT can never leave the track's text tags wiped -- safe
|
|
208
|
+
even when called on an autocommit connection."""
|
|
58
209
|
# Scope to the plugin-owned text rows: scanner-written binary tags
|
|
59
210
|
# (value_blob NOT NULL) must survive a sync (#82).
|
|
60
|
-
conn
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
211
|
+
with _savepoint(conn, "musefs_replace_tags"):
|
|
212
|
+
conn.execute("DELETE FROM tags WHERE track_id = ? AND value_blob IS NULL", (track_id,))
|
|
213
|
+
ordinals = {}
|
|
214
|
+
rows = []
|
|
215
|
+
for key, value in pairs:
|
|
216
|
+
ordinal = ordinals.get(key, 0)
|
|
217
|
+
ordinals[key] = ordinal + 1
|
|
218
|
+
rows.append((track_id, key, value, ordinal))
|
|
219
|
+
conn.executemany(
|
|
220
|
+
"INSERT INTO tags (track_id, key, value, ordinal) VALUES (?, ?, ?, ?)",
|
|
221
|
+
rows,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def merge_tags(conn, track_id, managed_pairs, delete_keys):
|
|
226
|
+
"""Per-key replace of the plugin-managed text tags, leaving unmanaged text
|
|
227
|
+
rows (the scan-seeded baseline) intact. ``managed_pairs`` is an ordered list
|
|
228
|
+
of (key, value); every key it names is cleared and rewritten with contiguous
|
|
229
|
+
ordinals. ``delete_keys`` names keys to clear without rewriting (tags the
|
|
230
|
+
plugin previously managed and the user has now removed). Both deletes are
|
|
231
|
+
scoped to ``value_blob IS NULL`` so scanner-written binary tags survive.
|
|
232
|
+
|
|
233
|
+
Atomic via an internal savepoint (see ``_savepoint``): the per-key deletes
|
|
234
|
+
and the rewrite either all land or none do, even on an autocommit
|
|
235
|
+
connection."""
|
|
236
|
+
with _savepoint(conn, "musefs_merge_tags"):
|
|
237
|
+
by_key = {}
|
|
238
|
+
for key, value in managed_pairs:
|
|
239
|
+
by_key.setdefault(key, []).append(value)
|
|
240
|
+
|
|
241
|
+
# Case-fold the key match: a scan seeds an unmapped tag in the file's
|
|
242
|
+
# native case (e.g. Vorbis ``LABEL``) while the plugin canonicalises to
|
|
243
|
+
# lowercase (``label``). Vorbis keys render case-insensitively, so an
|
|
244
|
+
# exact-case delete would leave the scan row and render a duplicate (#407).
|
|
245
|
+
for key in set(by_key) | set(delete_keys or ()):
|
|
246
|
+
conn.execute(
|
|
247
|
+
"DELETE FROM tags WHERE track_id = ? AND lower(key) = lower(?) "
|
|
248
|
+
"AND value_blob IS NULL",
|
|
249
|
+
(track_id, key),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
rows = [
|
|
253
|
+
(track_id, key, value, ordinal)
|
|
254
|
+
for key, values in by_key.items()
|
|
255
|
+
for ordinal, value in enumerate(values)
|
|
256
|
+
]
|
|
257
|
+
conn.executemany(
|
|
258
|
+
"INSERT INTO tags (track_id, key, value, ordinal) VALUES (?, ?, ?, ?)",
|
|
259
|
+
rows,
|
|
260
|
+
)
|
|
71
261
|
|
|
72
262
|
|
|
73
263
|
_EXT_MIME = {
|
|
@@ -107,13 +297,18 @@ def upsert_art(conn, data, mime):
|
|
|
107
297
|
def replace_track_art(conn, track_id, arts):
|
|
108
298
|
"""Replace the track's art rows. ``arts`` is an ordered list of
|
|
109
299
|
``(art_id, picture_type, description)``; each row's ``ordinal`` is its
|
|
110
|
-
list index.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
300
|
+
list index.
|
|
301
|
+
|
|
302
|
+
Atomic via an internal savepoint (see ``_savepoint``): the DELETE and the
|
|
303
|
+
re-insert either both land or neither does, even on an autocommit
|
|
304
|
+
connection."""
|
|
305
|
+
with _savepoint(conn, "musefs_replace_track_art"):
|
|
306
|
+
conn.execute("DELETE FROM track_art WHERE track_id = ?", (track_id,))
|
|
307
|
+
conn.executemany(
|
|
308
|
+
"INSERT INTO track_art (track_id, art_id, picture_type, description, "
|
|
309
|
+
"ordinal) VALUES (?, ?, ?, ?, ?)",
|
|
310
|
+
[
|
|
311
|
+
(track_id, art_id, picture_type, description, i)
|
|
312
|
+
for i, (art_id, picture_type, description) in enumerate(arts)
|
|
313
|
+
],
|
|
314
|
+
)
|
musefs_common/sync.py
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import sqlite3
|
|
3
4
|
from dataclasses import dataclass, field
|
|
4
5
|
|
|
5
6
|
from .constants import MAX_ART_BYTES
|
|
6
|
-
from .store import
|
|
7
|
+
from .store import (
|
|
8
|
+
_savepoint,
|
|
9
|
+
merge_tags,
|
|
10
|
+
replace_tags,
|
|
11
|
+
replace_track_art,
|
|
12
|
+
track_id_for_path,
|
|
13
|
+
upsert_art,
|
|
14
|
+
)
|
|
7
15
|
|
|
8
16
|
|
|
9
17
|
@dataclass(frozen=True)
|
|
@@ -26,6 +34,7 @@ class Record:
|
|
|
26
34
|
key: str
|
|
27
35
|
pairs: list = field(default_factory=list)
|
|
28
36
|
art: object = None # list[ArtImage] | None
|
|
37
|
+
delete_keys: object = None # list[str] of keys to clear without rewrite (merge mode)
|
|
29
38
|
|
|
30
39
|
|
|
31
40
|
@dataclass
|
|
@@ -34,21 +43,32 @@ class SyncStats:
|
|
|
34
43
|
skipped: int = 0 # path had no matching track row
|
|
35
44
|
art_linked: int = 0
|
|
36
45
|
skipped_art: int = 0 # art over the size cap (or, in the beets adapter, unreadable)
|
|
46
|
+
skipped_invalid: int = 0 # record violated a store CHECK constraint
|
|
47
|
+
invalid: list = field(default_factory=list) # (record key, sqlite error message)
|
|
37
48
|
|
|
38
49
|
def summary(self):
|
|
39
50
|
return (
|
|
40
51
|
f"synced={self.synced} skipped={self.skipped} "
|
|
41
|
-
f"art_linked={self.art_linked} skipped_art={self.skipped_art}"
|
|
52
|
+
f"art_linked={self.art_linked} skipped_art={self.skipped_art} "
|
|
53
|
+
f"skipped_invalid={self.skipped_invalid}"
|
|
42
54
|
)
|
|
43
55
|
|
|
44
56
|
|
|
45
|
-
def sync_one(conn, record, stats, *, dry_run=False):
|
|
57
|
+
def sync_one(conn, record, stats, *, dry_run=False, merge=False):
|
|
46
58
|
"""Sync one ``Record`` into the DB, mutating ``stats``. Caller owns the
|
|
47
|
-
transaction.
|
|
48
|
-
|
|
59
|
+
transaction. With ``merge=False`` (the default) all plugin-owned text tags are
|
|
60
|
+
replaced; with ``merge=True`` only the keys in ``record.pairs`` and
|
|
61
|
+
``record.delete_keys`` are touched (see ``merge_tags``). Either way,
|
|
62
|
+
scanner-written binary tags survive. Art is replaced when at least one image is
|
|
49
63
|
within ``MAX_ART_BYTES``; each over-cap image bumps ``skipped_art``, and if
|
|
50
64
|
every provided image is over cap any scan-seeded ``track_art`` is left
|
|
51
|
-
untouched.
|
|
65
|
+
untouched.
|
|
66
|
+
|
|
67
|
+
A record whose tags or art violate a store CHECK constraint (key/value/mime
|
|
68
|
+
length, ``picture_type`` range, control chars, ...) is rolled back through its
|
|
69
|
+
own savepoint and skipped -- it bumps ``skipped_invalid`` and appends
|
|
70
|
+
``(record.key, message)`` to ``invalid`` rather than aborting the whole batch
|
|
71
|
+
with an opaque commit-time ``IntegrityError`` (#420)."""
|
|
52
72
|
track_id = track_id_for_path(conn, record.key)
|
|
53
73
|
if track_id is None:
|
|
54
74
|
stats.skipped += 1
|
|
@@ -63,20 +83,29 @@ def sync_one(conn, record, stats, *, dry_run=False):
|
|
|
63
83
|
will_link_art = bool(kept)
|
|
64
84
|
|
|
65
85
|
if not dry_run:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
86
|
+
try:
|
|
87
|
+
with _savepoint(conn, "musefs_sync_one"):
|
|
88
|
+
if merge:
|
|
89
|
+
merge_tags(conn, track_id, record.pairs, record.delete_keys or [])
|
|
90
|
+
else:
|
|
91
|
+
replace_tags(conn, track_id, record.pairs)
|
|
92
|
+
if will_link_art:
|
|
93
|
+
arts = [
|
|
94
|
+
(upsert_art(conn, img.data, img.mime), img.picture_type, img.description)
|
|
95
|
+
for img in kept
|
|
96
|
+
]
|
|
97
|
+
replace_track_art(conn, track_id, arts)
|
|
98
|
+
except sqlite3.IntegrityError as err:
|
|
99
|
+
stats.skipped_invalid += 1
|
|
100
|
+
stats.invalid.append((record.key, str(err)))
|
|
101
|
+
return
|
|
73
102
|
|
|
74
103
|
if will_link_art:
|
|
75
104
|
stats.art_linked += 1
|
|
76
105
|
stats.synced += 1
|
|
77
106
|
|
|
78
107
|
|
|
79
|
-
def sync_files(conn, records, *, dry_run=False, stats=None):
|
|
108
|
+
def sync_files(conn, records, *, dry_run=False, stats=None, merge=False):
|
|
80
109
|
"""Sync an iterable of ``Record``s, returning the ``SyncStats``. Pass
|
|
81
110
|
``stats`` to accumulate into a caller-seeded instance (e.g. beets pre-counts
|
|
82
111
|
unreadable art); otherwise a fresh one is created. Caller owns the
|
|
@@ -84,5 +113,5 @@ def sync_files(conn, records, *, dry_run=False, stats=None):
|
|
|
84
113
|
if stats is None:
|
|
85
114
|
stats = SyncStats()
|
|
86
115
|
for record in records:
|
|
87
|
-
sync_one(conn, record, stats, dry_run=dry_run)
|
|
116
|
+
sync_one(conn, record, stats, dry_run=dry_run, merge=merge)
|
|
88
117
|
return stats
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-musefs
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Shared musefs SQLite-store contract for the beets and Picard plugins
|
|
5
|
+
Author: Conor Futro
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Sohex/musefs
|
|
8
|
+
Project-URL: Repository, https://github.com/Sohex/musefs
|
|
9
|
+
Project-URL: Issues, https://github.com/Sohex/musefs/issues
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: POSIX
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# python-musefs
|
|
23
|
+
|
|
24
|
+
The full guide now lives in the musefs documentation site:
|
|
25
|
+
|
|
26
|
+
- **Published:** <https://sohex.github.io/musefs/integrations/python-musefs.html>
|
|
27
|
+
- **In-repo source:** [`docs/src/integrations/python-musefs.md`](../../docs/src/integrations/python-musefs.md)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
musefs_common/__init__.py,sha256=fA6mE93fSYBjObMMeCjDKnOnmETrbc2xt1TrdMKJ5cE,1505
|
|
2
|
+
musefs_common/constants.py,sha256=M0zS6YCnYdE_JjIbvxg-GhbYdfbe30ccKZvbZ1WTZZ8,302
|
|
3
|
+
musefs_common/contract.py,sha256=tUflCxfFiaIzJiTqYL-o-bjKNH_s91EHkC2amk8sKxE,1472
|
|
4
|
+
musefs_common/errors.py,sha256=OUBXzpa4q7TPYvFFiTyoIfRC3SGRRgZcpXlr43RP0jk,1456
|
|
5
|
+
musefs_common/paths.py,sha256=dE3wwop5mRxoJ8RhdYwTIZpX4MPaUUw2LJhjcxApOIA,649
|
|
6
|
+
musefs_common/scan.py,sha256=C20WmDWhGJ3FqjNOssLsoxtx0KgsTg9PPlrGPhOkE2E,1662
|
|
7
|
+
musefs_common/schema.py,sha256=HFo2qG0xtTgBVgxm8hCjVQAjxTeCqaPvYklw08rhOoU,11853
|
|
8
|
+
musefs_common/store.py,sha256=pUNlad1FITJCKwBNy4EH98jQQTmb9Ao5ggLlgo99XwU,12769
|
|
9
|
+
musefs_common/sync.py,sha256=iXlekqpuRfBEoOrjvujnZQYfDgs3afENaPU0n3D59nQ,4200
|
|
10
|
+
python_musefs-1.1.0.dist-info/licenses/LICENSE,sha256=4VbfzhgMpuFrhFjBCtLptGV_bzCj_gML9y_9o2Tr1OQ,1068
|
|
11
|
+
python_musefs-1.1.0.dist-info/METADATA,sha256=R3v-Qu_1-kc0JKc_xPlgIcZPhNuzcjIo6rvuZ8gAjT8,1012
|
|
12
|
+
python_musefs-1.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
13
|
+
python_musefs-1.1.0.dist-info/top_level.txt,sha256=ejWexGk95-s11kZnTFwg1T3iG_dFcaE4vhcLnL3t3Ak,14
|
|
14
|
+
python_musefs-1.1.0.dist-info/RECORD,,
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: python-musefs
|
|
3
|
-
Version: 0.0.1
|
|
4
|
-
Summary: Shared musefs SQLite-store contract for the beets and Picard plugins
|
|
5
|
-
Author: Conor Futro
|
|
6
|
-
License-Expression: MIT
|
|
7
|
-
Project-URL: Homepage, https://github.com/Sohex/musefs
|
|
8
|
-
Project-URL: Repository, https://github.com/Sohex/musefs
|
|
9
|
-
Project-URL: Issues, https://github.com/Sohex/musefs/issues
|
|
10
|
-
Classifier: Development Status :: 4 - Beta
|
|
11
|
-
Classifier: Intended Audience :: Developers
|
|
12
|
-
Classifier: Operating System :: POSIX
|
|
13
|
-
Classifier: Programming Language :: Python :: 3
|
|
14
|
-
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
15
|
-
Requires-Python: >=3.8
|
|
16
|
-
Description-Content-Type: text/markdown
|
|
17
|
-
License-File: LICENSE
|
|
18
|
-
Provides-Extra: test
|
|
19
|
-
Requires-Dist: pytest>=7; extra == "test"
|
|
20
|
-
Dynamic: license-file
|
|
21
|
-
|
|
22
|
-
# python-musefs
|
|
23
|
-
|
|
24
|
-
The shared store-contract library behind the [beets](../beets/README.md),
|
|
25
|
-
[Picard](../picard/README.md), and [Lidarr](../lidarr/README.md) musefs
|
|
26
|
-
plugins. It is the single source of truth for how a plugin writes the musefs
|
|
27
|
-
SQLite store: the schema-version check, the `tags` / `art` / `track_art`
|
|
28
|
-
writes, sha256 art content-addressing, the `realpath_key` path normalization,
|
|
29
|
-
the `musefs scan` shell-out (`run_scan`), and the per-file sync write-loop
|
|
30
|
-
(`Record` / `sync_files`).
|
|
31
|
-
|
|
32
|
-
Field mapping stays in each plugin — beets expands multi-valued
|
|
33
|
-
`genres`/`composers` into one tag each, Picard takes the first value — so this
|
|
34
|
-
library deliberately does not own it.
|
|
35
|
-
|
|
36
|
-
## Consumers
|
|
37
|
-
|
|
38
|
-
- **beets** depends on this package via pip (`contrib/beets/pyproject.toml`).
|
|
39
|
-
- **Picard** cannot pip-install plugin dependencies, so the package is
|
|
40
|
-
**vendored** into `contrib/picard/musefs/_common/` by
|
|
41
|
-
`vendor_to_picard.py`. After any change here, re-run:
|
|
42
|
-
|
|
43
|
-
```bash
|
|
44
|
-
python contrib/python-musefs/vendor_to_picard.py
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
The Picard test `tests/test_vendor_sync.py` fails if the committed copy drifts.
|
|
48
|
-
- **Lidarr** depends on this package via pip (`contrib/lidarr/pyproject.toml`).
|
|
49
|
-
|
|
50
|
-
## Schema coupling
|
|
51
|
-
|
|
52
|
-
`musefs_common/schema.py` (`SCHEMA_SQL`, `USER_VERSION`) is **generated** from
|
|
53
|
-
the Rust migrations in `musefs-db/src/schema.rs` — do not edit it by hand.
|
|
54
|
-
`EXPECTED_USER_VERSION` (in `constants.py`) derives from it. When the Rust
|
|
55
|
-
schema bumps, regenerate and re-vendor:
|
|
56
|
-
|
|
57
|
-
```bash
|
|
58
|
-
MUSEFS_REGEN_SCHEMA_PY=1 cargo test -p musefs-db schema_py
|
|
59
|
-
python contrib/python-musefs/vendor_to_picard.py
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
A `musefs-db` unit test fails if the generated file drifts. This is all
|
|
63
|
-
independent of the package's own `__version__` (its release SemVer).
|
|
64
|
-
|
|
65
|
-
## Tests
|
|
66
|
-
|
|
67
|
-
```bash
|
|
68
|
-
cd contrib/python-musefs
|
|
69
|
-
python -m venv .venv && source .venv/bin/activate
|
|
70
|
-
pip install -e ".[test]"
|
|
71
|
-
python -m pytest -v
|
|
72
|
-
ruff check . && ruff format --check .
|
|
73
|
-
```
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
musefs_common/__init__.py,sha256=hV6UKgOJ4n98n-2OR9LVTSE3Yhhz_E4MSRERUzQOpqM,1265
|
|
2
|
-
musefs_common/constants.py,sha256=M0zS6YCnYdE_JjIbvxg-GhbYdfbe30ccKZvbZ1WTZZ8,302
|
|
3
|
-
musefs_common/contract.py,sha256=tUflCxfFiaIzJiTqYL-o-bjKNH_s91EHkC2amk8sKxE,1472
|
|
4
|
-
musefs_common/errors.py,sha256=OUBXzpa4q7TPYvFFiTyoIfRC3SGRRgZcpXlr43RP0jk,1456
|
|
5
|
-
musefs_common/paths.py,sha256=dE3wwop5mRxoJ8RhdYwTIZpX4MPaUUw2LJhjcxApOIA,649
|
|
6
|
-
musefs_common/scan.py,sha256=2rOFQQt0pE1uhF31huSwRtapU0QK3fFDGbscynwLtRE,1453
|
|
7
|
-
musefs_common/schema.py,sha256=Y9zE25PEFX-mVJzULT2aOi5AiS10pYp6_y50oirrrvo,8716
|
|
8
|
-
musefs_common/store.py,sha256=O3HNrq5clPMcWYGDiiKhfbDpBB3u8UNtpvIcqWP6_Gs,4316
|
|
9
|
-
musefs_common/sync.py,sha256=aCK0zacYZ0B5GWlS-MwOdMf4j6XutX7wi62QxZzXqP8,2835
|
|
10
|
-
python_musefs-0.0.1.dist-info/licenses/LICENSE,sha256=4VbfzhgMpuFrhFjBCtLptGV_bzCj_gML9y_9o2Tr1OQ,1068
|
|
11
|
-
python_musefs-0.0.1.dist-info/METADATA,sha256=_n-iNLP2PZQpW-JzfE44FL-y9fwnlu5iuS-5LqUpCk8,2664
|
|
12
|
-
python_musefs-0.0.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
13
|
-
python_musefs-0.0.1.dist-info/top_level.txt,sha256=ejWexGk95-s11kZnTFwg1T3iG_dFcaE4vhcLnL3t3Ak,14
|
|
14
|
-
python_musefs-0.0.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|