qBitrr2 5.7.0__py3-none-any.whl → 5.8.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 CHANGED
@@ -365,7 +365,7 @@ class Arr:
365
365
  f"{name}.EntrySearch.Overseerr.ApprovedOnly", fallback=True
366
366
  )
367
367
  self.search_requests_every_x_seconds = CONFIG.get(
368
- f"{name}.EntrySearch.SearchRequestsEvery", fallback=1800
368
+ f"{name}.EntrySearch.SearchRequestsEvery", fallback=300
369
369
  )
370
370
  self._temp_overseer_request_cache: dict[str, set[int | str]] = defaultdict(set)
371
371
  if self.ombi_search_requests or self.overseerr_requests:
@@ -465,10 +465,32 @@ class Arr:
465
465
  self.keep_temp_profile,
466
466
  )
467
467
  self.temp_quality_profile_ids = self.parse_quality_profiles()
468
+ # Create reverse mapping (temp_id → main_id) for O(1) lookups
469
+ self.main_quality_profile_ids = {
470
+ v: k for k, v in self.temp_quality_profile_ids.items()
471
+ }
472
+ self.profile_switch_retry_attempts = CONFIG.get(
473
+ f"{name}.EntrySearch.ProfileSwitchRetryAttempts", fallback=3
474
+ )
475
+ self.temp_profile_timeout_minutes = CONFIG.get(
476
+ f"{name}.EntrySearch.TempProfileResetTimeoutMinutes", fallback=0
477
+ )
468
478
  self.logger.info(
469
479
  "Parsed quality profile mappings: %s",
470
480
  {f"{k}→{v}": f"(main→temp)" for k, v in self.temp_quality_profile_ids.items()},
471
481
  )
482
+ if self.temp_profile_timeout_minutes > 0:
483
+ self.logger.info(
484
+ f"Temp profile timeout enabled: {self.temp_profile_timeout_minutes} minutes"
485
+ )
486
+
487
+ # Check if we should reset all temp profiles on startup
488
+ force_reset = CONFIG.get(f"{name}.EntrySearch.ForceResetTempProfiles", fallback=False)
489
+ if force_reset:
490
+ self.logger.info(
491
+ "ForceResetTempProfiles enabled - resetting all temp profiles on startup"
492
+ )
493
+ self._reset_all_temp_profiles()
472
494
 
473
495
  # Cache for valid quality profile IDs to avoid repeated API calls and warnings
474
496
  self._quality_profile_cache: dict[int, dict] = {}
@@ -1042,7 +1064,16 @@ class Arr:
1042
1064
  if not results:
1043
1065
  break
1044
1066
  for entry in results:
1067
+ # NOTE: 'type' field is not documented in official Overseerr API spec
1068
+ # but exists in practice. May break if Overseerr changes API.
1045
1069
  type__ = entry.get("type")
1070
+ if not type__:
1071
+ self.logger.debug(
1072
+ "Overseerr request missing 'type' field (entry ID: %s). "
1073
+ "This may indicate an API change.",
1074
+ entry.get("id", "unknown"),
1075
+ )
1076
+ continue
1046
1077
  if type__ == "movie":
1047
1078
  id__ = entry.get("media", {}).get("tmdbId")
1048
1079
  elif type__ == "tv":
@@ -1052,6 +1083,8 @@ class Arr:
1052
1083
  if not id__ or type_ != type__:
1053
1084
  continue
1054
1085
  media = entry.get("media") or {}
1086
+ # NOTE: 'status4k' field is not documented in official Overseerr API spec
1087
+ # but exists for 4K request tracking. Falls back to 'status' for non-4K.
1055
1088
  status_key = "status4k" if entry.get("is4k") else "status"
1056
1089
  status_value = _normalize_media_status(media.get(status_key))
1057
1090
  if entry.get("is4k"):
@@ -1074,7 +1107,7 @@ class Arr:
1074
1107
  try:
1075
1108
  if type_ == "movie":
1076
1109
  _entry = self.session.get(
1077
- url=f"{self.overseerr_uri}/api/v1/movies/{id__}",
1110
+ url=f"{self.overseerr_uri}/api/v1/movie/{id__}",
1078
1111
  headers={"X-Api-Key": self.overseerr_api_key},
1079
1112
  timeout=5,
1080
1113
  )
@@ -2918,6 +2951,10 @@ class Arr:
2918
2951
  # Only apply temp profiles for truly missing content (no file)
2919
2952
  # Do NOT apply for quality/custom format unmet or upgrade searches
2920
2953
  has_file = episode.get("hasFile", False)
2954
+ profile_switch_timestamp = None
2955
+ original_profile_for_db = None
2956
+ current_profile_for_db = None
2957
+
2921
2958
  self.logger.trace(
2922
2959
  "Temp quality profile check for '%s': searched=%s, has_file=%s, current_profile_id=%s, keep_temp=%s",
2923
2960
  db_entry.get("title", "Unknown"),
@@ -2928,22 +2965,31 @@ class Arr:
2928
2965
  )
2929
2966
  if (
2930
2967
  searched
2931
- and quality_profile_id in self.temp_quality_profile_ids.values()
2968
+ and quality_profile_id in self.main_quality_profile_ids.keys()
2932
2969
  and not self.keep_temp_profile
2933
2970
  ):
