qBitrr2 5.4.4__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/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
- cur[keys[-1]] = value
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
- for album in query:
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": getattr(series, "EntryId", None),
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 ["All Logs"] + log_files if log_files else []
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
- # Return last 2000 lines
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").splitlines()
1644
- tail = "\n".join(content[-2000:])
1764
+ content = file.read_text(encoding="utf-8", errors="ignore")
1645
1765
  except Exception:
1646
- tail = ""
1647
- return send_file(io.BytesIO(tail.encode("utf-8")), mimetype="text/plain")
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
- # Handle "All Logs" special case - serve the unified All.log file
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").splitlines()
1661
- tail = "\n".join(content[-2000:])
1785
+ content = file.read_text(encoding="utf-8", errors="ignore")
1662
1786
  except Exception:
1663
- tail = ""
1664
- return send_file(io.BytesIO(tail.encode("utf-8")), mimetype="text/plain")
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 (always)
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