beets-musefs 0.0.1__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.
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.4
2
+ Name: beets-musefs
3
+ Version: 0.0.1
4
+ Summary: Sync beets metadata into the musefs SQLite store
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.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: python-musefs>=0.1.0
19
+ Requires-Dist: beets>=1.6
20
+ Provides-Extra: test
21
+ Requires-Dist: pytest>=7; extra == "test"
22
+ Dynamic: license-file
23
+
24
+ # beets-musefs
25
+
26
+ A [beets](https://beets.io) plugin that syncs your beets metadata (tags + cover
27
+ art) into a [musefs](../../README.md) SQLite store, so a live musefs mount shows
28
+ a re-tagged view of your library without rewriting any audio.
29
+
30
+ ## How it fits together
31
+
32
+ - The plugin owns the **tags** (and **cover art**, when beets has it) of each
33
+ track, keyed by the file's canonical real path.
34
+ - The structural columns (audio offsets, size, mtime) can only come from musefs
35
+ probing the file, so the plugin runs `musefs scan` for you (via the `bin`
36
+ config) before syncing — it never tries to compute those itself.
37
+ - `beet musefs` scans the library and then syncs; the import/write hooks scan
38
+ just the touched file and then sync. musefs's auto-refresh shows changes live —
39
+ no remount, and **no separate scan step**.
40
+
41
+ ## Install (local / development)
42
+
43
+ No install needed — point beets at the plugin's `beetsplug` directory. beets adds
44
+ `pluginpath` entries directly to the `beetsplug` package path, so it must be the
45
+ `beetsplug` dir itself (not its parent). In your beets `config.yaml`:
46
+
47
+ The plugin depends on the shared `python-musefs` library, which is unpublished
48
+ and lives in this repo. Install it from the working tree **before** the plugin:
49
+
50
+ ```bash
51
+ pip install -e contrib/python-musefs
52
+ pip install -e "contrib/beets[test]"
53
+ ```
54
+
55
+ ```yaml
56
+ pluginpath: /path/to/musefs/contrib/beets/beetsplug
57
+ plugins: musefs
58
+ musefs:
59
+ db: ~/musefs.db # path to the musefs SQLite store (required)
60
+ bin: musefs # musefs executable for auto-scan; use a full path if
61
+ # not on $PATH, e.g. /path/to/musefs/target/release/musefs
62
+ # autoscan: yes # default; runs `musefs scan` for you. Set `no` to
63
+ # # manage scanning yourself (hooks then best-effort).
64
+ # fields: # optional: map extra beets fields to musefs keys
65
+ # comments: comment
66
+ ```
67
+
68
+ ## Workflow (test drive)
69
+
70
+ ```bash
71
+ # Sync beets metadata into the store. Auto-scans the library first (creating the
72
+ # DB if needed) — no separate `musefs scan` step.
73
+ beet musefs # everything
74
+ beet musefs albumartist:"Boards of Canada" # a subset (scans just those files)
75
+ beet musefs -n # dry run: report counts, write nothing
76
+
77
+ # Mount the re-tagged view.
78
+ musefs mount ~/mnt --db ~/musefs.db \
79
+ --template '$albumartist/$album/$tracknumber - $title'
80
+
81
+ # ...or mirror your beets library layout exactly, via the computed beets_path tag.
82
+ musefs mount ~/mnt --db ~/musefs.db --template '$!{beets_path}'
83
+ ```
84
+
85
+ Imports and tag write-backs auto-sync via event hooks: `beet import` and
86
+ `beet modify -w …` record the touched items and reconcile them once the command
87
+ finishes — when each file's path is final (beets has no move event, and a write
88
+ fires *before* its move). The reconcile scans the new path and prunes the row
89
+ left behind at the old one. A metadata-only `beet modify` (no `-w`) doesn't fire
90
+ a hook — re-run `beet musefs`. With `autoscan: no`, run `musefs scan` yourself
91
+ first; the hooks then skip gracefully if the DB is missing.
92
+
93
+ ## Notes
94
+
95
+ - **Cover art:** taken from the album's `artpath` (beets' external cover file).
96
+ beets art wins when present; otherwise any art `musefs scan` ingested from
97
+ embedded pictures is preserved.
98
+ - **Computed path (`beets_path`):** each sync also writes a `beets_path` text tag
99
+ holding the track's beets library-relative path (from your `paths:` config, via
100
+ `item.destination`), with the file extension removed — musefs re-appends it. Mount
101
+ with `--template '$!{beets_path}'` (the `$!{}` path field keeps `/` as directory
102
+ separators) to mirror your beets layout, including layouts musefs's own template
103
+ engine can't express. Set `write_path: no` in the `musefs:` config to skip it.
104
+ Do not add an extension in a template that consumes `beets_path`. See the
105
+ computed-tag workflow in [ARCHITECTURE.md](../../ARCHITECTURE.md).
106
+ - **Moves & deletes:** every sync (the command and the end-of-command reconcile)
107
+ prunes track rows whose backing file is no longer present, so renames/moves
108
+ don't leave stale entries. Caveat: a file that's merely offline at sync time
109
+ (e.g. an unmounted network share) is also pruned — sync while the library is
110
+ available.
111
+ - **Orphaned art:** replacing art can orphan old blobs; `musefs scan --revalidate`
112
+ garbage-collects them.
113
+ - **Schema version:** the plugin refuses to run if the DB's `user_version` differs
114
+ from the version it targets — rebuild after upgrading musefs.
115
+
116
+ ## Tests
117
+
118
+ The tests live under `tests/` and use a local virtualenv with beets + pytest.
119
+
120
+ ```bash
121
+ cd contrib/beets
122
+ uv venv # create .venv (once)
123
+ source .venv/bin/activate
124
+ uv pip install -e ../python-musefs # shared library (unpublished; install first)
125
+ uv pip install -r requirements.txt # beets + pytest
126
+
127
+ python -m pytest # unit + integration (no Rust binary)
128
+ python -m pytest -m musefs_bin # path-matching gate vs the real `musefs` binary
129
+ python -m pytest -m e2e # full beets -> mount -> playback end-to-end
130
+ ```
131
+
132
+ The `musefs_bin` gate shells out to the real `musefs` binary, so build it first
133
+ from the repo root (`cargo build`) and run it against a fresh build. The `e2e`
134
+ tier additionally needs `ffmpeg` and `/dev/fuse` + `fusermount`: it generates
135
+ audio, imports it with beets, retags, syncs, mounts via FUSE, and verifies the
136
+ mount's tags and byte-identical audio (including a move-reconcile case). Both
137
+ tiers are deselected from the default run and skip cleanly if their tools are
138
+ absent.
@@ -0,0 +1,8 @@
1
+ beets_musefs-0.0.1.dist-info/licenses/LICENSE,sha256=4VbfzhgMpuFrhFjBCtLptGV_bzCj_gML9y_9o2Tr1OQ,1068
2
+ beetsplug/__init__.py,sha256=3QXZPZKPrVUXAlEscNoqXp_015ZbUcENpjV7ZRUBoUs,140
3
+ beetsplug/_core.py,sha256=ZPmHyWx2hZq5id_emQZS3vzNffx7SZLdRDoYqBr1AK0,7233
4
+ beetsplug/musefs.py,sha256=_r5v1yw0r72nQmP4aYFnw3aCzKa_nYzukCpKe22BICw,8647
5
+ beets_musefs-0.0.1.dist-info/METADATA,sha256=1O1kZ7ThkoWXCHyz6z-sTcW9wB9eJigXjCgxZp7e-EM,6426
6
+ beets_musefs-0.0.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
7
+ beets_musefs-0.0.1.dist-info/top_level.txt,sha256=za8u6CXAGbgac8cUyn9NlsgXqqq-_4HHZcNmJnfmyWc,10
8
+ beets_musefs-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Conor Futro
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ beetsplug
beetsplug/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ # beetsplug is a namespace package shared by all beets plugins.
2
+ from pkgutil import extend_path
3
+
4
+ __path__ = extend_path(__path__, __name__)
beetsplug/_core.py ADDED
@@ -0,0 +1,198 @@
1
+ """beets-specific mapping for the musefs sync plugin: no beets imports here.
2
+
3
+ The shared store/scan/sync contract lives in the ``musefs_common`` package
4
+ (python-musefs); this module only maps beets items to musefs tag pairs and reads
5
+ album cover art into ``Record``s. ``musefs.py`` holds the BeetsPlugin adapter.
6
+ """
7
+
8
+ import os
9
+
10
+ from musefs_common import MAX_ART_BYTES, ArtImage, Record, realpath_key, sniff_mime
11
+
12
+ # beets field name -> musefs (Vorbis-lowercase) tag key, for direct copies.
13
+ # beets 2.x exposes genre/composer as the multi-valued `genres`/`composers`
14
+ # (lists); the singular keys are kept for simpler/older items. List values are
15
+ # expanded into one tag per element by _values().
16
+ DIRECT_FIELDS = {
17
+ "title": "title",
18
+ "artist": "artist",
19
+ "albumartist": "albumartist",
20
+ "album": "album",
21
+ }
22
+
23
+ # (list_field, scalar_field, store_key): beets carries some tags as both a list
24
+ # (genres/composers, beets 2.x) and a joined scalar (genre/composer). Emitting
25
+ # both duplicates rows, so prefer the list when present, else the scalar. The
26
+ # per-twin dedup below is scoped to one twin: if a user maps an extra_field onto
27
+ # the "genre"/"composer" store key, that row is emitted in the direct-fields
28
+ # loop and won't be deduped against these — an unlikely config we don't guard.
29
+ TWIN_FIELDS = (
30
+ ("genres", "genre", "genre"),
31
+ ("composers", "composer", "composer"),
32
+ )
33
+
34
+
35
+ def _values(value):
36
+ """Normalize a beets field value to a list of non-empty string values.
37
+ Multi-valued beets fields (genres, composers) arrive as lists; scalars
38
+ become a single-element list. Avoids stringifying a list as ``['Rock']``."""
39
+ if value is None:
40
+ return []
41
+ items = value if isinstance(value, (list, tuple)) else [value]
42
+ return [text for v in items if (text := str(v).strip())]
43
+
44
+
45
+ def _to_int(value):
46
+ """Coerce a beets field to int, tolerating None and non-numeric strings
47
+ (e.g. a malformed ``"1/12"`` track-of-total) so a bad tag can't abort sync."""
48
+ try:
49
+ return int(value or 0)
50
+ except (ValueError, TypeError):
51
+ return 0
52
+
53
+
54
+ def _format_date(item):
55
+ year = _to_int(getattr(item, "year", 0))
56
+ if not year:
57
+ return None
58
+ month = _to_int(getattr(item, "month", 0))
59
+ day = _to_int(getattr(item, "day", 0))
60
+ if month and day:
61
+ return f"{year:04d}-{month:02d}-{day:02d}"
62
+ return f"{year:04d}"
63
+
64
+
65
+ def map_fields(item, extra_fields=None):
66
+ """Map a beets item to a list of (musefs_key, value) pairs.
67
+
68
+ Empty strings and zero numerics are omitted. ``extra_fields`` merges into
69
+ (and can override) the direct-copy table.
70
+ """
71
+ fields = dict(DIRECT_FIELDS)
72
+ if extra_fields:
73
+ fields.update(extra_fields)
74
+
75
+ pairs = []
76
+ for beets_field, key in fields.items():
77
+ for text in _values(getattr(item, beets_field, None)):
78
+ pairs.append((key, text))
79
+
80
+ for list_field, scalar_field, key in TWIN_FIELDS:
81
+ values = _values(getattr(item, list_field, None)) or _values(
82
+ getattr(item, scalar_field, None)
83
+ )
84
+ seen = set()
85
+ for text in values:
86
+ if text not in seen:
87
+ seen.add(text)
88
+ pairs.append((key, text))
89
+
90
+ track = _to_int(getattr(item, "track", 0))
91
+ if track:
92
+ pairs.append(("tracknumber", str(track)))
93
+ disc = _to_int(getattr(item, "disc", 0))
94
+ if disc:
95
+ pairs.append(("discnumber", str(disc)))
96
+ date = _format_date(item)
97
+ if date:
98
+ pairs.append(("date", date))
99
+
100
+ return pairs
101
+
102
+
103
+ def _album_art_path(item):
104
+ """Return the album cover path (bytes/str) for an item, or None."""
105
+ get_album = getattr(item, "get_album", None)
106
+ album = get_album() if get_album else None
107
+ if album is None:
108
+ return None
109
+ artpath = getattr(album, "artpath", None)
110
+ return artpath or None
111
+
112
+
113
+ def _read_album_art(item, cache, stats):
114
+ """Return ``(data, mime)`` for the item's album cover, or None. Reads each
115
+ distinct cover once (cached by realpath). An unreadable or over-cap cover is
116
+ counted into ``stats.skipped_art`` once and cached as None (matches the
117
+ legacy ``_prepare_art`` counting before the python-musefs split).
118
+
119
+ Also size-capped here (not only in sync_one) so a shared over-cap cover is
120
+ counted once per distinct file — the double enforcement is intentional, not
121
+ dead code."""
122
+ artpath = _album_art_path(item)
123
+ if not artpath:
124
+ return None
125
+ key = realpath_key(artpath)
126
+ if key in cache:
127
+ return cache[key]
128
+ # Use the raw realpath, not realpath_key's lossy U+FFFD form: the file is
129
+ # only opened and extension-sniffed, not matched against the DB.
130
+ real = os.path.realpath(artpath)
131
+ try:
132
+ with open(real, "rb") as fh:
133
+ data = fh.read()
134
+ except OSError:
135
+ stats.skipped_art += 1
136
+ cache[key] = None
137
+ return None
138
+ if len(data) > MAX_ART_BYTES:
139
+ stats.skipped_art += 1
140
+ cache[key] = None
141
+ return None
142
+ art = (data, sniff_mime(data, os.fsdecode(real)))
143
+ cache[key] = art
144
+ return art
145
+
146
+
147
+ def _computed_path(item):
148
+ """Beets' library-relative path for ``item``, decoded to a SQLite-safe str
149
+ with the file extension removed (musefs re-appends it at render time).
150
+
151
+ Mirrors ``realpath_key``'s lossy normalization (U+FFFD for undecodable
152
+ bytes) so the value is always valid UTF-8, but without realpath's on-disk
153
+ resolution. Returns "" when beets yields no usable path.
154
+ """
155
+ raw = item.destination(relative_to_libdir=True)
156
+ decoded = os.fsdecode(raw)
157
+ safe = decoded.encode("utf-8", "surrogateescape").decode("utf-8", "replace")
158
+ return os.path.splitext(safe)[0].lstrip("/")
159
+
160
+
161
+ def _computed_path_or_skip(item, log):
162
+ """``_computed_path`` guarded so a bad destination never aborts a sync.
163
+
164
+ Returns "" (skip the tag) on any failure, warning through ``log`` if given.
165
+ """
166
+ try:
167
+ return _computed_path(item)
168
+ except Exception as exc:
169
+ if log is not None:
170
+ # beets' plugin logger is a StrFormatLogger ({}-style, not %-style).
171
+ log.warning("musefs: skipping beets_path for {!r}: {}", item.path, exc)
172
+ return ""
173
+
174
+
175
+ def build_records(items, *, fields=None, stats, write_path=True, log=None):
176
+ """Build ``Record``s for beets items: map tags and resolve album art (with a
177
+ per-run cache; unreadable/over-cap covers counted into ``stats.skipped_art``).
178
+ When ``write_path`` is set, also emit a ``beets_path`` tag with the track's
179
+ beets library-relative path (extension stripped); a failed computation is
180
+ skipped and warned through ``log``. ``stats`` is mutated and must be the same
181
+ instance passed to ``sync_files``."""
182
+ records = []
183
+ art_cache = {}
184
+ for item in items:
185
+ cover = _read_album_art(item, art_cache, stats)
186
+ pairs = map_fields(item, fields)
187
+ if write_path:
188
+ path = _computed_path_or_skip(item, log)
189
+ if path:
190
+ pairs.append(("beets_path", path))
191
+ records.append(
192
+ Record(
193
+ key=realpath_key(item.path),
194
+ pairs=pairs,
195
+ art=[ArtImage(*cover)] if cover else None,
196
+ )
197
+ )
198
+ return records
beetsplug/musefs.py ADDED
@@ -0,0 +1,230 @@
1
+ """beets plugin: sync canonical beets metadata into the musefs SQLite store."""
2
+
3
+ import os
4
+ import sqlite3
5
+ import subprocess
6
+
7
+ from beets import ui
8
+ from beets.plugins import BeetsPlugin
9
+ from musefs_common import (
10
+ SCAN_TIMEOUT_SECONDS,
11
+ ScanError,
12
+ SchemaMismatch,
13
+ SyncStats,
14
+ check_schema_version,
15
+ connect,
16
+ prune_missing,
17
+ realpath_key,
18
+ run_scan,
19
+ sync_files,
20
+ track_id_for_path,
21
+ )
22
+
23
+ from beetsplug import _core
24
+
25
+
26
+ class MusefsPlugin(BeetsPlugin):
27
+ def __init__(self):
28
+ super().__init__()
29
+ self.config.add({
30
+ "db": None,
31
+ "fields": {},
32
+ "bin": "musefs", # musefs executable (PATH name or full path)
33
+ "autoscan": True, # run `musefs scan` automatically before syncing
34
+ "write_path": True, # emit a beets_path tag for $!{beets_path} mounts
35
+ })
36
+ # beets has no file-move event, and `after_write` fires *before* a move
37
+ # (at the old path). So imports/writes are recorded and reconciled once
38
+ # at cli_exit, when each item's path is final, where we also prune rows
39
+ # whose backing file has moved away.
40
+ self._pending = []
41
+ self.register_listener("after_write", self._record)
42
+ self.register_listener("item_imported", self._record)
43
+ self.register_listener("album_imported", self._record_album)
44
+ self.register_listener("cli_exit", self._reconcile_pending)
45
+
46
+ # --- command ---------------------------------------------------------
47
+
48
+ def commands(self):
49
+ cmd = ui.Subcommand("musefs", help="sync beets metadata into the musefs DB")
50
+ cmd.parser.add_option(
51
+ "--db",
52
+ dest="db",
53
+ default=None,
54
+ help="path to the musefs SQLite store (overrides config)",
55
+ )
56
+ cmd.parser.add_option(
57
+ "-n",
58
+ "--dry-run",
59
+ dest="dry_run",
60
+ action="store_true",
61
+ default=False,
62
+ help="report what would change without writing",
63
+ )
64
+ cmd.func = self._command
65
+ return [cmd]
66
+
67
+ @staticmethod
68
+ def _query_from_args(args):
69
+ """Drop an optional leading `sync` verb so `beet musefs sync QUERY`
70
+ and `beet musefs QUERY` both work."""
71
+ if args and args[0] == "sync":
72
+ return list(args[1:])
73
+ return list(args)
74
+
75
+ def _command(self, lib, opts, args):
76
+ db_path = opts.db or self._db_path()
77
+ if not db_path:
78
+ raise ui.UserError("musefs: set `musefs.db` in config or pass --db")
79
+
80
+ query = self._query_from_args(args)
81
+ items = list(lib.items(query))
82
+ if self._autoscan() and not opts.dry_run:
83
+ # Full sync: one scan of the music dir. Query: scan only the matched
84
+ # files, so non-matched rows aren't re-seeded from their files.
85
+ targets = (
86
+ [os.fsdecode(i.path) for i in items] if query else [os.fsdecode(lib.directory)]
87
+ )
88
+ self._run_scan(db_path, targets)
89
+ stats = self._sync(db_path, items, dry_run=opts.dry_run)
90
+ if opts.dry_run:
91
+ pruned = 0
92
+ else:
93
+ prune_items = items if query else None
94
+ pruned = self._prune_missing(db_path, items=prune_items)
95
+ # ui.print_ (not self._log) so the summary always shows, not only at -v.
96
+ ui.print_(f"musefs: {stats.summary()} pruned={pruned}")
97
+
98
+ # --- event listeners -------------------------------------------------
99
+
100
+ def _record(self, item=None, **kwargs):
101
+ if item is not None:
102
+ self._pending.append(item)
103
+
104
+ def _record_album(self, album=None, **kwargs):
105
+ if album is not None:
106
+ self._pending.extend(album.items())
107
+
108
+ def _reconcile_pending(self, lib=None, **kwargs):
109
+ """End-of-command reconcile: sync every touched item at its final path,
110
+ then prune rows whose backing file moved away. Best-effort — a passive
111
+ hook must never abort the beets operation, so errors become warnings."""
112
+ pending, self._pending = self._pending, []
113
+ # Dedup by final on-disk path (an item may fire several events).
114
+ items = list({os.fsdecode(i.path): i for i in pending if i is not None}.values())
115
+ if not items:
116
+ return
117
+ db_path = self._db_path()
118
+ if not db_path:
119
+ self._log.warning("musefs: no `musefs.db` configured; skipping sync")
120
+ return
121
+ try:
122
+ if self._autoscan():
123
+ self._run_scan(db_path, [os.fsdecode(i.path) for i in items])
124
+ self._sync(db_path, items)
125
+ self._prune_missing(db_path)
126
+ except (ui.UserError, sqlite3.Error, OSError, subprocess.SubprocessError) as exc:
127
+ # A passive cli_exit hook must never abort the beets operation for an
128
+ # environmental failure (locked DB, vanished file, wedged scan); those
129
+ # degrade to a warning. An unexpected exception still propagates so a
130
+ # real bug surfaces instead of hiding behind a one-line warning.
131
+ self._log.warning("musefs: {}", exc)
132
+
133
+ # --- helpers ---------------------------------------------------------
134
+
135
+ def _db_path(self):
136
+ # `.get()` returns the raw config value (None if unset); only call
137
+ # as_filename() when set, so a genuine bad-type value still raises.
138
+ if self.config["db"].get() is None:
139
+ return None
140
+ return self.config["db"].as_filename()
141
+
142
+ def _fields(self):
143
+ return self.config["fields"].get(dict) or {}
144
+
145
+ def _autoscan(self):
146
+ return bool(self.config["autoscan"].get(bool))
147
+
148
+ def _write_path(self):
149
+ return bool(self.config["write_path"].get(bool))
150
+
151
+ def _bin(self):
152
+ return self.config["bin"].get(str) or "musefs"
153
+
154
+ def _run_scan(self, db_path, targets):
155
+ """Run `musefs scan <target...> --db <db>` once for the whole batch.
156
+ Creates the DB if missing and fills the structural columns the plugin
157
+ can't compute itself. Raises ui.UserError on failure."""
158
+ binary = self._bin()
159
+ try:
160
+ run_scan(binary, db_path, targets, timeout=SCAN_TIMEOUT_SECONDS)
161
+ except ScanError as exc:
162
+ raise self._scan_user_error(exc)
163
+
164
+ @staticmethod
165
+ def _scan_user_error(exc):
166
+ """Translate a python-musefs ScanError to beets' ui.UserError, preserving
167
+ the plugin's historical message text."""
168
+ if exc.kind == "not_found":
169
+ return ui.UserError(
170
+ f"musefs: binary '{exc.binary}' not found; set `musefs.bin` to "
171
+ f"the musefs executable path"
172
+ )
173
+ return ui.UserError(
174
+ f"musefs: `{exc.binary} scan` failed for {exc.target} "
175
+ f"(exit {exc.returncode}):\n{exc.stderr}"
176
+ )
177
+
178
+ @staticmethod
179
+ def _track_ids_for_items(conn, items):
180
+ ids = []
181
+ for item in items:
182
+ key = realpath_key(item.path)
183
+ track_id = track_id_for_path(conn, key)
184
+ if track_id is not None:
185
+ ids.append(track_id)
186
+ return ids
187
+
188
+ def _prune_missing(self, db_path, items=None):
189
+ """Drop rows whose backing file no longer exists (moved/deleted).
190
+ When ``items`` is provided, only their musefs track rows are checked.
191
+ Returns the number pruned."""
192
+ if not os.path.exists(db_path):
193
+ return 0
194
+ conn = connect(db_path)
195
+ try:
196
+ track_ids = None if items is None else self._track_ids_for_items(conn, items)
197
+ pruned = prune_missing(conn, track_ids)
198
+ conn.commit()
199
+ return pruned
200
+ finally:
201
+ conn.close()
202
+
203
+ def _sync(self, db_path, items, dry_run=False):
204
+ if not os.path.exists(db_path):
205
+ raise ui.UserError(
206
+ f"musefs: DB not found at {db_path}; enable `musefs.autoscan` "
207
+ f"or run `musefs scan` first"
208
+ )
209
+ conn = connect(db_path)
210
+ try:
211
+ check_schema_version(conn)
212
+ stats = SyncStats()
213
+ records = _core.build_records(
214
+ items,
215
+ fields=self._fields(),
216
+ stats=stats,
217
+ write_path=self._write_path(),
218
+ log=self._log,
219
+ )
220
+ sync_files(conn, records, dry_run=dry_run, stats=stats)
221
+ if dry_run:
222
+ conn.rollback()
223
+ else:
224
+ conn.commit()
225
+ return stats
226
+ except SchemaMismatch as exc:
227
+ conn.rollback()
228
+ raise ui.UserError(f"musefs: {exc}")
229
+ finally:
230
+ conn.close()