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.
- python_musefs-1.0.0/PKG-INFO +274 -0
- python_musefs-1.0.0/README.md +253 -0
- {python_musefs-0.0.1 → python_musefs-1.0.0}/pyproject.toml +1 -1
- {python_musefs-0.0.1 → python_musefs-1.0.0}/src/musefs_common/__init__.py +3 -1
- {python_musefs-0.0.1 → python_musefs-1.0.0}/src/musefs_common/schema.py +111 -149
- python_musefs-1.0.0/src/musefs_common/store.py +221 -0
- {python_musefs-0.0.1 → python_musefs-1.0.0}/src/musefs_common/sync.py +27 -13
- python_musefs-1.0.0/src/python_musefs.egg-info/PKG-INFO +274 -0
- {python_musefs-0.0.1 → python_musefs-1.0.0}/src/python_musefs.egg-info/SOURCES.txt +2 -0
- python_musefs-1.0.0/tests/test_atomicity.py +208 -0
- {python_musefs-0.0.1 → python_musefs-1.0.0}/tests/test_constants.py +1 -1
- python_musefs-1.0.0/tests/test_merge_tags.py +78 -0
- {python_musefs-0.0.1 → python_musefs-1.0.0}/tests/test_public_api.py +1 -1
- {python_musefs-0.0.1 → python_musefs-1.0.0}/tests/test_sync.py +36 -1
- python_musefs-0.0.1/PKG-INFO +0 -73
- python_musefs-0.0.1/README.md +0 -52
- python_musefs-0.0.1/src/musefs_common/store.py +0 -119
- python_musefs-0.0.1/src/python_musefs.egg-info/PKG-INFO +0 -73
- {python_musefs-0.0.1 → python_musefs-1.0.0}/LICENSE +0 -0
- {python_musefs-0.0.1 → python_musefs-1.0.0}/setup.cfg +0 -0
- {python_musefs-0.0.1 → python_musefs-1.0.0}/src/musefs_common/constants.py +0 -0
- {python_musefs-0.0.1 → python_musefs-1.0.0}/src/musefs_common/contract.py +0 -0
- {python_musefs-0.0.1 → python_musefs-1.0.0}/src/musefs_common/errors.py +0 -0
- {python_musefs-0.0.1 → python_musefs-1.0.0}/src/musefs_common/paths.py +0 -0
- {python_musefs-0.0.1 → python_musefs-1.0.0}/src/musefs_common/scan.py +0 -0
- {python_musefs-0.0.1 → python_musefs-1.0.0}/src/python_musefs.egg-info/dependency_links.txt +0 -0
- {python_musefs-0.0.1 → python_musefs-1.0.0}/src/python_musefs.egg-info/requires.txt +0 -0
- {python_musefs-0.0.1 → python_musefs-1.0.0}/src/python_musefs.egg-info/top_level.txt +0 -0
- {python_musefs-0.0.1 → python_musefs-1.0.0}/tests/test_contract.py +0 -0
- {python_musefs-0.0.1 → python_musefs-1.0.0}/tests/test_errors.py +0 -0
- {python_musefs-0.0.1 → python_musefs-1.0.0}/tests/test_paths.py +0 -0
- {python_musefs-0.0.1 → python_musefs-1.0.0}/tests/test_scan.py +0 -0
- {python_musefs-0.0.1 → python_musefs-1.0.0}/tests/test_store_art.py +0 -0
- {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
|
+
```
|
|
@@ -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__ = "
|
|
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",
|