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.
@@ -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()}
@@ -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
+ )
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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