lidarr-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.
- lidarr_musefs-0.0.1/LICENSE +21 -0
- lidarr_musefs-0.0.1/PKG-INFO +117 -0
- lidarr_musefs-0.0.1/README.md +95 -0
- lidarr_musefs-0.0.1/pyproject.toml +44 -0
- lidarr_musefs-0.0.1/setup.cfg +4 -0
- lidarr_musefs-0.0.1/src/lidarr_musefs.egg-info/PKG-INFO +117 -0
- lidarr_musefs-0.0.1/src/lidarr_musefs.egg-info/SOURCES.txt +28 -0
- lidarr_musefs-0.0.1/src/lidarr_musefs.egg-info/dependency_links.txt +1 -0
- lidarr_musefs-0.0.1/src/lidarr_musefs.egg-info/entry_points.txt +3 -0
- lidarr_musefs-0.0.1/src/lidarr_musefs.egg-info/requires.txt +4 -0
- lidarr_musefs-0.0.1/src/lidarr_musefs.egg-info/top_level.txt +1 -0
- lidarr_musefs-0.0.1/src/musefs_lidarr/__init__.py +3 -0
- lidarr_musefs-0.0.1/src/musefs_lidarr/api.py +155 -0
- lidarr_musefs-0.0.1/src/musefs_lidarr/cli_import.py +35 -0
- lidarr_musefs-0.0.1/src/musefs_lidarr/cli_sync.py +132 -0
- lidarr_musefs-0.0.1/src/musefs_lidarr/env.py +19 -0
- lidarr_musefs-0.0.1/src/musefs_lidarr/errors.py +18 -0
- lidarr_musefs-0.0.1/src/musefs_lidarr/events.py +73 -0
- lidarr_musefs-0.0.1/src/musefs_lidarr/import_link.py +95 -0
- lidarr_musefs-0.0.1/src/musefs_lidarr/mapping.py +120 -0
- lidarr_musefs-0.0.1/src/musefs_lidarr/sync.py +293 -0
- lidarr_musefs-0.0.1/tests/test_api.py +99 -0
- lidarr_musefs-0.0.1/tests/test_cli.py +247 -0
- lidarr_musefs-0.0.1/tests/test_env.py +19 -0
- lidarr_musefs-0.0.1/tests/test_events.py +132 -0
- lidarr_musefs-0.0.1/tests/test_import_link.py +127 -0
- lidarr_musefs-0.0.1/tests/test_mapping.py +137 -0
- lidarr_musefs-0.0.1/tests/test_path_gate.py +60 -0
- lidarr_musefs-0.0.1/tests/test_smoke.py +7 -0
- lidarr_musefs-0.0.1/tests/test_sync.py +339 -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,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lidarr-musefs
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Sync Lidarr 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
|
+
Provides-Extra: test
|
|
20
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# lidarr-musefs
|
|
24
|
+
|
|
25
|
+
A Lidarr integration that lets Lidarr import into a placeholder library tree
|
|
26
|
+
while musefs serves the real consumer-facing, re-tagged FUSE view.
|
|
27
|
+
|
|
28
|
+
The supported workflow keeps Lidarr as the downloader, matcher, and metadata
|
|
29
|
+
source, but prevents Lidarr from copying, moving, or rewriting backing audio
|
|
30
|
+
bytes. Lidarr's destination tree exists so Lidarr can track files. Point
|
|
31
|
+
Navidrome, Plex, Jellyfin, or other consumers at the musefs mount instead.
|
|
32
|
+
|
|
33
|
+
## Required Lidarr settings
|
|
34
|
+
|
|
35
|
+
- Settings -> Media Management -> Import Using Script: enabled.
|
|
36
|
+
- Import Script Path: `musefs-lidarr-import`.
|
|
37
|
+
- Metadata Provider -> Write Audio Tags: `Never`.
|
|
38
|
+
- File Date: `None`.
|
|
39
|
+
- Linux permission management: disabled.
|
|
40
|
+
|
|
41
|
+
Do not rely on Lidarr's built-in "Use Hardlinks instead of Copy" for this
|
|
42
|
+
workflow. Lidarr uses a hardlink-or-copy transfer mode internally, so a hardlink
|
|
43
|
+
failure can copy bytes. `musefs-lidarr-import` creates the destination entry
|
|
44
|
+
itself and fails closed.
|
|
45
|
+
|
|
46
|
+
## Environment
|
|
47
|
+
|
|
48
|
+
Import script:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
MUSEFS_LIDARR_LINK_MODE=symlink # default; use hardlink only if symlinks are unsuitable
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Sync script:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
MUSEFS_DB=/path/to/musefs.db
|
|
58
|
+
MUSEFS_BIN=musefs
|
|
59
|
+
MUSEFS_LIDARR_URL=http://localhost:8686
|
|
60
|
+
MUSEFS_LIDARR_API_KEY=your-api-key
|
|
61
|
+
MUSEFS_LIDARR_AUTOSCAN=1
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
API keys are redacted from logs and errors.
|
|
65
|
+
|
|
66
|
+
## Lidarr Custom Script
|
|
67
|
+
|
|
68
|
+
Configure a Custom Script notification:
|
|
69
|
+
|
|
70
|
+
- On Release Import: enabled.
|
|
71
|
+
- On Rename: enabled.
|
|
72
|
+
- Path: `musefs-lidarr-sync`.
|
|
73
|
+
|
|
74
|
+
Test events exit successfully without touching files or the database.
|
|
75
|
+
`TrackRetag` events are skipped with a warning because they fire after Lidarr
|
|
76
|
+
writes tags.
|
|
77
|
+
|
|
78
|
+
## Manual backfill
|
|
79
|
+
|
|
80
|
+
Run:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
musefs-lidarr-sync --all
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Manual backfill requires `MUSEFS_LIDARR_URL` and `MUSEFS_LIDARR_API_KEY`. It
|
|
87
|
+
queries all Lidarr artists and syncs their known track files into the musefs DB.
|
|
88
|
+
|
|
89
|
+
## Doctor
|
|
90
|
+
|
|
91
|
+
Run:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
musefs-lidarr-sync --doctor
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The doctor checks Lidarr's API for:
|
|
98
|
+
|
|
99
|
+
- `writeAudioTags = no`
|
|
100
|
+
- `fileDate = none`
|
|
101
|
+
- `setPermissionsLinux = false`
|
|
102
|
+
|
|
103
|
+
If `MUSEFS_LIDARR_URL` and `MUSEFS_LIDARR_API_KEY` are not configured, `doctor`
|
|
104
|
+
and sync fail because the integration cannot verify safe settings or build
|
|
105
|
+
complete per-track metadata.
|
|
106
|
+
|
|
107
|
+
## Smoke test
|
|
108
|
+
|
|
109
|
+
1. Build and install musefs.
|
|
110
|
+
2. Install `python-musefs` and `lidarr-musefs` into the environment Lidarr uses
|
|
111
|
+
for custom scripts.
|
|
112
|
+
3. Configure Import Using Script and Custom Script as described above.
|
|
113
|
+
4. Import a small album.
|
|
114
|
+
5. Confirm Lidarr's destination entry is a symlink by default.
|
|
115
|
+
6. Run `musefs mount /tmp/mnt --db "$MUSEFS_DB"`.
|
|
116
|
+
7. Confirm the mount shows Lidarr metadata.
|
|
117
|
+
8. Confirm the source file's bytes and mtime did not change.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# lidarr-musefs
|
|
2
|
+
|
|
3
|
+
A Lidarr integration that lets Lidarr import into a placeholder library tree
|
|
4
|
+
while musefs serves the real consumer-facing, re-tagged FUSE view.
|
|
5
|
+
|
|
6
|
+
The supported workflow keeps Lidarr as the downloader, matcher, and metadata
|
|
7
|
+
source, but prevents Lidarr from copying, moving, or rewriting backing audio
|
|
8
|
+
bytes. Lidarr's destination tree exists so Lidarr can track files. Point
|
|
9
|
+
Navidrome, Plex, Jellyfin, or other consumers at the musefs mount instead.
|
|
10
|
+
|
|
11
|
+
## Required Lidarr settings
|
|
12
|
+
|
|
13
|
+
- Settings -> Media Management -> Import Using Script: enabled.
|
|
14
|
+
- Import Script Path: `musefs-lidarr-import`.
|
|
15
|
+
- Metadata Provider -> Write Audio Tags: `Never`.
|
|
16
|
+
- File Date: `None`.
|
|
17
|
+
- Linux permission management: disabled.
|
|
18
|
+
|
|
19
|
+
Do not rely on Lidarr's built-in "Use Hardlinks instead of Copy" for this
|
|
20
|
+
workflow. Lidarr uses a hardlink-or-copy transfer mode internally, so a hardlink
|
|
21
|
+
failure can copy bytes. `musefs-lidarr-import` creates the destination entry
|
|
22
|
+
itself and fails closed.
|
|
23
|
+
|
|
24
|
+
## Environment
|
|
25
|
+
|
|
26
|
+
Import script:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
MUSEFS_LIDARR_LINK_MODE=symlink # default; use hardlink only if symlinks are unsuitable
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Sync script:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
MUSEFS_DB=/path/to/musefs.db
|
|
36
|
+
MUSEFS_BIN=musefs
|
|
37
|
+
MUSEFS_LIDARR_URL=http://localhost:8686
|
|
38
|
+
MUSEFS_LIDARR_API_KEY=your-api-key
|
|
39
|
+
MUSEFS_LIDARR_AUTOSCAN=1
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
API keys are redacted from logs and errors.
|
|
43
|
+
|
|
44
|
+
## Lidarr Custom Script
|
|
45
|
+
|
|
46
|
+
Configure a Custom Script notification:
|
|
47
|
+
|
|
48
|
+
- On Release Import: enabled.
|
|
49
|
+
- On Rename: enabled.
|
|
50
|
+
- Path: `musefs-lidarr-sync`.
|
|
51
|
+
|
|
52
|
+
Test events exit successfully without touching files or the database.
|
|
53
|
+
`TrackRetag` events are skipped with a warning because they fire after Lidarr
|
|
54
|
+
writes tags.
|
|
55
|
+
|
|
56
|
+
## Manual backfill
|
|
57
|
+
|
|
58
|
+
Run:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
musefs-lidarr-sync --all
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Manual backfill requires `MUSEFS_LIDARR_URL` and `MUSEFS_LIDARR_API_KEY`. It
|
|
65
|
+
queries all Lidarr artists and syncs their known track files into the musefs DB.
|
|
66
|
+
|
|
67
|
+
## Doctor
|
|
68
|
+
|
|
69
|
+
Run:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
musefs-lidarr-sync --doctor
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The doctor checks Lidarr's API for:
|
|
76
|
+
|
|
77
|
+
- `writeAudioTags = no`
|
|
78
|
+
- `fileDate = none`
|
|
79
|
+
- `setPermissionsLinux = false`
|
|
80
|
+
|
|
81
|
+
If `MUSEFS_LIDARR_URL` and `MUSEFS_LIDARR_API_KEY` are not configured, `doctor`
|
|
82
|
+
and sync fail because the integration cannot verify safe settings or build
|
|
83
|
+
complete per-track metadata.
|
|
84
|
+
|
|
85
|
+
## Smoke test
|
|
86
|
+
|
|
87
|
+
1. Build and install musefs.
|
|
88
|
+
2. Install `python-musefs` and `lidarr-musefs` into the environment Lidarr uses
|
|
89
|
+
for custom scripts.
|
|
90
|
+
3. Configure Import Using Script and Custom Script as described above.
|
|
91
|
+
4. Import a small album.
|
|
92
|
+
5. Confirm Lidarr's destination entry is a symlink by default.
|
|
93
|
+
6. Run `musefs mount /tmp/mnt --db "$MUSEFS_DB"`.
|
|
94
|
+
7. Confirm the mount shows Lidarr metadata.
|
|
95
|
+
8. Confirm the source file's bytes and mtime did not change.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "lidarr-musefs"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Sync Lidarr metadata into the musefs SQLite store"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Conor Futro" }]
|
|
14
|
+
dependencies = ["python-musefs>=0.1.0"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: POSIX",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Multimedia :: Sound/Audio",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/Sohex/musefs"
|
|
25
|
+
Repository = "https://github.com/Sohex/musefs"
|
|
26
|
+
Issues = "https://github.com/Sohex/musefs/issues"
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
test = ["pytest>=7"]
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
musefs-lidarr-import = "musefs_lidarr.cli_import:main"
|
|
33
|
+
musefs-lidarr-sync = "musefs_lidarr.cli_sync:main"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
pythonpath = ["src"]
|
|
41
|
+
markers = [
|
|
42
|
+
"musefs_bin: tests that shell out to the real `musefs` Rust binary (opt-in)",
|
|
43
|
+
]
|
|
44
|
+
addopts = "-m 'not musefs_bin'"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lidarr-musefs
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Sync Lidarr 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
|
+
Provides-Extra: test
|
|
20
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# lidarr-musefs
|
|
24
|
+
|
|
25
|
+
A Lidarr integration that lets Lidarr import into a placeholder library tree
|
|
26
|
+
while musefs serves the real consumer-facing, re-tagged FUSE view.
|
|
27
|
+
|
|
28
|
+
The supported workflow keeps Lidarr as the downloader, matcher, and metadata
|
|
29
|
+
source, but prevents Lidarr from copying, moving, or rewriting backing audio
|
|
30
|
+
bytes. Lidarr's destination tree exists so Lidarr can track files. Point
|
|
31
|
+
Navidrome, Plex, Jellyfin, or other consumers at the musefs mount instead.
|
|
32
|
+
|
|
33
|
+
## Required Lidarr settings
|
|
34
|
+
|
|
35
|
+
- Settings -> Media Management -> Import Using Script: enabled.
|
|
36
|
+
- Import Script Path: `musefs-lidarr-import`.
|
|
37
|
+
- Metadata Provider -> Write Audio Tags: `Never`.
|
|
38
|
+
- File Date: `None`.
|
|
39
|
+
- Linux permission management: disabled.
|
|
40
|
+
|
|
41
|
+
Do not rely on Lidarr's built-in "Use Hardlinks instead of Copy" for this
|
|
42
|
+
workflow. Lidarr uses a hardlink-or-copy transfer mode internally, so a hardlink
|
|
43
|
+
failure can copy bytes. `musefs-lidarr-import` creates the destination entry
|
|
44
|
+
itself and fails closed.
|
|
45
|
+
|
|
46
|
+
## Environment
|
|
47
|
+
|
|
48
|
+
Import script:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
MUSEFS_LIDARR_LINK_MODE=symlink # default; use hardlink only if symlinks are unsuitable
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Sync script:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
MUSEFS_DB=/path/to/musefs.db
|
|
58
|
+
MUSEFS_BIN=musefs
|
|
59
|
+
MUSEFS_LIDARR_URL=http://localhost:8686
|
|
60
|
+
MUSEFS_LIDARR_API_KEY=your-api-key
|
|
61
|
+
MUSEFS_LIDARR_AUTOSCAN=1
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
API keys are redacted from logs and errors.
|
|
65
|
+
|
|
66
|
+
## Lidarr Custom Script
|
|
67
|
+
|
|
68
|
+
Configure a Custom Script notification:
|
|
69
|
+
|
|
70
|
+
- On Release Import: enabled.
|
|
71
|
+
- On Rename: enabled.
|
|
72
|
+
- Path: `musefs-lidarr-sync`.
|
|
73
|
+
|
|
74
|
+
Test events exit successfully without touching files or the database.
|
|
75
|
+
`TrackRetag` events are skipped with a warning because they fire after Lidarr
|
|
76
|
+
writes tags.
|
|
77
|
+
|
|
78
|
+
## Manual backfill
|
|
79
|
+
|
|
80
|
+
Run:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
musefs-lidarr-sync --all
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Manual backfill requires `MUSEFS_LIDARR_URL` and `MUSEFS_LIDARR_API_KEY`. It
|
|
87
|
+
queries all Lidarr artists and syncs their known track files into the musefs DB.
|
|
88
|
+
|
|
89
|
+
## Doctor
|
|
90
|
+
|
|
91
|
+
Run:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
musefs-lidarr-sync --doctor
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The doctor checks Lidarr's API for:
|
|
98
|
+
|
|
99
|
+
- `writeAudioTags = no`
|
|
100
|
+
- `fileDate = none`
|
|
101
|
+
- `setPermissionsLinux = false`
|
|
102
|
+
|
|
103
|
+
If `MUSEFS_LIDARR_URL` and `MUSEFS_LIDARR_API_KEY` are not configured, `doctor`
|
|
104
|
+
and sync fail because the integration cannot verify safe settings or build
|
|
105
|
+
complete per-track metadata.
|
|
106
|
+
|
|
107
|
+
## Smoke test
|
|
108
|
+
|
|
109
|
+
1. Build and install musefs.
|
|
110
|
+
2. Install `python-musefs` and `lidarr-musefs` into the environment Lidarr uses
|
|
111
|
+
for custom scripts.
|
|
112
|
+
3. Configure Import Using Script and Custom Script as described above.
|
|
113
|
+
4. Import a small album.
|
|
114
|
+
5. Confirm Lidarr's destination entry is a symlink by default.
|
|
115
|
+
6. Run `musefs mount /tmp/mnt --db "$MUSEFS_DB"`.
|
|
116
|
+
7. Confirm the mount shows Lidarr metadata.
|
|
117
|
+
8. Confirm the source file's bytes and mtime did not change.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/lidarr_musefs.egg-info/PKG-INFO
|
|
5
|
+
src/lidarr_musefs.egg-info/SOURCES.txt
|
|
6
|
+
src/lidarr_musefs.egg-info/dependency_links.txt
|
|
7
|
+
src/lidarr_musefs.egg-info/entry_points.txt
|
|
8
|
+
src/lidarr_musefs.egg-info/requires.txt
|
|
9
|
+
src/lidarr_musefs.egg-info/top_level.txt
|
|
10
|
+
src/musefs_lidarr/__init__.py
|
|
11
|
+
src/musefs_lidarr/api.py
|
|
12
|
+
src/musefs_lidarr/cli_import.py
|
|
13
|
+
src/musefs_lidarr/cli_sync.py
|
|
14
|
+
src/musefs_lidarr/env.py
|
|
15
|
+
src/musefs_lidarr/errors.py
|
|
16
|
+
src/musefs_lidarr/events.py
|
|
17
|
+
src/musefs_lidarr/import_link.py
|
|
18
|
+
src/musefs_lidarr/mapping.py
|
|
19
|
+
src/musefs_lidarr/sync.py
|
|
20
|
+
tests/test_api.py
|
|
21
|
+
tests/test_cli.py
|
|
22
|
+
tests/test_env.py
|
|
23
|
+
tests/test_events.py
|
|
24
|
+
tests/test_import_link.py
|
|
25
|
+
tests/test_mapping.py
|
|
26
|
+
tests/test_path_gate.py
|
|
27
|
+
tests/test_smoke.py
|
|
28
|
+
tests/test_sync.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
musefs_lidarr
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from urllib.error import HTTPError, URLError
|
|
7
|
+
from urllib.parse import urlencode
|
|
8
|
+
from urllib.request import Request, urlopen
|
|
9
|
+
|
|
10
|
+
from .errors import ConfigError, LidarrApiError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def redacted(value: str | None) -> str:
|
|
14
|
+
"""Return ``"<redacted>"`` for a non-empty value, else ``"<missing>"``."""
|
|
15
|
+
return "<redacted>" if value else "<missing>"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class LidarrConfig:
|
|
20
|
+
"""Lidarr API connection settings (URL and API key)."""
|
|
21
|
+
|
|
22
|
+
url: str | None = None
|
|
23
|
+
api_key: str | None = None
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_env(cls, environ: dict[str, str] | None = None) -> "LidarrConfig":
|
|
27
|
+
"""Read URL/key from ``MUSEFS_LIDARR_URL``/``MUSEFS_LIDARR_API_KEY``.
|
|
28
|
+
|
|
29
|
+
Raises ``ConfigError`` if only one of the two is set.
|
|
30
|
+
"""
|
|
31
|
+
env = os.environ if environ is None else environ
|
|
32
|
+
url = env.get("MUSEFS_LIDARR_URL") or None
|
|
33
|
+
api_key = env.get("MUSEFS_LIDARR_API_KEY") or None
|
|
34
|
+
if bool(url) != bool(api_key):
|
|
35
|
+
raise ConfigError("MUSEFS_LIDARR_URL and MUSEFS_LIDARR_API_KEY must be set together")
|
|
36
|
+
return cls(url=url, api_key=api_key)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def enabled(self) -> bool:
|
|
40
|
+
"""True when both URL and API key are present."""
|
|
41
|
+
return bool(self.url and self.api_key)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class PreflightResult:
|
|
46
|
+
"""Outcome of the Lidarr settings preflight: ``ok`` plus any error strings."""
|
|
47
|
+
|
|
48
|
+
ok: bool
|
|
49
|
+
errors: list[str]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class LidarrClient:
|
|
53
|
+
"""Minimal read-only client for the Lidarr v1 REST API."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, config: LidarrConfig, *, opener=urlopen, timeout: int = 15):
|
|
56
|
+
if not config.url or not config.api_key:
|
|
57
|
+
raise ConfigError("Lidarr API configuration is required")
|
|
58
|
+
self._base = config.url.rstrip("/")
|
|
59
|
+
self._api_key = config.api_key
|
|
60
|
+
self._opener = opener
|
|
61
|
+
self._timeout = timeout
|
|
62
|
+
|
|
63
|
+
def get_json(self, path: str, params: dict[str, object] | None = None):
|
|
64
|
+
"""GET ``path`` with optional query params; return parsed JSON.
|
|
65
|
+
|
|
66
|
+
Raises ``LidarrApiError`` on HTTP, network, or JSON-decode failure.
|
|
67
|
+
"""
|
|
68
|
+
query = ""
|
|
69
|
+
if params:
|
|
70
|
+
clean = {k: v for k, v in params.items() if v is not None}
|
|
71
|
+
if clean:
|
|
72
|
+
query = "?" + urlencode(clean, doseq=True)
|
|
73
|
+
url = f"{self._base}{path}{query}"
|
|
74
|
+
request = Request(url, headers={"X-Api-Key": self._api_key})
|
|
75
|
+
try:
|
|
76
|
+
with self._opener(request, timeout=self._timeout) as response:
|
|
77
|
+
return json.loads(response.read().decode("utf-8"))
|
|
78
|
+
except HTTPError as exc:
|
|
79
|
+
raise LidarrApiError(
|
|
80
|
+
f"Lidarr API request failed with HTTP {exc.code}; api_key={redacted(self._api_key)}"
|
|
81
|
+
) from exc
|
|
82
|
+
except URLError as exc:
|
|
83
|
+
raise LidarrApiError(f"Lidarr API request failed: {exc.reason}") from exc
|
|
84
|
+
except json.JSONDecodeError as exc:
|
|
85
|
+
raise LidarrApiError("Lidarr API returned invalid JSON") from exc
|
|
86
|
+
|
|
87
|
+
def media_management_config(self):
|
|
88
|
+
"""Return Lidarr's media-management config."""
|
|
89
|
+
return self.get_json("/api/v1/config/mediamanagement")
|
|
90
|
+
|
|
91
|
+
def metadata_provider_config(self):
|
|
92
|
+
"""Return Lidarr's metadata-provider config."""
|
|
93
|
+
return self.get_json("/api/v1/config/metadataprovider")
|
|
94
|
+
|
|
95
|
+
def track_files(self, *, artist_id=None, album_id=None, track_file_ids=None):
|
|
96
|
+
"""Return track files filtered by artist, album, or track-file ids."""
|
|
97
|
+
params = {}
|
|
98
|
+
if artist_id is not None:
|
|
99
|
+
params["artistId"] = artist_id
|
|
100
|
+
if album_id is not None:
|
|
101
|
+
params["albumId"] = album_id
|
|
102
|
+
if track_file_ids:
|
|
103
|
+
params["trackFileIds"] = list(track_file_ids)
|
|
104
|
+
return self.get_json("/api/v1/trackfile", params)
|
|
105
|
+
|
|
106
|
+
def tracks(self, *, artist_id=None, album_id=None, track_ids=None):
|
|
107
|
+
"""Return tracks filtered by artist, album, or track ids."""
|
|
108
|
+
params = {}
|
|
109
|
+
if artist_id is not None:
|
|
110
|
+
params["artistId"] = artist_id
|
|
111
|
+
if album_id is not None:
|
|
112
|
+
params["albumId"] = album_id
|
|
113
|
+
if track_ids:
|
|
114
|
+
params["trackIds"] = list(track_ids)
|
|
115
|
+
return self.get_json("/api/v1/track", params)
|
|
116
|
+
|
|
117
|
+
def album(self, album_id: int):
|
|
118
|
+
"""Return a single album by id."""
|
|
119
|
+
return self.get_json(f"/api/v1/album/{album_id}")
|
|
120
|
+
|
|
121
|
+
def artists(self):
|
|
122
|
+
"""Return all artists in the Lidarr library."""
|
|
123
|
+
return self.get_json("/api/v1/artist")
|
|
124
|
+
|
|
125
|
+
def artist(self, artist_id: int):
|
|
126
|
+
"""Return a single artist by id."""
|
|
127
|
+
return self.get_json(f"/api/v1/artist/{artist_id}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _lower(value) -> str:
|
|
131
|
+
return str(value).strip().lower()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def check_safe_settings(metadata: dict, media_management: dict) -> PreflightResult:
|
|
135
|
+
"""Verify Lidarr won't mutate backing files.
|
|
136
|
+
|
|
137
|
+
Requires ``writeAudioTags=no``, ``fileDate=none``, and
|
|
138
|
+
``setPermissionsLinux`` falsy; collects a message per violation.
|
|
139
|
+
"""
|
|
140
|
+
errors = []
|
|
141
|
+
if _lower(metadata.get("writeAudioTags")) != "no":
|
|
142
|
+
errors.append(f"writeAudioTags must be no, got {metadata.get('writeAudioTags')}")
|
|
143
|
+
if _lower(media_management.get("fileDate")) != "none":
|
|
144
|
+
errors.append(f"fileDate must be none, got {media_management.get('fileDate')}")
|
|
145
|
+
if bool(media_management.get("setPermissionsLinux")):
|
|
146
|
+
errors.append("setPermissionsLinux must be false")
|
|
147
|
+
return PreflightResult(ok=not errors, errors=errors)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def run_preflight(client: LidarrClient) -> PreflightResult:
|
|
151
|
+
"""Fetch Lidarr's configs and run :func:`check_safe_settings`."""
|
|
152
|
+
return check_safe_settings(
|
|
153
|
+
metadata=client.metadata_provider_config(),
|
|
154
|
+
media_management=client.media_management_config(),
|
|
155
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from .env import lidarr_get
|
|
7
|
+
from .errors import MusefsLidarrError
|
|
8
|
+
from .import_link import ensure_link, parse_import_env
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run(environ: dict[str, str] | None = None) -> int:
|
|
12
|
+
"""Create the import link for one Lidarr script-import call; return exit code.
|
|
13
|
+
|
|
14
|
+
Lidarr's Test event is a no-op success. Other events read the source and
|
|
15
|
+
destination from the environment and create the link.
|
|
16
|
+
"""
|
|
17
|
+
env = os.environ if environ is None else environ
|
|
18
|
+
if lidarr_get(env, "Lidarr_EventType") == "Test":
|
|
19
|
+
print("musefs-lidarr-import: test ok")
|
|
20
|
+
return 0
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
request = parse_import_env(env)
|
|
24
|
+
ensure_link(request.source, request.destination, request.mode)
|
|
25
|
+
print(
|
|
26
|
+
f"musefs-lidarr-import: {request.mode.value} {request.source} -> {request.destination}"
|
|
27
|
+
)
|
|
28
|
+
return 0
|
|
29
|
+
except MusefsLidarrError as exc:
|
|
30
|
+
print(f"musefs-lidarr-import: {exc}", file=sys.stderr)
|
|
31
|
+
return 1
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main() -> int:
|
|
35
|
+
return run()
|