2934
- new_profile_id = list(self.temp_quality_profile_ids.keys())[
2935
- list(self.temp_quality_profile_ids.values()).index(
2936
- quality_profile_id
2937
- )
2938
- ]
2939
- data: JsonObject = {"qualityProfileId": new_profile_id}
2940
- self.logger.info(
2941
- "Upgrading quality profile for '%s': %s (ID:%s) → main profile (ID:%s) [Episode searched, reverting to main]",
2942
- db_entry.get("title", "Unknown"),
2943
- quality_profile_id,
2944
- quality_profile_id,
2945
- new_profile_id,
2971
+ new_profile_id = self.main_quality_profile_ids.get(
2972
+ quality_profile_id
2946
2973
  )
2974
+ if new_profile_id is None:
2975
+ self.logger.warning(
2976
+ f"Profile ID {quality_profile_id} not found in current temp→main mappings. "
2977
+ "Config may have changed. Skipping profile upgrade."
2978
+ )
2979
+ else:
2980
+ data: JsonObject = {"qualityProfileId": new_profile_id}
2981
+ self.logger.info(
2982
+ "Upgrading quality profile for '%s': temp profile (ID:%s) → main profile (ID:%s) [Episode searched, reverting to main]",
2983
+ db_entry.get("title", "Unknown"),
2984
+ quality_profile_id,
2985
+ new_profile_id,
2986
+ )
2987
+ # Reverting to main - clear tracking fields
2988
+ from datetime import datetime
2989
+
2990
+ profile_switch_timestamp = datetime.now()
2991
+ original_profile_for_db = None
2992
+ current_profile_for_db = None
2947
2993
  elif (
2948
2994
  not searched
2949
2995
  and not has_file
@@ -2957,6 +3003,12 @@ class Arr:
2957
3003
  quality_profile_id,
2958
3004
  new_profile_id,
2959
3005
  )
3006
+ # Downgrading to temp - track original and switch time
3007
+ from datetime import datetime
3008
+
3009
+ profile_switch_timestamp = datetime.now()
3010
+ original_profile_for_db = quality_profile_id
3011
+ current_profile_for_db = new_profile_id
2960
3012
  else:
2961
3013
  self.logger.trace(
2962
3014
  "No quality profile change for '%s': searched=%s, profile_id=%s (in_temps=%s, in_mains=%s)",
@@ -2967,18 +3019,34 @@ class Arr:
2967
3019
  quality_profile_id in self.temp_quality_profile_ids.keys(),
2968
3020
  )
2969
3021
  if data:
2970
- while True:
3022
+ profile_update_success = False
3023
+ for attempt in range(self.profile_switch_retry_attempts):
2971
3024
  try:
2972
3025
  self.client.upd_episode(episode["id"], data)
3026
+ profile_update_success = True
2973
3027
  break
2974
3028
  except (
2975
3029
  requests.exceptions.ChunkedEncodingError,
2976
3030
  requests.exceptions.ContentDecodingError,
2977
3031
  requests.exceptions.ConnectionError,
2978
3032
  JSONDecodeError,
2979
- ):
3033
+ ) as e:
3034
+ if attempt == self.profile_switch_retry_attempts - 1:
3035
+ self.logger.error(
3036
+ "Failed to update episode profile after %d attempts: %s",
3037
+ self.profile_switch_retry_attempts,
3038
+ e,
3039
+ )
3040
+ break
3041
+ time.sleep(1)
2980
3042
  continue
2981
3043
 
3044
+ # If profile update failed, don't track the change
3045
+ if not profile_update_success:
3046
+ profile_switch_timestamp = None
3047
+ original_profile_for_db = None
3048
+ current_profile_for_db = None
3049
+
2982
3050
  EntryId = episode.get("id")
2983
3051
  SeriesTitle = episode.get("series", {}).get("title")
2984
3052
  SeasonNumber = episode.get("seasonNumber")
@@ -3036,6 +3104,14 @@ class Arr:
3036
3104
  self.model_file.Reason: reason,
3037
3105
  }
3038
3106
 
