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

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