python-musefs 0.0.1__py3-none-any.whl → 1.0.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 +3 -1
- musefs_common/schema.py +111 -149
- musefs_common/store.py +124 -22
- musefs_common/sync.py +27 -13
- python_musefs-1.0.0.dist-info/METADATA +274 -0
- python_musefs-1.0.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.0.0.dist-info}/WHEEL +0 -0
- {python_musefs-0.0.1.dist-info → python_musefs-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {python_musefs-0.0.1.dist-info → python_musefs-1.0.0.dist-info}/top_level.txt +0 -0
musefs_common/__init__.py
CHANGED
|
@@ -13,6 +13,7 @@ from .scan import run_scan
|
|
|
13
13
|
from .store import (
|
|
14
14
|
check_schema_version,
|
|
15
15
|
connect,
|
|
16
|
+
merge_tags,
|
|
16
17
|
prune_missing,
|
|
17
18
|
replace_tags,
|
|
18
19
|
replace_track_art,
|
|
@@ -22,7 +23,7 @@ from .store import (
|
|
|
22
23
|
)
|
|
23
24
|
from .sync import ArtImage, Record, SyncStats, sync_files, sync_one
|
|
24
25
|
|
|
25
|
-
__version__ = "
|
|
26
|
+
__version__ = "1.0.0"
|
|
26
27
|
|
|
27
28
|
__all__ = [
|
|
28
29
|
"EXPECTED_USER_VERSION",
|
|
@@ -36,6 +37,7 @@ __all__ = [
|
|
|
36
37
|
"check_schema_version",
|
|
37
38
|
"track_id_for_path",
|
|
38
39
|
"prune_missing",
|
|
40
|
+
"merge_tags",
|
|
39
41
|
"replace_tags",
|
|
40
42
|
"upsert_art",
|
|
41
43
|
"replace_track_art",
|
musefs_common/schema.py
CHANGED
|
@@ -5,146 +5,21 @@
|
|
|
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
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
CREATE TABLE tags (
|
|
20
|
-
track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
|
21
|
-
key TEXT NOT NULL,
|
|
22
|
-
value TEXT NOT NULL,
|
|
23
|
-
ordinal INTEGER NOT NULL DEFAULT 0,
|
|
24
|
-
PRIMARY KEY (track_id, key, ordinal)
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
CREATE TABLE art (
|
|
28
|
-
id INTEGER PRIMARY KEY,
|
|
29
|
-
sha256 TEXT NOT NULL UNIQUE,
|
|
30
|
-
mime TEXT NOT NULL,
|
|
31
|
-
width INTEGER,
|
|
32
|
-
height INTEGER,
|
|
33
|
-
byte_len INTEGER NOT NULL,
|
|
34
|
-
data BLOB NOT NULL
|
|
35
|
-
);
|
|
36
|
-
|
|
37
|
-
CREATE TABLE track_art (
|
|
38
|
-
track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
|
39
|
-
art_id INTEGER NOT NULL REFERENCES art(id),
|
|
40
|
-
picture_type INTEGER NOT NULL DEFAULT 3,
|
|
41
|
-
description TEXT NOT NULL DEFAULT '',
|
|
42
|
-
ordinal INTEGER NOT NULL DEFAULT 0,
|
|
43
|
-
PRIMARY KEY (track_id, ordinal)
|
|
44
|
-
);
|
|
45
|
-
|
|
46
|
-
CREATE TRIGGER tags_ai AFTER INSERT ON tags BEGIN
|
|
47
|
-
UPDATE tracks SET content_version = content_version + 1,
|
|
48
|
-
updated_at = CAST(strftime('%s','now') AS INTEGER)
|
|
49
|
-
WHERE id = NEW.track_id;
|
|
50
|
-
END;
|
|
51
|
-
CREATE TRIGGER tags_au AFTER UPDATE ON tags BEGIN
|
|
52
|
-
UPDATE tracks SET content_version = content_version + 1,
|
|
53
|
-
updated_at = CAST(strftime('%s','now') AS INTEGER)
|
|
54
|
-
WHERE id = NEW.track_id;
|
|
55
|
-
END;
|
|
56
|
-
CREATE TRIGGER tags_ad AFTER DELETE ON tags BEGIN
|
|
57
|
-
UPDATE tracks SET content_version = content_version + 1,
|
|
58
|
-
updated_at = CAST(strftime('%s','now') AS INTEGER)
|
|
59
|
-
WHERE id = OLD.track_id;
|
|
60
|
-
END;
|
|
61
|
-
|
|
62
|
-
CREATE TRIGGER track_art_ai AFTER INSERT ON track_art BEGIN
|
|
63
|
-
UPDATE tracks SET content_version = content_version + 1,
|
|
64
|
-
updated_at = CAST(strftime('%s','now') AS INTEGER)
|
|
65
|
-
WHERE id = NEW.track_id;
|
|
66
|
-
END;
|
|
67
|
-
CREATE TRIGGER track_art_au AFTER UPDATE ON track_art BEGIN
|
|
68
|
-
UPDATE tracks SET content_version = content_version + 1,
|
|
69
|
-
updated_at = CAST(strftime('%s','now') AS INTEGER)
|
|
70
|
-
WHERE id = NEW.track_id;
|
|
71
|
-
END;
|
|
72
|
-
CREATE TRIGGER track_art_ad AFTER DELETE ON track_art BEGIN
|
|
73
|
-
UPDATE tracks SET content_version = content_version + 1,
|
|
74
|
-
updated_at = CAST(strftime('%s','now') AS INTEGER)
|
|
75
|
-
WHERE id = OLD.track_id;
|
|
76
|
-
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
|
-
|
|
105
|
-
CREATE TRIGGER tracks_changelog_ai AFTER INSERT ON tracks BEGIN
|
|
106
|
-
INSERT INTO track_changes (track_id) VALUES (NEW.id);
|
|
107
|
-
END;
|
|
108
|
-
CREATE TRIGGER tracks_changelog_au AFTER UPDATE ON tracks BEGIN
|
|
109
|
-
INSERT INTO track_changes (track_id) VALUES (NEW.id);
|
|
110
|
-
END;
|
|
111
|
-
CREATE TRIGGER tracks_changelog_ad AFTER DELETE ON tracks BEGIN
|
|
112
|
-
INSERT INTO track_changes (track_id) VALUES (OLD.id);
|
|
113
|
-
END;
|
|
114
|
-
|
|
115
|
-
-- Self-pruning ring: writers maintain it; the mount's read-only connections
|
|
116
|
-
-- never need to. Deletes only from the old end, so retained seqs stay contiguous.
|
|
117
|
-
CREATE TRIGGER track_changes_prune AFTER INSERT ON track_changes BEGIN
|
|
118
|
-
DELETE FROM track_changes WHERE seq <= NEW.seq - 8192;
|
|
119
|
-
END;
|
|
120
|
-
PRAGMA user_version = 3;
|
|
121
|
-
|
|
122
|
-
-- ── MIGRATION_V4 ──
|
|
123
|
-
CREATE TEMP TABLE _m4_tracks AS SELECT * FROM tracks;
|
|
124
|
-
CREATE TEMP TABLE _m4_tags AS SELECT * FROM tags;
|
|
125
|
-
CREATE TEMP TABLE _m4_art AS SELECT * FROM art;
|
|
126
|
-
CREATE TEMP TABLE _m4_track_art AS SELECT * FROM track_art;
|
|
127
|
-
|
|
128
|
-
DROP TABLE track_art;
|
|
129
|
-
DROP TABLE tags;
|
|
130
|
-
DROP TABLE art;
|
|
131
|
-
DROP TABLE tracks;
|
|
132
|
-
|
|
133
|
-
CREATE TABLE tracks (
|
|
134
|
-
id INTEGER PRIMARY KEY,
|
|
135
|
-
backing_path TEXT NOT NULL UNIQUE,
|
|
136
|
-
format TEXT NOT NULL,
|
|
137
|
-
audio_offset INTEGER NOT NULL,
|
|
138
|
-
audio_length INTEGER NOT NULL,
|
|
139
|
-
backing_size INTEGER NOT NULL,
|
|
140
|
-
backing_mtime INTEGER NOT NULL,
|
|
141
|
-
content_version INTEGER NOT NULL DEFAULT 0,
|
|
142
|
-
updated_at INTEGER NOT NULL,
|
|
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),
|
|
143
18
|
CHECK (format IN ('flac','mp3','m4a','opus','vorbis','oggflac','wav')),
|
|
144
19
|
CHECK (audio_offset >= 0),
|
|
145
20
|
CHECK (audio_length >= 0),
|
|
146
21
|
CHECK (backing_size >= 0),
|
|
147
|
-
CHECK (
|
|
22
|
+
CHECK (backing_mtime_ns >= 0),
|
|
148
23
|
CHECK (content_version >= 0),
|
|
149
24
|
CHECK (updated_at >= 0),
|
|
150
25
|
CHECK (audio_offset + audio_length <= backing_size)
|
|
@@ -158,7 +33,12 @@ CREATE TABLE tags (
|
|
|
158
33
|
value_blob BLOB,
|
|
159
34
|
PRIMARY KEY (track_id, key, ordinal),
|
|
160
35
|
CHECK (ordinal >= 0),
|
|
161
|
-
CHECK (value_blob IS NULL OR value = '')
|
|
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)
|
|
162
42
|
);
|
|
163
43
|
|
|
164
44
|
CREATE TABLE art (
|
|
@@ -172,7 +52,9 @@ CREATE TABLE art (
|
|
|
172
52
|
CHECK (byte_len = length(data)),
|
|
173
53
|
CHECK (length(sha256) = 64),
|
|
174
54
|
CHECK (width IS NULL OR width >= 0),
|
|
175
|
-
CHECK (height IS NULL OR height >= 0)
|
|
55
|
+
CHECK (height IS NULL OR height >= 0),
|
|
56
|
+
CHECK (length(mime) <= 255),
|
|
57
|
+
CHECK (byte_len <= 16711680)
|
|
176
58
|
);
|
|
177
59
|
|
|
178
60
|
CREATE TABLE track_art (
|
|
@@ -183,18 +65,35 @@ CREATE TABLE track_art (
|
|
|
183
65
|
ordinal INTEGER NOT NULL DEFAULT 0,
|
|
184
66
|
PRIMARY KEY (track_id, ordinal),
|
|
185
67
|
CHECK (picture_type BETWEEN 0 AND 20),
|
|
186
|
-
CHECK (ordinal >= 0)
|
|
68
|
+
CHECK (ordinal >= 0),
|
|
69
|
+
CHECK (length(description) <= 1024)
|
|
187
70
|
);
|
|
188
71
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
+
);
|
|
193
93
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
DROP TABLE _m4_tracks;
|
|
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);
|
|
198
97
|
|
|
199
98
|
CREATE TRIGGER tags_ai AFTER INSERT ON tags BEGIN
|
|
200
99
|
UPDATE tracks SET content_version = content_version + 1,
|
|
@@ -237,7 +136,70 @@ END;
|
|
|
237
136
|
CREATE TRIGGER tracks_changelog_ad AFTER DELETE ON tracks BEGIN
|
|
238
137
|
INSERT INTO track_changes (track_id) VALUES (OLD.id);
|
|
239
138
|
END;
|
|
240
|
-
|
|
139
|
+
|
|
140
|
+
-- Self-pruning ring: writers maintain it; the mount's read-only connections
|
|
141
|
+
-- never need to. Deletes only from the old end, so retained seqs stay contiguous.
|
|
142
|
+
CREATE TRIGGER track_changes_prune AFTER INSERT ON track_changes BEGIN
|
|
143
|
+
DELETE FROM track_changes WHERE seq <= NEW.seq - 8192;
|
|
144
|
+
END;
|
|
145
|
+
|
|
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;
|
|
163
|
+
|
|
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;
|
|
174
|
+
|
|
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;
|
|
190
|
+
|
|
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;
|
|
241
203
|
"""
|
|
242
204
|
|
|
243
|
-
USER_VERSION =
|
|
205
|
+
USER_VERSION = 1
|
musefs_common/store.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import contextlib
|
|
1
2
|
import hashlib
|
|
2
3
|
import os
|
|
3
4
|
import sqlite3
|
|
@@ -5,6 +6,64 @@ import sqlite3
|
|
|
5
6
|
from .constants import EXPECTED_USER_VERSION
|
|
6
7
|
from .errors import SchemaMismatch
|
|
7
8
|
|
|
9
|
+
# sqlite3.LEGACY_TRANSACTION_CONTROL is 3.12+; it is == -1. Use getattr so this
|
|
10
|
+
# module still imports on the 3.8 floor (where the constant does not exist).
|
|
11
|
+
_LEGACY = getattr(sqlite3, "LEGACY_TRANSACTION_CONTROL", -1)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _is_autocommit(conn):
|
|
15
|
+
"""True if the connection auto-commits each statement (no caller-owned
|
|
16
|
+
transaction will be committed for us)."""
|
|
17
|
+
ac = getattr(conn, "autocommit", _LEGACY) # 3.12+ attribute; _LEGACY on <3.12
|
|
18
|
+
if ac is True:
|
|
19
|
+
return True
|
|
20
|
+
if ac is False:
|
|
21
|
+
return False
|
|
22
|
+
return conn.isolation_level is None # legacy transaction control
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _is_legacy(conn):
|
|
26
|
+
"""True if the connection uses legacy transaction control (the <3.12 default
|
|
27
|
+
and the 3.12+ LEGACY_TRANSACTION_CONTROL mode)."""
|
|
28
|
+
return getattr(conn, "autocommit", _LEGACY) == _LEGACY
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@contextlib.contextmanager
|
|
32
|
+
def _savepoint(conn, name):
|
|
33
|
+
"""Make a DELETE+INSERT block atomic regardless of the connection's
|
|
34
|
+
transaction mode. On a caller-managed connection it nests via SAVEPOINT and
|
|
35
|
+
never commits the enclosing transaction; on an autocommit connection the
|
|
36
|
+
outermost call owns a transaction for the block (commit on success, rollback
|
|
37
|
+
on failure). Nested calls only nest -- they never BEGIN or commit -- so a
|
|
38
|
+
sync_one savepoint may wrap these per-function savepoints safely.
|
|
39
|
+
|
|
40
|
+
``name`` must be a hardcoded SQL identifier (it is interpolated into the SQL,
|
|
41
|
+
so never pass caller-controlled text)."""
|
|
42
|
+
autocommit = _is_autocommit(conn)
|
|
43
|
+
owns = not conn.in_transaction # outermost call: it opens & owns the txn
|
|
44
|
+
# Legacy mode never auto-BEGINs before SAVEPOINT, so a savepoint opened as
|
|
45
|
+
# the first statement of a batch would become the outermost transaction and
|
|
46
|
+
# commit durably on RELEASE. Force a nesting BEGIN there. PEP-249 modes
|
|
47
|
+
# auto-begin before any statement, so they need no nudge.
|
|
48
|
+
if owns and _is_legacy(conn):
|
|
49
|
+
conn.execute("BEGIN")
|
|
50
|
+
conn.execute(f"SAVEPOINT {name}")
|
|
51
|
+
try:
|
|
52
|
+
yield
|
|
53
|
+
except BaseException:
|
|
54
|
+
try:
|
|
55
|
+
conn.execute(f"ROLLBACK TO {name}")
|
|
56
|
+
conn.execute(f"RELEASE {name}")
|
|
57
|
+
if owns and autocommit:
|
|
58
|
+
conn.rollback()
|
|
59
|
+
except sqlite3.Error:
|
|
60
|
+
pass # never mask the original exception with a cleanup failure
|
|
61
|
+
raise
|
|
62
|
+
else:
|
|
63
|
+
conn.execute(f"RELEASE {name}")
|
|
64
|
+
if owns and autocommit:
|
|
65
|
+
conn.commit()
|
|
66
|
+
|
|
8
67
|
|
|
9
68
|
def connect(db_path):
|
|
10
69
|
"""Open the musefs DB with a busy timeout and foreign keys enabled."""
|
|
@@ -54,20 +113,58 @@ def prune_missing(conn, track_ids=None):
|
|
|
54
113
|
|
|
55
114
|
def replace_tags(conn, track_id, pairs):
|
|
56
115
|
"""Replace all tags for a track. Duplicate keys get incrementing ordinals
|
|
57
|
-
(mirroring musefs scan ingest).
|
|
116
|
+
(mirroring musefs scan ingest).
|
|
117
|
+
|
|
118
|
+
Atomic via an internal savepoint (see ``_savepoint``), so a crash between the
|
|
119
|
+
DELETE and the INSERT can never leave the track's text tags wiped -- safe
|
|
120
|
+
even when called on an autocommit connection."""
|
|
58
121
|
# Scope to the plugin-owned text rows: scanner-written binary tags
|
|
59
122
|
# (value_blob NOT NULL) must survive a sync (#82).
|
|
60
|
-
conn
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
123
|
+
with _savepoint(conn, "musefs_replace_tags"):
|
|
124
|
+
conn.execute("DELETE FROM tags WHERE track_id = ? AND value_blob IS NULL", (track_id,))
|
|
125
|
+
ordinals = {}
|
|
126
|
+
rows = []
|
|
127
|
+
for key, value in pairs:
|
|
128
|
+
ordinal = ordinals.get(key, 0)
|
|
129
|
+
ordinals[key] = ordinal + 1
|
|
130
|
+
rows.append((track_id, key, value, ordinal))
|
|
131
|
+
conn.executemany(
|
|
132
|
+
"INSERT INTO tags (track_id, key, value, ordinal) VALUES (?, ?, ?, ?)",
|
|
133
|
+
rows,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def merge_tags(conn, track_id, managed_pairs, delete_keys):
|
|
138
|
+
"""Per-key replace of the plugin-managed text tags, leaving unmanaged text
|
|
139
|
+
rows (the scan-seeded baseline) intact. ``managed_pairs`` is an ordered list
|
|
140
|
+
of (key, value); every key it names is cleared and rewritten with contiguous
|
|
141
|
+
ordinals. ``delete_keys`` names keys to clear without rewriting (tags the
|
|
142
|
+
plugin previously managed and the user has now removed). Both deletes are
|
|
143
|
+
scoped to ``value_blob IS NULL`` so scanner-written binary tags survive.
|
|
144
|
+
|
|
145
|
+
Atomic via an internal savepoint (see ``_savepoint``): the per-key deletes
|
|
146
|
+
and the rewrite either all land or none do, even on an autocommit
|
|
147
|
+
connection."""
|
|
148
|
+
with _savepoint(conn, "musefs_merge_tags"):
|
|
149
|
+
by_key = {}
|
|
150
|
+
for key, value in managed_pairs:
|
|
151
|
+
by_key.setdefault(key, []).append(value)
|
|
152
|
+
|
|
153
|
+
for key in set(by_key) | set(delete_keys or ()):
|
|
154
|
+
conn.execute(
|
|
155
|
+
"DELETE FROM tags WHERE track_id = ? AND key = ? AND value_blob IS NULL",
|
|
156
|
+
(track_id, key),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
rows = [
|
|
160
|
+
(track_id, key, value, ordinal)
|
|
161
|
+
for key, values in by_key.items()
|
|
162
|
+
for ordinal, value in enumerate(values)
|
|
163
|
+
]
|
|
164
|
+
conn.executemany(
|
|
165
|
+
"INSERT INTO tags (track_id, key, value, ordinal) VALUES (?, ?, ?, ?)",
|
|
166
|
+
rows,
|
|
167
|
+
)
|
|
71
168
|
|
|
72
169
|
|
|
73
170
|
_EXT_MIME = {
|
|
@@ -107,13 +204,18 @@ def upsert_art(conn, data, mime):
|
|
|
107
204
|
def replace_track_art(conn, track_id, arts):
|
|
108
205
|
"""Replace the track's art rows. ``arts`` is an ordered list of
|
|
109
206
|
``(art_id, picture_type, description)``; each row's ``ordinal`` is its
|
|
110
|
-
list index.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
207
|
+
list index.
|
|
208
|
+
|
|
209
|
+
Atomic via an internal savepoint (see ``_savepoint``): the DELETE and the
|
|
210
|
+
re-insert either both land or neither does, even on an autocommit
|
|
211
|
+
connection."""
|
|
212
|
+
with _savepoint(conn, "musefs_replace_track_art"):
|
|
213
|
+
conn.execute("DELETE FROM track_art WHERE track_id = ?", (track_id,))
|
|
214
|
+
conn.executemany(
|
|
215
|
+
"INSERT INTO track_art (track_id, art_id, picture_type, description, "
|
|
216
|
+
"ordinal) VALUES (?, ?, ?, ?, ?)",
|
|
217
|
+
[
|
|
218
|
+
(track_id, art_id, picture_type, description, i)
|
|
219
|
+
for i, (art_id, picture_type, description) in enumerate(arts)
|
|
220
|
+
],
|
|
221
|
+
)
|
musefs_common/sync.py
CHANGED
|
@@ -3,7 +3,14 @@ from __future__ import annotations
|
|
|
3
3
|
from dataclasses import dataclass, field
|
|
4
4
|
|
|
5
5
|
from .constants import MAX_ART_BYTES
|
|
6
|
-
from .store import
|
|
6
|
+
from .store import (
|
|
7
|
+
_savepoint,
|
|
8
|
+
merge_tags,
|
|
9
|
+
replace_tags,
|
|
10
|
+
replace_track_art,
|
|
11
|
+
track_id_for_path,
|
|
12
|
+
upsert_art,
|
|
13
|
+
)
|
|
7
14
|
|
|
8
15
|
|
|
9
16
|
@dataclass(frozen=True)
|
|
@@ -26,6 +33,7 @@ class Record:
|
|
|
26
33
|
key: str
|
|
27
34
|
pairs: list = field(default_factory=list)
|
|
28
35
|
art: object = None # list[ArtImage] | None
|
|
36
|
+
delete_keys: object = None # list[str] of keys to clear without rewrite (merge mode)
|
|
29
37
|
|
|
30
38
|
|
|
31
39
|
@dataclass
|
|
@@ -42,10 +50,12 @@ class SyncStats:
|
|
|
42
50
|
)
|
|
43
51
|
|
|
44
52
|
|
|
45
|
-
def sync_one(conn, record, stats, *, dry_run=False):
|
|
53
|
+
def sync_one(conn, record, stats, *, dry_run=False, merge=False):
|
|
46
54
|
"""Sync one ``Record`` into the DB, mutating ``stats``. Caller owns the
|
|
47
|
-
transaction.
|
|
48
|
-
|
|
55
|
+
transaction. With ``merge=False`` (the default) all plugin-owned text tags are
|
|
56
|
+
replaced; with ``merge=True`` only the keys in ``record.pairs`` and
|
|
57
|
+
``record.delete_keys`` are touched (see ``merge_tags``). Either way,
|
|
58
|
+
scanner-written binary tags survive. Art is replaced when at least one image is
|
|
49
59
|
within ``MAX_ART_BYTES``; each over-cap image bumps ``skipped_art``, and if
|
|
50
60
|
every provided image is over cap any scan-seeded ``track_art`` is left
|
|
51
61
|
untouched."""
|
|
@@ -63,20 +73,24 @@ def sync_one(conn, record, stats, *, dry_run=False):
|
|
|
63
73
|
will_link_art = bool(kept)
|
|
64
74
|
|
|
65
75
|
if not dry_run:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
76
|
+
with _savepoint(conn, "musefs_sync_one"):
|
|
77
|
+
if merge:
|
|
78
|
+
merge_tags(conn, track_id, record.pairs, record.delete_keys or [])
|
|
79
|
+
else:
|
|
80
|
+
replace_tags(conn, track_id, record.pairs)
|
|
81
|
+
if will_link_art:
|
|
82
|
+
arts = [
|
|
83
|
+
(upsert_art(conn, img.data, img.mime), img.picture_type, img.description)
|
|
84
|
+
for img in kept
|
|
85
|
+
]
|
|
86
|
+
replace_track_art(conn, track_id, arts)
|
|
73
87
|
|
|
74
88
|
if will_link_art:
|
|
75
89
|
stats.art_linked += 1
|
|
76
90
|
stats.synced += 1
|
|
77
91
|
|
|
78
92
|
|
|
79
|
-
def sync_files(conn, records, *, dry_run=False, stats=None):
|
|
93
|
+
def sync_files(conn, records, *, dry_run=False, stats=None, merge=False):
|
|
80
94
|
"""Sync an iterable of ``Record``s, returning the ``SyncStats``. Pass
|
|
81
95
|
``stats`` to accumulate into a caller-seeded instance (e.g. beets pre-counts
|
|
82
96
|
unreadable art); otherwise a fresh one is created. Caller owns the
|
|
@@ -84,5 +98,5 @@ def sync_files(conn, records, *, dry_run=False, stats=None):
|
|
|
84
98
|
if stats is None:
|
|
85
99
|
stats = SyncStats()
|
|
86
100
|
for record in records:
|
|
87
|
-
sync_one(conn, record, stats, dry_run=dry_run)
|
|
101
|
+
sync_one(conn, record, stats, dry_run=dry_run, merge=merge)
|
|
88
102
|
return stats
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-musefs
|
|
3
|
+
Version: 1.0.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 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
|
+
## Writing a plugin
|
|
37
|
+
|
|
38
|
+
A plugin turns host metadata (a beets item, a Picard track, a Lidarr release)
|
|
39
|
+
into musefs store writes. This library owns every store-touching step except the
|
|
40
|
+
field mapping: you supply the per-file tag and art values, and it handles the
|
|
41
|
+
schema check, the scan shell-out, content-addressing, and the write loop.
|
|
42
|
+
|
|
43
|
+
### The write flow
|
|
44
|
+
|
|
45
|
+
The canonical order is **connect → check_schema_version → run_scan → build
|
|
46
|
+
`Record`s → sync_files → commit → prune_missing**. The caller owns the
|
|
47
|
+
transaction — nothing here commits for you.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from musefs_common import (
|
|
51
|
+
SCAN_TIMEOUT_SECONDS,
|
|
52
|
+
ArtImage,
|
|
53
|
+
Record,
|
|
54
|
+
check_schema_version,
|
|
55
|
+
connect,
|
|
56
|
+
prune_missing,
|
|
57
|
+
realpath_key,
|
|
58
|
+
run_scan,
|
|
59
|
+
sync_files,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def sync(db_path, files, *, musefs_bin="musefs"):
|
|
64
|
+
# `run_scan` creates the DB if absent and fills the structural columns a
|
|
65
|
+
# plugin cannot compute (format, audio offset/length, backing size/mtime).
|
|
66
|
+
# On a brand-new store it must precede `connect`, which has nothing to open
|
|
67
|
+
# until the scan has created the file.
|
|
68
|
+
run_scan(musefs_bin, db_path, files, timeout=SCAN_TIMEOUT_SECONDS)
|
|
69
|
+
|
|
70
|
+
conn = connect(db_path)
|
|
71
|
+
try:
|
|
72
|
+
check_schema_version(conn) # raises SchemaMismatch on a version skew
|
|
73
|
+
|
|
74
|
+
records = [
|
|
75
|
+
Record(
|
|
76
|
+
key=realpath_key(path), # MUST equal the scanned row's backing_path
|
|
77
|
+
pairs=[("artist", artist), ("title", title)],
|
|
78
|
+
art=[ArtImage(data=cover, mime="image/jpeg")] if cover else None,
|
|
79
|
+
)
|
|
80
|
+
for path, artist, title, cover in host_metadata(files)
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
stats = sync_files(conn, records) # full-replace of plugin text tags
|
|
84
|
+
conn.commit() # the caller commits
|
|
85
|
+
|
|
86
|
+
prune_missing(conn) # drop rows whose backing file vanished
|
|
87
|
+
conn.commit()
|
|
88
|
+
return stats
|
|
89
|
+
finally:
|
|
90
|
+
conn.close()
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
For a dry run, pass `dry_run=True` to `sync_files` and `conn.rollback()` instead
|
|
94
|
+
of committing — `SyncStats` still reports what *would* change.
|
|
95
|
+
|
|
96
|
+
`run_scan` raises `ScanError` (`kind` ∈ `{"not_found", "timeout", "failed"}`)
|
|
97
|
+
and `check_schema_version` raises `SchemaMismatch`; a host adapter formats its
|
|
98
|
+
own user-facing message from the exception attributes (see the beets plugin's
|
|
99
|
+
`_scan_user_error`).
|
|
100
|
+
|
|
101
|
+
### The `Record` shape
|
|
102
|
+
|
|
103
|
+
One `Record` per file is your primary output. Its fields:
|
|
104
|
+
|
|
105
|
+
| field | type | meaning |
|
|
106
|
+
| ----- | ---- | ------- |
|
|
107
|
+
| `key` | `str` | The file's identity in the store. **Must** be `realpath_key(path)` — the canonicalized absolute path the scanner stored as `backing_path`. A `key` that matches no scanned row is silently counted in `SyncStats.skipped`, not written. |
|
|
108
|
+
| `pairs` | `list[tuple[str, str]]` | Ordered `(tag_key, value)` text tags. Duplicate keys are allowed and get contiguous ordinals (multi-valued tags). |
|
|
109
|
+
| `art` | `list[ArtImage] \| None` | Embedded pictures, already resolved to bytes. `None`/`[]` leaves existing art untouched. |
|
|
110
|
+
| `delete_keys` | `list[str] \| None` | Merge mode only: keys to clear without rewriting (see below). Ignored in replace mode. |
|
|
111
|
+
|
|
112
|
+
`ArtImage(data, mime, picture_type=3, description="")` is one picture: `data` is
|
|
113
|
+
raw bytes, `picture_type` is the ID3/FLAC type (3 = front cover). Images larger
|
|
114
|
+
than `MAX_ART_BYTES` are dropped and counted in `SyncStats.skipped_art`.
|
|
115
|
+
|
|
116
|
+
If every record lands in `skipped`, the `key`s and the scan target disagree —
|
|
117
|
+
both must canonicalize the same way, so scan the *real* files (not a symlink
|
|
118
|
+
farm) and build keys with `realpath_key`.
|
|
119
|
+
|
|
120
|
+
### Merge vs. replace, and sticky deletes
|
|
121
|
+
|
|
122
|
+
`sync_files(..., merge=False)` (the default) **replaces** every plugin-owned
|
|
123
|
+
text tag on each track: it clears all `value_blob IS NULL` rows and rewrites
|
|
124
|
+
them from `record.pairs`. Scanner-written binary tags always survive.
|
|
125
|
+
|
|
126
|
+
`sync_files(..., merge=True)` **merges**: only the keys named in `record.pairs`
|
|
127
|
+
and `record.delete_keys` are touched; other scan-seeded text tags stay. Use
|
|
128
|
+
merge when your plugin owns a *subset* of the tags and must not clobber the
|
|
129
|
+
rest. The store does not remember which keys you manage — **you** track your
|
|
130
|
+
managed-key set out of band (the contract is explicit that the store is not the
|
|
131
|
+
place for plugin state).
|
|
132
|
+
|
|
133
|
+
When the user removes a tag in the host, merge mode needs to delete the
|
|
134
|
+
now-orphaned store row. The beets plugin solves this with an **accumulating
|
|
135
|
+
managed-key set** (the `musefs_managed` pattern), worth copying:
|
|
136
|
+
|
|
137
|
+
- Persist, per file, the set of keys you have *ever* written (beets uses a
|
|
138
|
+
flexattr; any per-file host metadata works).
|
|
139
|
+
- On each sync, `delete_keys = previous_managed − keys_written_now`, and the new
|
|
140
|
+
persisted set is `previous_managed ∪ keys_written_now`.
|
|
141
|
+
- A key you stop writing becomes a tombstone: it keeps getting deleted on every
|
|
142
|
+
sync until you write it again. Persist the managed set **only after** the store
|
|
143
|
+
commit succeeds, so a failed sync doesn't lose the record of what you owe.
|
|
144
|
+
|
|
145
|
+
See `contrib/beets/beetsplug/_core.py` (`build_records` / `persist_managed`) for
|
|
146
|
+
the reference implementation.
|
|
147
|
+
|
|
148
|
+
### Store invariants you must respect
|
|
149
|
+
|
|
150
|
+
The full external-writer contract is in
|
|
151
|
+
[ARCHITECTURE.md](../../ARCHITECTURE.md#the-external-writer-contract). The rules
|
|
152
|
+
that bite plugin authors:
|
|
153
|
+
|
|
154
|
+
- **Write only `tags`, `art`, and `track_art`.** The scanner owns the structural
|
|
155
|
+
columns of `tracks` and all of `structural_blocks`; never compute them — run
|
|
156
|
+
`musefs scan` (i.e. `run_scan`). `CHECK` constraints reject malformed
|
|
157
|
+
structural shapes at commit, so you cannot persist them anyway.
|
|
158
|
+
- **Binary tags survive a sync.** `merge_tags` / `replace_tags` scope their
|
|
159
|
+
deletes to text rows (`value_blob IS NULL`), so the write loop never wipes
|
|
160
|
+
scanner-written binary tags. You may write binary tags yourself too — a binary
|
|
161
|
+
row carries its payload in `value_blob` and must leave `value` empty (the only
|
|
162
|
+
`CHECK` on the row).
|
|
163
|
+
- **Content-address art** through `upsert_art` (sha256 de-dup) rather than
|
|
164
|
+
inserting `art` rows by hand; `sync_files` does this for you.
|
|
165
|
+
- **Art rows are immutable.** A trigger rejects in-place updates of an
|
|
166
|
+
`art` row's content columns (`data`, `sha256`, `mime`, `byte_len`, `width`,
|
|
167
|
+
`height`). To change a track's art, insert a new content-addressed row via
|
|
168
|
+
`upsert_art` and relink it via `replace_track_art`.
|
|
169
|
+
- **Path layout is just a tag.** To drive a reorganized mount, write your
|
|
170
|
+
computed relative path into a custom tag (e.g. `beets_path`) and mount with
|
|
171
|
+
`--template '$!{beets_path}'`. musefs sanitizes each path segment, so a writer
|
|
172
|
+
cannot inject traversal.
|
|
173
|
+
|
|
174
|
+
## API reference
|
|
175
|
+
|
|
176
|
+
Everything in `__all__`, imported from the top-level `musefs_common` package.
|
|
177
|
+
|
|
178
|
+
**Connection & schema**
|
|
179
|
+
|
|
180
|
+
- `connect(db_path)` → `sqlite3.Connection` — open with a 5s busy timeout and
|
|
181
|
+
`foreign_keys = ON`.
|
|
182
|
+
- `check_schema_version(conn)` — raise `SchemaMismatch` unless the store's
|
|
183
|
+
`user_version` equals `EXPECTED_USER_VERSION`.
|
|
184
|
+
|
|
185
|
+
**Scanning**
|
|
186
|
+
|
|
187
|
+
- `run_scan(binary, db_path, target, *, timeout=None)` — shell out to `musefs
|
|
188
|
+
scan`; `target` is one path or an iterable, all scanned under one process.
|
|
189
|
+
Creates the DB if absent. Raises `ScanError`.
|
|
190
|
+
|
|
191
|
+
**Building records**
|
|
192
|
+
|
|
193
|
+
- `Record(key, pairs=[], art=None, delete_keys=None)` — one file's sync inputs
|
|
194
|
+
(see *The `Record` shape*).
|
|
195
|
+
- `ArtImage(data, mime, picture_type=3, description="")` — one embedded picture.
|
|
196
|
+
- `realpath_key(path)` — canonical path string matching the scanner's
|
|
197
|
+
`backing_path`; accepts `str`/`bytes`, returns `str`.
|
|
198
|
+
|
|
199
|
+
**Writing**
|
|
200
|
+
|
|
201
|
+
- `sync_files(conn, records, *, dry_run=False, stats=None, merge=False)` →
|
|
202
|
+
`SyncStats` — the write loop; caller owns the transaction. Pass `stats` to
|
|
203
|
+
accumulate into a caller-seeded instance.
|
|
204
|
+
- `sync_one(conn, record, stats, *, dry_run=False, merge=False)` — sync a single
|
|
205
|
+
record into a caller-supplied `SyncStats`.
|
|
206
|
+
- `SyncStats` — `synced` / `skipped` / `art_linked` / `skipped_art` counters,
|
|
207
|
+
plus `.summary()`.
|
|
208
|
+
|
|
209
|
+
**Lower-level store helpers** (called for you by `sync_files`; use directly only
|
|
210
|
+
for a custom write loop)
|
|
211
|
+
|
|
212
|
+
- `track_id_for_path(conn, key)` → track id or `None`.
|
|
213
|
+
- `merge_tags(conn, track_id, managed_pairs, delete_keys)` — per-key replace of
|
|
214
|
+
plugin-managed text tags, leaving unmanaged text rows intact.
|
|
215
|
+
- `replace_tags(conn, track_id, pairs)` — replace all plugin-owned text tags.
|
|
216
|
+
- `upsert_art(conn, data, mime)` → art id — content-address `data` by sha256,
|
|
217
|
+
inserting only if new.
|
|
218
|
+
- `replace_track_art(conn, track_id, arts)` — replace a track's `track_art`
|
|
219
|
+
rows; `arts` is `[(art_id, picture_type, description), …]`.
|
|
220
|
+
- `sniff_mime(data, path)` — image mime from magic bytes, falling back to file
|
|
221
|
+
extension.
|
|
222
|
+
- `prune_missing(conn, track_ids=None)` → count — delete tracks whose backing
|
|
223
|
+
file no longer exists (every track, or just `track_ids`).
|
|
224
|
+
|
|
225
|
+
**Constants**
|
|
226
|
+
|
|
227
|
+
- `EXPECTED_USER_VERSION` — schema `user_version` this library targets.
|
|
228
|
+
- `MAX_ART_BYTES` — per-image art cap; larger images are skipped.
|
|
229
|
+
- `SCAN_TIMEOUT_SECONDS` — default wall-clock cap for one `run_scan`.
|
|
230
|
+
|
|
231
|
+
**Exceptions**
|
|
232
|
+
|
|
233
|
+
- `SchemaMismatch(found)` — schema-version skew; `.found` is the DB's version.
|
|
234
|
+
- `ScanError(kind, *, binary, target, …)` — a `musefs scan` failure; `.kind` ∈
|
|
235
|
+
`{"not_found", "timeout", "failed"}`, with context attributes for messaging.
|
|
236
|
+
|
|
237
|
+
## Consumers
|
|
238
|
+
|
|
239
|
+
- **beets** depends on this package via pip (`contrib/beets/pyproject.toml`).
|
|
240
|
+
- **Picard** cannot pip-install plugin dependencies, so the package is
|
|
241
|
+
**vendored** into `contrib/picard/musefs/_common/` by
|
|
242
|
+
`vendor_to_picard.py`. After any change here, re-run:
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
python contrib/python-musefs/vendor_to_picard.py
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
The Picard test `tests/test_vendor_sync.py` fails if the committed copy drifts.
|
|
249
|
+
- **Lidarr** depends on this package via pip (`contrib/lidarr/pyproject.toml`).
|
|
250
|
+
|
|
251
|
+
## Schema coupling
|
|
252
|
+
|
|
253
|
+
`musefs_common/schema.py` (`SCHEMA_SQL`, `USER_VERSION`) is **generated** from
|
|
254
|
+
the Rust migrations in `musefs-db/src/schema.rs` — do not edit it by hand.
|
|
255
|
+
`EXPECTED_USER_VERSION` (in `constants.py`) derives from it. When the Rust
|
|
256
|
+
schema bumps, regenerate and re-vendor:
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
MUSEFS_REGEN_SCHEMA_PY=1 cargo test -p musefs-db schema_py
|
|
260
|
+
python contrib/python-musefs/vendor_to_picard.py
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
A `musefs-db` unit test fails if the generated file drifts. This is all
|
|
264
|
+
independent of the package's own `__version__` (its release SemVer).
|
|
265
|
+
|
|
266
|
+
## Tests
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
cd contrib/python-musefs
|
|
270
|
+
python -m venv .venv && source .venv/bin/activate
|
|
271
|
+
pip install -e ".[test]"
|
|
272
|
+
python -m pytest -v
|
|
273
|
+
ruff check . && ruff format --check .
|
|
274
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
musefs_common/__init__.py,sha256=e8xfwhe770V9_gnoJEjNqk4lJmpHcqaLgojSo9-MXks,1299
|
|
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=Blab4CIiFIQaaqh5IjMpUR0jD01xU0CFrl4AQQM3yKM,8511
|
|
8
|
+
musefs_common/store.py,sha256=nk34ghVlqMsF__PozYZHCu8Xd0QsoChl9bGTFsrdbwE,8721
|
|
9
|
+
musefs_common/sync.py,sha256=Rh65b69d92FLxNwN4Z939GmfUiZ1Sfw7F_vL31i4VtM,3375
|
|
10
|
+
python_musefs-1.0.0.dist-info/licenses/LICENSE,sha256=4VbfzhgMpuFrhFjBCtLptGV_bzCj_gML9y_9o2Tr1OQ,1068
|
|
11
|
+
python_musefs-1.0.0.dist-info/METADATA,sha256=F2pO6iiY0rn4HLaefxYYo73hc7dbc_KSgWWzvooCXm4,11971
|
|
12
|
+
python_musefs-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
13
|
+
python_musefs-1.0.0.dist-info/top_level.txt,sha256=ejWexGk95-s11kZnTFwg1T3iG_dFcaE4vhcLnL3t3Ak,14
|
|
14
|
+
python_musefs-1.0.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
|