3107
+ # Add profile tracking fields if temp profile feature is enabled
3108
+ if self.use_temp_for_missing and profile_switch_timestamp is not None:
3109
+ to_update[self.model_file.LastProfileSwitchTime] = (
3110
+ profile_switch_timestamp
3111
+ )
3112
+ to_update[self.model_file.OriginalProfileId] = original_profile_for_db
3113
+ to_update[self.model_file.CurrentProfileId] = current_profile_for_db
3114
+
3039
3115
  self.logger.debug(
3040
3116
  "Updating database entry | %s | S%02dE%03d [Searched:%s][Upgrade:%s][QualityMet:%s][CustomFormatMet:%s]",
3041
3117
  SeriesTitle.ljust(60, "."),
@@ -3152,46 +3228,42 @@ class Arr:
3152
3228
  searched = totalEpisodeCount == episodeFileCount
3153
3229
  else:
3154
3230
  searched = (episodeCount + monitoredEpisodeCount) == episodeFileCount
3231
+ # Sonarr series-level temp profile logic
3232
+ # NOTE: Sonarr only supports quality profiles at the series level (not episode level).
3233
+ # Individual episodes inherit the series profile. This is intentional and correct.
3234
+ # If ANY episodes are missing, the entire series uses temp profile to maximize
3235
+ # the chance of finding missing content (priority #1).
3155
3236
  if self.use_temp_for_missing:
3156
3237
  try:
3157
3238
  quality_profile_id = db_entry.get("qualityProfileId")
3158
3239
  if (
3159
3240
  searched
3160
- and quality_profile_id
3161
- in self.temp_quality_profile_ids.values()
3241
+ and quality_profile_id in self.main_quality_profile_ids.keys()
3162
3242
  and not self.keep_temp_profile
3163
3243
  ):
3164
- db_entry["qualityProfileId"] = list(
3165
- self.temp_quality_profile_ids.keys()
3166
- )[
3167
- list(self.temp_quality_profile_ids.values()).index(
3168
- quality_profile_id
3169
- )
3170
- ]
3244
+ new_main_id = self.main_quality_profile_ids[quality_profile_id]
3245
+ db_entry["qualityProfileId"] = new_main_id
3171
3246
  self.logger.debug(
3172
3247
  "Updating quality profile for %s to %s",
3173
3248
  db_entry["title"],
3174
- db_entry["qualityProfileId"],
3249
+ new_main_id,
3175
3250
  )
3176
3251
  elif (
3177
3252
  not searched
3178
3253
  and quality_profile_id in self.temp_quality_profile_ids.keys()
3179
3254
  ):
3180
- db_entry["qualityProfileId"] = self.temp_quality_profile_ids[
3181
- quality_profile_id
3182
- ]
3255
+ new_temp_id = self.temp_quality_profile_ids[quality_profile_id]
3256
+ db_entry["qualityProfileId"] = new_temp_id
3183
3257
  self.logger.debug(
3184
3258
  "Updating quality profile for %s to %s",
3185
3259
  db_entry["title"],
3186
- self.temp_quality_profile_ids[
3187
- db_entry["qualityProfileId"]
3188
- ],
3260
+ new_temp_id,
3189
3261
  )
3190
3262
  except KeyError:
3191
3263
  self.logger.warning(
3192
3264
  "Check quality profile settings for %s", db_entry["title"]
3193
3265
  )
3194
- while True:
3266
+ for attempt in range(self.profile_switch_retry_attempts):
3195
3267
  try:
3196
3268
  self.client.upd_series(db_entry)
3197
3269
  break
@@ -3200,7 +3272,15 @@ class Arr:
3200
3272
  requests.exceptions.ContentDecodingError,
3201
3273
  requests.exceptions.ConnectionError,
3202
3274
  JSONDecodeError,
3203
- ):
3275
+ ) as e:
3276
+ if attempt == self.profile_switch_retry_attempts - 1:
3277
+ self.logger.error(
3278
+ "Failed to update series profile after %d attempts: %s",
3279
+ self.profile_switch_retry_attempts,
3280
+ e,
3281
+ )
3282
+ break
3283
+ time.sleep(1)
3204
3284
  continue
3205
3285
 
3206
3286
  Title = seriesMetadata.get("title")
@@ -3324,6 +3404,10 @@ class Arr:
3324
3404
  self.model_queue.EntryId == db_entry["id"]
3325
3405
  ).execute()
3326
3406
 
3407
+ profile_switch_timestamp = None
3408
+ original_profile_for_db = None
3409
+ current_profile_for_db = None
3410
+
3327
3411
  if self.use_temp_for_missing:
3328
3412
  quality_profile_id = db_entry.get("qualityProfileId")
3329
3413
  # Only apply temp profiles for truly missing content (no file)
@@ -3331,46 +3415,69 @@ class Arr:
3331
3415
  has_file = db_entry.get("hasFile", False)
3332
3416
  if (
3333
3417
  searched
3334
- and quality_profile_id in self.temp_quality_profile_ids.values()
3418
+ and quality_profile_id in self.main_quality_profile_ids.keys()
3335
3419
  and not self.keep_temp_profile
3336
3420
  ):
3337
- db_entry["qualityProfileId"] = list(
3338
- self.temp_quality_profile_ids.keys()
3339
- )[
3340
- list(self.temp_quality_profile_ids.values()).index(
3341
- quality_profile_id
3342
- )
3343
- ]
3421
+ new_main_id = self.main_quality_profile_ids[quality_profile_id]
3422
+ db_entry["qualityProfileId"] = new_main_id
3344
3423
  self.logger.debug(
3345
3424
  "Updating quality profile for %s to %s",
3346
3425
  db_entry["title"],
3347
- db_entry["qualityProfileId"],
3426
+ new_main_id,
3348
3427
  )
3428
+ # Reverting to main - clear tracking fields
3429
+ from datetime import datetime
3430
+
3431
+ profile_switch_timestamp = datetime.now()
3432
+ original_profile_for_db = None
3433
+ current_profile_for_db = None
3349
3434
  elif (
3350
3435
  not searched
3351
3436
  and not has_file
3352
3437
  and quality_profile_id in self.temp_quality_profile_ids.keys()
3353
3438
  ):
3354
- db_entry["qualityProfileId"] = self.temp_quality_profile_ids[
3355
- quality_profile_id
3356
- ]
3439
+ new_temp_id = self.temp_quality_profile_ids[quality_profile_id]
3440
+ db_entry["qualityProfileId"] = new_temp_id
3357
3441
  self.logger.debug(
3358
3442
  "Updating quality profile for %s to %s",
3359
3443
  db_entry["title"],
3360
- db_entry["qualityProfileId"],
3444
+ new_temp_id,
3361
3445
  )
