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.
- navidrome_scripts/__init__.py +1 -0
- navidrome_scripts/artist_ratings.py +424 -0
- navidrome_scripts/catalog.py +45 -0
- navidrome_scripts/date_evidence.py +265 -0
- navidrome_scripts/dates.py +369 -0
- navidrome_scripts/duplicates.py +356 -0
- navidrome_scripts/indexed_duplicates.py +370 -0
- navidrome_scripts/library_review.py +281 -0
- navidrome_scripts/lidarr.py +43 -0
- navidrome_scripts/locking.py +88 -0
- navidrome_scripts/maintenance.py +308 -0
- navidrome_scripts/metadata.py +46 -0
- navidrome_scripts/navidrome_guard.py +202 -0
- navidrome_scripts/playlists.py +313 -0
- navidrome_scripts/provider_review.py +122 -0
- navidrome_scripts/reconciliation.py +210 -0
- navidrome_scripts/rename_rollback.py +273 -0
- navidrome_scripts/renames.py +360 -0
- navidrome_scripts/spotify.py +131 -0
- navidrome_scripts/subsonic.py +166 -0
- navidrome_scripts/tag_repair.py +382 -0
- navidrome_scripts/unmapped.py +317 -0
- navidrome_scripts-0.14.0.dist-info/METADATA +485 -0
- navidrome_scripts-0.14.0.dist-info/RECORD +27 -0
- navidrome_scripts-0.14.0.dist-info/WHEEL +5 -0
- navidrome_scripts-0.14.0.dist-info/entry_points.txt +11 -0
- navidrome_scripts-0.14.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|