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 +432 -202
- qBitrr/bundled_data.py +2 -2
- qBitrr/config.py +1 -1
- qBitrr/database.py +79 -0
- qBitrr/gen_config.py +84 -41
- qBitrr/main.py +230 -14
- qBitrr/search_activity_store.py +9 -39
- qBitrr/static/assets/ConfigView.js +4 -4
- qBitrr/static/assets/ConfigView.js.map +1 -1
- qBitrr/static/assets/LogsView.js +19 -19
- qBitrr/static/assets/LogsView.js.map +1 -1
- qBitrr/tables.py +11 -0
- {qbitrr2-5.7.0.dist-info → qbitrr2-5.8.0.dist-info}/METADATA +19 -1
- {qbitrr2-5.7.0.dist-info → qbitrr2-5.8.0.dist-info}/RECORD +18 -17
- {qbitrr2-5.7.0.dist-info → qbitrr2-5.8.0.dist-info}/WHEEL +1 -1
- {qbitrr2-5.7.0.dist-info → qbitrr2-5.8.0.dist-info}/entry_points.txt +0 -0
- {qbitrr2-5.7.0.dist-info → qbitrr2-5.8.0.dist-info}/licenses/LICENSE +0 -0
- {qbitrr2-5.7.0.dist-info → qbitrr2-5.8.0.dist-info}/top_level.txt +0 -0
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=
|
|
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/
|
|
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.
|
|
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 =
|
|
2935
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3418
|
+
and quality_profile_id in self.main_quality_profile_ids.keys()
|
|
3335
3419
|
and not self.keep_temp_profile
|
|
3336
3420
|
):
|
|
3337
|
-
|
|
3338
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3355
|
-
|
|
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
|
-
|
|
3444
|
+
new_temp_id,
|
|
3361
3445
|
)
|
|
3362
|
-
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
4678
|
-
"
|
|
4679
|
-
"This
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4700
|
-
"Database error (consecutive error #%d): %s.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
6301
|
-
|
|
6302
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6307
|
-
|
|
6308
|
-
|
|
6309
|
-
|
|
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
|
-
|
|
6330
|
-
|
|
6331
|
-
|
|
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
|
-
|
|
6334
|
-
|
|
6335
|
-
|
|
6336
|
-
|
|
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
|
-
|
|
6344
|
-
|
|
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
|
-
|
|
6359
|
-
|
|
6360
|
-
|
|
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
|
-
|
|
6363
|
-
class Meta:
|
|
6364
|
-
database = self.db
|
|
6531
|
+
from datetime import timedelta
|
|
6365
6532
|
|
|
6366
|
-
|
|
6367
|
-
|
|
6368
|
-
database = self.db
|
|
6533
|
+
timeout_threshold = datetime.now() - timedelta(minutes=self.temp_profile_timeout_minutes)
|
|
6534
|
+
reset_count = 0
|
|
6369
6535
|
|
|
6370
|
-
|
|
6371
|
-
|
|
6372
|
-
|
|
6373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6379
|
-
|
|
6380
|
-
|
|
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
|
-
|
|
6383
|
-
|
|
6384
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6389
|
-
|
|
6390
|
-
|
|
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
|
-
|
|
6393
|
-
|
|
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
|
-
|
|
6402
|
-
|
|
6403
|
-
|
|
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
|
-
|
|
6406
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6438
|
-
|
|
6439
|
-
|
|
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
|
-
|
|
6442
|
-
|
|
6443
|
-
|
|
6444
|
-
|
|
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
|
-
|
|
6452
|
-
self.
|
|
6453
|
-
|
|
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
|
-
|
|
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)
|