3362
- while True:
3446
+ # Downgrading to temp - track original and switch time
3447
+ from datetime import datetime
3448
+
3449
+ profile_switch_timestamp = datetime.now()
3450
+ original_profile_for_db = quality_profile_id
3451
+ current_profile_for_db = new_temp_id
3452
+
3453
+ profile_update_success = False
3454
+ for attempt in range(self.profile_switch_retry_attempts):
3363
3455
  try:
3364
3456
  self.client.upd_movie(db_entry)
3457
+ profile_update_success = True
3365
3458
  break
3366
3459
  except (
3367
3460
  requests.exceptions.ChunkedEncodingError,
3368
3461
  requests.exceptions.ContentDecodingError,
3369
3462
  requests.exceptions.ConnectionError,
3370
3463
  JSONDecodeError,
3371
- ):
3464
+ ) as e:
3465
+ if attempt == self.profile_switch_retry_attempts - 1:
3466
+ self.logger.error(
3467
+ "Failed to update movie profile after %d attempts: %s",
3468
+ self.profile_switch_retry_attempts,
3469
+ e,
3470
+ )
3471
+ break
3472
+ time.sleep(1)
3372
3473
  continue
3373
3474
 
3475
+ # If profile update failed, don't track the change
3476
+ if not profile_update_success:
3477
+ profile_switch_timestamp = None
3478
+ original_profile_for_db = None
3479
+ current_profile_for_db = None
3480
+
3374
3481
  title = db_entry["title"]
3375
3482
  monitored = db_entry["monitored"]
3376
3483
  tmdbId = db_entry["tmdbId"]
@@ -3423,6 +3530,12 @@ class Arr:
3423
3530
  self.model_file.QualityProfileName: qualityProfileName,
3424
3531
  }
3425
3532
 
3533
+ # Add profile tracking fields if temp profile feature is enabled
3534
+ if self.use_temp_for_missing and profile_switch_timestamp is not None:
3535
+ to_update[self.model_file.LastProfileSwitchTime] = profile_switch_timestamp
3536
+ to_update[self.model_file.OriginalProfileId] = original_profile_for_db
3537
+ to_update[self.model_file.CurrentProfileId] = current_profile_for_db
3538
+
3426
3539
  if request:
3427
3540
  to_update[self.model_file.IsRequest] = request
3428
3541
 
@@ -3870,18 +3983,17 @@ class Arr:
3870
3983
 
3871
3984
  # Temp profile management for Lidarr artists
3872
3985
  # Quality profiles in Lidarr are set at artist level, not album level
3986
+ # NOTE: Lidarr uses sizeOnDisk instead of hasFile because the Lidarr API
3987
+ # doesn't provide a hasFile boolean at artist level. sizeOnDisk > 0 is
3988
+ # equivalent to hasFile=True for Lidarr.
3873
3989
  if self.use_temp_for_missing and quality_profile_id:
3874
3990
  if (
3875
3991
  searched
3876
- and quality_profile_id in self.temp_quality_profile_ids.values()
3992
+ and quality_profile_id in self.main_quality_profile_ids.keys()
3877
3993
  and not self.keep_temp_profile
3878
3994
  ):
3879
3995
  # Artist has files, switch from temp back to main profile
3880
- main_profile_id = list(self.temp_quality_profile_ids.keys())[
3881
- list(self.temp_quality_profile_ids.values()).index(
3882
- quality_profile_id
3883
- )
3884
- ]
3996
+ main_profile_id = self.main_quality_profile_ids[quality_profile_id]
3885
3997
  artistMetadata["qualityProfileId"] = main_profile_id
3886
3998
  self.client.upd_artist(artistMetadata)
3887
3999
  quality_profile_id = main_profile_id
@@ -4662,42 +4774,70 @@ class Arr:
4662
4774
  current_time = time.time()
4663
4775
 
4664
4776
  # Track consecutive database errors for exponential backoff
4777
+ # Initialize tracking on first error ever
4778
+ if not hasattr(self, "_db_first_error_time"):
4779
+ self._db_first_error_time = current_time
4780
+
4781
+ # Reset if >5min since last error (new error sequence)
4665
4782
  if (
4666
4783
  current_time - self._db_last_error_time > 300
4667
4784
  ): # Reset if >5min since last error
4668
4785
  self._db_error_count = 0
4786
+ self._db_first_error_time = current_time
4787
+
4669
4788
  self._db_error_count += 1
4670
4789
  self._db_last_error_time = current_time
4671
4790
 
4791
+ # Check if errors have persisted for more than 5 minutes
4792
+ time_since_first_error = current_time - self._db_first_error_time
4793
+ if time_since_first_error > 300: # 5 minutes
4794
+ self.logger.critical(
4795
+ "Database errors have persisted for %.1f minutes. "
4796
+ "Signaling coordinated restart of ALL processes for database recovery...",
4797
+ time_since_first_error / 60,
4798
+ )
4799
+ # Signal all processes to restart (shared database affects everyone)
4800
+ self.manager.qbit_manager.database_restart_event.set()
4801
+ # Exit this process - main will restart all
4802
+ sys.exit(1)
4803
+
4672
4804
  # Calculate exponential backoff: 2min, 5min, 10min, 20min, 30min (max)
4673
4805
  delay_seconds = min(120 * (2 ** (self._db_error_count - 1)), 1800)
4674
4806
 
4675
4807
  # Log detailed error information based on error type
