python-musefs 0.0.1__py3-none-any.whl → 1.1.0__py3-none-any.whl

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