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.
- beets_musefs-0.0.1.dist-info/METADATA +138 -0
- beets_musefs-0.0.1.dist-info/RECORD +8 -0
- beets_musefs-0.0.1.dist-info/WHEEL +5 -0
- beets_musefs-0.0.1.dist-info/licenses/LICENSE +21 -0
- beets_musefs-0.0.1.dist-info/top_level.txt +1 -0
- beetsplug/__init__.py +4 -0
- beetsplug/_core.py +198 -0
- beetsplug/musefs.py +230 -0
|
@@ -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,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
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()
|