navidrome-scripts 0.14.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ __version__ = "0.14.0"
@@ -0,0 +1,424 @@
1
+ #!/usr/bin/env python3
2
+ """Update Navidrome track ratings for a specific artist.
3
+
4
+ This script targets a single artist and changes tracks with a given
5
+ rating (default from `.env` or CLI) to a new rating. It supports loading
6
+ defaults from a `.env` file and authenticates through Navidrome's
7
+ Subsonic-compatible API.
8
+
9
+ Usage examples:
10
+ # Use values from .env (including ARTIST)
11
+ navidrome-artist-ratings --env-file .env --dry-run
12
+
13
+ # Override ARTIST from CLI
14
+ navidrome-artist-ratings --env-file .env --artist "Zippy Kid" --dry-run
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import logging
20
+ import os
21
+ import re
22
+ import sys
23
+ import time
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Any, Dict, Iterable, List, Optional
27
+
28
+ import requests
29
+ from dotenv import load_dotenv
30
+ from . import __version__
31
+
32
+
33
+ for stream in (sys.stdout, sys.stderr):
34
+ reconfigure = getattr(stream, "reconfigure", None)
35
+ if callable(reconfigure):
36
+ reconfigure(errors="replace")
37
+
38
+ SUBSONIC_VERSION = "1.16.1"
39
+ CLIENT_NAME = "navidrome-scripts"
40
+ SCRIPT_VERSION = "v" + __version__
41
+ REQUEST_TIMEOUTS = [10, 20, 30, 45, 60]
42
+ RETRY_DELAYS = [3, 5, 10, 20, 30]
43
+
44
+
45
+ class SafeAsciiFormatter(logging.Formatter):
46
+ ansi_escape = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]")
47
+
48
+ def format(self, record: logging.LogRecord) -> str:
49
+ rendered = super().format(record)
50
+ rendered = self.ansi_escape.sub("", rendered)
51
+ return rendered.encode("ascii", "backslashreplace").decode("ascii")
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class CandidateTrack:
56
+ track_id: str
57
+ title: str
58
+ rating: int
59
+ album_name: str = ""
60
+
61
+
62
+ def configure_logging(log_dir: str, log_level: str) -> Path:
63
+ path = Path(log_dir)
64
+ if not path.is_absolute():
65
+ path = Path.cwd() / path
66
+ path.mkdir(parents=True, exist_ok=True)
67
+
68
+ logfile = path / f"update-artist-track-ratings_{int(time.time())}.log"
69
+ root = logging.getLogger()
70
+ root.handlers.clear()
71
+ root.setLevel(getattr(logging, log_level))
72
+
73
+ console_handler = logging.StreamHandler()
74
+ console_handler.setFormatter(SafeAsciiFormatter("%(message)s"))
75
+ root.addHandler(console_handler)
76
+
77
+ file_handler = logging.FileHandler(logfile, "a", encoding="ascii", errors="backslashreplace")
78
+ file_handler.setFormatter(SafeAsciiFormatter("[%(asctime)s] %(message)s"))
79
+ root.addHandler(file_handler)
80
+ return logfile
81
+
82
+
83
+ def encode_password(password: str) -> str:
84
+ return "enc:" + password.encode("utf-8").hex()
85
+
86
+
87
+ def build_subsonic_params(
88
+ username: Optional[str],
89
+ password: Optional[str],
90
+ token: Optional[str],
91
+ no_auth: bool,
92
+ ) -> Dict[str, str]:
93
+ params = {"v": SUBSONIC_VERSION, "c": CLIENT_NAME, "f": "json"}
94
+ if no_auth:
95
+ return params
96
+
97
+ if not username:
98
+ return params
99
+
100
+ params["u"] = username
101
+ if password:
102
+ params["p"] = encode_password(password)
103
+ elif token:
104
+ params["jwt"] = token
105
+ return params
106
+
107
+
108
+ def as_list(value: Any) -> List[Dict[str, Any]]:
109
+ if isinstance(value, list):
110
+ return [item for item in value if isinstance(item, dict)]
111
+ if isinstance(value, dict):
112
+ return [value]
113
+ return []
114
+
115
+
116
+ def format_connection_error(exc: Exception) -> str:
117
+ text = str(exc)
118
+ match = re.search(r"host='(?P<host>[^']+)'\s*,\s*port=(?P<port>\d+)", text)
119
+ if match:
120
+ hostport = f"{match.group('host')}:{match.group('port')}"
121
+ else:
122
+ hostport = "Navidrome"
123
+ if "Read timed out" in text:
124
+ return f"{hostport}; read timed out"
125
+ return f"{hostport}; {text.split('.')[0][:120]}"
126
+
127
+
128
+ class NavidromeClient:
129
+ def __init__(self, base_url: str, auth_params: Dict[str, str]) -> None:
130
+ self.base_url = base_url.rstrip("/")
131
+ self.auth_params = auth_params
132
+ self.session = requests.Session()
133
+
134
+ def request(self, endpoint: str, params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
135
+ url = f"{self.base_url}/rest/{endpoint}.view"
136
+ query = {**self.auth_params, **params}
137
+ for attempt, delay in enumerate(RETRY_DELAYS, start=1):
138
+ timeout = REQUEST_TIMEOUTS[min(attempt - 1, len(REQUEST_TIMEOUTS) - 1)]
139
+ try:
140
+ resp = self.session.get(url, params=query, timeout=timeout)
141
+ except (requests.ConnectionError, requests.Timeout) as exc:
142
+ if attempt >= len(RETRY_DELAYS):
143
+ logging.error("GET %s failed after %s attempts: %s", endpoint, attempt, format_connection_error(exc))
144
+ return None
145
+ logging.warning("GET %s connection issue: %s. Retrying in %ss...", endpoint, format_connection_error(exc), delay)
146
+ time.sleep(delay)
147
+ continue
148
+ except requests.RequestException as exc:
149
+ logging.error("GET %s failed: %s", endpoint, exc)
150
+ return None
151
+
152
+ if resp.status_code >= 500 and attempt < len(RETRY_DELAYS):
153
+ logging.warning("GET %s returned HTTP %s. Retrying in %ss...", endpoint, resp.status_code, delay)
154
+ time.sleep(delay)
155
+ continue
156
+
157
+ if resp.status_code != 200:
158
+ logging.error("GET %s returned HTTP %s", endpoint, resp.status_code)
159
+ return None
160
+
161
+ try:
162
+ data = resp.json()
163
+ except ValueError:
164
+ logging.error("GET %s did not return JSON", endpoint)
165
+ return None
166
+
167
+ wrapper = data.get("subsonic-response")
168
+ if not isinstance(wrapper, dict):
169
+ return data
170
+
171
+ if wrapper.get("status") == "failed":
172
+ err = wrapper.get("error") or {}
173
+ logging.error("Subsonic error from %s: %s", endpoint, err.get("message", "unknown error"))
174
+ return None
175
+
176
+ return wrapper
177
+
178
+ return None
179
+
180
+ def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]:
181
+ data = self.request("getArtist", {"id": artist_id})
182
+ artist = data.get("artist") if data else None
183
+ return artist if isinstance(artist, dict) else None
184
+
185
+ def get_album(self, album_id: str) -> Optional[Dict[str, Any]]:
186
+ data = self.request("getAlbum", {"id": album_id})
187
+ album = data.get("album") if data else None
188
+ return album if isinstance(album, dict) else None
189
+
190
+ def search_songs(self, artist: str) -> List[Dict[str, Any]]:
191
+ data = self.request("search2", {"query": artist})
192
+ if not data:
193
+ return []
194
+ results = data.get("searchResult2")
195
+ if not isinstance(results, dict):
196
+ return []
197
+ songs = as_list(results.get("song"))
198
+ artist_lower = artist.casefold()
199
+ filtered = [
200
+ song for song in songs
201
+ if str(song.get("artist", "")).casefold() == artist_lower
202
+ or str(song.get("artistId", "")).casefold() == artist_lower
203
+ ]
204
+ return filtered or songs
205
+
206
+ def set_rating(self, track_id: str, rating: int) -> bool:
207
+ data = self.request("setRating", {"id": track_id, "rating": str(rating)})
208
+ return bool(data and data.get("status") == "ok")
209
+
210
+
211
+ def track_candidate(track: Dict[str, Any], from_rating: int, album_name: str = "") -> Optional[CandidateTrack]:
212
+ track_id = track.get("id") or track.get("songId") or track.get("song_id") or track.get("trackId")
213
+ if not track_id:
214
+ return None
215
+
216
+ rating = None
217
+ for key in ("rating", "stars", "ratingScore", "userRating"):
218
+ if key not in track:
219
+ continue
220
+ try:
221
+ rating = int(track[key])
222
+ except (TypeError, ValueError):
223
+ pass
224
+
225
+ if rating is None or rating != from_rating:
226
+ return None
227
+
228
+ title = track.get("title") or track.get("name") or "<unknown>"
229
+ return CandidateTrack(str(track_id), str(title), rating, album_name)
230
+
231
+
232
+ def iter_artist_id_candidates(
233
+ client: NavidromeClient,
234
+ artist_id: str,
235
+ from_rating: int,
236
+ max_albums: Optional[int] = None,
237
+ ) -> Iterable[CandidateTrack]:
238
+ artist = client.get_artist(artist_id)
239
+ if not artist:
240
+ return
241
+
242
+ albums = as_list(artist.get("album"))
243
+ total_albums = len(albums)
244
+ if max_albums is not None:
245
+ albums = albums[:max_albums]
246
+ logging.info("Testing first %s of %s album(s).", len(albums), total_albums)
247
+ else:
248
+ logging.info("Fetching tracks from %s album(s).", total_albums)
249
+
250
+ for index, album in enumerate(albums, start=1):
251
+ album_id = album.get("id")
252
+ album_name = album.get("name") or album.get("title") or album_id or "<unknown>"
253
+ if not album_id:
254
+ continue
255
+ logging.info("Album %s/%s: %s", index, len(albums), album_name)
256
+ album_info = client.get_album(str(album_id))
257
+ if not album_info:
258
+ continue
259
+ for song in as_list(album_info.get("song")):
260
+ candidate = track_candidate(song, from_rating, str(album_name))
261
+ if candidate:
262
+ yield candidate
263
+
264
+
265
+ def iter_artist_name_candidates(
266
+ client: NavidromeClient,
267
+ artist: str,
268
+ from_rating: int,
269
+ ) -> Iterable[CandidateTrack]:
270
+ logging.info("Searching songs for artist name: %s", artist)
271
+ for song in client.search_songs(artist):
272
+ candidate = track_candidate(song, from_rating)
273
+ if candidate:
274
+ yield candidate
275
+
276
+
277
+ def flush_batch(
278
+ batch: List[CandidateTrack],
279
+ client: NavidromeClient,
280
+ to_rating: int,
281
+ dry_run: bool,
282
+ batch_number: int,
283
+ ) -> tuple[int, int]:
284
+ if not batch:
285
+ return (0, 0)
286
+
287
+ logging.info("Batch %s: %s track(s)", batch_number, len(batch))
288
+ changed = 0
289
+ failed = 0
290
+ for track in batch:
291
+ if dry_run:
292
+ logging.info("Preview: %s -> %s | %s (%s)", track.rating, to_rating, track.title, track.track_id)
293
+ continue
294
+
295
+ ok = client.set_rating(track.track_id, to_rating)
296
+ if ok:
297
+ changed += 1
298
+ logging.info("Updated: %s -> %s | %s (%s)", track.rating, to_rating, track.title, track.track_id)
299
+ else:
300
+ failed += 1
301
+ logging.warning("Failed: %s -> %s | %s (%s)", track.rating, to_rating, track.title, track.track_id)
302
+
303
+ batch.clear()
304
+ return (changed, failed)
305
+
306
+
307
+ def read_int_env(name: str, default: str) -> int:
308
+ raw = os.getenv(name, default)
309
+ try:
310
+ return int(raw)
311
+ except ValueError:
312
+ raise ValueError(f"{name} must be an integer, got {raw!r}") from None
313
+
314
+
315
+ def main(argv: Optional[List[str]] = None) -> int:
316
+ parser = argparse.ArgumentParser(description="Update Navidrome track ratings for a single artist")
317
+ parser.add_argument("--env-file", help="Path to .env file to load defaults from")
318
+ parser.add_argument("--base-url", help="Navidrome base URL, e.g. http://localhost:4533")
319
+ parser.add_argument("--token", help="Subsonic JWT token if your Navidrome setup exposes one")
320
+ parser.add_argument("--username", help="Username for Subsonic auth")
321
+ parser.add_argument("--password", help="Password for Subsonic auth")
322
+ parser.add_argument("--artist", help="Artist name to target (exact or partial). If omitted, reads ARTIST from .env")
323
+ parser.add_argument("--artist-id", action="store_true", help="Treat the provided ARTIST value as an artist ID")
324
+ parser.add_argument("--no-auth", action="store_true", help="Allow unauthenticated requests (may fail); only use if your server allows it")
325
+ parser.add_argument("--from-rating", type=int, default=None, help="Only change tracks currently with this rating")
326
+ parser.add_argument("--to-rating", type=int, default=None, help="New rating to apply")
327
+ parser.add_argument("--dry-run", action="store_true", help="Don't perform updates, just print what would be changed")
328
+ parser.add_argument("--max-albums", type=int, help="Only inspect the first N albums for a quick artist-id smoke test")
329
+ parser.add_argument("--batch-size", type=int, default=100, help="Flush updates after this many matching tracks (default 100)")
330
+ parser.add_argument("--log-dir", default="logs", help="Directory for timestamped run logs (default logs)")
331
+ parser.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"], help="Logging verbosity")
332
+ args = parser.parse_args(argv)
333
+
334
+ if args.env_file:
335
+ load_dotenv(args.env_file, override=True)
336
+ else:
337
+ load_dotenv(override=True)
338
+
339
+ logfile = configure_logging(args.log_dir, args.log_level)
340
+ start_time = time.time()
341
+ logging.info("Version: update-artist-track-ratings %s", SCRIPT_VERSION)
342
+ logging.info("Log file: %s", logfile)
343
+ if args.dry_run:
344
+ logging.info("Preview mode, no changes will be made.")
345
+
346
+ base_url = args.base_url or os.getenv("BASE_URL")
347
+ token = args.token or os.getenv("TOKEN") or os.getenv("API_TOKEN")
348
+ username = args.username or os.getenv("USERNAME") or os.getenv("USER")
349
+ password = args.password or os.getenv("PASSWORD") or os.getenv("PASS")
350
+ artist = args.artist or os.getenv("ARTIST")
351
+ try:
352
+ from_rating = args.from_rating if args.from_rating is not None else read_int_env("FROM_RATING", "5")
353
+ to_rating = args.to_rating if args.to_rating is not None else read_int_env("TO_RATING", "2")
354
+ except ValueError as exc:
355
+ logging.error("Error: %s", exc)
356
+ return 2
357
+
358
+ if not base_url:
359
+ logging.error("Error: base URL not provided. Set --base-url or BASE_URL in a .env file.")
360
+ return 2
361
+ if not artist:
362
+ logging.error("Error: artist not provided. Set --artist or ARTIST in .env.")
363
+ return 2
364
+ if args.batch_size <= 0:
365
+ logging.error("Error: --batch-size must be greater than zero.")
366
+ return 2
367
+ if not args.no_auth and not username:
368
+ logging.error("Error: username not provided. Set USERNAME, or pass --no-auth to attempt unauthenticated requests.")
369
+ return 2
370
+ if not args.no_auth and not (password or token):
371
+ logging.error("Error: no authentication secret provided. Set PASSWORD or TOKEN, or pass --no-auth to attempt unauthenticated requests.")
372
+ return 2
373
+
374
+ auth_params = build_subsonic_params(username, password, token, args.no_auth)
375
+ client = NavidromeClient(base_url, auth_params)
376
+ prefers_id = args.artist_id or (isinstance(artist, str) and len(artist) >= 8 and " " not in artist)
377
+
378
+ logging.info("Searching for tracks by artist: %s", artist)
379
+ logging.info("Ratings: %s -> %s | batch-size:%s", from_rating, to_rating, args.batch_size)
380
+
381
+ if prefers_id:
382
+ candidates = iter_artist_id_candidates(client, artist, from_rating, max_albums=args.max_albums)
383
+ else:
384
+ candidates = iter_artist_name_candidates(client, artist, from_rating)
385
+
386
+ batch: List[CandidateTrack] = []
387
+ total_found = 0
388
+ total_changed = 0
389
+ total_failed = 0
390
+ batch_number = 1
391
+
392
+ for candidate in candidates:
393
+ total_found += 1
394
+ batch.append(candidate)
395
+ if len(batch) >= args.batch_size:
396
+ changed, failed = flush_batch(batch, client, to_rating, args.dry_run, batch_number)
397
+ total_changed += changed
398
+ total_failed += failed
399
+ batch_number += 1
400
+
401
+ if batch:
402
+ changed, failed = flush_batch(batch, client, to_rating, args.dry_run, batch_number)
403
+ total_changed += changed
404
+ total_failed += failed
405
+
406
+ elapsed = time.time() - start_time
407
+ minutes = int(elapsed // 60)
408
+ seconds = int(elapsed % 60)
409
+ action = "Previewed" if args.dry_run else "Updated"
410
+ logging.info(
411
+ "Done. Found: %s | %s: %s | Failed: %s | Time: %sm %ss | Log: %s",
412
+ total_found,
413
+ action,
414
+ total_found if args.dry_run else total_changed,
415
+ total_failed,
416
+ minutes,
417
+ seconds,
418
+ logfile,
419
+ )
420
+ return 0 if total_failed == 0 else 1
421
+
422
+
423
+ if __name__ == "__main__":
424
+ raise SystemExit(main())
@@ -0,0 +1,45 @@
1
+ """Read-only MusicBrainz search used by library review."""
2
+ import re
3
+ import time
4
+ import unicodedata
5
+ import requests
6
+ from . import __version__
7
+
8
+ CLIENT = f"navidrome-scripts/{__version__} (https://gitea.rcs1.top/sickprodigy/navidrome-scripts)"
9
+
10
+ def normalize(value):
11
+ return re.sub(r"[^\w]+", "", unicodedata.normalize("NFKC", value).casefold())
12
+
13
+ def without_trailing_parenthetical(value):
14
+ return re.sub(r"\s*\([^()]*\)\s*$", "", value).strip()
15
+
16
+ def lucene_quote(value):
17
+ return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"'
18
+
19
+ class MusicBrainzClient:
20
+ def __init__(self):
21
+ self.session = requests.Session()
22
+ self.session.headers['User-Agent'] = CLIENT
23
+ self.last_request = 0.0
24
+
25
+ def search(self, entity, query, limit=10):
26
+ time.sleep(max(0, 1.1 - (time.monotonic() - self.last_request)))
27
+ try:
28
+ response = self.session.get('https://musicbrainz.org/ws/2/' + entity,
29
+ params={'query': query, 'limit': limit, 'fmt': 'json'}, timeout=(10, 60))
30
+ response.raise_for_status()
31
+ return response.json()
32
+ finally:
33
+ self.last_request = time.monotonic()
34
+
35
+ def lookup_release(self, identity):
36
+ if not re.fullmatch(r'[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}', identity):
37
+ raise ValueError('a valid exact release MBID is required')
38
+ time.sleep(max(0, 1.1 - (time.monotonic() - self.last_request)))
39
+ try:
40
+ response = self.session.get('https://musicbrainz.org/ws/2/release/' + identity,
41
+ params={'fmt':'json','inc':'artist-credits+release-groups+recordings'}, timeout=(10,60))
42
+ response.raise_for_status()
43
+ return response.json()
44
+ finally:
45
+ self.last_request = time.monotonic()