beets-musefs 0.0.1__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.
- beets_musefs-0.0.1/LICENSE +21 -0
- beets_musefs-0.0.1/PKG-INFO +138 -0
- beets_musefs-0.0.1/README.md +115 -0
- beets_musefs-0.0.1/beets_musefs.egg-info/PKG-INFO +138 -0
- beets_musefs-0.0.1/beets_musefs.egg-info/SOURCES.txt +20 -0
- beets_musefs-0.0.1/beets_musefs.egg-info/dependency_links.txt +1 -0
- beets_musefs-0.0.1/beets_musefs.egg-info/requires.txt +5 -0
- beets_musefs-0.0.1/beets_musefs.egg-info/top_level.txt +1 -0
- beets_musefs-0.0.1/beetsplug/__init__.py +4 -0
- beets_musefs-0.0.1/beetsplug/_core.py +198 -0
- beets_musefs-0.0.1/beetsplug/musefs.py +230 -0
- beets_musefs-0.0.1/pyproject.toml +41 -0
- beets_musefs-0.0.1/setup.cfg +4 -0
- beets_musefs-0.0.1/tests/test_build_records.py +118 -0
- beets_musefs-0.0.1/tests/test_contract.py +28 -0
- beets_musefs-0.0.1/tests/test_e2e.py +604 -0
- beets_musefs-0.0.1/tests/test_map_fields.py +101 -0
- beets_musefs-0.0.1/tests/test_path_gate.py +172 -0
- beets_musefs-0.0.1/tests/test_paths.py +44 -0
- beets_musefs-0.0.1/tests/test_plugin.py +344 -0
- beets_musefs-0.0.1/tests/test_reconcile.py +68 -0
- beets_musefs-0.0.1/tests/test_smoke.py +18 -0
|
@@ -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,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,115 @@
|
|
|
1
|
+
# beets-musefs
|
|
2
|
+
|
|
3
|
+
A [beets](https://beets.io) plugin that syncs your beets metadata (tags + cover
|
|
4
|
+
art) into a [musefs](../../README.md) SQLite store, so a live musefs mount shows
|
|
5
|
+
a re-tagged view of your library without rewriting any audio.
|
|
6
|
+
|
|
7
|
+
## How it fits together
|
|
8
|
+
|
|
9
|
+
- The plugin owns the **tags** (and **cover art**, when beets has it) of each
|
|
10
|
+
track, keyed by the file's canonical real path.
|
|
11
|
+
- The structural columns (audio offsets, size, mtime) can only come from musefs
|
|
12
|
+
probing the file, so the plugin runs `musefs scan` for you (via the `bin`
|
|
13
|
+
config) before syncing — it never tries to compute those itself.
|
|
14
|
+
- `beet musefs` scans the library and then syncs; the import/write hooks scan
|
|
15
|
+
just the touched file and then sync. musefs's auto-refresh shows changes live —
|
|
16
|
+
no remount, and **no separate scan step**.
|
|
17
|
+
|
|
18
|
+
## Install (local / development)
|
|
19
|
+
|
|
20
|
+
No install needed — point beets at the plugin's `beetsplug` directory. beets adds
|
|
21
|
+
`pluginpath` entries directly to the `beetsplug` package path, so it must be the
|
|
22
|
+
`beetsplug` dir itself (not its parent). In your beets `config.yaml`:
|
|
23
|
+
|
|
24
|
+
The plugin depends on the shared `python-musefs` library, which is unpublished
|
|
25
|
+
and lives in this repo. Install it from the working tree **before** the plugin:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install -e contrib/python-musefs
|
|
29
|
+
pip install -e "contrib/beets[test]"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```yaml
|
|
33
|
+
pluginpath: /path/to/musefs/contrib/beets/beetsplug
|
|
34
|
+
plugins: musefs
|
|
35
|
+
musefs:
|
|
36
|
+
db: ~/musefs.db # path to the musefs SQLite store (required)
|
|
37
|
+
bin: musefs # musefs executable for auto-scan; use a full path if
|
|
38
|
+
# not on $PATH, e.g. /path/to/musefs/target/release/musefs
|
|
39
|
+
# autoscan: yes # default; runs `musefs scan` for you. Set `no` to
|
|
40
|
+
# # manage scanning yourself (hooks then best-effort).
|
|
41
|
+
# fields: # optional: map extra beets fields to musefs keys
|
|
42
|
+
# comments: comment
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Workflow (test drive)
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# Sync beets metadata into the store. Auto-scans the library first (creating the
|
|
49
|
+
# DB if needed) — no separate `musefs scan` step.
|
|
50
|
+
beet musefs # everything
|
|
51
|
+
beet musefs albumartist:"Boards of Canada" # a subset (scans just those files)
|
|
52
|
+
beet musefs -n # dry run: report counts, write nothing
|
|
53
|
+
|
|
54
|
+
# Mount the re-tagged view.
|
|
55
|
+
musefs mount ~/mnt --db ~/musefs.db \
|
|
56
|
+
--template '$albumartist/$album/$tracknumber - $title'
|
|
57
|
+
|
|
58
|
+
# ...or mirror your beets library layout exactly, via the computed beets_path tag.
|
|
59
|
+
musefs mount ~/mnt --db ~/musefs.db --template '$!{beets_path}'
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Imports and tag write-backs auto-sync via event hooks: `beet import` and
|
|
63
|
+
`beet modify -w …` record the touched items and reconcile them once the command
|
|
64
|
+
finishes — when each file's path is final (beets has no move event, and a write
|
|
65
|
+
fires *before* its move). The reconcile scans the new path and prunes the row
|
|
66
|
+
left behind at the old one. A metadata-only `beet modify` (no `-w`) doesn't fire
|
|
67
|
+
a hook — re-run `beet musefs`. With `autoscan: no`, run `musefs scan` yourself
|
|
68
|
+
first; the hooks then skip gracefully if the DB is missing.
|
|
69
|
+
|
|
70
|
+
## Notes
|
|
71
|
+
|
|
72
|
+
- **Cover art:** taken from the album's `artpath` (beets' external cover file).
|
|
73
|
+
beets art wins when present; otherwise any art `musefs scan` ingested from
|
|
74
|
+
embedded pictures is preserved.
|
|
75
|
+
- **Computed path (`beets_path`):** each sync also writes a `beets_path` text tag
|
|
76
|
+
holding the track's beets library-relative path (from your `paths:` config, via
|
|
77
|
+
`item.destination`), with the file extension removed — musefs re-appends it. Mount
|
|
78
|
+
with `--template '$!{beets_path}'` (the `$!{}` path field keeps `/` as directory
|
|
79
|
+
separators) to mirror your beets layout, including layouts musefs's own template
|
|
80
|
+
engine can't express. Set `write_path: no` in the `musefs:` config to skip it.
|
|
81
|
+
Do not add an extension in a template that consumes `beets_path`. See the
|
|
82
|
+
computed-tag workflow in [ARCHITECTURE.md](../../ARCHITECTURE.md).
|
|
83
|
+
- **Moves & deletes:** every sync (the command and the end-of-command reconcile)
|
|
84
|
+
prunes track rows whose backing file is no longer present, so renames/moves
|
|
85
|
+
don't leave stale entries. Caveat: a file that's merely offline at sync time
|
|
86
|
+
(e.g. an unmounted network share) is also pruned — sync while the library is
|
|
87
|
+
available.
|
|
88
|
+
- **Orphaned art:** replacing art can orphan old blobs; `musefs scan --revalidate`
|
|
89
|
+
garbage-collects them.
|
|
90
|
+
- **Schema version:** the plugin refuses to run if the DB's `user_version` differs
|
|
91
|
+
from the version it targets — rebuild after upgrading musefs.
|
|
92
|
+
|
|
93
|
+
## Tests
|
|
94
|
+
|
|
95
|
+
The tests live under `tests/` and use a local virtualenv with beets + pytest.
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
cd contrib/beets
|
|
99
|
+
uv venv # create .venv (once)
|
|
100
|
+
source .venv/bin/activate
|
|
101
|
+
uv pip install -e ../python-musefs # shared library (unpublished; install first)
|
|
102
|
+
uv pip install -r requirements.txt # beets + pytest
|
|
103
|
+
|
|
104
|
+
python -m pytest # unit + integration (no Rust binary)
|
|
105
|
+
python -m pytest -m musefs_bin # path-matching gate vs the real `musefs` binary
|
|
106
|
+
python -m pytest -m e2e # full beets -> mount -> playback end-to-end
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The `musefs_bin` gate shells out to the real `musefs` binary, so build it first
|
|
110
|
+
from the repo root (`cargo build`) and run it against a fresh build. The `e2e`
|
|
111
|
+
tier additionally needs `ffmpeg` and `/dev/fuse` + `fusermount`: it generates
|
|
112
|
+
audio, imports it with beets, retags, syncs, mounts via FUSE, and verifies the
|
|
113
|
+
mount's tags and byte-identical audio (including a move-reconcile case). Both
|
|
114
|
+
tiers are deselected from the default run and skip cleanly if their tools are
|
|
115
|
+
absent.
|
|
@@ -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,20 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
beets_musefs.egg-info/PKG-INFO
|
|
5
|
+
beets_musefs.egg-info/SOURCES.txt
|
|
6
|
+
beets_musefs.egg-info/dependency_links.txt
|
|
7
|
+
beets_musefs.egg-info/requires.txt
|
|
8
|
+
beets_musefs.egg-info/top_level.txt
|
|
9
|
+
beetsplug/__init__.py
|
|
10
|
+
beetsplug/_core.py
|
|
11
|
+
beetsplug/musefs.py
|
|
12
|
+
tests/test_build_records.py
|
|
13
|
+
tests/test_contract.py
|
|
14
|
+
tests/test_e2e.py
|
|
15
|
+
tests/test_map_fields.py
|
|
16
|
+
tests/test_path_gate.py
|
|
17
|
+
tests/test_paths.py
|
|
18
|
+
tests/test_plugin.py
|
|
19
|
+
tests/test_reconcile.py
|
|
20
|
+
tests/test_smoke.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
beetsplug
|
|
@@ -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
|