python-musefs 0.0.1__tar.gz → 1.0.0__tar.gz

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.
Files changed (34) hide show
  1. python_musefs-1.0.0/PKG-INFO +274 -0
  2. python_musefs-1.0.0/README.md +253 -0
  3. {python_musefs-0.0.1 → python_musefs-1.0.0}/pyproject.toml +1 -1
  4. {python_musefs-0.0.1 → python_musefs-1.0.0}/src/musefs_common/__init__.py +3 -1
  5. {python_musefs-0.0.1 → python_musefs-1.0.0}/src/musefs_common/schema.py +111 -149
  6. python_musefs-1.0.0/src/musefs_common/store.py +221 -0
  7. {python_musefs-0.0.1 → python_musefs-1.0.0}/src/musefs_common/sync.py +27 -13
  8. python_musefs-1.0.0/src/python_musefs.egg-info/PKG-INFO +274 -0
  9. {python_musefs-0.0.1 → python_musefs-1.0.0}/src/python_musefs.egg-info/SOURCES.txt +2 -0
  10. python_musefs-1.0.0/tests/test_atomicity.py +208 -0
  11. {python_musefs-0.0.1 → python_musefs-1.0.0}/tests/test_constants.py +1 -1
  12. python_musefs-1.0.0/tests/test_merge_tags.py +78 -0
  13. {python_musefs-0.0.1 → python_musefs-1.0.0}/tests/test_public_api.py +1 -1
  14. {python_musefs-0.0.1 → python_musefs-1.0.0}/tests/test_sync.py +36 -1
  15. python_musefs-0.0.1/PKG-INFO +0 -73
  16. python_musefs-0.0.1/README.md +0 -52
  17. python_musefs-0.0.1/src/musefs_common/store.py +0 -119
  18. python_musefs-0.0.1/src/python_musefs.egg-info/PKG-INFO +0 -73
  19. {python_musefs-0.0.1 → python_musefs-1.0.0}/LICENSE +0 -0
  20. {python_musefs-0.0.1 → python_musefs-1.0.0}/setup.cfg +0 -0
  21. {python_musefs-0.0.1 → python_musefs-1.0.0}/src/musefs_common/constants.py +0 -0
  22. {python_musefs-0.0.1 → python_musefs-1.0.0}/src/musefs_common/contract.py +0 -0
  23. {python_musefs-0.0.1 → python_musefs-1.0.0}/src/musefs_common/errors.py +0 -0
  24. {python_musefs-0.0.1 → python_musefs-1.0.0}/src/musefs_common/paths.py +0 -0
  25. {python_musefs-0.0.1 → python_musefs-1.0.0}/src/musefs_common/scan.py +0 -0
  26. {python_musefs-0.0.1 → python_musefs-1.0.0}/src/python_musefs.egg-info/dependency_links.txt +0 -0
  27. {python_musefs-0.0.1 → python_musefs-1.0.0}/src/python_musefs.egg-info/requires.txt +0 -0
  28. {python_musefs-0.0.1 → python_musefs-1.0.0}/src/python_musefs.egg-info/top_level.txt +0 -0
  29. {python_musefs-0.0.1 → python_musefs-1.0.0}/tests/test_contract.py +0 -0
  30. {python_musefs-0.0.1 → python_musefs-1.0.0}/tests/test_errors.py +0 -0
  31. {python_musefs-0.0.1 → python_musefs-1.0.0}/tests/test_paths.py +0 -0
  32. {python_musefs-0.0.1 → python_musefs-1.0.0}/tests/test_scan.py +0 -0
  33. {python_musefs-0.0.1 → python_musefs-1.0.0}/tests/test_store_art.py +0 -0
  34. {python_musefs-0.0.1 → python_musefs-1.0.0}/tests/test_store_db.py +0 -0
