python-musefs 0.0.1__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 +49 -0
- musefs_common/constants.py +9 -0
- musefs_common/contract.py +44 -0
- musefs_common/errors.py +38 -0
- musefs_common/paths.py +16 -0
- musefs_common/scan.py +33 -0
- musefs_common/schema.py +243 -0
- musefs_common/store.py +119 -0
- musefs_common/sync.py +88 -0
- python_musefs-0.0.1.dist-info/METADATA +73 -0
- python_musefs-0.0.1.dist-info/RECORD +14 -0
- python_musefs-0.0.1.dist-info/WHEEL +5 -0
- python_musefs-0.0.1.dist-info/licenses/LICENSE +21 -0
- python_musefs-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""python-musefs: the shared musefs SQLite-store contract.
|
|
2
|
+
|
|
3
|
+
Single source of truth for the schema-version check, the tags/art/track_art
|
|
4
|
+
writes, art content-addressing, path-key normalization, the `musefs scan`
|
|
5
|
+
shell-out, and the per-file sync write-loop. Consumed by the beets plugin (as a
|
|
6
|
+
pip dependency) and by the Picard plugin (vendored into ``musefs/_common``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .constants import EXPECTED_USER_VERSION, MAX_ART_BYTES, SCAN_TIMEOUT_SECONDS
|
|
10
|
+
from .errors import ScanError, SchemaMismatch
|
|
11
|
+
from .paths import realpath_key
|
|
12
|
+
from .scan import run_scan
|
|
13
|
+
from .store import (
|
|
14
|
+
check_schema_version,
|
|
15
|
+
connect,
|
|
16
|
+
prune_missing,
|
|
17
|
+
replace_tags,
|
|
18
|
+
replace_track_art,
|
|
19
|
+
sniff_mime,
|
|
20
|
+
track_id_for_path,
|
|
21
|
+
upsert_art,
|
|
22
|
+
)
|
|
23
|
+
from .sync import ArtImage, Record, SyncStats, sync_files, sync_one
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"EXPECTED_USER_VERSION",
|
|
29
|
+
"MAX_ART_BYTES",
|
|
30
|
+
"SCAN_TIMEOUT_SECONDS",
|
|
31
|
+
"SchemaMismatch",
|
|
32
|
+
"ScanError",
|
|
33
|
+
"realpath_key",
|
|
34
|
+
"run_scan",
|
|
35
|
+
"connect",
|
|
36
|
+
"check_schema_version",
|
|
37
|
+
"track_id_for_path",
|
|
38
|
+
"prune_missing",
|
|
39
|
+
"replace_tags",
|
|
40
|
+
"upsert_art",
|
|
41
|
+
"replace_track_art",
|
|
42
|
+
"sniff_mime",
|
|
43
|
+
"ArtImage",
|
|
44
|
+
"Record",
|
|
45
|
+
"SyncStats",
|
|
46
|
+
"sync_one",
|
|
47
|
+
"sync_files",
|
|
48
|
+
"__version__",
|
|
49
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from .schema import USER_VERSION
|
|
2
|
+
|
|
3
|
+
EXPECTED_USER_VERSION = USER_VERSION
|
|
4
|
+
|
|
5
|
+
MAX_ART_BYTES = 16 * 1024 * 1024 - 64 * 1024
|
|
6
|
+
|
|
7
|
+
# Wall-clock cap (seconds) for a single `musefs scan` shell-out; a wedged scan
|
|
8
|
+
# (stuck disk, DB lock) raises ScanError(kind="timeout") rather than hanging.
|
|
9
|
+
SCAN_TIMEOUT_SECONDS = 120
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Canonical tag-row contract both plugins must satisfy.
|
|
2
|
+
|
|
3
|
+
Each plugin's test builds an equivalent host object (a beets ``Item`` from the
|
|
4
|
+
list fields, a Picard ``Metadata`` from ``getall``) carrying ``CONTRACT_VALUES``
|
|
5
|
+
and asserts its ``map_fields`` output, normalized, equals
|
|
6
|
+
``normalize_rows(CONTRACT_EXPECTED)``. This guards #84/#86 against future
|
|
7
|
+
divergence between the two mappers.
|
|
8
|
+
|
|
9
|
+
Scope: the genuinely-shared multi-value fields (``genre``, ``composer``). beets
|
|
10
|
+
has no multi-artist field, so ``artist``/``albumartist`` are single-valued here;
|
|
11
|
+
Picard's multi-artist expansion is tested in its own unit tests.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
|
|
16
|
+
CONTRACT_VALUES = {
|
|
17
|
+
"title": "Song",
|
|
18
|
+
"artist": "Alice",
|
|
19
|
+
"albumartist": "Alice",
|
|
20
|
+
"album": "Greatest Hits",
|
|
21
|
+
"genre": ["Rock", "Pop"],
|
|
22
|
+
"composer": ["Carol", "Dave"],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
CONTRACT_EXPECTED = [
|
|
26
|
+
("title", "Song"),
|
|
27
|
+
("artist", "Alice"),
|
|
28
|
+
("albumartist", "Alice"),
|
|
29
|
+
("album", "Greatest Hits"),
|
|
30
|
+
("genre", "Rock"),
|
|
31
|
+
("genre", "Pop"),
|
|
32
|
+
("composer", "Carol"),
|
|
33
|
+
("composer", "Dave"),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def normalize_rows(rows):
|
|
38
|
+
"""Group ``(key, value)`` rows by key into a comparison-stable dict. All
|
|
39
|
+
contract keys use set semantics (the store treats multi-values as a set), so
|
|
40
|
+
each key's values are returned sorted."""
|
|
41
|
+
grouped = defaultdict(list)
|
|
42
|
+
for key, value in rows:
|
|
43
|
+
grouped[key].append(value)
|
|
44
|
+
return {key: sorted(values) for key, values in grouped.items()}
|
musefs_common/errors.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from .constants import EXPECTED_USER_VERSION
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SchemaMismatch(Exception): # noqa: N818
|
|
5
|
+
"""Raised when the musefs DB schema version differs from what this library
|
|
6
|
+
targets (``EXPECTED_USER_VERSION``)."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, found):
|
|
9
|
+
self.found = found
|
|
10
|
+
super().__init__(
|
|
11
|
+
f"musefs DB user_version is {found}, plugin targets "
|
|
12
|
+
f"{EXPECTED_USER_VERSION}; the musefs and plugin versions have "
|
|
13
|
+
f"diverged."
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ScanError(Exception): # noqa: N818
|
|
18
|
+
"""A `musefs scan` invocation failed. ``kind`` is one of ``"not_found"``,
|
|
19
|
+
``"timeout"``, ``"failed"``; the remaining attributes carry enough context
|
|
20
|
+
for a host adapter to format its own user-facing message."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, kind, *, binary, target, timeout=None, returncode=None, stderr=""):
|
|
23
|
+
self.kind = kind
|
|
24
|
+
self.binary = binary
|
|
25
|
+
self.target = target
|
|
26
|
+
self.timeout = timeout
|
|
27
|
+
self.returncode = returncode
|
|
28
|
+
self.stderr = stderr
|
|
29
|
+
super().__init__(self._default_message())
|
|
30
|
+
|
|
31
|
+
def _default_message(self):
|
|
32
|
+
if self.kind == "not_found":
|
|
33
|
+
return f"musefs binary '{self.binary}' not found"
|
|
34
|
+
if self.kind == "timeout":
|
|
35
|
+
return f"`{self.binary} scan` for {self.target} timed out after {self.timeout}s"
|
|
36
|
+
return (
|
|
37
|
+
f"`{self.binary} scan` failed for {self.target} (exit {self.returncode}): {self.stderr}"
|
|
38
|
+
)
|
musefs_common/paths.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def realpath_key(path):
|
|
5
|
+
"""Canonical absolute path string matching musefs scan's stored
|
|
6
|
+
``backing_path`` (``std::fs::canonicalize`` + ``to_string_lossy``).
|
|
7
|
+
|
|
8
|
+
Accepts ``str`` or ``bytes`` and always returns ``str``.
|
|
9
|
+
"""
|
|
10
|
+
real = os.path.realpath(path)
|
|
11
|
+
if isinstance(real, bytes):
|
|
12
|
+
real = os.fsdecode(real)
|
|
13
|
+
# os.fsdecode uses surrogateescape; Rust's to_string_lossy uses U+FFFD for
|
|
14
|
+
# undecodable bytes. Normalize so a non-UTF-8 path component produces the
|
|
15
|
+
# same key string on both sides instead of silently mismatching.
|
|
16
|
+
return real.encode("utf-8", "surrogateescape").decode("utf-8", "replace")
|
musefs_common/scan.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
from .errors import ScanError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def run_scan(binary, db_path, target, *, timeout=None):
|
|
8
|
+
"""Run ``<binary> scan <target...> --db <db_path>``. ``target`` is a single
|
|
9
|
+
path or an iterable of paths; all targets precede the ``--db`` flag and are
|
|
10
|
+
scanned under one process (one DB open). Creates the DB if absent and fills
|
|
11
|
+
the structural columns a plugin can't compute. Raises ``ScanError`` (with
|
|
12
|
+
``kind`` in ``"not_found" | "timeout" | "failed"``) on failure; the caller
|
|
13
|
+
formats its own user-facing message from the exception attributes."""
|
|
14
|
+
if isinstance(target, (str, os.PathLike)):
|
|
15
|
+
targets = [target]
|
|
16
|
+
else:
|
|
17
|
+
targets = list(target)
|
|
18
|
+
display = str(targets[0]) if len(targets) == 1 else f"{len(targets)} target(s)"
|
|
19
|
+
argv = [binary, "scan", *(str(t) for t in targets), "--db", str(db_path)]
|
|
20
|
+
try:
|
|
21
|
+
result = subprocess.run(argv, capture_output=True, timeout=timeout)
|
|
22
|
+
except FileNotFoundError as exc:
|
|
23
|
+
raise ScanError("not_found", binary=binary, target=display) from exc
|
|
24
|
+
except subprocess.TimeoutExpired as exc:
|
|
25
|
+
raise ScanError("timeout", binary=binary, target=display, timeout=timeout) from exc
|
|
26
|
+
if result.returncode != 0:
|
|
27
|
+
raise ScanError(
|
|
28
|
+
"failed",
|
|
29
|
+
binary=binary,
|
|
30
|
+
target=display,
|
|
31
|
+
returncode=result.returncode,
|
|
32
|
+
stderr=result.stderr.decode(errors="replace").strip(),
|
|
33
|
+
)
|
musefs_common/schema.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# GENERATED from musefs-db/src/schema.rs — do not edit.
|
|
2
|
+
# Regenerate: MUSEFS_REGEN_SCHEMA_PY=1 cargo test -p musefs-db schema_py
|
|
3
|
+
# Re-vendor: python contrib/python-musefs/vendor_to_picard.py
|
|
4
|
+
|
|
5
|
+
SCHEMA_SQL = """\
|
|
6
|
+
-- ── MIGRATION_V1 ──
|
|
7
|
+
CREATE TABLE tracks (
|
|
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 INTEGER NOT NULL,
|
|
15
|
+
content_version INTEGER NOT NULL DEFAULT 0,
|
|
16
|
+
updated_at INTEGER NOT NULL
|
|
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,
|
|
143
|
+
CHECK (format IN ('flac','mp3','m4a','opus','vorbis','oggflac','wav')),
|
|
144
|
+
CHECK (audio_offset >= 0),
|
|
145
|
+
CHECK (audio_length >= 0),
|
|
146
|
+
CHECK (backing_size >= 0),
|
|
147
|
+
CHECK (backing_mtime >= 0),
|
|
148
|
+
CHECK (content_version >= 0),
|
|
149
|
+
CHECK (updated_at >= 0),
|
|
150
|
+
CHECK (audio_offset + audio_length <= backing_size)
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
CREATE TABLE tags (
|
|
154
|
+
track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
|
155
|
+
key TEXT NOT NULL,
|
|
156
|
+
value TEXT NOT NULL,
|
|
157
|
+
ordinal INTEGER NOT NULL DEFAULT 0,
|
|
158
|
+
value_blob BLOB,
|
|
159
|
+
PRIMARY KEY (track_id, key, ordinal),
|
|
160
|
+
CHECK (ordinal >= 0),
|
|
161
|
+
CHECK (value_blob IS NULL OR value = '')
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
CREATE TABLE art (
|
|
165
|
+
id INTEGER PRIMARY KEY,
|
|
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
|
+
|
|
199
|
+
CREATE TRIGGER tags_ai AFTER INSERT ON tags BEGIN
|
|
200
|
+
UPDATE tracks SET content_version = content_version + 1,
|
|
201
|
+
updated_at = CAST(strftime('%s','now') AS INTEGER)
|
|
202
|
+
WHERE id = NEW.track_id;
|
|
203
|
+
END;
|
|
204
|
+
CREATE TRIGGER tags_au AFTER UPDATE ON tags BEGIN
|
|
205
|
+
UPDATE tracks SET content_version = content_version + 1,
|
|
206
|
+
updated_at = CAST(strftime('%s','now') AS INTEGER)
|
|
207
|
+
WHERE id = NEW.track_id;
|
|
208
|
+
END;
|
|
209
|
+
CREATE TRIGGER tags_ad AFTER DELETE ON tags BEGIN
|
|
210
|
+
UPDATE tracks SET content_version = content_version + 1,
|
|
211
|
+
updated_at = CAST(strftime('%s','now') AS INTEGER)
|
|
212
|
+
WHERE id = OLD.track_id;
|
|
213
|
+
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;
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
USER_VERSION = 4
|
musefs_common/store.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import os
|
|
3
|
+
import sqlite3
|
|
4
|
+
|
|
5
|
+
from .constants import EXPECTED_USER_VERSION
|
|
6
|
+
from .errors import SchemaMismatch
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def connect(db_path):
|
|
10
|
+
"""Open the musefs DB with a busy timeout and foreign keys enabled."""
|
|
11
|
+
conn = sqlite3.connect(db_path)
|
|
12
|
+
# 5s busy timeout so a brief write doesn't fail while the FUSE mount reads.
|
|
13
|
+
conn.execute("PRAGMA busy_timeout = 5000")
|
|
14
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
15
|
+
return conn
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def check_schema_version(conn):
|
|
19
|
+
"""Raise ``SchemaMismatch`` unless the DB's ``user_version`` matches the
|
|
20
|
+
version this library targets. Call on an open connection from ``connect``."""
|
|
21
|
+
found = conn.execute("PRAGMA user_version").fetchone()[0]
|
|
22
|
+
if found != EXPECTED_USER_VERSION:
|
|
23
|
+
raise SchemaMismatch(found)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def track_id_for_path(conn, key):
|
|
27
|
+
"""Return the track id whose backing_path equals ``key``, or None."""
|
|
28
|
+
row = conn.execute("SELECT id FROM tracks WHERE backing_path = ?", (key,)).fetchone()
|
|
29
|
+
return row[0] if row else None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def prune_missing(conn, track_ids=None):
|
|
33
|
+
"""Delete track rows whose backing file no longer exists on disk.
|
|
34
|
+
|
|
35
|
+
When ``track_ids`` is provided, only those tracks are checked and
|
|
36
|
+
potentially pruned. Otherwise, every track in the database is checked.
|
|
37
|
+
Returns the number pruned.
|
|
38
|
+
"""
|
|
39
|
+
if track_ids is not None:
|
|
40
|
+
gone = []
|
|
41
|
+
for tid in track_ids:
|
|
42
|
+
row = conn.execute("SELECT backing_path FROM tracks WHERE id=?", (tid,)).fetchone()
|
|
43
|
+
if row is not None and not os.path.exists(row[0]):
|
|
44
|
+
gone.append((tid,))
|
|
45
|
+
else:
|
|
46
|
+
gone = [
|
|
47
|
+
(tid,)
|
|
48
|
+
for tid, path in conn.execute("SELECT id, backing_path FROM tracks")
|
|
49
|
+
if not os.path.exists(path)
|
|
50
|
+
]
|
|
51
|
+
conn.executemany("DELETE FROM tracks WHERE id = ?", gone)
|
|
52
|
+
return len(gone)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def replace_tags(conn, track_id, pairs):
|
|
56
|
+
"""Replace all tags for a track. Duplicate keys get incrementing ordinals
|
|
57
|
+
(mirroring musefs scan ingest)."""
|
|
58
|
+
# Scope to the plugin-owned text rows: scanner-written binary tags
|
|
59
|
+
# (value_blob NOT NULL) must survive a sync (#82).
|
|
60
|
+
conn.execute("DELETE FROM tags WHERE track_id = ? AND value_blob IS NULL", (track_id,))
|
|
61
|
+
ordinals = {}
|
|
62
|
+
rows = []
|
|
63
|
+
for key, value in pairs:
|
|
64
|
+
ordinal = ordinals.get(key, 0)
|
|
65
|
+
ordinals[key] = ordinal + 1
|
|
66
|
+
rows.append((track_id, key, value, ordinal))
|
|
67
|
+
conn.executemany(
|
|
68
|
+
"INSERT INTO tags (track_id, key, value, ordinal) VALUES (?, ?, ?, ?)",
|
|
69
|
+
rows,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_EXT_MIME = {
|
|
74
|
+
".jpg": "image/jpeg",
|
|
75
|
+
".jpeg": "image/jpeg",
|
|
76
|
+
".png": "image/png",
|
|
77
|
+
".webp": "image/webp",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def sniff_mime(data, path):
|
|
82
|
+
"""Detect image mime from magic bytes, falling back to file extension."""
|
|
83
|
+
if data[:3] == b"\xff\xd8\xff":
|
|
84
|
+
return "image/jpeg"
|
|
85
|
+
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
|
86
|
+
return "image/png"
|
|
87
|
+
# WebP: 'RIFF' <4-byte size> 'WEBP'.
|
|
88
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
89
|
+
return "image/webp"
|
|
90
|
+
ext = os.path.splitext(path)[1].lower()
|
|
91
|
+
return _EXT_MIME.get(ext, "application/octet-stream")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def upsert_art(conn, data, mime):
|
|
95
|
+
"""Content-address ``data`` by sha256 and return its art id, inserting only
|
|
96
|
+
if new (mirrors musefs Db::upsert_art). If the sha256 already exists, the
|
|
97
|
+
stored row (and its mime) is kept and the ``mime`` argument is ignored."""
|
|
98
|
+
sha = hashlib.sha256(data).hexdigest()
|
|
99
|
+
conn.execute(
|
|
100
|
+
"INSERT INTO art (sha256, mime, width, height, byte_len, data) "
|
|
101
|
+
"VALUES (?, ?, NULL, NULL, ?, ?) ON CONFLICT(sha256) DO NOTHING",
|
|
102
|
+
(sha, mime, len(data), data),
|
|
103
|
+
)
|
|
104
|
+
return conn.execute("SELECT id FROM art WHERE sha256 = ?", (sha,)).fetchone()[0]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def replace_track_art(conn, track_id, arts):
|
|
108
|
+
"""Replace the track's art rows. ``arts`` is an ordered list of
|
|
109
|
+
``(art_id, picture_type, description)``; each row's ``ordinal`` is its
|
|
110
|
+
list index."""
|
|
111
|
+
conn.execute("DELETE FROM track_art WHERE track_id = ?", (track_id,))
|
|
112
|
+
conn.executemany(
|
|
113
|
+
"INSERT INTO track_art (track_id, art_id, picture_type, description, "
|
|
114
|
+
"ordinal) VALUES (?, ?, ?, ?, ?)",
|
|
115
|
+
[
|
|
116
|
+
(track_id, art_id, picture_type, description, i)
|
|
117
|
+
for i, (art_id, picture_type, description) in enumerate(arts)
|
|
118
|
+
],
|
|
119
|
+
)
|
musefs_common/sync.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from .constants import MAX_ART_BYTES
|
|
6
|
+
from .store import replace_tags, replace_track_art, track_id_for_path, upsert_art
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ArtImage:
|
|
11
|
+
"""One embedded picture to sync: raw bytes, mime, ID3/FLAC picture type
|
|
12
|
+
(3 = front cover), and free-text description."""
|
|
13
|
+
|
|
14
|
+
data: bytes
|
|
15
|
+
mime: str
|
|
16
|
+
picture_type: int = 3
|
|
17
|
+
description: str = ""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Record:
|
|
22
|
+
"""One file's sync inputs: the realpath key, the (key, value) tag pairs, and
|
|
23
|
+
pre-resolved art as a list of ``ArtImage``s (``None``/empty list = no art
|
|
24
|
+
from the host)."""
|
|
25
|
+
|
|
26
|
+
key: str
|
|
27
|
+
pairs: list = field(default_factory=list)
|
|
28
|
+
art: object = None # list[ArtImage] | None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class SyncStats:
|
|
33
|
+
synced: int = 0
|
|
34
|
+
skipped: int = 0 # path had no matching track row
|
|
35
|
+
art_linked: int = 0
|
|
36
|
+
skipped_art: int = 0 # art over the size cap (or, in the beets adapter, unreadable)
|
|
37
|
+
|
|
38
|
+
def summary(self):
|
|
39
|
+
return (
|
|
40
|
+
f"synced={self.synced} skipped={self.skipped} "
|
|
41
|
+
f"art_linked={self.art_linked} skipped_art={self.skipped_art}"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def sync_one(conn, record, stats, *, dry_run=False):
|
|
46
|
+
"""Sync one ``Record`` into the DB, mutating ``stats``. Caller owns the
|
|
47
|
+
transaction. Tags are always fully replaced (scanner-written binary tags
|
|
48
|
+
survive — see ``replace_tags``). Art is replaced when at least one image is
|
|
49
|
+
within ``MAX_ART_BYTES``; each over-cap image bumps ``skipped_art``, and if
|
|
50
|
+
every provided image is over cap any scan-seeded ``track_art`` is left
|
|
51
|
+
untouched."""
|
|
52
|
+
track_id = track_id_for_path(conn, record.key)
|
|
53
|
+
if track_id is None:
|
|
54
|
+
stats.skipped += 1
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
kept = []
|
|
58
|
+
for img in record.art or []:
|
|
59
|
+
if len(img.data) > MAX_ART_BYTES:
|
|
60
|
+
stats.skipped_art += 1
|
|
61
|
+
else:
|
|
62
|
+
kept.append(img)
|
|
63
|
+
will_link_art = bool(kept)
|
|
64
|
+
|
|
65
|
+
if not dry_run:
|
|
66
|
+
replace_tags(conn, track_id, record.pairs)
|
|
67
|
+
if will_link_art:
|
|
68
|
+
arts = [
|
|
69
|
+
(upsert_art(conn, img.data, img.mime), img.picture_type, img.description)
|
|
70
|
+
for img in kept
|
|
71
|
+
]
|
|
72
|
+
replace_track_art(conn, track_id, arts)
|
|
73
|
+
|
|
74
|
+
if will_link_art:
|
|
75
|
+
stats.art_linked += 1
|
|
76
|
+
stats.synced += 1
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def sync_files(conn, records, *, dry_run=False, stats=None):
|
|
80
|
+
"""Sync an iterable of ``Record``s, returning the ``SyncStats``. Pass
|
|
81
|
+
``stats`` to accumulate into a caller-seeded instance (e.g. beets pre-counts
|
|
82
|
+
unreadable art); otherwise a fresh one is created. Caller owns the
|
|
83
|
+
transaction (commit on success, rollback for dry runs)."""
|
|
84
|
+
if stats is None:
|
|
85
|
+
stats = SyncStats()
|
|
86
|
+
for record in records:
|
|
87
|
+
sync_one(conn, record, stats, dry_run=dry_run)
|
|
88
|
+
return stats
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
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,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Conor Futro
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
musefs_common
|