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.
@@ -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,5 @@
1
+ python-musefs>=0.1.0
2
+ beets>=1.6
3
+
4
+ [test]
5
+ pytest>=7
@@ -0,0 +1 @@
1
+ beetsplug
@@ -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__)
@@ -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