@@ -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,253 @@
1
+ # python-musefs
2
+
3
+ The shared store-contract library behind the [beets](../beets/README.md),
4
+ [Picard](../picard/README.md), and [Lidarr](../lidarr/README.md) musefs
5
+ plugins. It is the single source of truth for how a plugin writes the musefs
6
+ SQLite store: the schema-version check, the `tags` / `art` / `track_art`
7
+ writes, sha256 art content-addressing, the `realpath_key` path normalization,
8
+ the `musefs scan` shell-out (`run_scan`), and the per-file sync write-loop
9
+ (`Record` / `sync_files`).
10
+
11
+ Field mapping stays in each plugin — beets expands multi-valued
12
+ `genres`/`composers` into one tag each, Picard takes the first value — so this
13
+ library deliberately does not own it.
14
+
15
+ ## Writing a plugin
16
+
17
+ A plugin turns host metadata (a beets item, a Picard track, a Lidarr release)
18
+ into musefs store writes. This library owns every store-touching step except the
19
+ field mapping: you supply the per-file tag and art values, and it handles the
20
+ schema check, the scan shell-out, content-addressing, and the write loop.
21
+
22
+ ### The write flow
23
+
24
+ The canonical order is **connect → check_schema_version → run_scan → build
25
+ `Record`s → sync_files → commit → prune_missing**. The caller owns the
26
+ transaction — nothing here commits for you.
27
+
28
+ ```python
29
+ from musefs_common import (
30
+ SCAN_TIMEOUT_SECONDS,
31
+ ArtImage,
32
+ Record,
33
+ check_schema_version,
34
+ connect,
35
+ prune_missing,
36
+ realpath_key,
37
+ run_scan,
38
+ sync_files,
39
+ )
40
+
41
+
42
+ def sync(db_path, files, *, musefs_bin="musefs"):
43
+ # `run_scan` creates the DB if absent and fills the structural columns a
44
+ # plugin cannot compute (format, audio offset/length, backing size/mtime).
45
+ # On a brand-new store it must precede `connect`, which has nothing to open
46
+ # until the scan has created the file.
47
+ run_scan(musefs_bin, db_path, files, timeout=SCAN_TIMEOUT_SECONDS)
48
+
49
+ conn = connect(db_path)
50
+ try:
51
+ check_schema_version(conn) # raises SchemaMismatch on a version skew
52
+
53
+ records = [
54
+ Record(
55
+ key=realpath_key(path), # MUST equal the scanned row's backing_path
56
+ pairs=[("artist", artist), ("title", title)],
57
+ art=[ArtImage(data=cover, mime="image/jpeg")] if cover else None,
58
+ )
59
+ for path, artist, title, cover in host_metadata(files)
60
+ ]
61
+
62
+ stats = sync_files(conn, records) # full-replace of plugin text tags
63
+ conn.commit() # the caller commits
64
+
65
+ prune_missing(conn) # drop rows whose backing file vanished
66
+ conn.commit()
67
+ return stats
68
+ finally:
69
+ conn.close()
70
+ ```
71
+
72
+ For a dry run, pass `dry_run=True` to `sync_files` and `conn.rollback()` instead
73
+ of committing — `SyncStats` still reports what *would* change.
74
+
75
+ `run_scan` raises `ScanError` (`kind` ∈ `{"not_found", "timeout", "failed"}`)
76
+ and `check_schema_version` raises `SchemaMismatch`; a host adapter formats its
77
+ own user-facing message from the exception attributes (see the beets plugin's
78
+ `_scan_user_error`).
79
+
80
+ ### The `Record` shape
81
+
82
+ One `Record` per file is your primary output. Its fields:
83
+
84
+ | field | type | meaning |
85
+ | ----- | ---- | ------- |
86
+ | `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. |
87
+ | `pairs` | `list[tuple[str, str]]` | Ordered `(tag_key, value)` text tags. Duplicate keys are allowed and get contiguous ordinals (multi-valued tags). |
88
+ | `art` | `list[ArtImage] \| None` | Embedded pictures, already resolved to bytes. `None`/`[]` leaves existing art untouched. |
89
+ | `delete_keys` | `list[str] \| None` | Merge mode only: keys to clear without rewriting (see below). Ignored in replace mode. |
90
+
91
+ `ArtImage(data, mime, picture_type=3, description="")` is one picture: `data` is
92
+ raw bytes, `picture_type` is the ID3/FLAC type (3 = front cover). Images larger
93
+ than `MAX_ART_BYTES` are dropped and counted in `SyncStats.skipped_art`.
94
+
95
+ If every record lands in `skipped`, the `key`s and the scan target disagree —
96
+ both must canonicalize the same way, so scan the *real* files (not a symlink
97
+ farm) and build keys with `realpath_key`.
98
+
99
+ ### Merge vs. replace, and sticky deletes
100
+
101
+ `sync_files(..., merge=False)` (the default) **replaces** every plugin-owned
102
+ text tag on each track: it clears all `value_blob IS NULL` rows and rewrites
103
+ them from `record.pairs`. Scanner-written binary tags always survive.
104
+
105
+ `sync_files(..., merge=True)` **merges**: only the keys named in `record.pairs`
106
+ and `record.delete_keys` are touched; other scan-seeded text tags stay. Use
107
+ merge when your plugin owns a *subset* of the tags and must not clobber the
108
+ rest. The store does not remember which keys you manage — **you** track your
109
+ managed-key set out of band (the contract is explicit that the store is not the
110
+ place for plugin state).
111
+
112
+ When the user removes a tag in the host, merge mode needs to delete the
113
+ now-orphaned store row. The beets plugin solves this with an **accumulating
114
+ managed-key set** (the `musefs_managed` pattern), worth copying:
115
+
116
+ - Persist, per file, the set of keys you have *ever* written (beets uses a
117
+ flexattr; any per-file host metadata works).
118
+ - On each sync, `delete_keys = previous_managed − keys_written_now`, and the new
119
+ persisted set is `previous_managed ∪ keys_written_now`.
120
+ - A key you stop writing becomes a tombstone: it keeps getting deleted on every
121
+ sync until you write it again. Persist the managed set **only after** the store
122
+ commit succeeds, so a failed sync doesn't lose the record of what you owe.
123
+
124
+ See `contrib/beets/beetsplug/_core.py` (`build_records` / `persist_managed`) for
125
+ the reference implementation.
126
+
127
+ ### Store invariants you must respect
128
+
129
+ The full external-writer contract is in
130
+ [ARCHITECTURE.md](../../ARCHITECTURE.md#the-external-writer-contract). The rules
131
+ that bite plugin authors:
132
+
133
+ - **Write only `tags`, `art`, and `track_art`.** The scanner owns the structural
134
+ columns of `tracks` and all of `structural_blocks`; never compute them — run
135
+ `musefs scan` (i.e. `run_scan`). `CHECK` constraints reject malformed
136
+ structural shapes at commit, so you cannot persist them anyway.
137
+ - **Binary tags survive a sync.** `merge_tags` / `replace_tags` scope their
138
+ deletes to text rows (`value_blob IS NULL`), so the write loop never wipes
139
+ scanner-written binary tags. You may write binary tags yourself too — a binary
140
+ row carries its payload in `value_blob` and must leave `value` empty (the only
141
+ `CHECK` on the row).
142
+ - **Content-address art** through `upsert_art` (sha256 de-dup) rather than
143
+ inserting `art` rows by hand; `sync_files` does this for you.
144
+ - **Art rows are immutable.** A trigger rejects in-place updates of an
145
+ `art` row's content columns (`data`, `sha256`, `mime`, `byte_len`, `width`,
146
+ `height`). To change a track's art, insert a new content-addressed row via
147
+ `upsert_art` and relink it via `replace_track_art`.
148
+ - **Path layout is just a tag.** To drive a reorganized mount, write your
149
+ computed relative path into a custom tag (e.g. `beets_path`) and mount with
150
+ `--template '$!{beets_path}'`. musefs sanitizes each path segment, so a writer
151
+ cannot inject traversal.
152
+
153
+ ## API reference
154
+
155
+ Everything in `__all__`, imported from the top-level `musefs_common` package.
156
+
157
+ **Connection & schema**
158
+
159
+ - `connect(db_path)` → `sqlite3.Connection` — open with a 5s busy timeout and
160
+ `foreign_keys = ON`.
161
+ - `check_schema_version(conn)` — raise `SchemaMismatch` unless the store's
162
+ `user_version` equals `EXPECTED_USER_VERSION`.
163
+
164
+ **Scanning**
165
+
166
+ - `run_scan(binary, db_path, target, *, timeout=None)` — shell out to `musefs
167
+ scan`; `target` is one path or an iterable, all scanned under one process.
168
+ Creates the DB if absent. Raises `ScanError`.
169
+
170
+ **Building records**
171
+
172
+ - `Record(key, pairs=[], art=None, delete_keys=None)` — one file's sync inputs
173
+ (see *The `Record` shape*).
174
+ - `ArtImage(data, mime, picture_type=3, description="")` — one embedded picture.
175
+ - `realpath_key(path)` — canonical path string matching the scanner's
176
+ `backing_path`; accepts `str`/`bytes`, returns `str`.
177
+
178
+ **Writing**
179
+
180
+ - `sync_files(conn, records, *, dry_run=False, stats=None, merge=False)` →
181
+ `SyncStats` — the write loop; caller owns the transaction. Pass `stats` to
182
+ accumulate into a caller-seeded instance.
183
+ - `sync_one(conn, record, stats, *, dry_run=False, merge=False)` — sync a single
184
+ record into a caller-supplied `SyncStats`.
185
+ - `SyncStats` — `synced` / `skipped` / `art_linked` / `skipped_art` counters,
186
+ plus `.summary()`.
187
+
188
+ **Lower-level store helpers** (called for you by `sync_files`; use directly only
189
+ for a custom write loop)
190
+
191
+ - `track_id_for_path(conn, key)` → track id or `None`.
192
+ - `merge_tags(conn, track_id, managed_pairs, delete_keys)` — per-key replace of
193
+ plugin-managed text tags, leaving unmanaged text rows intact.
194
+ - `replace_tags(conn, track_id, pairs)` — replace all plugin-owned text tags.
195
+ - `upsert_art(conn, data, mime)` → art id — content-address `data` by sha256,
196
+ inserting only if new.
197
+ - `replace_track_art(conn, track_id, arts)` — replace a track's `track_art`
198
+ rows; `arts` is `[(art_id, picture_type, description), …]`.
199
+ - `sniff_mime(data, path)` — image mime from magic bytes, falling back to file
200
+ extension.
201
+ - `prune_missing(conn, track_ids=None)` → count — delete tracks whose backing
202
+ file no longer exists (every track, or just `track_ids`).
203
+
204
+ **Constants**
205
+
206
+ - `EXPECTED_USER_VERSION` — schema `user_version` this library targets.
207
+ - `MAX_ART_BYTES` — per-image art cap; larger images are skipped.
208
+ - `SCAN_TIMEOUT_SECONDS` — default wall-clock cap for one `run_scan`.
209
+
210
+ **Exceptions**
211
+
212
+ - `SchemaMismatch(found)` — schema-version skew; `.found` is the DB's version.
213
+ - `ScanError(kind, *, binary, target, …)` — a `musefs scan` failure; `.kind` ∈
214
+ `{"not_found", "timeout", "failed"}`, with context attributes for messaging.
215
+
216
+ ## Consumers
217
+
218
+ - **beets** depends on this package via pip (`contrib/beets/pyproject.toml`).
219
+ - **Picard** cannot pip-install plugin dependencies, so the package is
220
+ **vendored** into `contrib/picard/musefs/_common/` by
221
+ `vendor_to_picard.py`. After any change here, re-run:
222
+
223
+ ```bash
224
+ python contrib/python-musefs/vendor_to_picard.py
225
+ ```
226
+
227
+ The Picard test `tests/test_vendor_sync.py` fails if the committed copy drifts.
228
+ - **Lidarr** depends on this package via pip (`contrib/lidarr/pyproject.toml`).
229
+
230
+ ## Schema coupling
231
+
232
+ `musefs_common/schema.py` (`SCHEMA_SQL`, `USER_VERSION`) is **generated** from
233
+ the Rust migrations in `musefs-db/src/schema.rs` — do not edit it by hand.
234
+ `EXPECTED_USER_VERSION` (in `constants.py`) derives from it. When the Rust
235
+ schema bumps, regenerate and re-vendor:
236
+
237
+ ```bash
238
+ MUSEFS_REGEN_SCHEMA_PY=1 cargo test -p musefs-db schema_py
239
+ python contrib/python-musefs/vendor_to_picard.py
240
+ ```
241
+
242
+ A `musefs-db` unit test fails if the generated file drifts. This is all
243
+ independent of the package's own `__version__` (its release SemVer).
244
+
245
+ ## Tests
246
+
247
+ ```bash
248
+ cd contrib/python-musefs
249
+ python -m venv .venv && source .venv/bin/activate
250
+ pip install -e ".[test]"
251
+ python -m pytest -v
252
+ ruff check . && ruff format --check .
253
+ ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-musefs"
7
- version = "0.0.1"
7
+ version = "1.0.0"
8
8
  description = "Shared musefs SQLite-store contract for the beets and Picard plugins"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -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",