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.
Files changed (30) hide show
  1. lidarr_musefs-0.0.1/LICENSE +21 -0
  2. lidarr_musefs-0.0.1/PKG-INFO +117 -0
  3. lidarr_musefs-0.0.1/README.md +95 -0
  4. lidarr_musefs-0.0.1/pyproject.toml +44 -0
  5. lidarr_musefs-0.0.1/setup.cfg +4 -0
  6. lidarr_musefs-0.0.1/src/lidarr_musefs.egg-info/PKG-INFO +117 -0
  7. lidarr_musefs-0.0.1/src/lidarr_musefs.egg-info/SOURCES.txt +28 -0
  8. lidarr_musefs-0.0.1/src/lidarr_musefs.egg-info/dependency_links.txt +1 -0
  9. lidarr_musefs-0.0.1/src/lidarr_musefs.egg-info/entry_points.txt +3 -0
  10. lidarr_musefs-0.0.1/src/lidarr_musefs.egg-info/requires.txt +4 -0
  11. lidarr_musefs-0.0.1/src/lidarr_musefs.egg-info/top_level.txt +1 -0
  12. lidarr_musefs-0.0.1/src/musefs_lidarr/__init__.py +3 -0
  13. lidarr_musefs-0.0.1/src/musefs_lidarr/api.py +155 -0
  14. lidarr_musefs-0.0.1/src/musefs_lidarr/cli_import.py +35 -0
  15. lidarr_musefs-0.0.1/src/musefs_lidarr/cli_sync.py +132 -0
  16. lidarr_musefs-0.0.1/src/musefs_lidarr/env.py +19 -0
  17. lidarr_musefs-0.0.1/src/musefs_lidarr/errors.py +18 -0
  18. lidarr_musefs-0.0.1/src/musefs_lidarr/events.py +73 -0
  19. lidarr_musefs-0.0.1/src/musefs_lidarr/import_link.py +95 -0
  20. lidarr_musefs-0.0.1/src/musefs_lidarr/mapping.py +120 -0
  21. lidarr_musefs-0.0.1/src/musefs_lidarr/sync.py +293 -0
  22. lidarr_musefs-0.0.1/tests/test_api.py +99 -0
  23. lidarr_musefs-0.0.1/tests/test_cli.py +247 -0
  24. lidarr_musefs-0.0.1/tests/test_env.py +19 -0
  25. lidarr_musefs-0.0.1/tests/test_events.py +132 -0
  26. lidarr_musefs-0.0.1/tests/test_import_link.py +127 -0
  27. lidarr_musefs-0.0.1/tests/test_mapping.py +137 -0
  28. lidarr_musefs-0.0.1/tests/test_path_gate.py +60 -0
  29. lidarr_musefs-0.0.1/tests/test_smoke.py +7 -0
  30. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,3 @@
1
+ [console_scripts]
2
+ musefs-lidarr-import = musefs_lidarr.cli_import:main
3
+ musefs-lidarr-sync = musefs_lidarr.cli_sync:main
@@ -0,0 +1,4 @@
1
+ python-musefs>=0.1.0
2
+
3
+ [test]
4
+ pytest>=7
@@ -0,0 +1 @@
1
+ musefs_lidarr
@@ -0,0 +1,3 @@
1
+ """Lidarr integration for syncing metadata into musefs."""
2
+
3
+ __version__ = "0.1.0"
@@ -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()