4808
+ # Use escalating severity: WARNING (1-2 errors), ERROR (3-4), CRITICAL (5+)
4809
+ if self._db_error_count <= 2:
4810
+ log_func = self.logger.warning
4811
+ elif self._db_error_count <= 4:
4812
+ log_func = self.logger.error
4813
+ else:
4814
+ log_func = self.logger.critical
4815
+
4676
4816
  if "disk i/o error" in error_msg:
4677
- self.logger.critical(
4678
- "Persistent database I/O error detected (consecutive error #%d). "
4679
- "This indicates disk issues, filesystem corruption, or resource exhaustion. "
4817
+ log_func(
4818
+ "Database I/O error detected (consecutive error #%d). "
4819
+ "This may indicate disk issues, filesystem corruption, or resource exhaustion. "
4680
4820
  "Attempting automatic recovery and retrying in %d seconds...",
4681
4821
  self._db_error_count,
4682
4822
  delay_seconds,
4683
4823
  )
4684
4824
  elif "database is locked" in error_msg:
4685
- self.logger.error(
4825
+ log_func(
4686
4826
  "Database locked error (consecutive error #%d). "
4687
4827
  "Retrying in %d seconds...",
4688
4828
  self._db_error_count,
4689
4829
  delay_seconds,
4690
4830
  )
4691
4831
  elif "disk image is malformed" in error_msg:
4692
- self.logger.critical(
4832
+ log_func(
4693
4833
  "Database corruption detected (consecutive error #%d). "
4694
4834
  "Attempting automatic recovery and retrying in %d seconds...",
4695
4835
  self._db_error_count,
4696
4836
  delay_seconds,
4697
4837
  )
4698
4838
  else:
4699
- self.logger.error(
4700
- "Database error (consecutive error #%d): %s. " "Retrying in %d seconds...",
4839
+ log_func(
4840
+ "Database error (consecutive error #%d): %s. Retrying in %d seconds...",
4701
4841
  self._db_error_count,
4702
4842
  error_msg,
4703
4843
  delay_seconds,
@@ -6068,27 +6208,30 @@ class Arr:
6068
6208
  def torrent_limit_check(
6069
6209
  self, torrent: qbittorrentapi.TorrentDictionary, seeding_time_limit, ratio_limit
6070
6210
  ) -> bool:
6071
- if (
6211
+ # -1 = Never remove (regardless of ratio/time limits)
6212
+ if self.seeding_mode_global_remove_torrent == -1:
6213
+ return False
6214
+ # 4 = AND (remove when BOTH ratio AND time limits met)
6215
+ elif (
6072
6216
  self.seeding_mode_global_remove_torrent == 4
6073
6217
  and torrent.ratio >= ratio_limit
6074
6218
  and torrent.seeding_time >= seeding_time_limit
6075
6219
  ):
6076
6220
  return True
6077
- if self.seeding_mode_global_remove_torrent == 3 and (
6221
+ # 3 = OR (remove when EITHER ratio OR time limit met)
6222
+ elif self.seeding_mode_global_remove_torrent == 3 and (
6078
6223
  torrent.ratio >= ratio_limit or torrent.seeding_time >= seeding_time_limit
6079
6224
  ):
6080
6225
  return True
6226
+ # 2 = Time only (remove when seeding time limit met)
6081
6227
  elif (
6082
6228
  self.seeding_mode_global_remove_torrent == 2
6083
6229
  and torrent.seeding_time >= seeding_time_limit
6084
6230
  ):
6085
6231
  return True
6232
+ # 1 = Ratio only (remove when upload ratio limit met)
6086
6233
  elif self.seeding_mode_global_remove_torrent == 1 and torrent.ratio >= ratio_limit:
6087
6234
  return True
6088
- elif self.seeding_mode_global_remove_torrent == -1 and (
6089
- torrent.ratio >= ratio_limit and torrent.seeding_time >= seeding_time_limit
6090
- ):
6091
- return True
6092
6235
  else:
6093
6236
  return False
6094
6237
 
@@ -6213,6 +6356,15 @@ class Arr:
6213
6356
  self.files_to_explicitly_delete = iter(_path_filter.copy())
6214
6357
 
6215
6358
  def parse_quality_profiles(self) -> dict[int, int]:
6359
+ """
6360
+ Parse quality profile name mappings into ID mappings.
6361
+
6362
+ Converts the configured profile name mappings (e.g., {"HD-1080p": "SD"})
6363
+ into ID mappings (e.g., {2: 1}) for faster lookups during profile switching.
6364
+
6365
+ Returns:
6366
+ dict[int, int]: Mapping of main_profile_id → temp_profile_id
6367
+ """
6216
6368
  temp_quality_profile_ids: dict[int, int] = {}
6217
6369
 
6218
6370
  self.logger.debug(
@@ -6297,160 +6449,209 @@ class Arr:
6297
6449
 
6298
6450
  return temp_quality_profile_ids
6299
6451
 
6300
- def register_search_mode(self):
6301
- if self.search_setup_completed:
6302
- return
6452
+ def _reset_all_temp_profiles(self):
6453
+ """Reset all items using temp profiles back to their original main profiles on startup."""
6454
+ reset_count = 0
6303
6455
 
6304
- db1, db2, db3, db4, db5 = self._get_models()
6456
+ try:
6457
+ # Get all items from Arr instance
6458
+ if self._name.lower().startswith("radarr"):
6459
+ items = self.client.get_movie()
6460
+ item_type = "movie"
6461
+ elif self._name.lower().startswith("sonarr") or self._name.lower().startswith(
6462
+ "animarr"
6463
+ ):
6464
+ items = self.client.get_series()
6465
+ item_type = "series"
6466
+ elif self._name.lower().startswith("lidarr"):
6467
+ items = self.client.get_artist()
6468
+ item_type = "artist"
6469
+ else:
6470
+ self.logger.warning(f"Unknown Arr type for temp profile reset: {self._name}")
6471
+ return
6305
6472
 
6306
- if not (
6307
- self.search_missing
6308
- or self.do_upgrade_search
6309
- or self.quality_unmet_search
6310
- or self.custom_format_unmet_search
6311
- or self.ombi_search_requests
6312
- or self.overseerr_requests
6313
- ):
6314
- if db5 and getattr(self, "torrents", None) is None:
6315
- self.torrent_db = SqliteDatabase(None)
6316
- self.torrent_db.init(
6317
- str(self._app_data_folder.joinpath("Torrents.db")),
6318
- pragmas={
6319
- "journal_mode": "wal",
6320
- "cache_size": -64_000,
6321
- "foreign_keys": 1,
6322
- "ignore_check_constraints": 0,
6323
- "synchronous": 0,
6324
- "read_uncommitted": 1,
6325
- },
6326
- timeout=15,
6327
- )
6473
+ self.logger.info(f"Checking {len(items)} {item_type}s for temp profile resets...")
6474
+
6475
+ for item in items:
6476
+ profile_id = item.get("qualityProfileId")
6328
6477
 
6329
- class Torrents(db5):
6330
- class Meta:
6331
- database = self.torrent_db
6478
+ # Check if item is currently using a temp profile
6479
+ if profile_id in self.main_quality_profile_ids.keys():
6480
+ # This is a temp profile - get the original main profile
6481
+ original_id = self.main_quality_profile_ids[profile_id]
6482
+ item["qualityProfileId"] = original_id
6332
6483
 
6333
- # Connect with retry logic for transient I/O errors
6334
- with_database_retry(
6335
- lambda: self.torrent_db.connect(),
6336
- logger=self.logger,
6484
+ # Update via API with retry logic
6485
+ for attempt in range(self.profile_switch_retry_attempts):
6486
+ try:
6487
+ if item_type == "movie":
6488
+ self.client.upd_movie(item)
6489
+ elif item_type == "series":
6490
+ self.client.upd_series(item)
6491
+ elif item_type == "artist":
6492
+ self.client.upd_artist(item)
6493
+
6494
+ reset_count += 1
6495
+ self.logger.info(
6496
+ f"Reset {item_type} '{item.get('title', item.get('artistName', 'Unknown'))}' "
6497
+ f"from temp profile (ID:{profile_id}) to main profile (ID:{original_id})"
6498
+ )
6499
+ break
6500
+ except (
6501
+ requests.exceptions.ChunkedEncodingError,
6502
+ requests.exceptions.ContentDecodingError,
6503
+ requests.exceptions.ConnectionError,
6504
+ JSONDecodeError,
6505
+ ) as e:
6506
+ if attempt == self.profile_switch_retry_attempts - 1:
6507
+ self.logger.error(
6508
+ f"Failed to reset {item_type} profile after {self.profile_switch_retry_attempts} attempts: {e}"
6509
+ )
6510
+ else:
6511
+ time.sleep(1)
6512
+ continue
6513
+
6514
+ if reset_count > 0:
6515
+ self.logger.info(
6516
+ f"ForceResetTempProfiles: Reset {reset_count} {item_type}s from temp to main profiles"
6517
+ )
6518
+ else:
6519
+ self.logger.info(
6520
+ f"ForceResetTempProfiles: No {item_type}s found using temp profiles"
6337
6521
  )
6338
- self.torrent_db.create_tables([Torrents])
6339
- self.torrents = Torrents
6340
- self.search_setup_completed = True
6341
- return
6342
6522
 
6343
- self.search_db_file.parent.mkdir(parents=True, exist_ok=True)
6344
- self.db = SqliteDatabase(None)
6345
- self.db.init(
6346
- str(self.search_db_file),
6347
- pragmas={
6348
- "journal_mode": "wal",
6349
- "cache_size": -64_000,
6350
- "foreign_keys": 1,
6351
- "ignore_check_constraints": 0,
6352
- "synchronous": 0,
6353
- "read_uncommitted": 1,
6354
- },
6355
- timeout=15,
6356
- )
6523
+ except Exception as e:
6524
+ self.logger.error(f"Error during temp profile reset: {e}", exc_info=True)
6357
6525
 
6358
- class Files(db1):
6359
- class Meta:
6360
- database = self.db
6526
+ def _check_temp_profile_timeouts(self):
6527
+ """Check for items with temp profiles that have exceeded the timeout and reset them."""
6528
+ if self.temp_profile_timeout_minutes == 0:
6529
+ return # Feature disabled
6361
6530
 
6362
- class Queue(db2):
6363
- class Meta:
6364
- database = self.db
6531
+ from datetime import timedelta
6365
6532
 
6366
- class PersistingQueue(FilesQueued):
6367
- class Meta:
6368
- database = self.db
6533
+ timeout_threshold = datetime.now() - timedelta(minutes=self.temp_profile_timeout_minutes)
6534
+ reset_count = 0
6369
6535
 
6370
- # Connect with retry logic for transient I/O errors
6371
- with_database_retry(
6372
- lambda: self.db.connect(),
6373
- logger=self.logger,
6374
- )
6536
+ try:
6537
+ # Query database for items with expired temp profiles
6538
+ db1, db2, db3, db4, db5 = self._get_models()
6539
+
6540
+ # Determine which model to use
6541
+ if self._name.lower().startswith("radarr"):
6542
+ model = self.movies_file_model
6543
+ item_type = "movie"
6544
+ elif self._name.lower().startswith("sonarr") or self._name.lower().startswith(
6545
+ "animarr"
6546
+ ):
6547
+ model = self.model_file # episodes
6548
+ item_type = "episode"
6549
+ elif self._name.lower().startswith("lidarr"):
6550
+ model = self.artists_file_model
6551
+ item_type = "artist"
6552
+ else:
6553
+ return
6375
6554
 
6376
- if db4:
6555
+ # Find items with temp profiles that have exceeded timeout
6556
+ expired_items = model.select().where(
6557
+ (model.LastProfileSwitchTime.is_null(False))
6558
+ & (model.LastProfileSwitchTime < timeout_threshold)
6559
+ & (model.CurrentProfileId.is_null(False))
6560
+ & (model.OriginalProfileId.is_null(False))
6561
+ )
6377
6562
 
6378
- class Tracks(db4):
6379
- class Meta:
6380
- database = self.db
6563
+ for db_item in expired_items:
6564
+ entry_id = db_item.EntryId
6565
+ current_profile = db_item.CurrentProfileId
6566
+ original_profile = db_item.OriginalProfileId
6567
+
6568
+ # Verify current profile is still a temp profile in our mappings
6569
+ if current_profile not in self.main_quality_profile_ids.keys():
6570
+ # Not a temp profile anymore, clear tracking
6571
+ model.update(
6572
+ LastProfileSwitchTime=None, CurrentProfileId=None, OriginalProfileId=None
6573
+ ).where(model.EntryId == entry_id).execute()
6574
+ continue
6381
6575
 
6382
- self.track_file_model = Tracks
6383
- else:
6384
- self.track_file_model = None
6576
+ # Reset to original profile via Arr API
6577
+ try:
6578
+ if item_type == "movie":
6579
+ item = self.client.get_movie(entry_id)
6580
+ item["qualityProfileId"] = original_profile
6581
+ self.client.upd_movie(item)
6582
+ elif item_type == "episode":
6583
+ # For episodes, we need to update the series
6584
+ series_id = db_item.SeriesId
6585
+ series = self.client.get_series(series_id)
6586
+ series["qualityProfileId"] = original_profile
6587
+ self.client.upd_series(series)
6588
+ elif item_type == "artist":
6589
+ artist = self.client.get_artist(entry_id)
6590
+ artist["qualityProfileId"] = original_profile
6591
+ self.client.upd_artist(artist)
6592
+
6593
+ # Clear tracking fields in database
6594
+ model.update(
6595
+ LastProfileSwitchTime=None, CurrentProfileId=None, OriginalProfileId=None
6596
+ ).where(model.EntryId == entry_id).execute()
6597
+
6598
+ reset_count += 1
6599
+ self.logger.info(
6600
+ f"Timeout reset: {item_type} ID {entry_id} from temp profile (ID:{current_profile}) "
6601
+ f"to main profile (ID:{original_profile}) after {self.temp_profile_timeout_minutes} minutes"
6602
+ )
6385
6603
 
6386
- if db3 and self.type == "sonarr":
6604
+ except Exception as e:
6605
+ self.logger.error(
6606
+ f"Failed to reset {item_type} ID {entry_id} after timeout: {e}"
6607
+ )
6387
6608
 
6388
- class Series(db3):
6389
- class Meta:
6390
- database = self.db
6609
+ if reset_count > 0:
6610
+ self.logger.info(
6611
+ f"TempProfileTimeout: Reset {reset_count} {item_type}s from temp to main profiles"
6612
+ )
6391
6613
 
6392
- try:
6393
- self.db.create_tables([Files, Queue, PersistingQueue, Series], safe=True)
6394
- except Exception as e:
6395
- self.logger.error("Failed to create database tables for Sonarr: %s", e)
6396
- raise
6397
- self.series_file_model = Series
6398
- self.artists_file_model = None
6399
- elif db3 and self.type == "lidarr":
6614
+ except Exception as e:
6615
+ self.logger.error(f"Error checking temp profile timeouts: {e}", exc_info=True)
6400
6616
 
6401
- class Artists(db3):
6402
- class Meta:
6403
- database = self.db
6617
+ def register_search_mode(self):
6618
+ """Initialize database models using the single shared database."""
6619
+ if self.search_setup_completed:
6620
+ return
6404
6621
 
6405
- try:
6406
- self.db.create_tables([Files, Queue, PersistingQueue, Artists, Tracks], safe=True)
6407
- except Exception as e:
6408
- self.logger.error("Failed to create database tables for Lidarr: %s", e)
6409
- raise
6410
- self.artists_file_model = Artists
6411
- self.series_file_model = None # Lidarr uses artists, not series
6412
- else:
6413
- # Radarr or any type without db3/db4 (series/artists/tracks models)
6414
- try:
6415
- self.db.create_tables([Files, Queue, PersistingQueue], safe=True)
6416
- except Exception as e:
6417
- self.logger.error("Failed to create database tables for Radarr: %s", e)
6418
- raise
6419
- self.artists_file_model = None
6420
- self.series_file_model = None
6622
+ # Import the shared database
6623
+ from qBitrr.database import get_database
6421
6624
 
6422
- if db5:
6423
- self.torrent_db = SqliteDatabase(None)
6424
- self.torrent_db.init(
6425
- str(self._app_data_folder.joinpath("Torrents.db")),
6426
- pragmas={
6427
- "journal_mode": "wal",
6428
- "cache_size": -64_000,
6429
- "foreign_keys": 1,
6430
- "ignore_check_constraints": 0,
6431
- "synchronous": 0,
6432
- "read_uncommitted": 1,
6433
- },
6434
- timeout=15,
6435
- )
6625
+ self.db = get_database()
6436
6626
 
6437
- class Torrents(db5):
6438
- class Meta:
6439
- database = self.torrent_db
6627
+ # Get the appropriate model classes for this Arr type
6628
+ file_model, queue_model, series_or_artist_model, track_model, torrent_model = (
6629
+ self._get_models()
6630
+ )
6440
6631
 
6441
- # Connect with retry logic for transient I/O errors
6442
- with_database_retry(
6443
- lambda: self.torrent_db.connect(),
6444
- logger=self.logger,
6445
- )
6446
- self.torrent_db.create_tables([Torrents])
6447
- self.torrents = Torrents
6448
- else:
6449
- self.torrents = None
6632
+ # Set model references for this instance
6633
+ self.model_file = file_model
6634
+ self.model_queue = queue_model
6635
+ self.persistent_queue = FilesQueued
6450
6636
 
6451
- self.model_file = Files
6452
- self.model_queue = Queue
6453
- self.persistent_queue = PersistingQueue
6637
+ # Set type-specific models
6638
+ if self.type == "sonarr":
6639
+ self.series_file_model = series_or_artist_model
6640
+ self.artists_file_model = None
6641
+ self.track_file_model = None
6642
+ elif self.type == "lidarr":
6643
+ self.series_file_model = None
6644
+ self.artists_file_model = series_or_artist_model
6645
+ self.track_file_model = track_model
6646
+ else: # radarr
6647
+ self.series_file_model = None
6648
+ self.artists_file_model = None
6649
+ self.track_file_model = None
6650
+
6651
+ # Set torrents model if TAGLESS is enabled
6652
+ self.torrents = torrent_model if TAGLESS else None
6653
+
6654
+ self.logger.debug("Database initialization completed for %s", self._name)
6454
6655
  self.search_setup_completed = True
6455
6656
 
6456
6657
  def _get_models(
@@ -6650,6 +6851,18 @@ class Arr:
6650
6851
 
6651
6852
  def run_search_loop(self) -> NoReturn:
6652
6853
  run_logs(self.logger)
6854
+ self.logger.info(
6855
+ "Search loop starting for %s (SearchMissing=%s, DoUpgradeSearch=%s, "
6856
+ "QualityUnmetSearch=%s, CustomFormatUnmetSearch=%s, "
6857
+ "Overseerr=%s, Ombi=%s)",
6858
+ self._name,
6859
+ self.search_missing,
6860
+ self.do_upgrade_search,
6861
+ self.quality_unmet_search,
6862
+ self.custom_format_unmet_search,
6863
+ self.overseerr_requests,
6864
+ self.ombi_search_requests,
6865
+ )
6653
6866
  try:
6654
6867
  if not (
6655
6868
  self.search_missing
@@ -6666,6 +6879,7 @@ class Arr:
6666
6879
  totcommands = -1
6667
6880
  self.db_update_processed = False
6668
6881
  event = self.manager.qbit_manager.shutdown_event
6882
+ self.logger.info("Search loop initialized successfully, entering main loop")
6669
6883
  while not event.is_set():
6670
6884
  if self.loop_completed:
6671
6885
  years_index = 0
@@ -6684,7 +6898,13 @@ class Arr:
6684
6898
  self.db_maybe_reset_entry_searched_state()
6685
6899
  self.refresh_download_queue()
6686
6900
  self.db_update()
6687
- # self.run_request_search()
6901
+
6902
+ # Check for expired temp profiles if feature is enabled
6903
+ if self.use_temp_for_missing and self.temp_profile_timeout_minutes > 0:
6904
+ self._check_temp_profile_timeouts()
6905
+
6906
+ # Check for new Overseerr/Ombi requests and trigger searches
6907
+ self.run_request_search()
6688
6908
  try:
6689
6909
  if self.search_by_year:
6690
6910
  if years.index(self.search_current_year) != years_count - 1:
@@ -6807,6 +7027,16 @@ class Arr:
6807
7027
  except KeyboardInterrupt:
6808
7028
  self.logger.hnotice("Detected Ctrl+C - Terminating process")
6809
7029
  sys.exit(0)
7030
+ except Exception as e:
7031
+ self.logger.critical(
7032
+ "Search loop crashed unexpectedly for %s: %s",
7033
+ self._name,
7034
+ e,
7035
+ exc_info=True,
7036
+ )
7037
+ raise
7038
+ finally:
7039
+ self.logger.warning("Search loop terminated for %s", self._name)
6810
7040
 
6811
7041
  def run_torrent_loop(self) -> NoReturn:
6812
7042
  run_logs(self.logger)