python-musefs 1.0.0__tar.gz → 1.1.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.1.0/PKG-INFO +27 -0
- python_musefs-1.1.0/README.md +6 -0
- {python_musefs-1.0.0 → python_musefs-1.1.0}/pyproject.toml +1 -1
- {python_musefs-1.0.0 → python_musefs-1.1.0}/src/musefs_common/__init__.py +11 -1
- {python_musefs-1.0.0 → python_musefs-1.1.0}/src/musefs_common/scan.py +11 -7
- {python_musefs-1.0.0 → python_musefs-1.1.0}/src/musefs_common/schema.py +66 -1
- {python_musefs-1.0.0 → python_musefs-1.1.0}/src/musefs_common/store.py +94 -1
- {python_musefs-1.0.0 → python_musefs-1.1.0}/src/musefs_common/sync.py +28 -13
- python_musefs-1.1.0/src/python_musefs.egg-info/PKG-INFO +27 -0
- {python_musefs-1.0.0 → python_musefs-1.1.0}/tests/test_constants.py +1 -1
- {python_musefs-1.0.0 → python_musefs-1.1.0}/tests/test_merge_tags.py +36 -0
- {python_musefs-1.0.0 → python_musefs-1.1.0}/tests/test_public_api.py +6 -1
- python_musefs-1.1.0/tests/test_store_db.py +268 -0
- {python_musefs-1.0.0 → python_musefs-1.1.0}/tests/test_sync.py +74 -2
- python_musefs-1.0.0/PKG-INFO +0 -274
- python_musefs-1.0.0/README.md +0 -253
- python_musefs-1.0.0/src/python_musefs.egg-info/PKG-INFO +0 -274
- python_musefs-1.0.0/tests/test_store_db.py +0 -76
- {python_musefs-1.0.0 → python_musefs-1.1.0}/LICENSE +0 -0
- {python_musefs-1.0.0 → python_musefs-1.1.0}/setup.cfg +0 -0
- {python_musefs-1.0.0 → python_musefs-1.1.0}/src/musefs_common/constants.py +0 -0
- {python_musefs-1.0.0 → python_musefs-1.1.0}/src/musefs_common/contract.py +0 -0
- {python_musefs-1.0.0 → python_musefs-1.1.0}/src/musefs_common/errors.py +0 -0
- {python_musefs-1.0.0 → python_musefs-1.1.0}/src/musefs_common/paths.py +0 -0
- {python_musefs-1.0.0 → python_musefs-1.1.0}/src/python_musefs.egg-info/SOURCES.txt +0 -0
- {python_musefs-1.0.0 → python_musefs-1.1.0}/src/python_musefs.egg-info/dependency_links.txt +0 -0
- {python_musefs-1.0.0 → python_musefs-1.1.0}/src/python_musefs.egg-info/requires.txt +0 -0
- {python_musefs-1.0.0 → python_musefs-1.1.0}/src/python_musefs.egg-info/top_level.txt +0 -0
- {python_musefs-1.0.0 → python_musefs-1.1.0}/tests/test_atomicity.py +0 -0
- {python_musefs-1.0.0 → python_musefs-1.1.0}/tests/test_contract.py +0 -0
- {python_musefs-1.0.0 → python_musefs-1.1.0}/tests/test_errors.py +0 -0
- {python_musefs-1.0.0 → python_musefs-1.1.0}/tests/test_paths.py +0 -0
- {python_musefs-1.0.0 → python_musefs-1.1.0}/tests/test_scan.py +0 -0
- {python_musefs-1.0.0 → python_musefs-1.1.0}/tests/test_store_art.py +0 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-musefs
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Shared musefs SQLite-store contract for the beets and Picard plugins
|
|
5
|
+
Author: Conor Futro
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Sohex/musefs
|
|
8
|
+
Project-URL: Repository, https://github.com/Sohex/musefs
|
|
9
|
+
Project-URL: Issues, https://github.com/Sohex/musefs/issues
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: POSIX
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# python-musefs
|
|
23
|
+
|
|
24
|
+
The full guide now lives in the musefs documentation site:
|
|
25
|
+
|
|
26
|
+
- **Published:** <https://sohex.github.io/musefs/integrations/python-musefs.html>
|
|
27
|
+
- **In-repo source:** [`docs/src/integrations/python-musefs.md`](../../docs/src/integrations/python-musefs.md)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# python-musefs
|
|
2
|
+
|
|
3
|
+
The full guide now lives in the musefs documentation site:
|
|
4
|
+
|
|
5
|
+
- **Published:** <https://sohex.github.io/musefs/integrations/python-musefs.html>
|
|
6
|
+
- **In-repo source:** [`docs/src/integrations/python-musefs.md`](../../docs/src/integrations/python-musefs.md)
|
|
@@ -11,19 +11,24 @@ from .errors import ScanError, SchemaMismatch
|
|
|
11
11
|
from .paths import realpath_key
|
|
12
12
|
from .scan import run_scan
|
|
13
13
|
from .store import (
|
|
14
|
+
TagRow,
|
|
14
15
|
check_schema_version,
|
|
15
16
|
connect,
|
|
17
|
+
delete_tracks,
|
|
16
18
|
merge_tags,
|
|
17
19
|
prune_missing,
|
|
18
20
|
replace_tags,
|
|
19
21
|
replace_track_art,
|
|
20
22
|
sniff_mime,
|
|
23
|
+
tags_for_track,
|
|
21
24
|
track_id_for_path,
|
|
25
|
+
track_ids_by_tag,
|
|
26
|
+
track_ids_for_paths,
|
|
22
27
|
upsert_art,
|
|
23
28
|
)
|
|
24
29
|
from .sync import ArtImage, Record, SyncStats, sync_files, sync_one
|
|
25
30
|
|
|
26
|
-
__version__ = "1.
|
|
31
|
+
__version__ = "1.1.0"
|
|
27
32
|
|
|
28
33
|
__all__ = [
|
|
29
34
|
"EXPECTED_USER_VERSION",
|
|
@@ -36,6 +41,11 @@ __all__ = [
|
|
|
36
41
|
"connect",
|
|
37
42
|
"check_schema_version",
|
|
38
43
|
"track_id_for_path",
|
|
44
|
+
"track_ids_for_paths",
|
|
45
|
+
"track_ids_by_tag",
|
|
46
|
+
"tags_for_track",
|
|
47
|
+
"TagRow",
|
|
48
|
+
"delete_tracks",
|
|
39
49
|
"prune_missing",
|
|
40
50
|
"merge_tags",
|
|
41
51
|
"replace_tags",
|
|
@@ -4,19 +4,23 @@ import subprocess
|
|
|
4
4
|
from .errors import ScanError
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
def run_scan(binary, db_path, target, *, timeout=None):
|
|
8
|
-
"""Run ``<binary> scan <target...> --db <db_path
|
|
9
|
-
path or an iterable of paths; all targets precede the ``--db``
|
|
10
|
-
scanned under one process (one DB open). Creates the DB if
|
|
11
|
-
the structural columns a plugin can't compute.
|
|
12
|
-
``
|
|
13
|
-
|
|
7
|
+
def run_scan(binary, db_path, target, *, revalidate=False, timeout=None):
|
|
8
|
+
"""Run ``<binary> scan <target...> --db <db_path> [--revalidate]``. ``target``
|
|
9
|
+
is a single path or an iterable of paths; all targets precede the ``--db``
|
|
10
|
+
flag and are scanned under one process (one DB open). Creates the DB if
|
|
11
|
+
absent and fills the structural columns a plugin can't compute. With
|
|
12
|
+
``revalidate``, the scanner re-checks stamps, prunes rows whose backing file
|
|
13
|
+
is gone, and GCs orphaned art. Raises ``ScanError`` (with ``kind`` in
|
|
14
|
+
``"not_found" | "timeout" | "failed"``) on failure; the caller formats its
|
|
15
|
+
own user-facing message from the exception attributes."""
|
|
14
16
|
if isinstance(target, (str, os.PathLike)):
|
|
15
17
|
targets = [target]
|
|
16
18
|
else:
|
|
17
19
|
targets = list(target)
|
|
18
20
|
display = str(targets[0]) if len(targets) == 1 else f"{len(targets)} target(s)"
|
|
19
21
|
argv = [binary, "scan", *(str(t) for t in targets), "--db", str(db_path)]
|
|
22
|
+
if revalidate:
|
|
23
|
+
argv.append("--revalidate")
|
|
20
24
|
try:
|
|
21
25
|
result = subprocess.run(argv, capture_output=True, timeout=timeout)
|
|
22
26
|
except FileNotFoundError as exc:
|
|
@@ -200,6 +200,71 @@ CREATE TRIGGER structural_blocks_ad AFTER DELETE ON structural_blocks BEGIN
|
|
|
200
200
|
UPDATE tracks SET content_version = content_version + 1 WHERE id = OLD.track_id;
|
|
201
201
|
END;
|
|
202
202
|
PRAGMA user_version = 1;
|
|
203
|
+
|
|
204
|
+
-- ── MIGRATION_V2 ──
|
|
205
|
+
-- fingerprint/content_hash are scanner-owned content identities. Neither is
|
|
206
|
+
-- UNIQUE and the index is NON-unique BY DESIGN: duplicate-content tracks (same
|
|
207
|
+
-- album in two places, genuine dupes) legitimately share both values, and a
|
|
208
|
+
-- UNIQUE constraint would abort the scan batch on the second copy. Correctness
|
|
209
|
+
-- comes from the refind logic (unique-missing candidate + confirmation), not
|
|
210
|
+
-- from DB uniqueness. Both columns carry a length(x) = 64 CHECK locking them
|
|
211
|
+
-- to SHA-256 hex (Task E2 benchmark locked the hash to SHA-256: under a
|
|
212
|
+
-- realistic SSD/HDD I/O profile the fingerprint adds ~8.6%; the RAM
|
|
213
|
+
-- microbench's higher ratio is an I/O-elimination artifact — see
|
|
214
|
+
-- the benchmarks docs). Hash function is now fixed, so the CHECK is added here.
|
|
215
|
+
ALTER TABLE tracks ADD COLUMN fingerprint TEXT
|
|
216
|
+
CHECK (fingerprint IS NULL OR length(fingerprint) = 64);
|
|
217
|
+
ALTER TABLE tracks ADD COLUMN content_hash TEXT
|
|
218
|
+
CHECK (content_hash IS NULL OR length(content_hash) = 64);
|
|
219
|
+
CREATE INDEX tracks_fingerprint_idx ON tracks(fingerprint);
|
|
220
|
+
|
|
221
|
+
-- Rebuild `tags` with a byte-accurate value cap (#505). SQLite's length() on
|
|
222
|
+
-- TEXT counts characters, so the V1 `CHECK (length(value) <= 262144)` was up to
|
|
223
|
+
-- ~4x looser than the documented 256 KiB byte bound; length(CAST(value AS BLOB))
|
|
224
|
+
-- counts bytes. SQLite cannot alter a CHECK in place, so recreate the table
|
|
225
|
+
-- (V2 is unreleased — this is folded in rather than added as a new migration).
|
|
226
|
+
-- Pre-existing over-cap rows (only reachable on an upgraded store) are dropped:
|
|
227
|
+
-- the read-time guard already counts bytes, so they were unreadable anyway, and
|
|
228
|
+
-- carrying them would abort the rebuild on the new CHECK.
|
|
229
|
+
CREATE TABLE tags_new (
|
|
230
|
+
track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
|
231
|
+
key TEXT NOT NULL,
|
|
232
|
+
value TEXT NOT NULL,
|
|
233
|
+
ordinal INTEGER NOT NULL DEFAULT 0,
|
|
234
|
+
value_blob BLOB,
|
|
235
|
+
PRIMARY KEY (track_id, key, ordinal),
|
|
236
|
+
CHECK (ordinal >= 0),
|
|
237
|
+
CHECK (value_blob IS NULL OR value = ''),
|
|
238
|
+
CHECK (length(key) <= 256),
|
|
239
|
+
CHECK (length(key) >= 1
|
|
240
|
+
AND key NOT GLOB '*[' || char(1) || '-' || char(31) || ']*'),
|
|
241
|
+
CHECK (length(CAST(value AS BLOB)) <= 262144),
|
|
242
|
+
CHECK (value_blob IS NULL OR length(value_blob) <= 16711680)
|
|
243
|
+
);
|
|
244
|
+
INSERT INTO tags_new (track_id, key, value, ordinal, value_blob)
|
|
245
|
+
SELECT track_id, key, value, ordinal, value_blob FROM tags
|
|
246
|
+
WHERE length(CAST(value AS BLOB)) <= 262144;
|
|
247
|
+
DROP TABLE tags;
|
|
248
|
+
ALTER TABLE tags_new RENAME TO tags;
|
|
249
|
+
|
|
250
|
+
-- DROP TABLE tags dropped its INSERT/UPDATE/DELETE triggers; recreate them
|
|
251
|
+
-- verbatim so the content_version/updated_at bump contract is unchanged.
|
|
252
|
+
CREATE TRIGGER tags_ai AFTER INSERT ON tags BEGIN
|
|
253
|
+
UPDATE tracks SET content_version = content_version + 1,
|
|
254
|
+
updated_at = CAST(strftime('%s','now') AS INTEGER)
|
|
255
|
+
WHERE id = NEW.track_id;
|
|
256
|
+
END;
|
|
257
|
+
CREATE TRIGGER tags_au AFTER UPDATE ON tags BEGIN
|
|
258
|
+
UPDATE tracks SET content_version = content_version + 1,
|
|
259
|
+
updated_at = CAST(strftime('%s','now') AS INTEGER)
|
|
260
|
+
WHERE id = NEW.track_id;
|
|
261
|
+
END;
|
|
262
|
+
CREATE TRIGGER tags_ad AFTER DELETE ON tags BEGIN
|
|
263
|
+
UPDATE tracks SET content_version = content_version + 1,
|
|
264
|
+
updated_at = CAST(strftime('%s','now') AS INTEGER)
|
|
265
|
+
WHERE id = OLD.track_id;
|
|
266
|
+
END;
|
|
267
|
+
PRAGMA user_version = 2;
|
|
203
268
|
"""
|
|
204
269
|
|
|
205
|
-
USER_VERSION =
|
|
270
|
+
USER_VERSION = 2
|
|
@@ -2,10 +2,27 @@ import contextlib
|
|
|
2
2
|
import hashlib
|
|
3
3
|
import os
|
|
4
4
|
import sqlite3
|
|
5
|
+
from dataclasses import dataclass
|
|
5
6
|
|
|
6
7
|
from .constants import EXPECTED_USER_VERSION
|
|
7
8
|
from .errors import SchemaMismatch
|
|
8
9
|
|
|
10
|
+
# SQLite caps a statement's host parameters (SQLITE_MAX_VARIABLE_NUMBER: 999 on
|
|
11
|
+
# the <3.32 floor). Chunk bulk IN-lists below it so large lookups never trip it.
|
|
12
|
+
_MAX_SQL_VARS = 900
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class TagRow:
|
|
17
|
+
"""One tag row read back from the store: the key, the text value, and the
|
|
18
|
+
raw ``value_blob``. Plugin-owned text tags have ``value_blob is None``;
|
|
19
|
+
scanner-written binary tags have ``value == ""`` and ``value_blob`` bytes."""
|
|
20
|
+
|
|
21
|
+
key: str
|
|
22
|
+
value: str
|
|
23
|
+
value_blob: object = None # bytes | None
|
|
24
|
+
|
|
25
|
+
|
|
9
26
|
# sqlite3.LEGACY_TRANSACTION_CONTROL is 3.12+; it is == -1. Use getattr so this
|
|
10
27
|
# module still imports on the 3.8 floor (where the constant does not exist).
|
|
11
28
|
_LEGACY = getattr(sqlite3, "LEGACY_TRANSACTION_CONTROL", -1)
|
|
@@ -88,6 +105,77 @@ def track_id_for_path(conn, key):
|
|
|
88
105
|
return row[0] if row else None
|
|
89
106
|
|
|
90
107
|
|
|
108
|
+
def track_ids_for_paths(conn, keys):
|
|
109
|
+
"""Resolve many ``backing_path`` keys to track ids in one pass, returning a
|
|
110
|
+
``{key: id}`` dict that omits keys with no matching track row. The IN-list is
|
|
111
|
+
chunked under SQLite's host-parameter cap so arbitrarily large lookups work
|
|
112
|
+
(the bulk counterpart to ``track_id_for_path``)."""
|
|
113
|
+
# Deduplicate while preserving first-seen order: a key repeated across chunk
|
|
114
|
+
# boundaries would re-fetch its row in a later chunk and trip the duplicate
|
|
115
|
+
# guard below even on a conformant DB.
|
|
116
|
+
keys = list(dict.fromkeys(keys))
|
|
117
|
+
out = {}
|
|
118
|
+
for start in range(0, len(keys), _MAX_SQL_VARS):
|
|
119
|
+
chunk = keys[start : start + _MAX_SQL_VARS]
|
|
120
|
+
placeholders = ",".join("?" for _ in chunk)
|
|
121
|
+
rows = conn.execute(
|
|
122
|
+
f"SELECT backing_path, id FROM tracks WHERE backing_path IN ({placeholders})",
|
|
123
|
+
chunk,
|
|
124
|
+
)
|
|
125
|
+
for backing_path, track_id in rows:
|
|
126
|
+
if backing_path in out:
|
|
127
|
+
# backing_path is UNIQUE in the schema, so a duplicate means a
|
|
128
|
+
# non-conformant DB; collapsing it would silently hide a track
|
|
129
|
+
# from prune (#478). Fail loudly instead.
|
|
130
|
+
raise ValueError(
|
|
131
|
+
f"duplicate backing_path {backing_path!r} in tracks "
|
|
132
|
+
f"(ids {out[backing_path]} and {track_id})"
|
|
133
|
+
)
|
|
134
|
+
out[backing_path] = track_id
|
|
135
|
+
return out
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def track_ids_by_tag(conn, key, value):
|
|
139
|
+
"""Return a list of track ids whose plugin-owned text tag ``(key, value)``
|
|
140
|
+
matches (order unspecified, possibly empty).
|
|
141
|
+
|
|
142
|
+
Scoped to text rows (``value_blob IS NULL``); scanner-written binary tags
|
|
143
|
+
never match. The intent-based counterpart to ``prune_missing``'s
|
|
144
|
+
existence-based scoping: used to map a source's "I deleted this album/artist"
|
|
145
|
+
signal back to the rows it tagged.
|
|
146
|
+
"""
|
|
147
|
+
rows = conn.execute(
|
|
148
|
+
"SELECT track_id FROM tags WHERE key = ? AND value = ? AND value_blob IS NULL",
|
|
149
|
+
(key, value),
|
|
150
|
+
)
|
|
151
|
+
return [track_id for (track_id,) in rows]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def delete_tracks(conn, track_ids):
|
|
155
|
+
"""Unconditionally delete the given track rows; return the count actually
|
|
156
|
+
deleted (an already-gone id contributes 0).
|
|
157
|
+
|
|
158
|
+
The intent-based delete: unlike ``prune_missing`` it does not check on-disk
|
|
159
|
+
existence. ``tags`` and ``track_art`` rows cascade away via the schema's
|
|
160
|
+
``ON DELETE CASCADE`` (``connect`` enables ``foreign_keys = ON``).
|
|
161
|
+
"""
|
|
162
|
+
deleted = 0
|
|
163
|
+
for track_id in track_ids:
|
|
164
|
+
deleted += conn.execute("DELETE FROM tracks WHERE id = ?", (track_id,)).rowcount
|
|
165
|
+
return deleted
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def tags_for_track(conn, track_id):
|
|
169
|
+
"""Read back a track's tag rows as an ordered ``list[TagRow]`` (by key, then
|
|
170
|
+
ordinal). Includes both plugin-owned text tags (``value_blob is None``) and
|
|
171
|
+
scanner-written binary tags (``value == ""``, ``value_blob`` bytes)."""
|
|
172
|
+
rows = conn.execute(
|
|
173
|
+
"SELECT key, value, value_blob FROM tags WHERE track_id = ? ORDER BY key, ordinal",
|
|
174
|
+
(track_id,),
|
|
175
|
+
)
|
|
176
|
+
return [TagRow(key, value, value_blob) for key, value, value_blob in rows]
|
|
177
|
+
|
|
178
|
+
|
|
91
179
|
def prune_missing(conn, track_ids=None):
|
|
92
180
|
"""Delete track rows whose backing file no longer exists on disk.
|
|
93
181
|
|
|
@@ -150,9 +238,14 @@ def merge_tags(conn, track_id, managed_pairs, delete_keys):
|
|
|
150
238
|
for key, value in managed_pairs:
|
|
151
239
|
by_key.setdefault(key, []).append(value)
|
|
152
240
|
|
|
241
|
+
# Case-fold the key match: a scan seeds an unmapped tag in the file's
|
|
242
|
+
# native case (e.g. Vorbis ``LABEL``) while the plugin canonicalises to
|
|
243
|
+
# lowercase (``label``). Vorbis keys render case-insensitively, so an
|
|
244
|
+
# exact-case delete would leave the scan row and render a duplicate (#407).
|
|
153
245
|
for key in set(by_key) | set(delete_keys or ()):
|
|
154
246
|
conn.execute(
|
|
155
|
-
"DELETE FROM tags WHERE track_id = ? AND key = ?
|
|
247
|
+
"DELETE FROM tags WHERE track_id = ? AND lower(key) = lower(?) "
|
|
248
|
+
"AND value_blob IS NULL",
|
|
156
249
|
(track_id, key),
|
|
157
250
|
)
|
|
158
251
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import sqlite3
|
|
3
4
|
from dataclasses import dataclass, field
|
|
4
5
|
|
|
5
6
|
from .constants import MAX_ART_BYTES
|
|
@@ -42,11 +43,14 @@ class SyncStats:
|
|
|
42
43
|
skipped: int = 0 # path had no matching track row
|
|
43
44
|
art_linked: int = 0
|
|
44
45
|
skipped_art: int = 0 # art over the size cap (or, in the beets adapter, unreadable)
|
|
46
|
+
skipped_invalid: int = 0 # record violated a store CHECK constraint
|
|
47
|
+
invalid: list = field(default_factory=list) # (record key, sqlite error message)
|
|
45
48
|
|
|
46
49
|
def summary(self):
|
|
47
50
|
return (
|
|
48
51
|
f"synced={self.synced} skipped={self.skipped} "
|
|
49
|
-
f"art_linked={self.art_linked} skipped_art={self.skipped_art}"
|
|
52
|
+
f"art_linked={self.art_linked} skipped_art={self.skipped_art} "
|
|
53
|
+
f"skipped_invalid={self.skipped_invalid}"
|
|
50
54
|
)
|
|
51
55
|
|
|
52
56
|
|
|
@@ -58,7 +62,13 @@ def sync_one(conn, record, stats, *, dry_run=False, merge=False):
|
|
|
58
62
|
scanner-written binary tags survive. Art is replaced when at least one image is
|
|
59
63
|
within ``MAX_ART_BYTES``; each over-cap image bumps ``skipped_art``, and if
|
|
60
64
|
every provided image is over cap any scan-seeded ``track_art`` is left
|
|
61
|
-
untouched.
|
|
65
|
+
untouched.
|
|
66
|
+
|
|
67
|
+
A record whose tags or art violate a store CHECK constraint (key/value/mime
|
|
68
|
+
length, ``picture_type`` range, control chars, ...) is rolled back through its
|
|
69
|
+
own savepoint and skipped -- it bumps ``skipped_invalid`` and appends
|
|
70
|
+
``(record.key, message)`` to ``invalid`` rather than aborting the whole batch
|
|
71
|
+
with an opaque commit-time ``IntegrityError`` (#420)."""
|
|
62
72
|
track_id = track_id_for_path(conn, record.key)
|
|
63
73
|
if track_id is None:
|
|
64
74
|
stats.skipped += 1
|
|
@@ -73,17 +83,22 @@ def sync_one(conn, record, stats, *, dry_run=False, merge=False):
|
|
|
73
83
|
will_link_art = bool(kept)
|
|
74
84
|
|
|
75
85
|
if not dry_run:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
try:
|
|
87
|
+
with _savepoint(conn, "musefs_sync_one"):
|
|
88
|
+
if merge:
|
|
89
|
+
merge_tags(conn, track_id, record.pairs, record.delete_keys or [])
|
|
90
|
+
else:
|
|
91
|
+
replace_tags(conn, track_id, record.pairs)
|
|
92
|
+
if will_link_art:
|
|
93
|
+
arts = [
|
|
94
|
+
(upsert_art(conn, img.data, img.mime), img.picture_type, img.description)
|
|
95
|
+
for img in kept
|
|
96
|
+
]
|
|
97
|
+
replace_track_art(conn, track_id, arts)
|
|
98
|
+
except sqlite3.IntegrityError as err:
|
|
99
|
+
stats.skipped_invalid += 1
|
|
100
|
+
stats.invalid.append((record.key, str(err)))
|
|
101
|
+
return
|
|
87
102
|
|
|
88
103
|
if will_link_art:
|
|
89
104
|
stats.art_linked += 1
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-musefs
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Shared musefs SQLite-store contract for the beets and Picard plugins
|
|
5
|
+
Author: Conor Futro
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Sohex/musefs
|
|
8
|
+
Project-URL: Repository, https://github.com/Sohex/musefs
|
|
9
|
+
Project-URL: Issues, https://github.com/Sohex/musefs/issues
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: POSIX
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# python-musefs
|
|
23
|
+
|
|
24
|
+
The full guide now lives in the musefs documentation site:
|
|
25
|
+
|
|
26
|
+
- **Published:** <https://sohex.github.io/musefs/integrations/python-musefs.html>
|
|
27
|
+
- **In-repo source:** [`docs/src/integrations/python-musefs.md`](../../docs/src/integrations/python-musefs.md)
|
|
@@ -76,3 +76,39 @@ def test_merge_preserves_binary_tags(db_path):
|
|
|
76
76
|
assert text_tags(conn, tid)["comment"] == ["text"]
|
|
77
77
|
finally:
|
|
78
78
|
conn.close()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_merge_replaces_case_variant_scan_key(db_path):
|
|
82
|
+
"""A scan seeds an unmapped Vorbis key in the file's native (upper) case;
|
|
83
|
+
the plugin's lowercase canonical key must replace it, not coexist (#407)."""
|
|
84
|
+
conn = connect(db_path)
|
|
85
|
+
try:
|
|
86
|
+
tid = insert_track(conn, "/m/e.flac")
|
|
87
|
+
# Scanner-seeded row, native FLAC Vorbis case (uppercase).
|
|
88
|
+
replace_tags(conn, tid, [("LABEL", "New Friends")])
|
|
89
|
+
# Plugin sync writes the canonical lowercase key for the same field.
|
|
90
|
+
merge_tags(conn, tid, [("label", "New Friends")], delete_keys=[])
|
|
91
|
+
conn.commit()
|
|
92
|
+
rows = conn.execute(
|
|
93
|
+
"SELECT key, value FROM tags WHERE track_id=? AND value_blob IS NULL", (tid,)
|
|
94
|
+
).fetchall()
|
|
95
|
+
# Exactly one row survives (no LABEL/label duplicate that renders twice).
|
|
96
|
+
assert rows == [("label", "New Friends")]
|
|
97
|
+
finally:
|
|
98
|
+
conn.close()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_merge_delete_keys_clears_case_variant(db_path):
|
|
102
|
+
"""delete_keys must also clear a scan-seeded case variant of the named key."""
|
|
103
|
+
conn = connect(db_path)
|
|
104
|
+
try:
|
|
105
|
+
tid = insert_track(conn, "/m/f.flac")
|
|
106
|
+
replace_tags(conn, tid, [("LABEL", "Old")])
|
|
107
|
+
merge_tags(conn, tid, [], delete_keys=["label"])
|
|
108
|
+
conn.commit()
|
|
109
|
+
rows = conn.execute(
|
|
110
|
+
"SELECT key FROM tags WHERE track_id=? AND value_blob IS NULL", (tid,)
|
|
111
|
+
).fetchall()
|
|
112
|
+
assert rows == []
|
|
113
|
+
finally:
|
|
114
|
+
conn.close()
|
|
@@ -2,7 +2,7 @@ import musefs_common
|
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
def test_version_is_package_semver_not_schema_version():
|
|
5
|
-
assert musefs_common.__version__ == "1.
|
|
5
|
+
assert musefs_common.__version__ == "1.1.0"
|
|
6
6
|
assert musefs_common.__version__ != str(musefs_common.EXPECTED_USER_VERSION)
|
|
7
7
|
|
|
8
8
|
|
|
@@ -18,7 +18,12 @@ def test_public_api_surface():
|
|
|
18
18
|
"connect",
|
|
19
19
|
"check_schema_version",
|
|
20
20
|
"track_id_for_path",
|
|
21
|
+
"track_ids_for_paths",
|
|
22
|
+
"track_ids_by_tag",
|
|
23
|
+
"tags_for_track",
|
|
24
|
+
"TagRow",
|
|
21
25
|
"prune_missing",
|
|
26
|
+
"delete_tracks",
|
|
22
27
|
"replace_tags",
|
|
23
28
|
"upsert_art",
|
|
24
29
|
"replace_track_art",
|