qBitrr2 5.4.5__py3-none-any.whl → 5.5.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.
- qBitrr/arss.py +457 -127
- qBitrr/bundled_data.py +2 -2
- qBitrr/config_version.py +144 -0
- qBitrr/db_lock.py +189 -0
- qBitrr/db_recovery.py +202 -0
- qBitrr/gen_config.py +285 -3
- qBitrr/main.py +171 -5
- qBitrr/search_activity_store.py +6 -2
- qBitrr/static/assets/ArrView.js +1 -1
- qBitrr/static/assets/ArrView.js.map +1 -1
- qBitrr/static/assets/ConfigView.js +4 -3
- qBitrr/static/assets/ConfigView.js.map +1 -1
- qBitrr/static/assets/LogsView.js +17 -39
- qBitrr/static/assets/LogsView.js.map +1 -1
- qBitrr/static/assets/ProcessesView.js +1 -1
- qBitrr/static/assets/ProcessesView.js.map +1 -1
- qBitrr/static/assets/app.css +1 -1
- qBitrr/static/assets/app.js +1 -9
- qBitrr/static/assets/app.js.map +1 -1
- qBitrr/static/assets/react-select.esm.js +1 -8
- qBitrr/static/assets/react-select.esm.js.map +1 -1
- qBitrr/static/assets/table.js +2 -20
- qBitrr/static/assets/table.js.map +1 -1
- qBitrr/static/assets/vendor.js +1 -25
- qBitrr/static/assets/vendor.js.map +1 -1
- qBitrr/static/sw.js +5 -0
- qBitrr/tables.py +27 -0
- qBitrr/webui.py +523 -23
- {qbitrr2-5.4.5.dist-info → qbitrr2-5.5.0.dist-info}/METADATA +88 -13
- qbitrr2-5.5.0.dist-info/RECORD +63 -0
- qbitrr2-5.4.5.dist-info/RECORD +0 -61
- {qbitrr2-5.4.5.dist-info → qbitrr2-5.5.0.dist-info}/WHEEL +0 -0
- {qbitrr2-5.4.5.dist-info → qbitrr2-5.5.0.dist-info}/entry_points.txt +0 -0
- {qbitrr2-5.4.5.dist-info → qbitrr2-5.5.0.dist-info}/licenses/LICENSE +0 -0
- {qbitrr2-5.4.5.dist-info → qbitrr2-5.5.0.dist-info}/top_level.txt +0 -0
qBitrr/webui.py
CHANGED
|
@@ -27,13 +27,23 @@ from qBitrr.versioning import fetch_latest_release, fetch_release_by_tag
|
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
def _toml_set(doc, dotted_key: str, value: Any):
|
|
30
|
+
from tomlkit import inline_table, table
|
|
31
|
+
|
|
30
32
|
keys = dotted_key.split(".")
|
|
31
33
|
cur = doc
|
|
32
34
|
for k in keys[:-1]:
|
|
33
35
|
if k not in cur or not isinstance(cur[k], dict):
|
|
34
|
-
cur[k] =
|
|
36
|
+
cur[k] = table()
|
|
35
37
|
cur = cur[k]
|
|
36
|
-
|
|
38
|
+
|
|
39
|
+
# Convert plain Python dicts to inline tables for proper TOML serialization
|
|
40
|
+
# This ensures dicts are rendered as inline {key = "value"} not as sections [key]
|
|
41
|
+
if isinstance(value, dict) and not hasattr(value, "as_string"):
|
|
42
|
+
inline = inline_table()
|
|
43
|
+
inline.update(value)
|
|
44
|
+
cur[keys[-1]] = inline
|
|
45
|
+
else:
|
|
46
|
+
cur[keys[-1]] = value
|
|
37
47
|
|
|
38
48
|
|
|
39
49
|
def _toml_delete(doc, dotted_key: str) -> None:
|
|
@@ -446,6 +456,18 @@ class WebUI:
|
|
|
446
456
|
page_items = query.order_by(model.Title.asc()).paginate(page + 1, page_size).iterator()
|
|
447
457
|
movies = []
|
|
448
458
|
for movie in page_items:
|
|
459
|
+
# Read quality profile from database
|
|
460
|
+
quality_profile_id = (
|
|
461
|
+
getattr(movie, "QualityProfileId", None)
|
|
462
|
+
if hasattr(model, "QualityProfileId")
|
|
463
|
+
else None
|
|
464
|
+
)
|
|
465
|
+
quality_profile_name = (
|
|
466
|
+
getattr(movie, "QualityProfileName", None)
|
|
467
|
+
if hasattr(model, "QualityProfileName")
|
|
468
|
+
else None
|
|
469
|
+
)
|
|
470
|
+
|
|
449
471
|
movies.append(
|
|
450
472
|
{
|
|
451
473
|
"id": movie.EntryId,
|
|
@@ -460,6 +482,8 @@ class WebUI:
|
|
|
460
482
|
"minCustomFormatScore": movie.MinCustomFormatScore,
|
|
461
483
|
"customFormatMet": self._safe_bool(movie.CustomFormatMet),
|
|
462
484
|
"reason": movie.Reason,
|
|
485
|
+
"qualityProfileId": quality_profile_id,
|
|
486
|
+
"qualityProfileName": quality_profile_name,
|
|
463
487
|
}
|
|
464
488
|
)
|
|
465
489
|
return {
|
|
@@ -486,6 +510,7 @@ class WebUI:
|
|
|
486
510
|
has_file: bool | None = None,
|
|
487
511
|
quality_met: bool | None = None,
|
|
488
512
|
is_request: bool | None = None,
|
|
513
|
+
group_by_artist: bool = True,
|
|
489
514
|
) -> dict[str, Any]:
|
|
490
515
|
if not self._ensure_arr_db(arr):
|
|
491
516
|
return {
|
|
@@ -519,6 +544,10 @@ class WebUI:
|
|
|
519
544
|
}
|
|
520
545
|
page = max(page, 0)
|
|
521
546
|
page_size = max(page_size, 1)
|
|
547
|
+
|
|
548
|
+
# Quality profiles are now stored in the database
|
|
549
|
+
# No need to fetch from API
|
|
550
|
+
|
|
522
551
|
with db.connection_context():
|
|
523
552
|
base_query = model.select()
|
|
524
553
|
|
|
@@ -573,10 +602,40 @@ class WebUI:
|
|
|
573
602
|
if is_request is not None:
|
|
574
603
|
query = query.where(model.IsRequest == is_request)
|
|
575
604
|
|
|
576
|
-
total = query.count()
|
|
577
|
-
query = query.order_by(model.Title).paginate(page + 1, page_size)
|
|
578
605
|
albums = []
|
|
579
|
-
|
|
606
|
+
|
|
607
|
+
if group_by_artist:
|
|
608
|
+
# Paginate by artists: Two-pass approach with Peewee
|
|
609
|
+
# First, get all distinct artist names from the filtered query
|
|
610
|
+
# Use a subquery to get distinct artists efficiently
|
|
611
|
+
artists_subquery = (
|
|
612
|
+
query.select(model.ArtistTitle).distinct().order_by(model.ArtistTitle)
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
# Convert to list to avoid multiple iterations
|
|
616
|
+
all_artists = [row.ArtistTitle for row in artists_subquery]
|
|
617
|
+
total = len(all_artists)
|
|
618
|
+
|
|
619
|
+
# Paginate the artist list in Python
|
|
620
|
+
start_idx = page * page_size
|
|
621
|
+
end_idx = start_idx + page_size
|
|
622
|
+
paginated_artists = all_artists[start_idx:end_idx]
|
|
623
|
+
|
|
624
|
+
# Fetch all albums for these paginated artists
|
|
625
|
+
if paginated_artists:
|
|
626
|
+
album_results = list(
|
|
627
|
+
query.where(model.ArtistTitle.in_(paginated_artists)).order_by(
|
|
628
|
+
model.ArtistTitle, model.ReleaseDate
|
|
629
|
+
)
|
|
630
|
+
)
|
|
631
|
+
else:
|
|
632
|
+
album_results = []
|
|
633
|
+
else:
|
|
634
|
+
# Flat mode: paginate by albums as before
|
|
635
|
+
total = query.count()
|
|
636
|
+
album_results = list(query.order_by(model.Title).paginate(page + 1, page_size))
|
|
637
|
+
|
|
638
|
+
for album in album_results:
|
|
580
639
|
# Always fetch tracks from database (Lidarr only)
|
|
581
640
|
track_model = getattr(arr, "track_file_model", None)
|
|
582
641
|
tracks_list = []
|
|
@@ -623,6 +682,10 @@ class WebUI:
|
|
|
623
682
|
|
|
624
683
|
track_missing_count = max(track_monitored_count - track_available_count, 0)
|
|
625
684
|
|
|
685
|
+
# Get quality profile from database model
|
|
686
|
+
quality_profile_id = getattr(album, "QualityProfileId", None)
|
|
687
|
+
quality_profile_name = getattr(album, "QualityProfileName", None)
|
|
688
|
+
|
|
626
689
|
# Build album data in Sonarr-like structure
|
|
627
690
|
album_item = {
|
|
628
691
|
"album": {
|
|
@@ -645,6 +708,8 @@ class WebUI:
|
|
|
645
708
|
"minCustomFormatScore": album.MinCustomFormatScore,
|
|
646
709
|
"customFormatMet": self._safe_bool(album.CustomFormatMet),
|
|
647
710
|
"reason": album.Reason,
|
|
711
|
+
"qualityProfileId": quality_profile_id,
|
|
712
|
+
"qualityProfileName": quality_profile_name,
|
|
648
713
|
},
|
|
649
714
|
"totals": {
|
|
650
715
|
"available": track_available_count,
|
|
@@ -832,6 +897,7 @@ class WebUI:
|
|
|
832
897
|
missing_condition = episodes_model.EpisodeFileId.is_null(True) | (
|
|
833
898
|
episodes_model.EpisodeFileId == 0
|
|
834
899
|
)
|
|
900
|
+
|
|
835
901
|
with db.connection_context():
|
|
836
902
|
monitored_count = (
|
|
837
903
|
episodes_model.select(fn.COUNT(episodes_model.EntryId))
|
|
@@ -961,11 +1027,27 @@ class WebUI:
|
|
|
961
1027
|
}
|
|
962
1028
|
if not seasons:
|
|
963
1029
|
continue
|
|
1030
|
+
|
|
1031
|
+
# Get quality profile for this series from database
|
|
1032
|
+
series_id = getattr(series, "EntryId", None)
|
|
1033
|
+
quality_profile_id = (
|
|
1034
|
+
getattr(series, "QualityProfileId", None)
|
|
1035
|
+
if hasattr(series_model, "QualityProfileId")
|
|
1036
|
+
else None
|
|
1037
|
+
)
|
|
1038
|
+
quality_profile_name = (
|
|
1039
|
+
getattr(series, "QualityProfileName", None)
|
|
1040
|
+
if hasattr(series_model, "QualityProfileName")
|
|
1041
|
+
else None
|
|
1042
|
+
)
|
|
1043
|
+
|
|
964
1044
|
payload.append(
|
|
965
1045
|
{
|
|
966
1046
|
"series": {
|
|
967
|
-
"id":
|
|
1047
|
+
"id": series_id,
|
|
968
1048
|
"title": getattr(series, "Title", "") or "",
|
|
1049
|
+
"qualityProfileId": quality_profile_id,
|
|
1050
|
+
"qualityProfileName": quality_profile_name,
|
|
969
1051
|
},
|
|
970
1052
|
"totals": {
|
|
971
1053
|
"available": series_available,
|
|
@@ -1062,7 +1144,15 @@ class WebUI:
|
|
|
1062
1144
|
seasons: dict[str, dict[str, Any]] = {}
|
|
1063
1145
|
series_monitored = 0
|
|
1064
1146
|
series_available = 0
|
|
1147
|
+
# Track quality profile from first episode (all episodes in a series share the same profile)
|
|
1148
|
+
quality_profile_id = None
|
|
1149
|
+
quality_profile_name = None
|
|
1065
1150
|
for ep in episodes_query.iterator():
|
|
1151
|
+
# Capture quality profile from first episode if available
|
|
1152
|
+
if quality_profile_id is None and hasattr(ep, "QualityProfileId"):
|
|
1153
|
+
quality_profile_id = getattr(ep, "QualityProfileId", None)
|
|
1154
|
+
if quality_profile_name is None and hasattr(ep, "QualityProfileName"):
|
|
1155
|
+
quality_profile_name = getattr(ep, "QualityProfileName", None)
|
|
1066
1156
|
season_value = getattr(ep, "SeasonNumber", None)
|
|
1067
1157
|
season_key = str(season_value) if season_value is not None else "unknown"
|
|
1068
1158
|
season_bucket = seasons.setdefault(
|
|
@@ -1109,6 +1199,35 @@ class WebUI:
|
|
|
1109
1199
|
seasons = {key: data for key, data in seasons.items() if data["episodes"]}
|
|
1110
1200
|
if not seasons:
|
|
1111
1201
|
continue
|
|
1202
|
+
|
|
1203
|
+
# If quality profile is still None, fetch from Sonarr API
|
|
1204
|
+
if quality_profile_id is None and series_id is not None:
|
|
1205
|
+
try:
|
|
1206
|
+
client = getattr(arr, "client", None)
|
|
1207
|
+
if client and hasattr(client, "get_series"):
|
|
1208
|
+
series_data = client.get_series(series_id)
|
|
1209
|
+
if series_data:
|
|
1210
|
+
quality_profile_id = series_data.get("qualityProfileId")
|
|
1211
|
+
# Get quality profile name from cache or API
|
|
1212
|
+
if quality_profile_id:
|
|
1213
|
+
quality_cache = getattr(arr, "_quality_profile_cache", {})
|
|
1214
|
+
if quality_profile_id in quality_cache:
|
|
1215
|
+
quality_profile_name = quality_cache[
|
|
1216
|
+
quality_profile_id
|
|
1217
|
+
].get("name")
|
|
1218
|
+
elif hasattr(client, "get_quality_profile"):
|
|
1219
|
+
try:
|
|
1220
|
+
profile = client.get_quality_profile(
|
|
1221
|
+
quality_profile_id
|
|
1222
|
+
)
|
|
1223
|
+
quality_profile_name = (
|
|
1224
|
+
profile.get("name") if profile else None
|
|
1225
|
+
)
|
|
1226
|
+
except Exception:
|
|
1227
|
+
pass
|
|
1228
|
+
except Exception:
|
|
1229
|
+
pass
|
|
1230
|
+
|
|
1112
1231
|
payload.append(
|
|
1113
1232
|
{
|
|
1114
1233
|
"series": {
|
|
@@ -1121,6 +1240,8 @@ class WebUI:
|
|
|
1121
1240
|
else str(series_id)
|
|
1122
1241
|
)
|
|
1123
1242
|
),
|
|
1243
|
+
"qualityProfileId": quality_profile_id,
|
|
1244
|
+
"qualityProfileName": quality_profile_name,
|
|
1124
1245
|
},
|
|
1125
1246
|
"totals": {
|
|
1126
1247
|
"available": series_available,
|
|
@@ -1617,9 +1738,8 @@ class WebUI:
|
|
|
1617
1738
|
def _list_logs() -> list[str]:
|
|
1618
1739
|
if not logs_root.exists():
|
|
1619
1740
|
return []
|
|
1620
|
-
# Add "All Logs" as first option
|
|
1621
1741
|
log_files = sorted(f.name for f in logs_root.glob("*.log*"))
|
|
1622
|
-
return
|
|
1742
|
+
return log_files
|
|
1623
1743
|
|
|
1624
1744
|
@app.get("/api/logs")
|
|
1625
1745
|
def api_logs():
|
|
@@ -1638,30 +1758,41 @@ class WebUI:
|
|
|
1638
1758
|
file = _resolve_log_file(name)
|
|
1639
1759
|
if file is None or not file.exists():
|
|
1640
1760
|
return jsonify({"error": "not found"}), 404
|
|
1641
|
-
|
|
1761
|
+
|
|
1762
|
+
# Stream full log file to support dynamic loading in LazyLog
|
|
1642
1763
|
try:
|
|
1643
|
-
content = file.read_text(encoding="utf-8", errors="ignore")
|
|
1644
|
-
tail = "\n".join(content[-2000:])
|
|
1764
|
+
content = file.read_text(encoding="utf-8", errors="ignore")
|
|
1645
1765
|
except Exception:
|
|
1646
|
-
|
|
1647
|
-
|
|
1766
|
+
content = ""
|
|
1767
|
+
response = send_file(
|
|
1768
|
+
io.BytesIO(content.encode("utf-8")),
|
|
1769
|
+
mimetype="text/plain",
|
|
1770
|
+
as_attachment=False,
|
|
1771
|
+
)
|
|
1772
|
+
response.headers["Content-Type"] = "text/plain; charset=utf-8"
|
|
1773
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
1774
|
+
return response
|
|
1648
1775
|
|
|
1649
1776
|
@app.get("/web/logs/<name>")
|
|
1650
1777
|
def web_log(name: str):
|
|
1651
|
-
#
|
|
1652
|
-
if name == "All Logs":
|
|
1653
|
-
name = "All.log"
|
|
1654
|
-
|
|
1655
|
-
# Regular single log file
|
|
1778
|
+
# Public endpoint for Authentik bypass - no token required
|
|
1656
1779
|
file = _resolve_log_file(name)
|
|
1657
1780
|
if file is None or not file.exists():
|
|
1658
1781
|
return jsonify({"error": "not found"}), 404
|
|
1782
|
+
|
|
1783
|
+
# Stream full log file to support dynamic loading in LazyLog
|
|
1659
1784
|
try:
|
|
1660
|
-
content = file.read_text(encoding="utf-8", errors="ignore")
|
|
1661
|
-
tail = "\n".join(content[-2000:])
|
|
1785
|
+
content = file.read_text(encoding="utf-8", errors="ignore")
|
|
1662
1786
|
except Exception:
|
|
1663
|
-
|
|
1664
|
-
|
|
1787
|
+
content = ""
|
|
1788
|
+
response = send_file(
|
|
1789
|
+
io.BytesIO(content.encode("utf-8")),
|
|
1790
|
+
mimetype="text/plain",
|
|
1791
|
+
as_attachment=False,
|
|
1792
|
+
)
|
|
1793
|
+
response.headers["Content-Type"] = "text/plain; charset=utf-8"
|
|
1794
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
1795
|
+
return response
|
|
1665
1796
|
|
|
1666
1797
|
@app.get("/api/logs/<name>/download")
|
|
1667
1798
|
def api_log_download(name: str):
|
|
@@ -1868,7 +1999,7 @@ class WebUI:
|
|
|
1868
1999
|
has_file=has_file,
|
|
1869
2000
|
)
|
|
1870
2001
|
else:
|
|
1871
|
-
# Grouped mode: return albums with tracks
|
|
2002
|
+
# Grouped mode: return albums with tracks, paginated by artist
|
|
1872
2003
|
payload = self._lidarr_albums_from_db(
|
|
1873
2004
|
arr,
|
|
1874
2005
|
q,
|
|
@@ -1878,6 +2009,7 @@ class WebUI:
|
|
|
1878
2009
|
has_file=has_file,
|
|
1879
2010
|
quality_met=quality_met,
|
|
1880
2011
|
is_request=is_request,
|
|
2012
|
+
group_by_artist=True,
|
|
1881
2013
|
)
|
|
1882
2014
|
payload["category"] = category
|
|
1883
2015
|
return jsonify(payload)
|
|
@@ -2141,6 +2273,23 @@ class WebUI:
|
|
|
2141
2273
|
except Exception:
|
|
2142
2274
|
pass
|
|
2143
2275
|
data = _toml_to_jsonable(CONFIG.config)
|
|
2276
|
+
|
|
2277
|
+
# Check config version and add warning if mismatch
|
|
2278
|
+
from qBitrr.config_version import get_config_version, validate_config_version
|
|
2279
|
+
|
|
2280
|
+
is_valid, validation_result = validate_config_version(CONFIG)
|
|
2281
|
+
if not is_valid:
|
|
2282
|
+
# Add version mismatch warning to response
|
|
2283
|
+
response_data = {
|
|
2284
|
+
"config": data,
|
|
2285
|
+
"warning": {
|
|
2286
|
+
"type": "config_version_mismatch",
|
|
2287
|
+
"message": validation_result,
|
|
2288
|
+
"currentVersion": get_config_version(CONFIG),
|
|
2289
|
+
},
|
|
2290
|
+
}
|
|
2291
|
+
return jsonify(response_data)
|
|
2292
|
+
|
|
2144
2293
|
return jsonify(data)
|
|
2145
2294
|
except Exception as e:
|
|
2146
2295
|
return jsonify({"error": str(e)}), 500
|
|
@@ -2152,6 +2301,15 @@ class WebUI:
|
|
|
2152
2301
|
if not isinstance(changes, dict):
|
|
2153
2302
|
return jsonify({"error": "changes must be an object"}), 400
|
|
2154
2303
|
|
|
2304
|
+
# Prevent ConfigVersion from being modified by user
|
|
2305
|
+
protected_keys = {"Settings.ConfigVersion"}
|
|
2306
|
+
for key in protected_keys:
|
|
2307
|
+
if key in changes:
|
|
2308
|
+
return (
|
|
2309
|
+
jsonify({"error": f"Cannot modify protected configuration key: {key}"}),
|
|
2310
|
+
403,
|
|
2311
|
+
)
|
|
2312
|
+
|
|
2155
2313
|
# Define key categories
|
|
2156
2314
|
frontend_only_keys = {
|
|
2157
2315
|
"WebUI.LiveArr",
|
|
@@ -2282,6 +2440,348 @@ class WebUI:
|
|
|
2282
2440
|
def web_update_config():
|
|
2283
2441
|
return _handle_config_update()
|
|
2284
2442
|
|
|
2443
|
+
@app.post("/api/arr/test-connection")
|
|
2444
|
+
def api_arr_test_connection():
|
|
2445
|
+
"""
|
|
2446
|
+
Test connection to Arr instance without saving config.
|
|
2447
|
+
Accepts temporary URI/APIKey and returns connection status + quality profiles.
|
|
2448
|
+
"""
|
|
2449
|
+
if (resp := require_token()) is not None:
|
|
2450
|
+
return resp
|
|
2451
|
+
|
|
2452
|
+
try:
|
|
2453
|
+
data = request.get_json()
|
|
2454
|
+
if not data:
|
|
2455
|
+
return jsonify({"success": False, "message": "Missing request body"}), 400
|
|
2456
|
+
|
|
2457
|
+
arr_type = data.get("arrType") # "radarr" | "sonarr" | "lidarr"
|
|
2458
|
+
uri = data.get("uri")
|
|
2459
|
+
api_key = data.get("apiKey")
|
|
2460
|
+
|
|
2461
|
+
# Validate inputs
|
|
2462
|
+
if not all([arr_type, uri, api_key]):
|
|
2463
|
+
return (
|
|
2464
|
+
jsonify(
|
|
2465
|
+
{
|
|
2466
|
+
"success": False,
|
|
2467
|
+
"message": "Missing required fields: arrType, uri, or apiKey",
|
|
2468
|
+
}
|
|
2469
|
+
),
|
|
2470
|
+
400,
|
|
2471
|
+
)
|
|
2472
|
+
|
|
2473
|
+
# Try to find existing Arr instance with matching URI
|
|
2474
|
+
existing_arr = None
|
|
2475
|
+
managed = _managed_objects()
|
|
2476
|
+
for group_name, arr_instance in managed.items():
|
|
2477
|
+
if hasattr(arr_instance, "uri") and hasattr(arr_instance, "apikey"):
|
|
2478
|
+
if arr_instance.uri == uri and arr_instance.apikey == api_key:
|
|
2479
|
+
existing_arr = arr_instance
|
|
2480
|
+
self.logger.info(f"Using existing Arr instance: {group_name}")
|
|
2481
|
+
break
|
|
2482
|
+
|
|
2483
|
+
# Use existing client if available, otherwise create temporary one
|
|
2484
|
+
if existing_arr and hasattr(existing_arr, "client"):
|
|
2485
|
+
client = existing_arr.client
|
|
2486
|
+
self.logger.info(f"Reusing existing client for {existing_arr._name}")
|
|
2487
|
+
else:
|
|
2488
|
+
# Create temporary Arr API client
|
|
2489
|
+
self.logger.info(f"Creating temporary {arr_type} client for {uri}")
|
|
2490
|
+
if arr_type == "radarr":
|
|
2491
|
+
from pyarr import RadarrAPI
|
|
2492
|
+
|
|
2493
|
+
client = RadarrAPI(uri, api_key)
|
|
2494
|
+
elif arr_type == "sonarr":
|
|
2495
|
+
from pyarr import SonarrAPI
|
|
2496
|
+
|
|
2497
|
+
client = SonarrAPI(uri, api_key)
|
|
2498
|
+
elif arr_type == "lidarr":
|
|
2499
|
+
from pyarr import LidarrAPI
|
|
2500
|
+
|
|
2501
|
+
client = LidarrAPI(uri, api_key)
|
|
2502
|
+
else:
|
|
2503
|
+
return (
|
|
2504
|
+
jsonify({"success": False, "message": f"Invalid arrType: {arr_type}"}),
|
|
2505
|
+
400,
|
|
2506
|
+
)
|
|
2507
|
+
|
|
2508
|
+
# Test connection (no timeout - Flask/Waitress handles this)
|
|
2509
|
+
try:
|
|
2510
|
+
self.logger.info(f"Testing connection to {arr_type} at {uri}")
|
|
2511
|
+
|
|
2512
|
+
# Get system info to verify connection
|
|
2513
|
+
system_info = client.get_system_status()
|
|
2514
|
+
self.logger.info(
|
|
2515
|
+
f"System status retrieved: {system_info.get('version', 'unknown')}"
|
|
2516
|
+
)
|
|
2517
|
+
|
|
2518
|
+
# Fetch quality profiles with retry logic (same as backend)
|
|
2519
|
+
from json import JSONDecodeError
|
|
2520
|
+
|
|
2521
|
+
import requests
|
|
2522
|
+
from pyarr.exceptions import PyarrServerError
|
|
2523
|
+
|
|
2524
|
+
max_retries = 3
|
|
2525
|
+
retry_count = 0
|
|
2526
|
+
quality_profiles = []
|
|
2527
|
+
|
|
2528
|
+
while retry_count < max_retries:
|
|
2529
|
+
try:
|
|
2530
|
+
quality_profiles = client.get_quality_profile()
|
|
2531
|
+
self.logger.info(
|
|
2532
|
+
f"Quality profiles retrieved: {len(quality_profiles)} profiles"
|
|
2533
|
+
)
|
|
2534
|
+
break
|
|
2535
|
+
except (
|
|
2536
|
+
requests.exceptions.ChunkedEncodingError,
|
|
2537
|
+
requests.exceptions.ContentDecodingError,
|
|
2538
|
+
requests.exceptions.ConnectionError,
|
|
2539
|
+
JSONDecodeError,
|
|
2540
|
+
) as e:
|
|
2541
|
+
retry_count += 1
|
|
2542
|
+
self.logger.warning(
|
|
2543
|
+
f"Transient error fetching quality profiles (attempt {retry_count}/{max_retries}): {e}"
|
|
2544
|
+
)
|
|
2545
|
+
if retry_count >= max_retries:
|
|
2546
|
+
self.logger.error("Failed to fetch quality profiles after retries")
|
|
2547
|
+
quality_profiles = []
|
|
2548
|
+
break
|
|
2549
|
+
time.sleep(1)
|
|
2550
|
+
except PyarrServerError as e:
|
|
2551
|
+
self.logger.error(f"Server error fetching quality profiles: {e}")
|
|
2552
|
+
quality_profiles = []
|
|
2553
|
+
break
|
|
2554
|
+
except Exception as e:
|
|
2555
|
+
self.logger.error(f"Unexpected error fetching quality profiles: {e}")
|
|
2556
|
+
quality_profiles = []
|
|
2557
|
+
break
|
|
2558
|
+
|
|
2559
|
+
# Format response
|
|
2560
|
+
return jsonify(
|
|
2561
|
+
{
|
|
2562
|
+
"success": True,
|
|
2563
|
+
"message": "Connected successfully",
|
|
2564
|
+
"systemInfo": {
|
|
2565
|
+
"version": system_info.get("version", "unknown"),
|
|
2566
|
+
"branch": system_info.get("branch"),
|
|
2567
|
+
},
|
|
2568
|
+
"qualityProfiles": [
|
|
2569
|
+
{"id": p["id"], "name": p["name"]} for p in quality_profiles
|
|
2570
|
+
],
|
|
2571
|
+
}
|
|
2572
|
+
)
|
|
2573
|
+
|
|
2574
|
+
except Exception as e:
|
|
2575
|
+
# Handle specific error types
|
|
2576
|
+
error_msg = str(e)
|
|
2577
|
+
# Log full error for debugging but sanitize user-facing message
|
|
2578
|
+
self.logger.error(f"Connection test failed: {error_msg}")
|
|
2579
|
+
|
|
2580
|
+
if "401" in error_msg or "Unauthorized" in error_msg:
|
|
2581
|
+
return (
|
|
2582
|
+
jsonify(
|
|
2583
|
+
{"success": False, "message": "Unauthorized: Invalid API key"}
|
|
2584
|
+
),
|
|
2585
|
+
401,
|
|
2586
|
+
)
|
|
2587
|
+
elif "404" in error_msg:
|
|
2588
|
+
return (
|
|
2589
|
+
jsonify(
|
|
2590
|
+
{"success": False, "message": f"Not found: Check URI ({uri})"}
|
|
2591
|
+
),
|
|
2592
|
+
404,
|
|
2593
|
+
)
|
|
2594
|
+
elif "Connection refused" in error_msg or "ConnectionError" in error_msg:
|
|
2595
|
+
return (
|
|
2596
|
+
jsonify(
|
|
2597
|
+
{
|
|
2598
|
+
"success": False,
|
|
2599
|
+
"message": f"Connection refused: Cannot reach {uri}",
|
|
2600
|
+
}
|
|
2601
|
+
),
|
|
2602
|
+
503,
|
|
2603
|
+
)
|
|
2604
|
+
else:
|
|
2605
|
+
# Generic error message - details logged above
|
|
2606
|
+
return (
|
|
2607
|
+
jsonify({"success": False, "message": "Connection test failed"}),
|
|
2608
|
+
500,
|
|
2609
|
+
)
|
|
2610
|
+
|
|
2611
|
+
except Exception as e:
|
|
2612
|
+
self.logger.error("Test connection error: %s", e)
|
|
2613
|
+
return jsonify({"success": False, "message": "Connection test failed"}), 500
|
|
2614
|
+
|
|
2615
|
+
@app.post("/web/arr/test-connection")
|
|
2616
|
+
def web_arr_test_connection():
|
|
2617
|
+
"""
|
|
2618
|
+
Test connection to Arr instance without saving config.
|
|
2619
|
+
Accepts temporary URI/APIKey and returns connection status + quality profiles.
|
|
2620
|
+
Public endpoint (mirrors /api/arr/test-connection).
|
|
2621
|
+
"""
|
|
2622
|
+
try:
|
|
2623
|
+
data = request.get_json()
|
|
2624
|
+
if not data:
|
|
2625
|
+
return jsonify({"success": False, "message": "Missing request body"}), 400
|
|
2626
|
+
|
|
2627
|
+
arr_type = data.get("arrType") # "radarr" | "sonarr" | "lidarr"
|
|
2628
|
+
uri = data.get("uri")
|
|
2629
|
+
api_key = data.get("apiKey")
|
|
2630
|
+
|
|
2631
|
+
# Validate inputs
|
|
2632
|
+
if not all([arr_type, uri, api_key]):
|
|
2633
|
+
return (
|
|
2634
|
+
jsonify(
|
|
2635
|
+
{
|
|
2636
|
+
"success": False,
|
|
2637
|
+
"message": "Missing required fields: arrType, uri, or apiKey",
|
|
2638
|
+
}
|
|
2639
|
+
),
|
|
2640
|
+
400,
|
|
2641
|
+
)
|
|
2642
|
+
|
|
2643
|
+
# Try to find existing Arr instance with matching URI
|
|
2644
|
+
existing_arr = None
|
|
2645
|
+
managed = _managed_objects()
|
|
2646
|
+
for group_name, arr_instance in managed.items():
|
|
2647
|
+
if hasattr(arr_instance, "uri") and hasattr(arr_instance, "apikey"):
|
|
2648
|
+
if arr_instance.uri == uri and arr_instance.apikey == api_key:
|
|
2649
|
+
existing_arr = arr_instance
|
|
2650
|
+
self.logger.info(f"Using existing Arr instance: {group_name}")
|
|
2651
|
+
break
|
|
2652
|
+
|
|
2653
|
+
# Use existing client if available, otherwise create temporary one
|
|
2654
|
+
if existing_arr and hasattr(existing_arr, "client"):
|
|
2655
|
+
client = existing_arr.client
|
|
2656
|
+
self.logger.info(f"Reusing existing client for {existing_arr._name}")
|
|
2657
|
+
else:
|
|
2658
|
+
# Create temporary Arr API client
|
|
2659
|
+
self.logger.info(f"Creating temporary {arr_type} client for {uri}")
|
|
2660
|
+
if arr_type == "radarr":
|
|
2661
|
+
from pyarr import RadarrAPI
|
|
2662
|
+
|
|
2663
|
+
client = RadarrAPI(uri, api_key)
|
|
2664
|
+
elif arr_type == "sonarr":
|
|
2665
|
+
from pyarr import SonarrAPI
|
|
2666
|
+
|
|
2667
|
+
client = SonarrAPI(uri, api_key)
|
|
2668
|
+
elif arr_type == "lidarr":
|
|
2669
|
+
from pyarr import LidarrAPI
|
|
2670
|
+
|
|
2671
|
+
client = LidarrAPI(uri, api_key)
|
|
2672
|
+
else:
|
|
2673
|
+
return (
|
|
2674
|
+
jsonify({"success": False, "message": f"Invalid arrType: {arr_type}"}),
|
|
2675
|
+
400,
|
|
2676
|
+
)
|
|
2677
|
+
|
|
2678
|
+
# Test connection (no timeout - Flask/Waitress handles this)
|
|
2679
|
+
try:
|
|
2680
|
+
self.logger.info(f"Testing connection to {arr_type} at {uri}")
|
|
2681
|
+
|
|
2682
|
+
# Get system info to verify connection
|
|
2683
|
+
system_info = client.get_system_status()
|
|
2684
|
+
self.logger.info(
|
|
2685
|
+
f"System status retrieved: {system_info.get('version', 'unknown')}"
|
|
2686
|
+
)
|
|
2687
|
+
|
|
2688
|
+
# Fetch quality profiles with retry logic (same as backend)
|
|
2689
|
+
from json import JSONDecodeError
|
|
2690
|
+
|
|
2691
|
+
import requests
|
|
2692
|
+
from pyarr.exceptions import PyarrServerError
|
|
2693
|
+
|
|
2694
|
+
max_retries = 3
|
|
2695
|
+
retry_count = 0
|
|
2696
|
+
quality_profiles = []
|
|
2697
|
+
|
|
2698
|
+
while retry_count < max_retries:
|
|
2699
|
+
try:
|
|
2700
|
+
quality_profiles = client.get_quality_profile()
|
|
2701
|
+
self.logger.info(
|
|
2702
|
+
f"Quality profiles retrieved: {len(quality_profiles)} profiles"
|
|
2703
|
+
)
|
|
2704
|
+
break
|
|
2705
|
+
except (
|
|
2706
|
+
requests.exceptions.ChunkedEncodingError,
|
|
2707
|
+
requests.exceptions.ContentDecodingError,
|
|
2708
|
+
requests.exceptions.ConnectionError,
|
|
2709
|
+
JSONDecodeError,
|
|
2710
|
+
) as e:
|
|
2711
|
+
retry_count += 1
|
|
2712
|
+
self.logger.warning(
|
|
2713
|
+
f"Transient error fetching quality profiles (attempt {retry_count}/{max_retries}): {e}"
|
|
2714
|
+
)
|
|
2715
|
+
if retry_count >= max_retries:
|
|
2716
|
+
self.logger.error("Failed to fetch quality profiles after retries")
|
|
2717
|
+
quality_profiles = []
|
|
2718
|
+
break
|
|
2719
|
+
time.sleep(1)
|
|
2720
|
+
except PyarrServerError as e:
|
|
2721
|
+
self.logger.error(f"Server error fetching quality profiles: {e}")
|
|
2722
|
+
quality_profiles = []
|
|
2723
|
+
break
|
|
2724
|
+
except Exception as e:
|
|
2725
|
+
self.logger.error(f"Unexpected error fetching quality profiles: {e}")
|
|
2726
|
+
quality_profiles = []
|
|
2727
|
+
break
|
|
2728
|
+
|
|
2729
|
+
# Format response
|
|
2730
|
+
return jsonify(
|
|
2731
|
+
{
|
|
2732
|
+
"success": True,
|
|
2733
|
+
"message": "Connected successfully",
|
|
2734
|
+
"systemInfo": {
|
|
2735
|
+
"version": system_info.get("version", "unknown"),
|
|
2736
|
+
"branch": system_info.get("branch"),
|
|
2737
|
+
},
|
|
2738
|
+
"qualityProfiles": [
|
|
2739
|
+
{"id": p["id"], "name": p["name"]} for p in quality_profiles
|
|
2740
|
+
],
|
|
2741
|
+
}
|
|
2742
|
+
)
|
|
2743
|
+
|
|
2744
|
+
except Exception as e:
|
|
2745
|
+
# Handle specific error types
|
|
2746
|
+
error_msg = str(e)
|
|
2747
|
+
# Log full error for debugging but sanitize user-facing message
|
|
2748
|
+
self.logger.error(f"Connection test failed: {error_msg}")
|
|
2749
|
+
|
|
2750
|
+
if "401" in error_msg or "Unauthorized" in error_msg:
|
|
2751
|
+
return (
|
|
2752
|
+
jsonify(
|
|
2753
|
+
{"success": False, "message": "Unauthorized: Invalid API key"}
|
|
2754
|
+
),
|
|
2755
|
+
401,
|
|
2756
|
+
)
|
|
2757
|
+
elif "404" in error_msg:
|
|
2758
|
+
return (
|
|
2759
|
+
jsonify(
|
|
2760
|
+
{"success": False, "message": f"Not found: Check URI ({uri})"}
|
|
2761
|
+
),
|
|
2762
|
+
404,
|
|
2763
|
+
)
|
|
2764
|
+
elif "Connection refused" in error_msg or "ConnectionError" in error_msg:
|
|
2765
|
+
return (
|
|
2766
|
+
jsonify(
|
|
2767
|
+
{
|
|
2768
|
+
"success": False,
|
|
2769
|
+
"message": f"Connection refused: Cannot reach {uri}",
|
|
2770
|
+
}
|
|
2771
|
+
),
|
|
2772
|
+
503,
|
|
2773
|
+
)
|
|
2774
|
+
else:
|
|
2775
|
+
# Generic error message - details logged above
|
|
2776
|
+
return (
|
|
2777
|
+
jsonify({"success": False, "message": "Connection test failed"}),
|
|
2778
|
+
500,
|
|
2779
|
+
)
|
|
2780
|
+
|
|
2781
|
+
except Exception as e:
|
|
2782
|
+
self.logger.error("Test connection error: %s", e)
|
|
2783
|
+
return jsonify({"success": False, "message": "Connection test failed"}), 500
|
|
2784
|
+
|
|
2285
2785
|
def _reload_all(self):
|
|
2286
2786
|
# Set rebuilding flag
|
|
2287
2787
|
self._rebuilding_arrs = True
|