From 24beaeaea363cd8f0fbe0f6179f97fe2951e4af5 Mon Sep 17 00:00:00 2001 From: Shaun Agius Date: Thu, 26 Feb 2026 12:00:24 +0100 Subject: [PATCH 01/66] fix: premature torrent removals, HnR clear mode, tests, .cursor in gitignore --- .gitignore | 1 + config.example.toml | 3 + docs/configuration/seeding.md | 26 ++- docs/configuration/torrents.md | 2 +- qBitrr/arss.py | 264 +++++++++++++------------ qBitrr/config_version.py | 2 +- qBitrr/gen_config.py | 70 +++++++ qBitrr/main.py | 2 + qBitrr/qbit_category_manager.py | 47 ++++- tests/__init__.py | 1 + tests/unit/__init__.py | 1 + tests/unit/test_torrent_limit_check.py | 117 +++++++++++ webui/src/config/tooltips.ts | 4 + webui/src/pages/ConfigView.tsx | 12 ++ 14 files changed, 410 insertions(+), 142 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_torrent_limit_check.py diff --git a/.gitignore b/.gitignore index b12aa26cf..0a897d720 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ .run/ .vscode/ .vs/ +.cursor # Testing /qBitrr/testing/test.py diff --git a/config.example.toml b/config.example.toml index 6d19ca028..8c630b94c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -158,6 +158,9 @@ RemoveTorrent = -1 # Enable Hit and Run protection for managed category torrents HitAndRunMode = false +# HnR clear mode: and = require both ratio and time; or = either; disabled = no HnR +HitAndRunClearMode = "disabled" + # Minimum seed ratio before removal allowed (HnR protection) MinSeedRatio = 1.0 diff --git a/docs/configuration/seeding.md b/docs/configuration/seeding.md index f517ebe5e..f2559564c 100644 --- a/docs/configuration/seeding.md +++ b/docs/configuration/seeding.md @@ -611,6 +611,29 @@ HitAndRunMode = true Enable Hit and Run (HnR) protection for this tracker. When `true`, torrents with this tracker will **not** be removed until HnR obligations are met, regardless of `RemoveTorrent` settings. See [Hit and Run Protection](#hit-and-run-protection) for details on how obligations are calculated. +**Backwards compatibility:** If `HitAndRunClearMode` is not set, `HitAndRunMode = true` is treated as clear mode `"and"`. + +--- + +#### HitAndRunClearMode + +```toml +HitAndRunClearMode = "and" # or "or", "disabled" +``` + +**Type:** String: `"and"` | `"or"` | `"disabled"` +**Default:** `"disabled"` (no HnR). Legacy: `HitAndRunMode = true` migrates to `"and"`. + +Controls **how** ratio and time clear HnR for full downloads (when both `MinSeedRatio` and `MinSeedingTimeDays` are set): + +| Value | Meaning | +|-------|---------| +| `"and"` | HnR clears only when **both** minimum ratio **and** minimum time are met | +| `"or"` | HnR clears when **either** minimum ratio **or** minimum time is met | +| `"disabled"` | No HnR protection; torrents can be removed per `RemoveTorrent` only | + +When only one of ratio or time is set (e.g. `MinSeedingTimeDays = 0`), that single condition is used regardless of clear mode. Partial downloads always use `HitAndRunPartialSeedRatio` only. + --- #### MinSeedRatio @@ -622,7 +645,7 @@ MinSeedRatio = 1.0 **Type:** Float **Default:** `1.0` -Minimum upload ratio required to clear HnR. A value of `1.0` means you must upload at least as much as you downloaded. For full downloads, **either** ratio OR time clears the obligation (whichever comes first). +Minimum upload ratio required to clear HnR. A value of `1.0` means you must upload at least as much as you downloaded. For full downloads, whether **both** ratio and time are required or **either** clears the obligation is controlled by [`HitAndRunClearMode`](#hitandrunclearmode) (`"and"` vs `"or"`). --- @@ -971,6 +994,7 @@ tail -f ~/logs/Radarr-Movies.log | grep -i "remov\|seed\|ratio" MaxUploadRatio = 2.0 # Must not be -1 MaxSeedingTime = 604800 # Must not be -1 ``` + If `RemoveTorrent` is 1–4 but **neither** `MaxUploadRatio` nor `MaxSeedingTime` is set (both -1 or unset), qBitrr will **not** remove torrents for seeding limits and will log a single warning per run. Set at least one limit to enable ratio/time-based removal. 3. **Check import mode:** ```toml diff --git a/docs/configuration/torrents.md b/docs/configuration/torrents.md index c10049152..35fe3c811 100644 --- a/docs/configuration/torrents.md +++ b/docs/configuration/torrents.md @@ -333,7 +333,7 @@ DoNotRemoveSlow = true - Slow/stalled torrents are removed based on `StalledDelay` -**Recommendation:** `true` to avoid removing torrents on slow but working trackers +**Recommendation:** `true` to avoid removing torrents on slow but working trackers. When `false`, torrents that exceed `MaximumETA` are removed **after** they complete downloading (see [MaximumETA](#maximumeta)). --- diff --git a/qBitrr/arss.py b/qBitrr/arss.py index fa9d701e2..93492b07f 100755 --- a/qBitrr/arss.py +++ b/qBitrr/arss.py @@ -575,6 +575,7 @@ def __init__( self.missing_files_post_delete = set() self.downloads_with_bad_error_message_blocklist = set() self.needs_cleanup = False + self._warned_no_seeding_limits = False self.last_search_description: str | None = None self.last_search_timestamp: str | None = None @@ -1699,6 +1700,30 @@ def _process_failed(self) -> None: skip_blacklist = { i.upper() for i in self.skip_blacklist.union(self.missing_files_post_delete) } + if to_delete_all or self.remove_from_qbit or self.skip_blacklist: + n_delete = len(self.delete) + n_missing = len(self.missing_files_post_delete) + n_bad_msg = len(self.downloads_with_bad_error_message_blocklist) + n_remove = len(self.remove_from_qbit) + n_skip = len(self.skip_blacklist) + self.logger.info( + "Deletion summary: delete=%d, missing_files=%d, bad_error_blocklist=%d, " + "remove_from_qbit=%d, skip_blacklist=%d", + n_delete, + n_missing, + n_bad_msg, + n_remove, + n_skip, + ) + if to_delete_all and self.logger.isEnabledFor(10): # DEBUG + sample = list(to_delete_all)[:5] + names = [ + self.manager.qbit_manager.name_cache.get(h, h) for h in sample + ] + self.logger.debug( + "Deletion sample (first 5): %s", + list(zip(sample, names)), + ) if to_delete_all: self.needs_cleanup = True payload = self.process_entries(to_delete_all) @@ -4835,6 +4860,7 @@ def process_torrents(self): for instance, t in torrents_with_instances if hasattr(t, "category") ] + self._warned_no_seeding_limits = False self.category_torrent_count = len(torrents_with_instances) if not len(torrents_with_instances): raise DelayLoopException(length=LOOP_SLEEP_TIMER, type="no_downloads") @@ -5119,8 +5145,11 @@ def _enhanced_database_recovery(self): raise DatabaseRecoveryError("All automatic recovery methods failed") def _process_single_torrent_failed_cat(self, torrent: qbittorrentapi.TorrentDictionary): + self._mark_for_deletion(torrent, "manually failed") + + def _process_single_torrent_recheck_cat(self, torrent: qbittorrentapi.TorrentDictionary): self.logger.notice( - "Deleting manually failed torrent: " + "Re-checking manually set torrent: " "[Progress: %s%%][Added On: %s]" "[Availability: %s%%][Time Left: %s]" "[Last active: %s] " @@ -5134,25 +5163,40 @@ def _process_single_torrent_failed_cat(self, torrent: qbittorrentapi.TorrentDict torrent.name, torrent.hash, ) - self.delete.add(torrent.hash) + self.recheck.add(torrent.hash) - def _process_single_torrent_recheck_cat(self, torrent: qbittorrentapi.TorrentDictionary): - self.logger.notice( - "Re-checking manually set torrent: " - "[Progress: %s%%][Added On: %s]" - "[Availability: %s%%][Time Left: %s]" - "[Last active: %s] " - "| [%s] | %s (%s)", + def _mark_for_deletion( + self, + torrent: qbittorrentapi.TorrentDictionary, + reason: str, + ratio_limit=None, + seeding_time_limit=None, + ) -> None: + """Mark torrent for deletion and log reason with current stats and effective limits.""" + extra = "" + if ratio_limit is not None or seeding_time_limit is not None: + parts = [] + if ratio_limit is not None: + parts.append( + "ratio_limit=%s" % (ratio_limit if ratio_limit > 0 else "unset") + ) + if seeding_time_limit is not None: + parts.append( + "seeding_time_limit=%s" + % (seeding_time_limit if seeding_time_limit > 0 else "unset") + ) + extra = " [%s]" % ", ".join(parts) + self.logger.info( + "Marking for deletion (%s): [Progress: %s%%][Ratio: %s][Seeding time: %s] | %s (%s)%s", + reason, round(torrent.progress * 100, 2), - datetime.fromtimestamp(torrent.added_on), - round(torrent.availability * 100, 2), - timedelta(seconds=torrent.eta), - datetime.fromtimestamp(torrent.last_activity), - torrent.state_enum, + torrent.ratio, + timedelta(seconds=torrent.seeding_time), torrent.name, torrent.hash, + extra, ) - self.recheck.add(torrent.hash) + self.delete.add(torrent.hash) def _process_single_torrent_ignored(self, torrent: qbittorrentapi.TorrentDictionary): # Do not touch torrents that are currently being ignored. @@ -5238,24 +5282,8 @@ def _process_single_torrent_stalled_torrent( torrent.added_on < time.time() - self.ignore_torrents_younger_than and torrent.last_activity < (time.time() - self.ignore_torrents_younger_than) ): - self.logger.info( - "Deleting Stale torrent: %s | " - "[Progress: %s%%][Added On: %s]" - "[Availability: %s%%][Time Left: %s]" - "[Last active: %s] " - "| [%s] | %s (%s)", - extra, - round(torrent.progress * 100, 2), - datetime.fromtimestamp(torrent.added_on), - round(torrent.availability * 100, 2), - timedelta(seconds=torrent.eta), - datetime.fromtimestamp(torrent.last_activity), - torrent.state_enum, - torrent.name, - torrent.hash, - ) if self._hnr_allows_delete(torrent, "stale torrent deletion"): - self.delete.add(torrent.hash) + self._mark_for_deletion(torrent, "stale torrent deletion") else: self.logger.trace( "Ignoring Stale torrent: " @@ -5287,23 +5315,8 @@ def _process_single_torrent_percentage_threshold( # However if its completely dead and no activity is observed, then lets # remove it and requeue a new torrent. if maximum_eta > 0 and torrent.last_activity < (time.time() - maximum_eta): - self.logger.info( - "Deleting Stale torrent: Last activity is older than Maximum ETA " - "[Progress: %s%%][Added On: %s]" - "[Availability: %s%%][Time Left: %s]" - "[Last active: %s] " - "| [%s] | %s (%s)", - round(torrent.progress * 100, 2), - datetime.fromtimestamp(torrent.added_on), - round(torrent.availability * 100, 2), - timedelta(seconds=torrent.eta), - datetime.fromtimestamp(torrent.last_activity), - torrent.state_enum, - torrent.name, - torrent.hash, - ) if self._hnr_allows_delete(torrent, "stale high-percentage deletion"): - self.delete.add(torrent.hash) + self._mark_for_deletion(torrent, "stale high-percentage deletion") else: self.logger.trace( "Skipping torrent: Reached Maximum completed " @@ -5523,47 +5536,29 @@ def _process_single_torrent_delete_slow(self, torrent: qbittorrentapi.TorrentDic torrent.hash, ) if self._hnr_allows_delete(torrent, "slow torrent deletion"): - self.delete.add(torrent.hash) + self._mark_for_deletion(torrent, "slow torrent deletion") def _process_single_torrent_delete_cfunmet( self, torrent: qbittorrentapi.TorrentDictionary, instance_name: str = "" ): - self.logger.info( - "Removing CF unmet torrent: " - "[Progress: %s%%][Added On: %s]" - "[Ratio: %s%%][Seeding time: %s]" - "[Last active: %s] " - "| [%s] | %s (%s)", - round(torrent.progress * 100, 2), - datetime.fromtimestamp(torrent.added_on), - torrent.ratio, - timedelta(seconds=torrent.seeding_time), - datetime.fromtimestamp(torrent.last_activity), - torrent.state_enum, - torrent.name, - torrent.hash, - ) if self._hnr_allows_delete(torrent, "CF unmet deletion"): - self.delete.add(torrent.hash) + self._mark_for_deletion(torrent, "CF unmet deletion") def _process_single_torrent_delete_ratio_seed(self, torrent: qbittorrentapi.TorrentDictionary): - self.logger.info( - "Removing completed torrent: " - "[Progress: %s%%][Added On: %s]" - "[Ratio: %s%%][Seeding time: %s]" - "[Last active: %s] " - "| [%s] | %s (%s)", - round(torrent.progress * 100, 2), - datetime.fromtimestamp(torrent.added_on), - torrent.ratio, - timedelta(seconds=torrent.seeding_time), - datetime.fromtimestamp(torrent.last_activity), - torrent.state_enum, - torrent.name, - torrent.hash, - ) + data_settings, data_torrent = self._get_torrent_limit_meta(torrent) + r_dat = data_settings.get("ratio_limit", -5) + r_tor = data_torrent.get("ratio_limit", -5) + t_dat = data_settings.get("seeding_time_limit", -5) + t_tor = data_torrent.get("seeding_time_limit", -5) + ratio_limit = max(r_dat, r_tor) if (r_dat > 0 or r_tor > 0) else -5 + seeding_time_limit = max(t_dat, t_tor) if (t_dat > 0 or t_tor > 0) else -5 if self._hnr_allows_delete(torrent, "ratio/seed limit deletion"): - self.delete.add(torrent.hash) + self._mark_for_deletion( + torrent, + "ratio/seed limit deletion", + ratio_limit=ratio_limit, + seeding_time_limit=seeding_time_limit, + ) def _process_single_torrent_process_files( self, torrent: qbittorrentapi.TorrentDictionary, special_case: bool = False @@ -5629,23 +5624,8 @@ def _process_single_torrent_process_files( # If all files in the torrent are marked for exclusion then delete the # torrent. if total == 0: - self.logger.info( - "Deleting All files ignored: " - "[Progress: %s%%][Added On: %s]" - "[Availability: %s%%][Time Left: %s]" - "[Last active: %s] " - "| [%s] | %s (%s)", - round(torrent.progress * 100, 2), - datetime.fromtimestamp(torrent.added_on), - round(torrent.availability * 100, 2), - timedelta(seconds=torrent.eta), - datetime.fromtimestamp(torrent.last_activity), - torrent.state_enum, - torrent.name, - torrent.hash, - ) if self._hnr_allows_delete(torrent, "all-files-excluded deletion"): - self.delete.add(torrent.hash) + self._mark_for_deletion(torrent, "all-files-excluded deletion") # Mark all bad files and folder for exclusion. elif _remove_files and torrent.hash not in self.change_priority: self.change_priority[torrent.hash] = list(_remove_files) @@ -5763,6 +5743,13 @@ def _get_most_important_tracker_and_tags( max_item = max(new_list, key=self.__return_max) if new_list else {} return max_item, set(itertools.chain.from_iterable(_list_of_tags)) + def _resolve_hnr_clear_mode(self, tracker_or_config: dict) -> str: + """Resolve HnR clear mode: 'and' | 'or' | 'disabled'. Backwards compat: HitAndRunMode true -> 'and'.""" + raw = tracker_or_config.get("HitAndRunClearMode") + if isinstance(raw, str) and raw.strip().lower() in ("and", "or", "disabled"): + return raw.strip().lower() + return "and" if tracker_or_config.get("HitAndRunMode", False) else "disabled" + def _get_torrent_limit_meta(self, torrent: qbittorrentapi.TorrentDictionary): _, monitored_trackers = self._get_torrent_important_trackers(torrent) most_important_tracker, _unique_tags = self._get_most_important_tracker_and_tags( @@ -5813,6 +5800,7 @@ def _get_torrent_limit_meta(self, torrent: qbittorrentapi.TorrentDictionary): "super_seeding": most_important_tracker.get("SuperSeedMode", torrent.super_seeding), "max_eta": most_important_tracker.get("MaximumETA", self.maximum_eta), "hnr_mode": most_important_tracker.get("HitAndRunMode", False), + "hnr_clear_mode": self._resolve_hnr_clear_mode(most_important_tracker), "hnr_min_seed_ratio": most_important_tracker.get("MinSeedRatio", 1.0), "hnr_min_seeding_time_days": most_important_tracker.get("MinSeedingTimeDays", 0), "hnr_min_download_percent": most_important_tracker.get( @@ -6365,7 +6353,9 @@ def _hnr_allows_delete(self, torrent: qbittorrentapi.TorrentDictionary, reason: Fetches tracker metadata and checks HnR. Returns True if deletion is allowed, False if HnR protection blocks it. """ - if not any(t.get("HitAndRunMode", False) for t in self.monitored_trackers): + if not any( + self._resolve_hnr_clear_mode(t) != "disabled" for t in self.monitored_trackers + ): return True # Fast path: no HnR on any tracker # If the HnR-enabled tracker reports the torrent as unregistered/dead, @@ -6408,7 +6398,7 @@ def _hnr_tracker_is_dead(self, torrent: qbittorrentapi.TorrentDictionary) -> boo hnr_hosts = { _extract_tracker_host(t.get("URI") or "") for t in self.monitored_trackers - if t.get("HitAndRunMode", False) + if self._resolve_hnr_clear_mode(t) != "disabled" } - {""} if not hnr_hosts: return False @@ -6428,8 +6418,8 @@ def _hnr_safe_to_remove( self, torrent: qbittorrentapi.TorrentDictionary, tracker_meta: dict ) -> bool: """Returns True only if Hit and Run obligations are met.""" - hnr_enabled = tracker_meta.get("hnr_mode", False) - if not hnr_enabled: + clear_mode = (tracker_meta.get("hnr_clear_mode") or "disabled").strip().lower() + if clear_mode == "disabled": return True min_ratio = tracker_meta.get("hnr_min_seed_ratio", 1.0) @@ -6449,12 +6439,22 @@ def _hnr_safe_to_remove( ratio_met = torrent.ratio >= min_ratio if min_ratio > 0 else False time_met = effective_seeding_time >= min_time_secs if min_time_secs > 0 else False - if min_ratio > 0 and min_time_secs > 0: - return ratio_met or time_met # Either clears HnR - elif min_ratio > 0: - return ratio_met - elif min_time_secs > 0: - return time_met + if clear_mode == "and": + if min_ratio > 0 and min_time_secs > 0: + return ratio_met and time_met + if min_ratio > 0: + return ratio_met + if min_time_secs > 0: + return time_met + return True + if clear_mode == "or": + if min_ratio > 0 and min_time_secs > 0: + return ratio_met or time_met + if min_ratio > 0: + return ratio_met + if min_time_secs > 0: + return time_met + return True return True def torrent_limit_check( @@ -6463,30 +6463,36 @@ def torrent_limit_check( # -1 = Never remove (regardless of ratio/time limits) if self.seeding_mode_global_remove_torrent == -1: return False - # 4 = AND (remove when BOTH ratio AND time limits met) - elif ( - self.seeding_mode_global_remove_torrent == 4 - and torrent.ratio >= ratio_limit - and torrent.seeding_time >= seeding_time_limit - ): - return True - # 3 = OR (remove when EITHER ratio OR time limit met) - elif self.seeding_mode_global_remove_torrent == 3 and ( - torrent.ratio >= ratio_limit or torrent.seeding_time >= seeding_time_limit - ): - return True - # 2 = Time only (remove when seeding time limit met) - elif ( - self.seeding_mode_global_remove_torrent == 2 - and torrent.seeding_time >= seeding_time_limit - ): - return True - # 1 = Ratio only (remove when upload ratio limit met) - elif self.seeding_mode_global_remove_torrent == 1 and torrent.ratio >= ratio_limit: - return True - else: + + # Treat limits <= 0 as unset; only consider a limit "met" when it is set (>0) and satisfied + ratio_limit_valid = ratio_limit is not None and ratio_limit > 0 + time_limit_valid = ( + seeding_time_limit is not None and seeding_time_limit > 0 + ) + ratio_met = ratio_limit_valid and torrent.ratio >= ratio_limit + time_met = time_limit_valid and torrent.seeding_time >= seeding_time_limit + + mode = self.seeding_mode_global_remove_torrent + if mode in (1, 2, 3, 4) and not ratio_limit_valid and not time_limit_valid: + if not self._warned_no_seeding_limits: + self.logger.warning( + "RemoveTorrent=%s but neither MaxUploadRatio nor MaxSeedingTime is set; " + "skipping seeding-based removal until at least one limit is configured", + mode, + ) + self._warned_no_seeding_limits = True return False + if mode == 4: + return ratio_met and time_met + if mode == 3: + return ratio_met or time_met + if mode == 2: + return time_met + if mode == 1: + return ratio_met + return False + def refresh_download_queue(self): self.queue = self.get_queue() or [] self.queue_active_count = len(self.queue) diff --git a/qBitrr/config_version.py b/qBitrr/config_version.py index 73f983b45..9083eb7bd 100644 --- a/qBitrr/config_version.py +++ b/qBitrr/config_version.py @@ -16,7 +16,7 @@ from qBitrr.gen_config import MyConfig # Current expected config version - updated automatically by bump2version -EXPECTED_CONFIG_VERSION = "5.9.1" +EXPECTED_CONFIG_VERSION = "5.9.2" # Legacy integer version → semver mapping _LEGACY_VERSION_MAP = { diff --git a/qBitrr/gen_config.py b/qBitrr/gen_config.py index 74c03e57f..e1b13e7b3 100755 --- a/qBitrr/gen_config.py +++ b/qBitrr/gen_config.py @@ -349,6 +349,17 @@ def _add_qbit_section(config: TOMLDocument): "HitAndRunMode", False, ) + _gen_default_line( + category_seeding, + [ + "HnR clear mode: when ratio/time can remove HnR obligation.", + " and = require both MinSeedRatio AND MinSeedingTimeDays (when both set)", + " or = require either ratio OR time", + " disabled = no HnR protection. Legacy: HitAndRunMode true is migrated to and.", + ], + "HitAndRunClearMode", + "disabled", + ) _gen_default_line( category_seeding, "Minimum seed ratio before removal allowed (HnR protection)", @@ -1235,6 +1246,7 @@ def _migrate_qbit_category_settings(config: MyConfig) -> bool: seeding["MaxSeedingTime"] = -1 seeding["RemoveTorrent"] = -1 seeding["HitAndRunMode"] = False + seeding["HitAndRunClearMode"] = "disabled" seeding["MinSeedRatio"] = 1.0 seeding["MinSeedingTimeDays"] = 0 seeding["HitAndRunMinimumDownloadPercent"] = 10 @@ -1302,6 +1314,7 @@ def _migrate_hnr_settings(config: MyConfig) -> bool: arr_types = ["Radarr", "Sonarr", "Lidarr", "Animarr"] hnr_seeding_defaults = { "HitAndRunMode": False, + "HitAndRunClearMode": "disabled", "MinSeedRatio": 1.0, "MinSeedingTimeDays": 0, "HitAndRunMinimumDownloadPercent": 10, @@ -1438,6 +1451,57 @@ def _migrate_hnr_settings(config: MyConfig) -> bool: return changes_made +def _migrate_hnr_clear_mode(config: MyConfig) -> bool: + """ + Add HitAndRunClearMode and migrate HitAndRunMode true -> "and". + + Migration runs if ConfigVersion < "5.9.2". + Sets HitAndRunClearMode = "and" where HitAndRunMode is true and key missing; + otherwise sets HitAndRunClearMode = "disabled" when key missing. + Returns True if any change was made. + """ + from qBitrr.config_version import _parse_version, get_config_version + + current_version = _parse_version(get_config_version(config)) + if current_version >= _parse_version("5.9.2"): + return False + + changes_made = False + for key in list(config.config.keys()): + section = config.config[str(key)] + if not isinstance(section, dict): + continue + # CategorySeeding + if "CategorySeeding" in section: + cs = section["CategorySeeding"] + if isinstance(cs, dict) and "HitAndRunClearMode" not in cs: + cs["HitAndRunClearMode"] = ( + "and" if cs.get("HitAndRunMode", False) else "disabled" + ) + changes_made = True + # Trackers list + if "Trackers" in section and isinstance(section["Trackers"], list): + for tracker in section["Trackers"]: + if isinstance(tracker, dict) and "HitAndRunClearMode" not in tracker: + tracker["HitAndRunClearMode"] = ( + "and" if tracker.get("HitAndRunMode", False) else "disabled" + ) + changes_made = True + if "Torrent" in section and isinstance(section["Torrent"], dict): + tt = section["Torrent"] + if "Trackers" in tt and isinstance(tt["Trackers"], list): + for tracker in tt["Trackers"]: + if isinstance(tracker, dict) and "HitAndRunClearMode" not in tracker: + tracker["HitAndRunClearMode"] = ( + "and" if tracker.get("HitAndRunMode", False) else "disabled" + ) + changes_made = True + + if changes_made: + print("Migration 5.9.1→5.9.2: Added HitAndRunClearMode (and/or/disabled)") + return changes_made + + def _normalize_theme_value(value: Any) -> str: """ Normalize theme value to always be 'Light' or 'Dark' (case insensitive input). @@ -1610,6 +1674,7 @@ def ensure_value(config_section: str, key: str, default_value: Any) -> bool: # Validate HnR fields on CategorySeeding and Tracker sections hnr_category_defaults = { "HitAndRunMode": False, + "HitAndRunClearMode": "disabled", "MinSeedRatio": 1.0, "MinSeedingTimeDays": 0, "HitAndRunMinimumDownloadPercent": 10, @@ -1618,6 +1683,7 @@ def ensure_value(config_section: str, key: str, default_value: Any) -> bool: } hnr_tracker_defaults = { "HitAndRunMode": False, + "HitAndRunClearMode": "disabled", "MinSeedRatio": 1.0, "MinSeedingTimeDays": 0, "HitAndRunMinimumDownloadPercent": 10, @@ -1722,6 +1788,10 @@ def apply_config_migrations(config: MyConfig) -> None: if _migrate_hnr_settings(config): changes_made = True + # Add HitAndRunClearMode and migrate HitAndRunMode true -> "and" (< 5.9.2) + if _migrate_hnr_clear_mode(config): + changes_made = True + # Validate and fill config (this also ensures ConfigVersion field exists) if _validate_and_fill_config(config): changes_made = True diff --git a/qBitrr/main.py b/qBitrr/main.py index 27af352e5..db3f8d0c8 100644 --- a/qBitrr/main.py +++ b/qBitrr/main.py @@ -568,6 +568,7 @@ def _init_instance(self, section_name: str, instance_name: str) -> None: # Load HnR protection settings hnr_keys = { "HitAndRunMode": False, + "HitAndRunClearMode": "disabled", "MinSeedRatio": 1.0, "MinSeedingTimeDays": 0, "HitAndRunPartialSeedRatio": 1.0, @@ -699,6 +700,7 @@ def _reload_qbit_category_configs(self) -> None: ] hnr_keys = { "HitAndRunMode": False, + "HitAndRunClearMode": "disabled", "MinSeedRatio": 1.0, "MinSeedingTimeDays": 0, "HitAndRunPartialSeedRatio": 1.0, diff --git a/qBitrr/qbit_category_manager.py b/qBitrr/qbit_category_manager.py index 76bba774d..11ad116b8 100644 --- a/qBitrr/qbit_category_manager.py +++ b/qBitrr/qbit_category_manager.py @@ -151,7 +151,7 @@ def _process_single_torrent(self, torrent: TorrentDictionary, category: str): self._apply_tags(torrent, tags) # Merge tracker overrides into effective config (tracker wins over category # for seeding fields; skip non-seeding tracker metadata keys) - _skip = {"URI", "Priority", "Name", "AddTags", "HitAndRunMode", + _skip = {"URI", "Priority", "Name", "AddTags", "HitAndRunMode", "HitAndRunClearMode", "MinSeedRatio", "MinSeedingTimeDays", "HitAndRunMinimumDownloadPercent", "HitAndRunPartialSeedRatio", "TrackerUpdateBuffer"} effective_config = { @@ -309,7 +309,7 @@ def _should_remove_torrent(self, torrent: TorrentDictionary, config: dict) -> bo tracker_config = self._get_tracker_config(torrent) hnr_config = tracker_config if tracker_config else config - if hnr_config.get("HitAndRunMode", False) and self._hnr_tracker_is_dead( + if self._hnr_clear_mode_enabled(hnr_config) and self._hnr_tracker_is_dead( torrent, hnr_config ): self.logger.debug( @@ -329,6 +329,15 @@ def _should_remove_torrent(self, torrent: TorrentDictionary, config: dict) -> bo return should_remove + def _hnr_clear_mode_enabled(self, config: dict) -> bool: + """True if HnR protection is enabled (clear mode is not 'disabled').""" + mode = (config.get("HitAndRunClearMode") or "").strip().lower() + if mode in ("and", "or"): + return True + if mode == "disabled": + return False + return bool(config.get("HitAndRunMode", False)) # Legacy: HitAndRunMode true -> enabled + def _get_tracker_config(self, torrent: TorrentDictionary) -> dict | None: """Find the highest-priority matching tracker config for this torrent.""" if not self.trackers: @@ -408,8 +417,16 @@ def _hnr_safe_to_remove(self, torrent: TorrentDictionary, config: dict) -> bool: Returns: True if HnR obligations are met (safe to remove), False otherwise """ - if not config.get("HitAndRunMode", False): - return True + clear_mode = (config.get("HitAndRunClearMode") or "").strip().lower() + if clear_mode == "disabled": + if not config.get("HitAndRunMode", False): + return True + # Legacy: HitAndRunMode true with no HitAndRunClearMode -> treat as "and" + clear_mode = "and" + elif clear_mode not in ("and", "or"): + clear_mode = "and" if config.get("HitAndRunMode", False) else "disabled" + if clear_mode == "disabled": + return True min_ratio = config.get("MinSeedRatio", 1.0) min_time_secs = config.get("MinSeedingTimeDays", 0) * 86400 @@ -428,12 +445,22 @@ def _hnr_safe_to_remove(self, torrent: TorrentDictionary, config: dict) -> bool: ratio_met = torrent.ratio >= min_ratio if min_ratio > 0 else False time_met = effective_seeding_time >= min_time_secs if min_time_secs > 0 else False - if min_ratio > 0 and min_time_secs > 0: - return ratio_met or time_met # Either clears HnR - elif min_ratio > 0: - return ratio_met - elif min_time_secs > 0: - return time_met + if clear_mode == "and": + if min_ratio > 0 and min_time_secs > 0: + return ratio_met and time_met + if min_ratio > 0: + return ratio_met + if min_time_secs > 0: + return time_met + return True + if clear_mode == "or": + if min_ratio > 0 and min_time_secs > 0: + return ratio_met or time_met + if min_ratio > 0: + return ratio_met + if min_time_secs > 0: + return time_met + return True return True def _remove_torrent(self, torrent: TorrentDictionary, category: str): diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..66173aec4 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Test package diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 000000000..a0291f069 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +# Unit tests diff --git a/tests/unit/test_torrent_limit_check.py b/tests/unit/test_torrent_limit_check.py new file mode 100644 index 000000000..b9d317dd9 --- /dev/null +++ b/tests/unit/test_torrent_limit_check.py @@ -0,0 +1,117 @@ +"""Unit tests for torrent_limit_check (seeding removal modes with valid/unset limits).""" + +from __future__ import annotations + +import pytest +from unittest.mock import MagicMock + + +def _make_torrent(ratio: float = 0.0, seeding_time: int = 0): + t = MagicMock() + t.ratio = ratio + t.seeding_time = seeding_time + return t + + +def _make_arr(remove_torrent: int, warned: bool = False): + """Minimal Arr-like object with only attributes used by torrent_limit_check.""" + arr = MagicMock() + arr.seeding_mode_global_remove_torrent = remove_torrent + arr._warned_no_seeding_limits = warned + return arr + + +def torrent_limit_check_standalone( + arr, torrent, seeding_time_limit, ratio_limit +) -> bool: + """Replicate torrent_limit_check logic for testing (avoids importing full Arr).""" + if arr.seeding_mode_global_remove_torrent == -1: + return False + + ratio_limit_valid = ratio_limit is not None and ratio_limit > 0 + time_limit_valid = seeding_time_limit is not None and seeding_time_limit > 0 + ratio_met = ratio_limit_valid and torrent.ratio >= ratio_limit + time_met = time_limit_valid and torrent.seeding_time >= seeding_time_limit + + mode = arr.seeding_mode_global_remove_torrent + if mode in (1, 2, 3, 4) and not ratio_limit_valid and not time_limit_valid: + return False + + if mode == 4: + return ratio_met and time_met + if mode == 3: + return ratio_met or time_met + if mode == 2: + return time_met + if mode == 1: + return ratio_met + return False + + +class TestTorrentLimitCheck: + """Test removal modes with set/unset ratio and time limits.""" + + def test_remove_torrent_never_returns_false_regardless_of_limits(self): + arr = _make_arr(-1) + t = _make_torrent(ratio=10.0, seeding_time=100000) + assert torrent_limit_check_standalone(arr, t, 100, 2.0) is False + assert torrent_limit_check_standalone(arr, t, -5, -5) is False + + def test_mode_1_ratio_only_requires_set_ratio_limit(self): + arr = _make_arr(1) + t = _make_torrent(ratio=2.0, seeding_time=0) + # Limit set and met + assert torrent_limit_check_standalone(arr, t, -5, 2.0) is True + assert torrent_limit_check_standalone(arr, t, -5, 1.5) is True + # Limit set but not met + assert torrent_limit_check_standalone(arr, t, -5, 3.0) is False + # Time limit unset is ignored for mode 1 + assert torrent_limit_check_standalone(arr, t, -5, 2.0) is True + # Both unset -> no removal + assert torrent_limit_check_standalone(arr, t, -5, -5) is False + + def test_mode_2_time_only_requires_set_time_limit(self): + arr = _make_arr(2) + t = _make_torrent(ratio=0.0, seeding_time=1000) + # Time limit set and met + assert torrent_limit_check_standalone(arr, t, 500, -5) is True + assert torrent_limit_check_standalone(arr, t, 1000, -5) is True + # Time limit set but not met + assert torrent_limit_check_standalone(arr, t, 2000, -5) is False + # Ratio unset is ignored for mode 2 + assert torrent_limit_check_standalone(arr, t, 500, -5) is True + # Both unset -> no removal + assert torrent_limit_check_standalone(arr, t, -5, -5) is False + + def test_mode_3_or_requires_at_least_one_set_and_met(self): + arr = _make_arr(3) + t = _make_torrent(ratio=2.0, seeding_time=1000) + # Ratio set and met, time unset + assert torrent_limit_check_standalone(arr, t, -5, 2.0) is True + # Time set and met, ratio unset + assert torrent_limit_check_standalone(arr, t, 500, -5) is True + # Both set, one met + assert torrent_limit_check_standalone(arr, t, 500, 3.0) is True # time met + assert torrent_limit_check_standalone(arr, t, 2000, 1.5) is True # ratio met + # Both set and met + assert torrent_limit_check_standalone(arr, t, 500, 1.0) is True + # Both unset -> no removal + assert torrent_limit_check_standalone(arr, t, -5, -5) is False + # Both set but neither met + assert torrent_limit_check_standalone(arr, t, 2000, 5.0) is False + + def test_mode_4_and_requires_both_set_and_both_met(self): + arr = _make_arr(4) + t = _make_torrent(ratio=2.0, seeding_time=1000) + # Both set and met + assert torrent_limit_check_standalone(arr, t, 500, 1.5) is True + # Ratio met, time not met + assert torrent_limit_check_standalone(arr, t, 2000, 1.5) is False + # Time met, ratio not met + assert torrent_limit_check_standalone(arr, t, 500, 5.0) is False + # Ratio unset -> cannot remove (ratio_met is False) + assert torrent_limit_check_standalone(arr, t, 500, -5) is False + # Time unset -> cannot remove (time_met is False) + assert torrent_limit_check_standalone(arr, t, -5, 1.5) is False + # Both unset -> no removal + assert torrent_limit_check_standalone(arr, t, -5, -5) is False diff --git a/webui/src/config/tooltips.ts b/webui/src/config/tooltips.ts index 204aa8e52..90f71fe9d 100644 --- a/webui/src/config/tooltips.ts +++ b/webui/src/config/tooltips.ts @@ -165,6 +165,8 @@ export const FIELD_TOOLTIPS: Record = { "Removal policy: -1 do not remove, 1 remove on ratio, 2 remove on time, 3 remove on ratio or time, 4 remove on ratio and time.", "CategorySeeding.HitAndRunMode": "Enable Hit and Run protection for managed category torrents.", + "CategorySeeding.HitAndRunClearMode": + "When HnR is enabled: and = require both ratio and time to clear; or = either clears; disabled = no HnR.", "CategorySeeding.MinSeedRatio": "Minimum seed ratio before removal allowed for managed categories (HnR protection).", "CategorySeeding.MinSeedingTimeDays": @@ -177,6 +179,8 @@ export const FIELD_TOOLTIPS: Record = { "Extra seconds buffer for tracker stats lag in managed categories.", HitAndRunMode: "Enable Hit and Run protection for this tracker. Prevents torrent removal until seeding obligations are met.", + HitAndRunClearMode: + "When HnR is enabled: and = require both ratio and time; or = either clears; disabled = no HnR.", MinSeedRatio: "Minimum seed ratio before HnR obligation is cleared (e.g. 1.0 for 1:1 ratio).", MinSeedingTimeDays: diff --git a/webui/src/pages/ConfigView.tsx b/webui/src/pages/ConfigView.tsx index add4e5332..e02794e29 100644 --- a/webui/src/pages/ConfigView.tsx +++ b/webui/src/pages/ConfigView.tsx @@ -457,6 +457,12 @@ const QBIT_FIELDS: FieldDefinition[] = [ path: ["CategorySeeding", "HitAndRunMode"], type: "checkbox", }, + { + label: "HnR Clear Mode", + path: ["CategorySeeding", "HitAndRunClearMode"], + type: "select", + options: ["and", "or", "disabled"], + }, { label: "Min Seed Ratio", path: ["CategorySeeding", "MinSeedRatio"], @@ -1078,6 +1084,12 @@ const ARR_TRACKER_FIELDS: FieldDefinition[] = [ path: ["HitAndRunMode"], type: "checkbox", }, + { + label: "HnR Clear Mode", + path: ["HitAndRunClearMode"], + type: "select", + options: ["and", "or", "disabled"], + }, { label: "Min Seed Ratio", path: ["MinSeedRatio"], From 087a2e10502fb1b293029bdebf092636cb241bca Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:04:18 +0000 Subject: [PATCH 02/66] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- qBitrr/arss.py | 45 +- qBitrr/config.py | 2 + qBitrr/gen_config.py | 4 +- qBitrr/main.py | 4 +- qBitrr/qbit_category_manager.py | 20 +- tests/unit/test_torrent_limit_check.py | 8 +- webui/package-lock.json | 4456 ++++++++++++------------ 7 files changed, 2269 insertions(+), 2270 deletions(-) diff --git a/qBitrr/arss.py b/qBitrr/arss.py index 93492b07f..d826eb66c 100755 --- a/qBitrr/arss.py +++ b/qBitrr/arss.py @@ -1717,9 +1717,7 @@ def _process_failed(self) -> None: ) if to_delete_all and self.logger.isEnabledFor(10): # DEBUG sample = list(to_delete_all)[:5] - names = [ - self.manager.qbit_manager.name_cache.get(h, h) for h in sample - ] + names = [self.manager.qbit_manager.name_cache.get(h, h) for h in sample] self.logger.debug( "Deletion sample (first 5): %s", list(zip(sample, names)), @@ -5177,9 +5175,7 @@ def _mark_for_deletion( if ratio_limit is not None or seeding_time_limit is not None: parts = [] if ratio_limit is not None: - parts.append( - "ratio_limit=%s" % (ratio_limit if ratio_limit > 0 else "unset") - ) + parts.append("ratio_limit=%s" % (ratio_limit if ratio_limit > 0 else "unset")) if seeding_time_limit is not None: parts.append( "seeding_time_limit=%s" @@ -5860,7 +5856,11 @@ def _should_leave_alone( remove_torrent = False hnr_override = False - if is_downloading and remove_torrent and not self._hnr_safe_to_remove(torrent, data_settings): + if ( + is_downloading + and remove_torrent + and not self._hnr_safe_to_remove(torrent, data_settings) + ): self.logger.debug( "HnR protection: keeping downloading torrent [%s] (ratio=%.2f, seeding=%s)", torrent.name, @@ -5873,7 +5873,9 @@ def _should_leave_alone( if hnr_override: return_value = True else: - return_value = not (is_uploading and self.torrent_limit_check(torrent, seeding_time_limit, ratio_limit)) + return_value = not ( + is_uploading and self.torrent_limit_check(torrent, seeding_time_limit, ratio_limit) + ) if data_settings.get("super_seeding", False) or data_torrent.get("super_seeding", False): return_value = True if self.in_tags(torrent, "qBitrr-free_space_paused", instance_name): @@ -5943,22 +5945,18 @@ def _process_single_torrent_trackers( ) if monitored_trackers and most_important_tracker: if ( - (r := most_important_tracker.get( + r := most_important_tracker.get( "DownloadRateLimit", self.seeding_mode_global_download_limit - )) - != 0 - and torrent.dl_limit != r - ): + ) + ) != 0 and torrent.dl_limit != r: torrent.set_download_limit(limit=r) elif r < 0: torrent.set_upload_limit(limit=-1) if ( - (r := most_important_tracker.get( + r := most_important_tracker.get( "UploadRateLimit", self.seeding_mode_global_upload_limit - )) - != 0 - and torrent.up_limit != r - ): + ) + ) != 0 and torrent.up_limit != r: torrent.set_upload_limit(limit=r) elif r < 0: torrent.set_upload_limit(limit=-1) @@ -6162,7 +6160,8 @@ def _process_single_torrent( self._process_single_torrent_process_files(torrent, True) elif torrent.hash in self.timed_ignore_cache: if ( - torrent.state_enum in (TorrentStates.STOPPED_DOWNLOAD, TorrentStates.STOPPED_UPLOAD) + torrent.state_enum + in (TorrentStates.STOPPED_DOWNLOAD, TorrentStates.STOPPED_UPLOAD) and not self.in_tags(torrent, "qBitrr-free_space_paused", instance_name) and not self.in_tags(torrent, "qBitrr-ignored", instance_name) ): @@ -6353,9 +6352,7 @@ def _hnr_allows_delete(self, torrent: qbittorrentapi.TorrentDictionary, reason: Fetches tracker metadata and checks HnR. Returns True if deletion is allowed, False if HnR protection blocks it. """ - if not any( - self._resolve_hnr_clear_mode(t) != "disabled" for t in self.monitored_trackers - ): + if not any(self._resolve_hnr_clear_mode(t) != "disabled" for t in self.monitored_trackers): return True # Fast path: no HnR on any tracker # If the HnR-enabled tracker reports the torrent as unregistered/dead, @@ -6466,9 +6463,7 @@ def torrent_limit_check( # Treat limits <= 0 as unset; only consider a limit "met" when it is set (>0) and satisfied ratio_limit_valid = ratio_limit is not None and ratio_limit > 0 - time_limit_valid = ( - seeding_time_limit is not None and seeding_time_limit > 0 - ) + time_limit_valid = seeding_time_limit is not None and seeding_time_limit > 0 ratio_met = ratio_limit_valid and torrent.ratio >= ratio_limit time_met = time_limit_valid and torrent.seeding_time >= seeding_time_limit diff --git a/qBitrr/config.py b/qBitrr/config.py index b23aecf47..9bc9362e0 100644 --- a/qBitrr/config.py +++ b/qBitrr/config.py @@ -153,6 +153,8 @@ def process_flags() -> argparse.Namespace | bool: IGNORE_TORRENTS_YOUNGER_THAN = ENVIRO_CONFIG.settings.ignore_torrents_younger_than or CONFIG.get( "Settings.IgnoreTorrentsYoungerThan", fallback=180 ) + + def _has_any_qbit_section() -> bool: return any(s == "qBit" or s.startswith("qBit-") for s in CONFIG.sections()) diff --git a/qBitrr/gen_config.py b/qBitrr/gen_config.py index e1b13e7b3..48d45c2fd 100755 --- a/qBitrr/gen_config.py +++ b/qBitrr/gen_config.py @@ -1475,9 +1475,7 @@ def _migrate_hnr_clear_mode(config: MyConfig) -> bool: if "CategorySeeding" in section: cs = section["CategorySeeding"] if isinstance(cs, dict) and "HitAndRunClearMode" not in cs: - cs["HitAndRunClearMode"] = ( - "and" if cs.get("HitAndRunMode", False) else "disabled" - ) + cs["HitAndRunClearMode"] = "and" if cs.get("HitAndRunMode", False) else "disabled" changes_made = True # Trackers list if "Trackers" in section and isinstance(section["Trackers"], list): diff --git a/qBitrr/main.py b/qBitrr/main.py index db3f8d0c8..85a26fc21 100644 --- a/qBitrr/main.py +++ b/qBitrr/main.py @@ -469,9 +469,7 @@ def _initialize_qbit_instances(self) -> None: self._init_instance(section, section) self.logger.info("Initialized qBit instance: %s", section) except Exception as e: - self.logger.error( - "Failed to initialize qBit instance '%s': %s", section, e - ) + self.logger.error("Failed to initialize qBit instance '%s': %s", section, e) self.instance_health[section] = False # Set current_qbit_version from the first initialized instance (for legacy compatibility) diff --git a/qBitrr/qbit_category_manager.py b/qBitrr/qbit_category_manager.py index 11ad116b8..bc7a71fcd 100644 --- a/qBitrr/qbit_category_manager.py +++ b/qBitrr/qbit_category_manager.py @@ -151,9 +151,19 @@ def _process_single_torrent(self, torrent: TorrentDictionary, category: str): self._apply_tags(torrent, tags) # Merge tracker overrides into effective config (tracker wins over category # for seeding fields; skip non-seeding tracker metadata keys) - _skip = {"URI", "Priority", "Name", "AddTags", "HitAndRunMode", "HitAndRunClearMode", - "MinSeedRatio", "MinSeedingTimeDays", "HitAndRunMinimumDownloadPercent", - "HitAndRunPartialSeedRatio", "TrackerUpdateBuffer"} + _skip = { + "URI", + "Priority", + "Name", + "AddTags", + "HitAndRunMode", + "HitAndRunClearMode", + "MinSeedRatio", + "MinSeedingTimeDays", + "HitAndRunMinimumDownloadPercent", + "HitAndRunPartialSeedRatio", + "TrackerUpdateBuffer", + } effective_config = { **config, **{k: v for k, v in tracker_config.items() if k not in _skip}, @@ -372,9 +382,7 @@ def _get_tracker_config(self, torrent: TorrentDictionary) -> dict | None: cfg_host = _extract_tracker_host(uri) # Use apex/suffix matching: cfg_host "example.com" matches both # "example.com" (exact) and "sub.example.com" (subdomain) - host_match = any( - h == cfg_host or h.endswith("." + cfg_host) for h in torrent_hosts - ) + host_match = any(h == cfg_host or h.endswith("." + cfg_host) for h in torrent_hosts) if cfg_host and host_match and priority > best_priority: best = tracker_cfg best_priority = priority diff --git a/tests/unit/test_torrent_limit_check.py b/tests/unit/test_torrent_limit_check.py index b9d317dd9..29536da5e 100644 --- a/tests/unit/test_torrent_limit_check.py +++ b/tests/unit/test_torrent_limit_check.py @@ -2,10 +2,10 @@ from __future__ import annotations -import pytest from unittest.mock import MagicMock + def _make_torrent(ratio: float = 0.0, seeding_time: int = 0): t = MagicMock() t.ratio = ratio @@ -21,9 +21,7 @@ def _make_arr(remove_torrent: int, warned: bool = False): return arr -def torrent_limit_check_standalone( - arr, torrent, seeding_time_limit, ratio_limit -) -> bool: +def torrent_limit_check_standalone(arr, torrent, seeding_time_limit, ratio_limit) -> bool: """Replicate torrent_limit_check logic for testing (avoids importing full Arr).""" if arr.seeding_mode_global_remove_torrent == -1: return False @@ -91,7 +89,7 @@ def test_mode_3_or_requires_at_least_one_set_and_met(self): # Time set and met, ratio unset assert torrent_limit_check_standalone(arr, t, 500, -5) is True # Both set, one met - assert torrent_limit_check_standalone(arr, t, 500, 3.0) is True # time met + assert torrent_limit_check_standalone(arr, t, 500, 3.0) is True # time met assert torrent_limit_check_standalone(arr, t, 2000, 1.5) is True # ratio met # Both set and met assert torrent_limit_check_standalone(arr, t, 500, 1.0) is True diff --git a/webui/package-lock.json b/webui/package-lock.json index 81efe6b94..277da1e41 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -1,12 +1,8 @@ { - "name": "webui", - "version": "0.0.0", "lockfileVersion": 3, - "requires": true, + "name": "webui", "packages": { "": { - "name": "webui", - "version": "0.0.0", "dependencies": { "@mantine/core": "^8.3.15", "@mantine/dates": "^8.3.15", @@ -46,26 +42,24 @@ "engines": { "node": ">=20.0.0", "npm": ">=9.0.0" - } + }, + "name": "webui", + "version": "0.0.0" }, "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", "dev": true, - "license": "MIT", "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "version": "5.2.0" }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", @@ -73,24 +67,23 @@ }, "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "version": "7.29.0" }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, - "license": "MIT", "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "version": "7.29.0" }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "dev": true, - "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -108,19 +101,20 @@ "json5": "^2.2.3", "semver": "^6.3.1" }, + "dev": true, "engines": { "node": ">=6.9.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/babel" - } + }, + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "version": "7.29.0" }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", @@ -130,14 +124,13 @@ }, "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "version": "7.29.1" }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", - "dev": true, - "license": "MIT", "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", @@ -145,163 +138,164 @@ "lru-cache": "^5.1.1", "semver": "^6.3.1" }, + "dev": true, "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "version": "7.28.6" }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "license": "MIT", "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "version": "7.28.0" }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "license": "MIT", "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "version": "7.28.6" }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", - "dev": true, - "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, + "dev": true, "engines": { "node": ">=6.9.0" }, + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "license": "MIT", "peerDependencies": { "@babel/core": "^7.0.0" - } + }, + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "version": "7.28.6" }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "dev": true, - "license": "MIT", "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "version": "7.28.6" }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "license": "MIT", "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "version": "7.27.1" }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "license": "MIT", "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "version": "7.28.5" }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, - "license": "MIT", "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "version": "7.27.1" }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", - "dev": true, - "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" }, + "dev": true, "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "version": "7.28.6" }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, "bin": { "parser": "bin/babel-parser.js" }, + "dependencies": { + "@babel/types": "^7.29.0" + }, "engines": { "node": ">=6.0.0" - } + }, + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "version": "7.29.0" }, "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, + "dev": true, "engines": { "node": ">=6.9.0" }, + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "license": "MIT", "peerDependencies": { "@babel/core": "^7.0.0-0" - } + }, + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "version": "7.27.1" }, "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, + "dev": true, "engines": { "node": ">=6.9.0" }, + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "license": "MIT", "peerDependencies": { "@babel/core": "^7.0.0-0" - } + }, + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "version": "7.27.1" }, "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", - "license": "MIT", "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "version": "7.28.6" }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "license": "MIT", "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", @@ -309,13 +303,13 @@ }, "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "version": "7.28.6" }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -327,26 +321,26 @@ }, "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "version": "7.29.0" }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "version": "7.29.0" }, "node_modules/@emotion/babel-plugin": { - "version": "11.13.5", - "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", - "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", - "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", @@ -359,44 +353,44 @@ "find-root": "^1.1.0", "source-map": "^0.5.7", "stylis": "4.2.0" - } + }, + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "version": "11.13.5" }, "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "version": "1.9.0" }, "node_modules/@emotion/cache": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", - "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", - "license": "MIT", "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "stylis": "4.2.0" - } + }, + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "version": "11.14.0" }, "node_modules/@emotion/hash": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "version": "0.9.2" }, "node_modules/@emotion/memoize": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "version": "0.9.0" }, "node_modules/@emotion/react": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", - "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -407,6 +401,8 @@ "@emotion/weak-memoize": "^0.4.0", "hoist-non-react-statics": "^3.3.1" }, + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", "peerDependencies": { "react": ">=16.8.0" }, @@ -414,591 +410,591 @@ "@types/react": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "version": "11.14.0" }, "node_modules/@emotion/serialize": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", - "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", - "license": "MIT", "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/unitless": "^0.10.0", "@emotion/utils": "^1.4.2", "csstype": "^3.0.2" - } + }, + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "version": "1.3.3" }, "node_modules/@emotion/sheet": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "version": "1.4.0" }, "node_modules/@emotion/unitless": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "version": "0.10.0" }, "node_modules/@emotion/use-insertion-effect-with-fallbacks": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", "license": "MIT", "peerDependencies": { "react": ">=16.8.0" - } + }, + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "version": "1.2.0" }, "node_modules/@emotion/utils": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "version": "1.4.2" }, "node_modules/@emotion/weak-memoize": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "version": "0.4.0" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "license": "MIT", "optional": true, "os": [ "aix" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "license": "MIT", "optional": true, "os": [ "android" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "license": "MIT", "optional": true, "os": [ "android" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ "x64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "license": "MIT", "optional": true, "os": [ "android" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "license": "MIT", "optional": true, "os": [ "darwin" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "license": "MIT", "optional": true, "os": [ "darwin" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "license": "MIT", "optional": true, "os": [ "freebsd" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "license": "MIT", "optional": true, "os": [ "freebsd" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", "cpu": [ "arm64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", "license": "MIT", "optional": true, "os": [ "netbsd" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "license": "MIT", "optional": true, "os": [ "netbsd" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", "cpu": [ "arm64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", "license": "MIT", "optional": true, "os": [ "openbsd" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "cpu": [ "x64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "license": "MIT", "optional": true, "os": [ "openbsd" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", "cpu": [ "arm64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", "license": "MIT", "optional": true, "os": [ "openharmony" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "license": "MIT", "optional": true, "os": [ "sunos" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "license": "MIT", "optional": true, "os": [ "win32" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "license": "MIT", "optional": true, "os": [ "win32" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "cpu": [ "x64" ], "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "license": "MIT", "optional": true, "os": [ "win32" ], - "engines": { - "node": ">=18" - } + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" }, + "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/eslint" }, + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "license": "MIT", "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } + }, + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "version": "4.9.1" }, "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/eslint" - } + }, + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "version": "3.4.3" }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, - "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } + }, + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "version": "4.12.2" }, "node_modules/@eslint/config-array": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz", - "integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==", - "dev": true, - "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^3.0.2", "debug": "^4.3.1", "minimatch": "^10.2.1" }, + "dev": true, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" - } + }, + "integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz", + "version": "0.23.2" }, "node_modules/@eslint/config-helpers": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz", - "integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==", - "dev": true, - "license": "Apache-2.0", "dependencies": { "@eslint/core": "^1.1.0" }, + "dev": true, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" - } + }, + "integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz", + "version": "0.5.2" }, "node_modules/@eslint/core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", - "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", - "dev": true, - "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, + "dev": true, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" - } + }, + "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", + "version": "1.1.0" }, "node_modules/@eslint/js": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", - "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, - "license": "MIT", "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" }, + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "license": "MIT", "peerDependencies": { "eslint": "^10.0.0" }, @@ -1006,188 +1002,186 @@ "eslint": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "version": "10.0.1" }, "node_modules/@eslint/object-schema": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz", - "integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" - } + }, + "integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz", + "version": "3.0.2" }, "node_modules/@eslint/plugin-kit": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz", - "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==", - "dev": true, - "license": "Apache-2.0", "dependencies": { "@eslint/core": "^1.1.0", "levn": "^0.4.1" }, + "dev": true, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" - } + }, + "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz", + "version": "0.6.0" }, "node_modules/@floating-ui/core": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", - "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", - "license": "MIT", "dependencies": { "@floating-ui/utils": "^0.2.10" - } + }, + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "version": "1.7.4" }, "node_modules/@floating-ui/dom": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", - "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", - "license": "MIT", "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" - } + }, + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "version": "1.7.5" }, "node_modules/@floating-ui/react": { - "version": "0.27.17", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.17.tgz", - "integrity": "sha512-LGVZKHwmWGg6MRHjLLgsfyaX2y2aCNgnD1zT/E6B+/h+vxg+nIJUqHPAlTzsHDyqdgEpJ1Np5kxWuFEErXzoGg==", - "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.1.7", "@floating-ui/utils": "^0.2.10", "tabbable": "^6.0.0" }, + "integrity": "sha512-LGVZKHwmWGg6MRHjLLgsfyaX2y2aCNgnD1zT/E6B+/h+vxg+nIJUqHPAlTzsHDyqdgEpJ1Np5kxWuFEErXzoGg==", + "license": "MIT", "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" - } + }, + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.17.tgz", + "version": "0.27.17" }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", - "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", - "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.7.5" }, + "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", + "license": "MIT", "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" - } + }, + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", + "version": "2.1.7" }, "node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "version": "0.2.10" }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">=18.18.0" - } + }, + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "version": "0.19.1" }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" }, + "dev": true, "engines": { "node": ">=18.18.0" - } + }, + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "version": "0.16.7" }, "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">=12.22" }, "funding": { "type": "github", "url": "https://github.com/sponsors/nzakas" - } + }, + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "version": "1.0.1" }, "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">=18.18" }, "funding": { "type": "github", "url": "https://github.com/sponsors/nzakas" - } + }, + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "version": "0.4.3" }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - } + }, + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "version": "0.3.13" }, "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" - } + }, + "dev": true, + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "version": "2.3.5" }, "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", "engines": { "node": ">=6.0.0" - } + }, + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "version": "3.1.2" }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "version": "1.5.5" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" - } + }, + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "version": "0.3.31" }, "node_modules/@mantine/core": { - "version": "8.3.15", - "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.15.tgz", - "integrity": "sha512-wBn/GogB4x7a2Uj7Ztt3amRaApjED+9XqfE4wyCLh88R7KV55k9vnTdCx+irI/GLOOu9tXNUGm3a4t5sTajwkQ==", - "license": "MIT", "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", @@ -1196,42 +1190,42 @@ "react-textarea-autosize": "8.5.9", "type-fest": "^4.41.0" }, + "integrity": "sha512-wBn/GogB4x7a2Uj7Ztt3amRaApjED+9XqfE4wyCLh88R7KV55k9vnTdCx+irI/GLOOu9tXNUGm3a4t5sTajwkQ==", + "license": "MIT", "peerDependencies": { "@mantine/hooks": "8.3.15", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" - } + }, + "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.15.tgz", + "version": "8.3.15" }, "node_modules/@mantine/dates": { - "version": "8.3.15", - "resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-8.3.15.tgz", - "integrity": "sha512-4WlGHCOAE4in88rQFNlPVl14e7WFWb+YBqxmx4rvAXLj9xLgUxYJO44fva1eIOwNPlTqwbx+GgsEr/HwlcmDMg==", - "license": "MIT", "dependencies": { "clsx": "^2.1.1" }, + "integrity": "sha512-4WlGHCOAE4in88rQFNlPVl14e7WFWb+YBqxmx4rvAXLj9xLgUxYJO44fva1eIOwNPlTqwbx+GgsEr/HwlcmDMg==", + "license": "MIT", "peerDependencies": { "@mantine/core": "8.3.15", "@mantine/hooks": "8.3.15", "dayjs": ">=1.0.0", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" - } + }, + "resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-8.3.15.tgz", + "version": "8.3.15" }, "node_modules/@mantine/hooks": { - "version": "8.3.15", - "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.15.tgz", "integrity": "sha512-AUSnpUlzttHzJht3CJ1YWi16iy6NWRwtyWO5RLGHHsmiW05DyG0qOPKF8+R5dLHuOCnl3XOu4roI2Y1ku9U04Q==", "license": "MIT", "peerDependencies": { "react": "^18.x || ^19.x" - } + }, + "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.15.tgz", + "version": "8.3.15" }, "node_modules/@melloware/react-logviewer": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/@melloware/react-logviewer/-/react-logviewer-6.3.5.tgz", - "integrity": "sha512-Tl9shKsJYvZBxUxCVu1ddKWd8YN7RAAMjOj9upE3RHl+CTR6Py6ArCqpjQ7nGQM2bgao8qYG0VMHubTCPRPxqQ==", - "license": "MPL-2.0", "dependencies": { "hotkeys-js": "4.0.0", "immutable": "5.1.4", @@ -1239,374 +1233,373 @@ "react-string-replace": "2.0.1", "virtua": "0.48.3" }, + "integrity": "sha512-Tl9shKsJYvZBxUxCVu1ddKWd8YN7RAAMjOj9upE3RHl+CTR6Py6ArCqpjQ7nGQM2bgao8qYG0VMHubTCPRPxqQ==", + "license": "MPL-2.0", "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" - } + }, + "resolved": "https://registry.npmjs.org/@melloware/react-logviewer/-/react-logviewer-6.3.5.tgz", + "version": "6.3.5" }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.3", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", - "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", "dev": true, - "license": "MIT" + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "version": "1.0.0-rc.3" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", "cpu": [ "arm" ], "dev": true, + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", "license": "MIT", "optional": true, "os": [ "android" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "version": "4.57.1" }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", "cpu": [ "arm64" ], "dev": true, + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", "license": "MIT", "optional": true, "os": [ "android" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "version": "4.57.1" }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", "cpu": [ "arm64" ], "dev": true, + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", "license": "MIT", "optional": true, "os": [ "darwin" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "version": "4.57.1" }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", "cpu": [ "x64" ], "dev": true, + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", "license": "MIT", "optional": true, "os": [ "darwin" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "version": "4.57.1" }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", "cpu": [ "arm64" ], "dev": true, + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", "license": "MIT", "optional": true, "os": [ "freebsd" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "version": "4.57.1" }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", "cpu": [ "x64" ], "dev": true, + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", "license": "MIT", "optional": true, "os": [ "freebsd" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "version": "4.57.1" }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", "cpu": [ "arm" ], "dev": true, + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "version": "4.57.1" }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", "cpu": [ "arm" ], "dev": true, + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "version": "4.57.1" }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", "cpu": [ "arm64" ], "dev": true, + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "version": "4.57.1" }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", "cpu": [ "arm64" ], "dev": true, + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "version": "4.57.1" }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", "cpu": [ "loong64" ], "dev": true, + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "version": "4.57.1" }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", "cpu": [ "loong64" ], "dev": true, + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "version": "4.57.1" }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", "cpu": [ "ppc64" ], "dev": true, + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "version": "4.57.1" }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", "cpu": [ "ppc64" ], "dev": true, + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "version": "4.57.1" }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", "cpu": [ "riscv64" ], "dev": true, + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "version": "4.57.1" }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", "cpu": [ "riscv64" ], "dev": true, + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "version": "4.57.1" }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", "cpu": [ "s390x" ], "dev": true, + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "version": "4.57.1" }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", "cpu": [ "x64" ], "dev": true, + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "version": "4.57.1" }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", "cpu": [ "x64" ], "dev": true, + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "version": "4.57.1" }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", "cpu": [ "x64" ], "dev": true, + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", "license": "MIT", "optional": true, "os": [ "openbsd" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "version": "4.57.1" }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", "cpu": [ "arm64" ], "dev": true, + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", "license": "MIT", "optional": true, "os": [ "openharmony" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "version": "4.57.1" }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", "cpu": [ "arm64" ], "dev": true, + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "version": "4.57.1" }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", "cpu": [ "ia32" ], "dev": true, + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "version": "4.57.1" }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", "cpu": [ "x64" ], "dev": true, + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "version": "4.57.1" }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", "cpu": [ "x64" ], "dev": true, + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "version": "4.57.1" }, "node_modules/@tailwindcss/node": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.0.tgz", - "integrity": "sha512-Yv+fn/o2OmL5fh/Ir62VXItdShnUxfpkMA4Y7jdeC8O81WPB8Kf6TT6GSHvnqgSwDzlB5iT7kDpeXxLsUS0T6Q==", - "dev": true, - "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", @@ -1615,17 +1608,20 @@ "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.0" - } + }, + "dev": true, + "integrity": "sha512-Yv+fn/o2OmL5fh/Ir62VXItdShnUxfpkMA4Y7jdeC8O81WPB8Kf6TT6GSHvnqgSwDzlB5iT7kDpeXxLsUS0T6Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.0.tgz", - "integrity": "sha512-AZqQzADaj742oqn2xjl5JbIOzZB/DGCYF/7bpvhA8KvjUj9HJkag6bBuwZvH1ps6dfgxNHyuJVlzSr2VpMgdTQ==", "dev": true, - "license": "MIT", "engines": { "node": ">= 20" }, + "integrity": "sha512-AZqQzADaj742oqn2xjl5JbIOzZB/DGCYF/7bpvhA8KvjUj9HJkag6bBuwZvH1ps6dfgxNHyuJVlzSr2VpMgdTQ==", + "license": "MIT", "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.0", "@tailwindcss/oxide-darwin-arm64": "4.2.0", @@ -1639,165 +1635,164 @@ "@tailwindcss/oxide-wasm32-wasi": "4.2.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.0", "@tailwindcss/oxide-win32-x64-msvc": "4.2.0" - } + }, + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.0.tgz", - "integrity": "sha512-F0QkHAVaW/JNBWl4CEKWdZ9PMb0khw5DCELAOnu+RtjAfx5Zgw+gqCHFvqg3AirU1IAd181fwOtJQ5I8Yx5wtw==", "cpu": [ "arm64" ], "dev": true, + "engines": { + "node": ">= 20" + }, + "integrity": "sha512-F0QkHAVaW/JNBWl4CEKWdZ9PMb0khw5DCELAOnu+RtjAfx5Zgw+gqCHFvqg3AirU1IAd181fwOtJQ5I8Yx5wtw==", "license": "MIT", "optional": true, "os": [ "android" ], - "engines": { - "node": ">= 20" - } + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.0.tgz", - "integrity": "sha512-I0QylkXsBsJMZ4nkUNSR04p6+UptjcwhcVo3Zu828ikiEqHjVmQL9RuQ6uT/cVIiKpvtVA25msu/eRV97JeNSA==", "cpu": [ "arm64" ], "dev": true, + "engines": { + "node": ">= 20" + }, + "integrity": "sha512-I0QylkXsBsJMZ4nkUNSR04p6+UptjcwhcVo3Zu828ikiEqHjVmQL9RuQ6uT/cVIiKpvtVA25msu/eRV97JeNSA==", "license": "MIT", "optional": true, "os": [ "darwin" ], - "engines": { - "node": ">= 20" - } + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.0.tgz", - "integrity": "sha512-6TmQIn4p09PBrmnkvbYQ0wbZhLtbaksCDx7Y7R3FYYx0yxNA7xg5KP7dowmQ3d2JVdabIHvs3Hx4K3d5uCf8xg==", "cpu": [ "x64" ], "dev": true, + "engines": { + "node": ">= 20" + }, + "integrity": "sha512-6TmQIn4p09PBrmnkvbYQ0wbZhLtbaksCDx7Y7R3FYYx0yxNA7xg5KP7dowmQ3d2JVdabIHvs3Hx4K3d5uCf8xg==", "license": "MIT", "optional": true, "os": [ "darwin" ], - "engines": { - "node": ">= 20" - } + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.0.tgz", - "integrity": "sha512-qBudxDvAa2QwGlq9y7VIzhTvp2mLJ6nD/G8/tI70DCDoneaUeLWBJaPcbfzqRIWraj+o969aDQKvKW9dvkUizw==", "cpu": [ "x64" ], "dev": true, + "engines": { + "node": ">= 20" + }, + "integrity": "sha512-qBudxDvAa2QwGlq9y7VIzhTvp2mLJ6nD/G8/tI70DCDoneaUeLWBJaPcbfzqRIWraj+o969aDQKvKW9dvkUizw==", "license": "MIT", "optional": true, "os": [ "freebsd" ], - "engines": { - "node": ">= 20" - } + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.0.tgz", - "integrity": "sha512-7XKkitpy5NIjFZNUQPeUyNJNJn1CJeV7rmMR+exHfTuOsg8rxIO9eNV5TSEnqRcaOK77zQpsyUkBWmPy8FgdSg==", "cpu": [ "arm" ], "dev": true, + "engines": { + "node": ">= 20" + }, + "integrity": "sha512-7XKkitpy5NIjFZNUQPeUyNJNJn1CJeV7rmMR+exHfTuOsg8rxIO9eNV5TSEnqRcaOK77zQpsyUkBWmPy8FgdSg==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">= 20" - } + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.0.tgz", - "integrity": "sha512-Mff5a5Q3WoQR01pGU1gr29hHM1N93xYrKkGXfPw/aRtK4bOc331Ho4Tgfsm5WDGvpevqMpdlkCojT3qlCQbCpA==", "cpu": [ "arm64" ], "dev": true, + "engines": { + "node": ">= 20" + }, + "integrity": "sha512-Mff5a5Q3WoQR01pGU1gr29hHM1N93xYrKkGXfPw/aRtK4bOc331Ho4Tgfsm5WDGvpevqMpdlkCojT3qlCQbCpA==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">= 20" - } + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.0.tgz", - "integrity": "sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==", "cpu": [ "arm64" ], "dev": true, + "engines": { + "node": ">= 20" + }, + "integrity": "sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">= 20" - } + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.0.tgz", - "integrity": "sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==", "cpu": [ "x64" ], "dev": true, + "engines": { + "node": ">= 20" + }, + "integrity": "sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">= 20" - } + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.0.tgz", - "integrity": "sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==", "cpu": [ "x64" ], "dev": true, + "engines": { + "node": ">= 20" + }, + "integrity": "sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==", "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">= 20" - } + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.0.tgz", - "integrity": "sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1809,9 +1804,6 @@ "cpu": [ "wasm32" ], - "dev": true, - "license": "MIT", - "optional": true, "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", @@ -1820,127 +1812,129 @@ "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, + "dev": true, "engines": { "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.8.1", - "dev": true, - "inBundle": true, + }, + "integrity": "sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==", "license": "MIT", "optional": true, + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.0.tgz", + "version": "4.2.0" + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.8.1", + }, "dev": true, "inBundle": true, "license": "MIT", "optional": true, + "version": "1.8.1" + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { "dependencies": { "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", + }, "dev": true, "inBundle": true, "license": "MIT", "optional": true, + "version": "1.8.1" + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "dependencies": { "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", + }, "dev": true, "inBundle": true, "license": "MIT", "optional": true, + "version": "1.1.0" + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, + "dev": true, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" - } + }, + "inBundle": true, + "license": "MIT", + "optional": true, + "version": "1.1.1" }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.1", + "dependencies": { + "tslib": "^2.4.0" + }, "dev": true, "inBundle": true, "license": "MIT", "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } + "version": "0.10.1" }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", "dev": true, "inBundle": true, "license": "0BSD", - "optional": true + "optional": true, + "version": "2.8.1" }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.0.tgz", - "integrity": "sha512-2UU/15y1sWDEDNJXxEIrfWKC2Yb4YgIW5Xz2fKFqGzFWfoMHWFlfa1EJlGO2Xzjkq/tvSarh9ZTjvbxqWvLLXA==", "cpu": [ "arm64" ], "dev": true, + "engines": { + "node": ">= 20" + }, + "integrity": "sha512-2UU/15y1sWDEDNJXxEIrfWKC2Yb4YgIW5Xz2fKFqGzFWfoMHWFlfa1EJlGO2Xzjkq/tvSarh9ZTjvbxqWvLLXA==", "license": "MIT", "optional": true, "os": [ "win32" ], - "engines": { - "node": ">= 20" - } + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.0.tgz", - "integrity": "sha512-CrFadmFoc+z76EV6LPG1jx6XceDsaCG3lFhyLNo/bV9ByPrE+FnBPckXQVP4XRkN76h3Fjt/a+5Er/oA/nCBvQ==", "cpu": [ "x64" ], "dev": true, + "engines": { + "node": ">= 20" + }, + "integrity": "sha512-CrFadmFoc+z76EV6LPG1jx6XceDsaCG3lFhyLNo/bV9ByPrE+FnBPckXQVP4XRkN76h3Fjt/a+5Er/oA/nCBvQ==", "license": "MIT", "optional": true, "os": [ "win32" ], - "engines": { - "node": ">= 20" - } + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tailwindcss/postcss": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.0.tgz", - "integrity": "sha512-u6YBacGpOm/ixPfKqfgrJEjMfrYmPD7gEFRoygS/hnQaRtV0VCBdpkx5Ouw9pnaLRwwlgGCuJw8xLpaR0hOrQg==", - "dev": true, - "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.0", "@tailwindcss/oxide": "4.2.0", "postcss": "^8.5.6", "tailwindcss": "4.2.0" - } + }, + "dev": true, + "integrity": "sha512-u6YBacGpOm/ixPfKqfgrJEjMfrYmPD7gEFRoygS/hnQaRtV0VCBdpkx5Ouw9pnaLRwwlgGCuJw8xLpaR0hOrQg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/@tanstack/react-table": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", - "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", - "license": "MIT", "dependencies": { "@tanstack/table-core": "8.21.3" }, @@ -1951,221 +1945,220 @@ "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" - } + }, + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "version": "8.21.3" }, "node_modules/@tanstack/table-core": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", - "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", - "license": "MIT", "engines": { "node": ">=12" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" - } + }, + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "version": "8.21.3" }, "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + }, "dev": true, + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "version": "7.20.5" + }, + "node_modules/@types/babel__generator": { "dependencies": { "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + }, "dev": true, + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "version": "7.27.0" + }, + "node_modules/@types/babel__template": { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + }, "dev": true, + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "version": "7.4.4" + }, + "node_modules/@types/babel__traverse": { "dependencies": { "@babel/types": "^7.28.2" - } + }, + "dev": true, + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "version": "7.28.0" }, "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "license": "MIT", "dependencies": { "@types/ms": "*" - } + }, + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "version": "4.1.12" }, "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", "dev": true, - "license": "MIT" + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "version": "4.3.1" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "version": "1.0.8" }, "node_modules/@types/estree-jsx": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", - "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", - "license": "MIT", "dependencies": { "@types/estree": "*" - } + }, + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "version": "1.0.5" }, "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "license": "MIT", "dependencies": { "@types/unist": "*" - } + }, + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "version": "3.0.4" }, "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, - "license": "MIT" + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "version": "7.0.15" }, "node_modules/@types/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", "dev": true, - "license": "MIT" + "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", + "version": "4.17.23" }, "node_modules/@types/lodash-es": { - "version": "4.17.12", - "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", - "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", - "dev": true, - "license": "MIT", "dependencies": { "@types/lodash": "*" - } + }, + "dev": true, + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "version": "4.17.12" }, "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "license": "MIT", "dependencies": { "@types/unist": "*" - } + }, + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "version": "4.0.4" }, "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "version": "2.1.0" }, "node_modules/@types/node": { - "version": "25.3.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", - "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", - "dev": true, - "license": "MIT", "dependencies": { "undici-types": "~7.18.0" - } + }, + "dev": true, + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", + "version": "25.3.0" }, "node_modules/@types/parse-json": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "version": "4.0.2" }, "node_modules/@types/prismjs": { - "version": "1.26.6", - "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "version": "1.26.6" }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, - "license": "MIT", "dependencies": { "csstype": "^3.2.2" - } + }, + "dev": true, + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "version": "19.2.14" }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" - } + }, + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "version": "19.2.3" }, "node_modules/@types/react-syntax-highlighter": { - "version": "15.5.13", - "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", - "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==", - "dev": true, - "license": "MIT", "dependencies": { "@types/react": "*" - } + }, + "dev": true, + "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", + "version": "15.5.13" }, "node_modules/@types/react-transition-group": { - "version": "4.4.12", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", "license": "MIT", "peerDependencies": { "@types/react": "*" - } + }, + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "version": "4.4.12" }, "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "version": "3.0.3" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", - "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", - "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.0", @@ -2176,6 +2169,7 @@ "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, + "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2183,28 +2177,27 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, + "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", + "license": "MIT", "peerDependencies": { "@typescript-eslint/parser": "^8.56.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" - } + }, + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", + "version": "8.56.0" }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, - "license": "MIT", "engines": { "node": ">= 4" - } + }, + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "version": "7.0.5" }, "node_modules/@typescript-eslint/parser": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", - "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", - "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -2212,6 +2205,7 @@ "@typescript-eslint/visitor-keys": "8.56.0", "debug": "^4.4.3" }, + "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2219,22 +2213,22 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, + "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", + "license": "MIT", "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" - } + }, + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", + "version": "8.56.0" }, "node_modules/@typescript-eslint/project-service": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", - "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", - "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.56.0", "@typescript-eslint/types": "^8.56.0", "debug": "^4.4.3" }, + "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2242,34 +2236,34 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, + "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", + "license": "MIT", "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" - } + }, + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", + "version": "8.56.0" }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", - "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", - "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/types": "8.56.0", "@typescript-eslint/visitor-keys": "8.56.0" }, + "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" - } + }, + "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", + "version": "8.56.0" }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", - "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2277,16 +2271,15 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, + "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", + "license": "MIT", "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" - } + }, + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", + "version": "8.56.0" }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", - "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", - "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/types": "8.56.0", "@typescript-eslint/typescript-estree": "8.56.0", @@ -2294,6 +2287,7 @@ "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, + "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2301,31 +2295,30 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, + "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", + "license": "MIT", "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" - } + }, + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", + "version": "8.56.0" }, "node_modules/@typescript-eslint/types": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", - "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" - } + }, + "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", + "version": "8.56.0" }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", - "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", - "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/project-service": "8.56.0", "@typescript-eslint/tsconfig-utils": "8.56.0", @@ -2337,6 +2330,7 @@ "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, + "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2344,51 +2338,51 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, + "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", + "license": "MIT", "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" - } + }, + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", + "version": "8.56.0" }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz", - "integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==", - "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^5.0.2" }, + "dev": true, "engines": { "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" - } + }, + "integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz", + "version": "9.0.6" }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", "bin": { "semver": "bin/semver.js" }, + "dev": true, "engines": { "node": ">=10" - } + }, + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "version": "7.7.4" }, "node_modules/@typescript-eslint/utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", - "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", - "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", "@typescript-eslint/typescript-estree": "8.56.0" }, + "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2396,41 +2390,40 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, + "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", + "license": "MIT", "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" - } + }, + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", + "version": "8.56.0" }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", - "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", - "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/types": "8.56.0", "eslint-visitor-keys": "^5.0.0" }, + "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" - } + }, + "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", + "version": "8.56.0" }, "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "license": "ISC" + "license": "ISC", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "version": "1.3.0" }, "node_modules/@vitejs/plugin-react": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", - "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", - "dev": true, - "license": "MIT", "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", @@ -2439,58 +2432,73 @@ "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, + "dev": true, "engines": { "node": "^20.19.0 || >=22.12.0" }, + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "license": "MIT", "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } + }, + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "version": "5.1.4" }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", "bin": { "acorn": "bin/acorn" }, + "dev": true, "engines": { "node": ">=0.4.0" - } + }, + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "version": "8.16.0" }, "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } + }, + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "version": "5.3.2" }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" }, + "dev": true, "funding": { "type": "github", "url": "https://github.com/sponsors/epoberezkin" - } + }, + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "version": "6.12.6" }, "node_modules/autoprefixer": { - "version": "10.4.24", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", - "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001766", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, "dev": true, + "engines": { + "node": "^10 || ^12 || >=14" + }, "funding": [ { "type": "opencollective", @@ -2505,29 +2513,15 @@ "url": "https://github.com/sponsors/ai" } ], + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001766", - "fraction.js": "^5.3.4", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, "peerDependencies": { "postcss": "^8.1.0" - } + }, + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "version": "10.4.24" }, "node_modules/babel-plugin-macros": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", - "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", @@ -2536,56 +2530,70 @@ "engines": { "node": ">=10", "npm": ">=6" - } + }, + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "version": "3.1.0" }, "node_modules/bail": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", - "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "version": "2.0.2" }, "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT", "engines": { "node": "18 || 20 || >=22" - } + }, + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "version": "4.0.4" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", - "dev": true, - "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" - } + }, + "dev": true, + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "version": "2.9.19" }, "node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", - "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, + "dev": true, "engines": { "node": "18 || 20 || >=22" - } + }, + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "version": "5.0.3" }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "bin": { + "browserslist": "cli.js" + }, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, "dev": true, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + }, "funding": [ { "type": "opencollective", @@ -2600,34 +2608,21 @@ "url": "https://github.com/sponsors/ai" } ], + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "version": "4.28.1" }, "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "license": "MIT", "engines": { "node": ">=6" - } + }, + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "version": "3.1.0" }, "node_modules/caniuse-lite": { - "version": "1.0.30001769", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", - "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", "dev": true, "funding": [ { @@ -2643,89 +2638,88 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "CC-BY-4.0" + "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "license": "CC-BY-4.0", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", + "version": "1.0.30001769" }, "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "version": "2.0.1" }, "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "version": "2.0.2" }, "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "version": "2.1.0" }, "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "version": "3.0.0" }, "node_modules/character-reference-invalid": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", - "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "version": "2.0.1" }, "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", "engines": { "node": ">=6" - } + }, + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "version": "2.1.1" }, "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "version": "2.0.3" }, "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, - "license": "MIT" + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "version": "2.0.0" }, "node_modules/cosmiconfig": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", - "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", - "license": "MIT", "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", @@ -2735,157 +2729,159 @@ }, "engines": { "node": ">=10" - } + }, + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "version": "7.1.0" }, "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" }, + "dev": true, "engines": { "node": ">= 8" - } + }, + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "version": "7.0.6" }, "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "version": "3.2.3" }, "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", "dependencies": { "ms": "^2.1.3" }, "engines": { "node": ">=6.0" }, + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "peerDependenciesMeta": { "supports-color": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "version": "4.4.3" }, "node_modules/decode-named-character-reference": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", - "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", - "license": "MIT", "dependencies": { "character-entities": "^2.0.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "version": "1.3.0" }, "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, - "license": "MIT" + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "version": "0.1.4" }, "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", "engines": { "node": ">=6" - } + }, + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "version": "2.0.3" }, "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">=8" - } + }, + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "version": "2.1.2" }, "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "version": "1.1.0" }, "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "license": "MIT", "dependencies": { "dequal": "^2.0.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "version": "1.1.0" }, "node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" - } + }, + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "version": "5.2.1" }, "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", "dev": true, - "license": "ISC" + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "version": "1.5.286" }, "node_modules/enhanced-resolve": { - "version": "5.19.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", - "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", - "dev": true, - "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" }, + "dev": true, "engines": { "node": ">=10.13.0" - } + }, + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "version": "5.19.0" }, "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" - } + }, + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "version": "1.3.4" }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, + "dev": true, "engines": { "node": ">=18" }, + "hasInstallScript": true, + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "license": "MIT", "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", @@ -2913,36 +2909,36 @@ "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" - } + }, + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "version": "0.27.3" }, "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" - } + }, + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "version": "3.2.0" }, "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "license": "MIT", "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "version": "4.0.0" }, "node_modules/eslint": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.1.tgz", - "integrity": "sha512-20MV9SUdeN6Jd84xESsKhRly+/vxI+hwvpBMA93s+9dAcjdCuCojn4IqUGS3lvVaqjVYGYHSRMCpeFtF2rQYxQ==", - "dev": true, - "license": "MIT", + "bin": { + "eslint": "bin/eslint.js" + }, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -2975,15 +2971,15 @@ "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, - "bin": { - "eslint": "bin/eslint.js" - }, + "dev": true, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" }, + "integrity": "sha512-20MV9SUdeN6Jd84xESsKhRly+/vxI+hwvpBMA93s+9dAcjdCuCojn4IqUGS3lvVaqjVYGYHSRMCpeFtF2rQYxQ==", + "license": "MIT", "peerDependencies": { "jiti": "*" }, @@ -2991,14 +2987,11 @@ "jiti": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.1.tgz", + "version": "10.0.1" }, "node_modules/eslint-plugin-react-hooks": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", - "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", - "dev": true, - "license": "MIT", "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", @@ -3006,177 +2999,180 @@ "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, + "dev": true, "engines": { "node": ">=18" }, + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "license": "MIT", "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" - } + }, + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "version": "7.0.1" }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.1.tgz", - "integrity": "sha512-Y5sJsreCUdGcF4mLD70iJNa47Z6CX4MsqJoJBARDC/fBhmacSby7k73UuValr0F9M7GfWKpEqS4NMsniWkVxQw==", "dev": true, + "integrity": "sha512-Y5sJsreCUdGcF4mLD70iJNa47Z6CX4MsqJoJBARDC/fBhmacSby7k73UuValr0F9M7GfWKpEqS4NMsniWkVxQw==", "license": "MIT", "peerDependencies": { "eslint": "^9 || ^10" - } + }, + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.1.tgz", + "version": "0.5.1" }, "node_modules/eslint-scope": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz", - "integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==", - "dev": true, - "license": "BSD-2-Clause", "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, + "dev": true, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" - } + }, + "integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==", + "license": "BSD-2-Clause", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz", + "version": "9.1.1" }, "node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" - } + }, + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "version": "5.0.1" }, "node_modules/espree": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.1.tgz", - "integrity": "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==", - "dev": true, - "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" }, + "dev": true, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" - } + }, + "integrity": "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==", + "license": "BSD-2-Clause", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.1.tgz", + "version": "11.1.1" }, "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" }, + "dev": true, "engines": { "node": ">=0.10" - } + }, + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "license": "BSD-3-Clause", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "version": "1.7.0" }, "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, + "dev": true, "engines": { "node": ">=4.0" - } + }, + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "version": "4.3.0" }, "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "license": "BSD-2-Clause", "engines": { "node": ">=4.0" - } + }, + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "version": "5.3.0" }, "node_modules/estree-util-is-identifier-name": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", - "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", - "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "version": "3.0.0" }, "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, - "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" - } + }, + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "version": "2.0.3" }, "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "version": "3.0.2" }, "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "version": "3.1.3" }, "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, - "license": "MIT" + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "version": "2.1.0" }, "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, - "license": "MIT" + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "version": "2.0.6" }, "node_modules/fault": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", - "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", - "license": "MIT", "dependencies": { "format": "^0.2.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", + "version": "1.0.4" }, "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, - "license": "MIT", "engines": { "node": ">=12.0.0" }, + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -3184,193 +3180,191 @@ "picomatch": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "version": "6.5.0" }, "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", "dependencies": { "flat-cache": "^4.0.0" }, + "dev": true, "engines": { "node": ">=16.0.0" - } + }, + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "version": "8.0.0" }, "node_modules/find-root": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "version": "1.1.0" }, "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" }, + "dev": true, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "version": "5.0.0" }, "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" }, + "dev": true, "engines": { "node": ">=16" - } + }, + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "version": "4.0.1" }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, - "license": "ISC" + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "version": "3.3.3" }, "node_modules/format": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", - "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", "engines": { "node": ">=0.4.x" - } + }, + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "version": "0.2.2" }, "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", "dev": true, - "license": "MIT", "engines": { "node": "*" }, "funding": { "type": "github", "url": "https://github.com/sponsors/rawify" - } + }, + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "version": "5.3.4" }, "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + }, "hasInstallScript": true, + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "license": "MIT", "optional": true, "os": [ "darwin" ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "version": "2.3.3" }, "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" - } + }, + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "version": "1.1.2" }, "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, - "license": "MIT", "engines": { "node": ">=6.9.0" - } + }, + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "version": "1.0.0-beta.2" }, "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "license": "MIT", "engines": { "node": ">=6" - } + }, + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "version": "1.0.1" }, "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", "dependencies": { "is-glob": "^4.0.3" }, + "dev": true, "engines": { "node": ">=10.13.0" - } + }, + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "version": "6.0.2" }, "node_modules/globals": { - "version": "17.3.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz", - "integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz", + "version": "17.3.0" }, "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true, - "license": "ISC" + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "version": "4.2.11" }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, "engines": { "node": ">= 0.4" - } + }, + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "version": "2.0.2" }, "node_modules/hast-util-parse-selector": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", - "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", - "license": "MIT", "dependencies": { "@types/hast": "^3.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "version": "4.0.0" }, "node_modules/hast-util-to-jsx-runtime": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", - "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", - "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", @@ -3391,26 +3385,26 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "version": "2.3.6" }, "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "license": "MIT", "dependencies": { "@types/hast": "^3.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "version": "3.0.0" }, "node_modules/hastscript": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", - "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", - "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", @@ -3421,99 +3415,99 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "version": "9.0.1" }, "node_modules/hermes-estree": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", - "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", "dev": true, - "license": "MIT" + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "version": "0.25.1" }, "node_modules/hermes-parser": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", - "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", - "dev": true, - "license": "MIT", "dependencies": { "hermes-estree": "0.25.1" - } + }, + "dev": true, + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "version": "0.25.1" }, "node_modules/highlight.js": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", - "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", - "license": "BSD-3-Clause", "engines": { "node": "*" - } + }, + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "version": "10.7.3" }, "node_modules/highlightjs-vue": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", - "license": "CC0-1.0" + "license": "CC0-1.0", + "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", + "version": "1.0.0" }, "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "license": "BSD-3-Clause", "dependencies": { "react-is": "^16.7.0" - } + }, + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "version": "3.3.2" }, "node_modules/hotkeys-js": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/hotkeys-js/-/hotkeys-js-4.0.0.tgz", - "integrity": "sha512-gIoeqMWYqPIItc4HaseVbtTRpEpBbeufZMUcoWtN62JZdDq3KadS1ijN6wpaDjTzRK7PjT3QOPUcx+yNT0rrZQ==", - "license": "MIT", "funding": { "url": "https://jaywcjlove.github.io/#/sponsor" - } + }, + "integrity": "sha512-gIoeqMWYqPIItc4HaseVbtTRpEpBbeufZMUcoWtN62JZdDq3KadS1ijN6wpaDjTzRK7PjT3QOPUcx+yNT0rrZQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/hotkeys-js/-/hotkeys-js-4.0.0.tgz", + "version": "4.0.0" }, "node_modules/html-url-attributes": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", - "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", - "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "version": "3.0.1" }, "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, - "license": "MIT", "engines": { "node": ">= 4" - } + }, + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "version": "5.3.2" }, "node_modules/immer": { - "version": "11.1.4", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", - "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", - "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" - } + }, + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "version": "11.1.4" }, "node_modules/immutable": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", + "version": "5.1.4" }, "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "license": "MIT", "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -3523,39 +3517,39 @@ }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "version": "3.3.1" }, "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.8.19" - } + }, + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "version": "0.1.4" }, "node_modules/inline-style-parser": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "version": "0.2.7" }, "node_modules/is-alphabetical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", - "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "version": "2.0.1" }, "node_modules/is-alphanumerical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", - "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", - "license": "MIT", "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" @@ -3563,19 +3557,19 @@ "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "version": "2.0.1" }, "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "version": "0.2.1" }, "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "license": "MIT", "dependencies": { "hasown": "^2.0.2" }, @@ -3584,171 +3578,171 @@ }, "funding": { "url": "https://github.com/sponsors/ljharb" - } + }, + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "version": "2.16.1" }, "node_modules/is-decimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", - "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "version": "2.0.1" }, "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" - } + }, + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "version": "2.1.1" }, "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" }, + "dev": true, "engines": { "node": ">=0.10.0" - } + }, + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "version": "4.0.3" }, "node_modules/is-hexadecimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", - "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "version": "2.0.1" }, "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "license": "MIT", "engines": { "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "version": "4.1.0" }, "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, - "license": "ISC" + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "version": "2.0.0" }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, - "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" - } + }, + "dev": true, + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "version": "2.6.1" }, "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "version": "4.0.0" }, "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "license": "MIT", "bin": { "jsesc": "bin/jsesc" }, "engines": { "node": ">=6" - } + }, + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "version": "3.1.0" }, "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, - "license": "MIT" + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "version": "3.0.1" }, "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "version": "2.3.1" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, - "license": "MIT" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "version": "0.4.1" }, "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, - "license": "MIT" + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "version": "1.0.1" }, "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", "bin": { "json5": "lib/cli.js" }, + "dev": true, "engines": { "node": ">=6" - } + }, + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "version": "2.2.3" }, "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", "dependencies": { "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + }, "dev": true, + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "license": "MIT", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "version": "4.5.4" + }, + "node_modules/levn": { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" }, + "dev": true, "engines": { "node": ">= 0.8.0" - } + }, + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "version": "0.4.1" }, "node_modules/lightningcss": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", - "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", - "dev": true, - "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" }, + "dev": true, "engines": { "node": ">= 12.0.0" }, @@ -3756,6 +3750,8 @@ "type": "opencollective", "url": "https://opencollective.com/parcel" }, + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "license": "MPL-2.0", "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", @@ -3768,294 +3764,292 @@ "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" - } + }, + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "version": "1.31.1" }, "node_modules/lightningcss-android-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", - "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", "cpu": [ "arm64" ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], "engines": { "node": ">= 12.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" - } + }, + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "version": "1.31.1" }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", - "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", "cpu": [ "arm64" ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], "engines": { "node": ">= 12.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" - } + }, + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "version": "1.31.1" }, "node_modules/lightningcss-darwin-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", - "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", "cpu": [ "x64" ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], "engines": { "node": ">= 12.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" - } + }, + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "version": "1.31.1" }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", - "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", "cpu": [ "x64" ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], "engines": { "node": ">= 12.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" - } + }, + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "version": "1.31.1" }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", - "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", "cpu": [ "arm" ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], "engines": { "node": ">= 12.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" - } + }, + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "version": "1.31.1" }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", - "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", "cpu": [ "arm64" ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], "engines": { "node": ">= 12.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" - } + }, + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "version": "1.31.1" }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", - "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", "cpu": [ "arm64" ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], "engines": { "node": ">= 12.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" - } + }, + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "version": "1.31.1" }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", - "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", "cpu": [ "x64" ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], "engines": { "node": ">= 12.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" - } + }, + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "version": "1.31.1" }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", - "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", "cpu": [ "x64" ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], "engines": { "node": ">= 12.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" - } + }, + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "version": "1.31.1" }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", - "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", "cpu": [ "arm64" ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], "engines": { "node": ">= 12.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" - } + }, + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "version": "1.31.1" }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", - "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", "cpu": [ "x64" ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], "engines": { "node": ">= 12.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" - } + }, + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "version": "1.31.1" }, "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "version": "1.2.4" }, "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", "dependencies": { "p-locate": "^5.0.0" }, + "dev": true, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "version": "6.0.0" }, "node_modules/lodash-es": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "version": "4.17.23" }, "node_modules/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "version": "3.1.0" }, "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", + "bin": { + "loose-envify": "cli.js" + }, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, - "bin": { - "loose-envify": "cli.js" - } + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "version": "1.4.0" }, "node_modules/lowlight": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", - "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", - "license": "MIT", "dependencies": { "fault": "^1.0.0", "highlight.js": "~10.7.0" @@ -4063,33 +4057,33 @@ "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", + "version": "1.20.0" }, "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", "dependencies": { "yallist": "^3.0.2" - } + }, + "dev": true, + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "version": "5.1.1" }, "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" - } + }, + "dev": true, + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "version": "0.30.21" }, "node_modules/mdast-util-from-markdown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", - "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", - "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", @@ -4107,13 +4101,13 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "version": "2.0.2" }, "node_modules/mdast-util-mdx-expression": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", - "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", - "license": "MIT", "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", @@ -4125,13 +4119,13 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "version": "2.0.1" }, "node_modules/mdast-util-mdx-jsx": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", - "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", - "license": "MIT", "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", @@ -4149,13 +4143,13 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "version": "3.2.0" }, "node_modules/mdast-util-mdxjs-esm": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", - "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", - "license": "MIT", "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", @@ -4167,13 +4161,13 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "version": "2.0.1" }, "node_modules/mdast-util-phrasing": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", - "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", - "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" @@ -4181,13 +4175,13 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "version": "4.1.0" }, "node_modules/mdast-util-to-hast": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", @@ -4202,13 +4196,13 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "version": "13.2.1" }, "node_modules/mdast-util-to-markdown": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", - "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", - "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", @@ -4223,42 +4217,32 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "version": "2.1.2" }, "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", - "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", - "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "version": "4.0.0" }, "node_modules/memoize-one": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "version": "6.0.0" }, "node_modules/micromark": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", - "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", @@ -4277,12 +4261,7 @@ "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", - "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4293,7 +4272,12 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "version": "4.0.2" + }, + "node_modules/micromark-core-commonmark": { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", @@ -4311,12 +4295,7 @@ "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-destination": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", - "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4327,17 +4306,17 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "version": "2.0.3" + }, + "node_modules/micromark-factory-destination": { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-label": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", - "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4348,18 +4327,18 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/micromark-factory-label": { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4370,16 +4349,16 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/micromark-factory-space": { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", - "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4390,18 +4369,18 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/micromark-factory-title": { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", - "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4412,18 +4391,18 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/micromark-factory-whitespace": { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4434,16 +4413,16 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/micromark-util-character": { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-chunked": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", - "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4454,15 +4433,15 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "version": "2.1.1" + }, + "node_modules/micromark-util-chunked": { "dependencies": { "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", - "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4473,17 +4452,17 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/micromark-util-classify-character": { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-combine-extensions": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", - "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4494,16 +4473,16 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/micromark-util-combine-extensions": { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", - "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4514,15 +4493,15 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/micromark-util-decode-numeric-character-reference": { "dependencies": { "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", - "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4533,18 +4512,18 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "version": "2.0.2" + }, + "node_modules/micromark-util-decode-string": { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4555,12 +4534,12 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT" + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "version": "2.0.1" }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", - "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "node_modules/micromark-util-encode": { "funding": [ { "type": "GitHub Sponsors", @@ -4571,12 +4550,12 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT" + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "version": "2.0.1" }, - "node_modules/micromark-util-normalize-identifier": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", - "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "node_modules/micromark-util-html-tag-name": { "funding": [ { "type": "GitHub Sponsors", @@ -4587,15 +4566,15 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/micromark-util-normalize-identifier": { "dependencies": { "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", - "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4606,15 +4585,15 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/micromark-util-resolve-all": { "dependencies": { "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4625,17 +4604,17 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/micromark-util-sanitize-uri": { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-subtokenize": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", - "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + }, "funding": [ { "type": "GitHub Sponsors", @@ -4646,18 +4625,34 @@ "url": "https://opencollective.com/unified" } ], + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/micromark-util-subtokenize": { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" - } + }, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "version": "2.1.0" }, "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", "funding": [ { "type": "GitHub Sponsors", @@ -4668,12 +4663,12 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT" + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "version": "2.0.1" }, "node_modules/micromark-util-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", "funding": [ { "type": "GitHub Sponsors", @@ -4684,84 +4679,82 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT" + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "version": "2.0.2" }, "node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", - "dev": true, - "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.2" }, + "dev": true, "engines": { "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" - } + }, + "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", + "license": "BlueOak-1.0.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", + "version": "10.2.2" }, "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "version": "3.0.1" }, "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "version": "2.1.3" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, "dev": true, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + }, "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "version": "3.3.11" }, "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, - "license": "MIT" + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "version": "1.4.0" }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true, - "license": "MIT" + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "version": "2.0.27" }, "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", "engines": { "node": ">=0.10.0" - } + }, + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "version": "4.1.1" }, "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -4770,59 +4763,60 @@ "type-check": "^0.4.0", "word-wrap": "^1.2.5" }, + "dev": true, "engines": { "node": ">= 0.8.0" - } + }, + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "version": "0.9.4" }, "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" }, + "dev": true, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "version": "3.1.0" }, "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", "dependencies": { "p-limit": "^3.0.2" }, + "dev": true, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "version": "5.0.0" }, "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "license": "MIT", "dependencies": { "callsites": "^3.0.0" }, "engines": { "node": ">=6" - } + }, + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "version": "1.0.1" }, "node_modules/parse-entities": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", - "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", - "license": "MIT", "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", @@ -4835,19 +4829,19 @@ "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "version": "4.0.2" }, "node_modules/parse-entities/node_modules/@types/unist": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "version": "2.0.11" }, "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -4859,67 +4853,76 @@ }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "version": "5.2.0" }, "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" - } + }, + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "version": "4.0.0" }, "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" - } + }, + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "version": "3.1.1" }, "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "version": "1.0.7" }, "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "license": "MIT", "engines": { "node": ">=8" - } + }, + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "version": "4.0.0" }, "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" + "license": "ISC", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "version": "1.1.1" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" - } + }, + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "version": "4.0.3" }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, "dev": true, + "engines": { + "node": "^10 || ^12 || >=14" + }, "funding": [ { "type": "opencollective", @@ -4934,99 +4937,90 @@ "url": "https://github.com/sponsors/ai" } ], + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "version": "8.5.6" }, "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true, - "license": "MIT" + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.8.0" - } + }, + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "version": "1.2.1" }, "node_modules/prismjs": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", - "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", - "license": "MIT", "engines": { "node": ">=6" - } + }, + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "version": "1.30.0" }, "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" - } + }, + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "version": "15.8.1" }, "node_modules/property-information": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "version": "7.1.0" }, "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" - } + }, + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "version": "2.3.1" }, "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", - "license": "MIT", "engines": { "node": ">=0.10.0" - } + }, + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "version": "19.2.4" }, "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", - "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", "peerDependencies": { "react": "^19.2.4" - } + }, + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "version": "19.2.4" }, "node_modules/react-hook-form": { - "version": "7.71.2", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", - "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", - "license": "MIT", "engines": { "node": ">=18.0.0" }, @@ -5034,21 +5028,21 @@ "type": "opencollective", "url": "https://opencollective.com/react-hook-form" }, + "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", + "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" - } + }, + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", + "version": "7.71.2" }, "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "version": "16.13.1" }, "node_modules/react-markdown": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", - "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", - "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", @@ -5066,36 +5060,36 @@ "type": "opencollective", "url": "https://opencollective.com/unified" }, + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", "peerDependencies": { "@types/react": ">=18", "react": ">=18" - } + }, + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "version": "10.1.0" }, "node_modules/react-number-format": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.4.tgz", "integrity": "sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==", "license": "MIT", "peerDependencies": { "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } + }, + "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.4.tgz", + "version": "5.4.4" }, "node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" - } + }, + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "version": "0.18.0" }, "node_modules/react-remove-scroll": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", - "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", - "license": "MIT", "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", @@ -5106,6 +5100,8 @@ "engines": { "node": ">=10" }, + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" @@ -5114,13 +5110,11 @@ "@types/react": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "version": "2.7.2" }, "node_modules/react-remove-scroll-bar": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", - "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", - "license": "MIT", "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" @@ -5128,6 +5122,8 @@ "engines": { "node": ">=10" }, + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -5136,13 +5132,11 @@ "@types/react": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "version": "2.3.8" }, "node_modules/react-select": { - "version": "5.10.2", - "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz", - "integrity": "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.0", "@emotion/cache": "^11.4.0", @@ -5154,22 +5148,22 @@ "react-transition-group": "^4.3.0", "use-isomorphic-layout-effect": "^1.2.0" }, + "integrity": "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==", + "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } + }, + "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz", + "version": "5.10.2" }, "node_modules/react-string-replace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/react-string-replace/-/react-string-replace-2.0.1.tgz", "integrity": "sha512-J7y/IxZkwhOgCexu6GtkNH2FIa9f/z6ykvK1YXS8VLIdvcP8Vyx3LAUFsfIFvq3aQbmin2d+clFR1J50LnR2yA==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/react-string-replace/-/react-string-replace-2.0.1.tgz", + "version": "2.0.1" }, "node_modules/react-style-singleton": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", - "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", - "license": "MIT", "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" @@ -5177,6 +5171,8 @@ "engines": { "node": ">=10" }, + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" @@ -5185,13 +5181,11 @@ "@types/react": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "version": "2.2.3" }, "node_modules/react-syntax-highlighter": { - "version": "16.1.0", - "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.0.tgz", - "integrity": "sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.4", "highlight.js": "^10.4.1", @@ -5203,15 +5197,15 @@ "engines": { "node": ">= 16.20.2" }, + "integrity": "sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg==", + "license": "MIT", "peerDependencies": { "react": ">= 0.14.0" - } + }, + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.0.tgz", + "version": "16.1.0" }, "node_modules/react-textarea-autosize": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", - "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", @@ -5220,31 +5214,31 @@ "engines": { "node": ">=10" }, + "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==", + "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } + }, + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", + "version": "8.5.9" }, "node_modules/react-transition-group": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", - "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", - "license": "BSD-3-Clause", "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" - } + }, + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "version": "4.4.5" }, "node_modules/refractor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", - "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", - "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", "@types/prismjs": "^1.0.0", @@ -5254,13 +5248,13 @@ "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", + "version": "5.0.0" }, "node_modules/remark-parse": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", - "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", - "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", @@ -5270,13 +5264,13 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "version": "11.0.0" }, "node_modules/remark-rehype": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", - "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", - "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", @@ -5287,53 +5281,55 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "version": "11.1.2" }, "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "license": "MIT", + "bin": { + "resolve": "bin/resolve" + }, "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, - "bin": { - "resolve": "bin/resolve" - }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" - } + }, + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "version": "1.22.11" }, "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "license": "MIT", "engines": { "node": ">=4" - } + }, + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "version": "4.0.0" }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, "bin": { "rollup": "dist/bin/rollup" }, + "dependencies": { + "@types/estree": "1.0.8" + }, + "dev": true, "engines": { "node": ">=18.0.0", "npm": ">=8.0.0" }, + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "license": "MIT", "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", @@ -5361,81 +5357,79 @@ "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" - } + }, + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "version": "4.57.1" }, "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "version": "0.27.0" }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", "bin": { "semver": "bin/semver.js" - } + }, + "dev": true, + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "version": "6.3.1" }, "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" }, + "dev": true, "engines": { "node": ">=8" - } + }, + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "version": "2.0.0" }, "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" - } + }, + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "version": "3.0.0" }, "node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" - } + }, + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "version": "0.5.7" }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, - "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" - } + }, + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "version": "1.2.1" }, "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "version": "2.0.2" }, "node_modules/stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "license": "MIT", "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" @@ -5443,178 +5437,178 @@ "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "version": "4.0.4" }, "node_modules/style-to-js": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", - "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", - "license": "MIT", "dependencies": { "style-to-object": "1.0.14" - } + }, + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "version": "1.1.21" }, "node_modules/style-to-object": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", - "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", - "license": "MIT", "dependencies": { "inline-style-parser": "0.2.7" - } + }, + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "version": "1.0.14" }, "node_modules/stylis": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "license": "MIT", "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" - } + }, + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "version": "1.0.0" }, "node_modules/tabbable": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", - "license": "MIT" + "license": "MIT", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "version": "6.4.0" }, "node_modules/tailwindcss": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.0.tgz", - "integrity": "sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==", "dev": true, - "license": "MIT" + "integrity": "sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.0.tgz", + "version": "4.2.0" }, "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" - } + }, + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "version": "2.3.0" }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" }, + "dev": true, "engines": { "node": ">=12.0.0" }, "funding": { "url": "https://github.com/sponsors/SuperchupuDev" - } + }, + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "version": "0.2.15" }, "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "version": "3.0.1" }, "node_modules/trough": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", - "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "version": "2.2.0" }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, - "license": "MIT", "engines": { "node": ">=18.12" }, + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "license": "MIT", "peerDependencies": { "typescript": ">=4.8.4" - } + }, + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "version": "2.4.0" }, "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "version": "2.8.1" }, "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" }, + "dev": true, "engines": { "node": ">= 0.8.0" - } + }, + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "version": "0.4.0" }, "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "version": "4.41.0" }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, + "dev": true, "engines": { "node": ">=14.17" - } + }, + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "version": "5.9.3" }, "node_modules/typescript-eslint": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz", - "integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==", - "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/eslint-plugin": "8.56.0", "@typescript-eslint/parser": "8.56.0", "@typescript-eslint/typescript-estree": "8.56.0", "@typescript-eslint/utils": "8.56.0" }, + "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -5622,23 +5616,23 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, + "integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==", + "license": "MIT", "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" - } + }, + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz", + "version": "8.56.0" }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, - "license": "MIT" + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "version": "7.18.2" }, "node_modules/unified": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", - "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", - "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -5651,52 +5645,52 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "version": "11.0.5" }, "node_modules/unist-util-is": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", - "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "version": "6.0.1" }, "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "version": "5.0.0" }, "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "version": "4.0.0" }, "node_modules/unist-util-visit": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", - "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", @@ -5705,13 +5699,13 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "version": "5.1.0" }, "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" @@ -5719,12 +5713,20 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "version": "6.0.2" }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "bin": { + "update-browserslist-db": "cli.js" + }, + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, "dev": true, "funding": [ { @@ -5740,39 +5742,33 @@ "url": "https://github.com/sponsors/ai" } ], + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, "peerDependencies": { "browserslist": ">= 4.21.0" - } + }, + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "version": "1.2.3" }, "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" - } + }, + "dev": true, + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "version": "4.4.1" }, "node_modules/use-callback-ref": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", - "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", - "license": "MIT", "dependencies": { "tslib": "^2.0.0" }, "engines": { "node": ">=10" }, + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" @@ -5781,11 +5777,11 @@ "@types/react": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "version": "1.3.3" }, "node_modules/use-composed-ref": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz", "integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==", "license": "MIT", "peerDependencies": { @@ -5795,11 +5791,11 @@ "@types/react": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz", + "version": "1.4.0" }, "node_modules/use-isomorphic-layout-effect": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", "license": "MIT", "peerDependencies": { @@ -5809,16 +5805,16 @@ "@types/react": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "version": "1.2.1" }, "node_modules/use-latest": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz", - "integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==", - "license": "MIT", "dependencies": { "use-isomorphic-layout-effect": "^1.1.1" }, + "integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==", + "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, @@ -5826,13 +5822,11 @@ "@types/react": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz", + "version": "1.3.0" }, "node_modules/use-sidecar": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", - "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", - "license": "MIT", "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" @@ -5840,6 +5834,8 @@ "engines": { "node": ">=10" }, + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" @@ -5848,13 +5844,11 @@ "@types/react": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "version": "1.1.3" }, "node_modules/vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" @@ -5862,13 +5856,13 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "version": "6.0.3" }, "node_modules/vfile-message": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" @@ -5876,11 +5870,13 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - } + }, + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "version": "4.0.3" }, "node_modules/virtua": { - "version": "0.48.3", - "resolved": "https://registry.npmjs.org/virtua/-/virtua-0.48.3.tgz", "integrity": "sha512-WTGPqMshLNtTQaIhvNo6EnjMvBP0GAYN8ZJsTY6HZqQCJlspW0L9IrIDi4vYV2J7uBlnuSqemnw40IKTg/aeGA==", "license": "MIT", "peerDependencies": { @@ -5906,14 +5902,14 @@ "vue": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/virtua/-/virtua-0.48.3.tgz", + "version": "0.48.3" }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "dev": true, - "license": "MIT", + "bin": { + "vite": "bin/vite.js" + }, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5922,15 +5918,15 @@ "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, - "bin": { - "vite": "bin/vite.js" - }, + "dev": true, "engines": { "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" }, + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "license": "MIT", "optionalDependencies": { "fsevents": "~2.3.3" }, @@ -5981,95 +5977,99 @@ "yaml": { "optional": true } - } + }, + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "version": "7.3.1" }, "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, "bin": { "node-which": "bin/node-which" }, + "dependencies": { + "isexe": "^2.0.0" + }, + "dev": true, "engines": { "node": ">= 8" - } + }, + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "version": "2.0.2" }, "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" - } + }, + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "version": "1.2.5" }, "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, - "license": "ISC" + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "version": "3.1.1" }, "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "license": "ISC", "engines": { "node": ">= 6" - } + }, + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "version": "1.10.2" }, "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "version": "0.1.0" }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, - "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" - } + }, + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "version": "4.3.6" }, "node_modules/zod-validation-error": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", - "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=18.0.0" }, + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "license": "MIT", "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" - } + }, + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "version": "4.0.2" }, "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" - } + }, + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "version": "2.0.4" } - } + }, + "requires": true, + "version": "0.0.0" } From 0644e2aefc4ad6cc175ea4ed76b6f78feddc293a Mon Sep 17 00:00:00 2001 From: Feramance Date: Thu, 26 Feb 2026 12:15:39 +0100 Subject: [PATCH 03/66] fix(webui): avoid duplicate qBit prefix in process names; update docs release date - Use instance name as-is when it already starts with 'qbit' - Update docs index latest release date to February 2026 --- docs/index.md | 2 +- qBitrr/webui.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/index.md b/docs/index.md index dfa25404d..45e6a0519 100644 --- a/docs/index.md +++ b/docs/index.md @@ -349,7 +349,7 @@ Optimize for private tracker requirements: ### Current Version -**Latest Release**: v5.9.1 (January 2025) +**Latest Release**: v5.9.1 (February 2026) - ✅ Production ready - ✅ Active development diff --git a/qBitrr/webui.py b/qBitrr/webui.py index 0ee5705de..c32a023c0 100644 --- a/qBitrr/webui.py +++ b/qBitrr/webui.py @@ -1678,10 +1678,15 @@ def _populate_process_metadata(arr_obj, proc_kind, payload_dict): except Exception: alive = False pid = None + display_name = ( + instance_name + if instance_name.lower().startswith("qbit") + else f"qBit-{instance_name}" + ) procs.append( { "category": cat, - "name": f"qBit-{instance_name}", + "name": display_name, "kind": "category", "pid": pid, "alive": alive, From 5e22002b75a0d5347398c99d2fb9830748b1aeab Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:16:23 +0000 Subject: [PATCH 04/66] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/unit/test_torrent_limit_check.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/test_torrent_limit_check.py b/tests/unit/test_torrent_limit_check.py index 29536da5e..1fab148bc 100644 --- a/tests/unit/test_torrent_limit_check.py +++ b/tests/unit/test_torrent_limit_check.py @@ -5,7 +5,6 @@ from unittest.mock import MagicMock - def _make_torrent(ratio: float = 0.0, seeding_time: int = 0): t = MagicMock() t.ratio = ratio From d5868d24ae16fc1b198edd80ff48c8ff4a983dac Mon Sep 17 00:00:00 2001 From: Feramance Date: Thu, 26 Feb 2026 13:25:47 +0100 Subject: [PATCH 05/66] fix: pass qBit client to has_internet to avoid 'Instance default not found' warning has_internet() was given qBitManager in PlaceHolderArr and FreeSpaceManager loops; it then called transfer_info() with default instance_name='default'. Config uses section names (e.g. 'qBit'), so get_client('default') logged a warning. Use manager.client (first available client) at both call sites. --- qBitrr/arss.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qBitrr/arss.py b/qBitrr/arss.py index d826eb66c..a70c0b35f 100755 --- a/qBitrr/arss.py +++ b/qBitrr/arss.py @@ -7564,7 +7564,7 @@ def process_torrents(self): self.category_torrent_count = len(torrents) if not len(torrents): raise DelayLoopException(length=LOOP_SLEEP_TIMER, type="no_downloads") - if not has_internet(self.manager.qbit_manager): + if not has_internet(self.manager.qbit_manager.client): self.manager.qbit_manager.should_delay_torrent_scan = True raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="internet") if self.manager.qbit_manager.should_delay_torrent_scan: @@ -7796,7 +7796,7 @@ def process_torrents(self): ) if not len(torrents): raise DelayLoopException(length=LOOP_SLEEP_TIMER, type="no_downloads") - if not has_internet(self.manager.qbit_manager): + if not has_internet(self.manager.qbit_manager.client): self.manager.qbit_manager.should_delay_torrent_scan = True raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="internet") if self.manager.qbit_manager.should_delay_torrent_scan: From 8787c50e67d9e9bd298fef368b23010d2105fa8c Mon Sep 17 00:00:00 2001 From: Feramance Date: Thu, 26 Feb 2026 13:27:37 +0100 Subject: [PATCH 06/66] Added compose --- .gitignore | 1 - docker-compose.yml | 23 +++++++++++++++++++++++ docs/changelog.md | 2 +- 3 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 docker-compose.yml diff --git a/.gitignore b/.gitignore index 0a897d720..c07a666e3 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,5 @@ test_webui.sh # Development CLAUDE.md -compose.yml opencodetmp/ .claude diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..ea2327cff --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +services: + qbitrr: + container_name: qbitrr + build: + context: . + dockerfile: Dockerfile + environment: + - UMASK=000 + - TZ=${TZ} + - PUID=${USER_ID} + - PGID=${USER_ID} + restart: always + tty: true + volumes: + - /mnt/media/Torrents:/torrents + - .config:/config + logging: + driver: json-file + options: + max-size: 5g + max-file: 1 + ports: + - 6969:6969 diff --git a/docs/changelog.md b/docs/changelog.md index 04c99a55c..60b7a6ed9 120000 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1 +1 @@ -../CHANGELOG.md \ No newline at end of file +../CHANGELOG.md From 25e8fc8a6a7c791a0cc233299bbde645a17c2f5e Mon Sep 17 00:00:00 2001 From: Feramance Date: Thu, 26 Feb 2026 14:13:01 +0100 Subject: [PATCH 07/66] feat: automated startup database repair Run PRAGMA integrity_check before first connect; if not ok, attempt WAL checkpoint then full dump/restore repair. Ensures corrupted qbitrr.db is repaired automatically on restart without manual steps. --- qBitrr/database.py | 67 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/qBitrr/database.py b/qBitrr/database.py index f666457b8..4de014c2c 100644 --- a/qBitrr/database.py +++ b/qBitrr/database.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import sqlite3 from pathlib import Path from peewee import SqliteDatabase @@ -29,6 +30,69 @@ _db: SqliteDatabase | None = None +def _startup_integrity_check_and_repair(db_path: Path) -> None: + """ + Run PRAGMA integrity_check on existing database; if not ok, try WAL checkpoint + then full repair. Runs once at startup before any connection so repair has no contention. + """ + if not db_path.exists(): + return + result = "unknown" + try: + conn = sqlite3.connect(str(db_path), timeout=10.0) + cursor = conn.cursor() + cursor.execute("PRAGMA integrity_check") + result = cursor.fetchone()[0] + conn.close() + if result == "ok": + return + except Exception as e: + logger.warning("Startup integrity check failed (will attempt repair): %s", e) + + logger.warning( + "Database integrity check failed (result=%s). Attempting automatic repair...", + result, + ) + from qBitrr.db_recovery import checkpoint_wal, repair_database + + if checkpoint_wal(db_path, logger_override=logger): + try: + conn = sqlite3.connect(str(db_path), timeout=10.0) + cursor = conn.cursor() + cursor.execute("PRAGMA integrity_check") + result = cursor.fetchone()[0] + conn.close() + if result == "ok": + logger.info("WAL checkpoint resolved database issue - integrity ok") + return + except Exception as e: + logger.debug("Integrity re-check after checkpoint failed: %s", e) + + logger.warning("Attempting full database repair (dump/restore)...") + if repair_database(db_path, backup=True, logger_override=logger): + try: + conn = sqlite3.connect(str(db_path), timeout=10.0) + cursor = conn.cursor() + cursor.execute("PRAGMA integrity_check") + result = cursor.fetchone()[0] + conn.close() + if result == "ok": + logger.info("Database repair successful - integrity verified") + return + logger.critical( + "Database repair completed but integrity still failing: %s. " + "Continuing anyway; consider manual repair or fresh DB.", + result, + ) + except Exception as e: + logger.critical("Database repair verification failed: %s. Continuing anyway.", e) + else: + logger.critical( + "Automatic database repair failed. " + "Consider stopping qBitrr and running scripts/repair_database.py manually." + ) + + def get_database() -> SqliteDatabase: """Get or create the global database instance.""" global _db @@ -51,6 +115,9 @@ def get_database() -> SqliteDatabase: timeout=15, ) + # Automatic startup repair: integrity check and repair before any connection + _startup_integrity_check_and_repair(db_path) + # Connect with retry logic with_database_retry( lambda: _db.connect(reuse_if_open=True), From ad82ba103f21924b0696f125cc21b546a7e0a0e2 Mon Sep 17 00:00:00 2001 From: Feramance Date: Thu, 26 Feb 2026 14:17:03 +0100 Subject: [PATCH 08/66] fix: reset download limit with set_download_limit when DownloadRateLimit < 0 When tracker DownloadRateLimit is negative (unlimited), the elif branch was incorrectly calling set_upload_limit(limit=-1) instead of set_download_limit(limit=-1), resetting upload instead of download. --- qBitrr/arss.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qBitrr/arss.py b/qBitrr/arss.py index a70c0b35f..5d66af758 100755 --- a/qBitrr/arss.py +++ b/qBitrr/arss.py @@ -5951,7 +5951,7 @@ def _process_single_torrent_trackers( ) != 0 and torrent.dl_limit != r: torrent.set_download_limit(limit=r) elif r < 0: - torrent.set_upload_limit(limit=-1) + torrent.set_download_limit(limit=-1) if ( r := most_important_tracker.get( "UploadRateLimit", self.seeding_mode_global_upload_limit From 6d1d213bfa3ca24ad06fe2038a2cdbf29f930da3 Mon Sep 17 00:00:00 2001 From: Feramance Date: Thu, 26 Feb 2026 14:21:47 +0100 Subject: [PATCH 09/66] fix: catch DatabaseRecoveryError on startup; offer fresh DB when repair fails - Wrap repair_database() in try/except so startup does not crash on 'database disk image is malformed' or other repair failures. - On DatabaseRecoveryError, move corrupt DB to qbitrr.db.corrupt. so a fresh database is created on connect (avoids repeated failures). --- qBitrr/database.py | 66 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 20 deletions(-) diff --git a/qBitrr/database.py b/qBitrr/database.py index 4de014c2c..c113b9693 100644 --- a/qBitrr/database.py +++ b/qBitrr/database.py @@ -4,6 +4,7 @@ import logging import sqlite3 +import time from pathlib import Path from peewee import SqliteDatabase @@ -30,6 +31,23 @@ _db: SqliteDatabase | None = None +def _offer_fresh_start(db_path: Path) -> None: + """ + Move corrupt database aside and allow a fresh DB to be created on connect. + Use only after repair has failed; causes loss of existing DB data. + """ + try: + ts = int(time.time()) + corrupt_path = db_path.parent / f"qbitrr.db.corrupt.{ts}" + db_path.rename(corrupt_path) + logger.warning( + "Moved corrupt database to %s; a fresh database will be created on connect.", + corrupt_path.name, + ) + except Exception as e: + logger.debug("Could not move corrupt database aside: %s", e) + + def _startup_integrity_check_and_repair(db_path: Path) -> None: """ Run PRAGMA integrity_check on existing database; if not ok, try WAL checkpoint @@ -69,28 +87,36 @@ def _startup_integrity_check_and_repair(db_path: Path) -> None: logger.debug("Integrity re-check after checkpoint failed: %s", e) logger.warning("Attempting full database repair (dump/restore)...") - if repair_database(db_path, backup=True, logger_override=logger): - try: - conn = sqlite3.connect(str(db_path), timeout=10.0) - cursor = conn.cursor() - cursor.execute("PRAGMA integrity_check") - result = cursor.fetchone()[0] - conn.close() - if result == "ok": - logger.info("Database repair successful - integrity verified") - return - logger.critical( - "Database repair completed but integrity still failing: %s. " - "Continuing anyway; consider manual repair or fresh DB.", - result, - ) - except Exception as e: - logger.critical("Database repair verification failed: %s. Continuing anyway.", e) - else: + try: + if repair_database(db_path, backup=True, logger_override=logger): + try: + conn = sqlite3.connect(str(db_path), timeout=10.0) + cursor = conn.cursor() + cursor.execute("PRAGMA integrity_check") + result = cursor.fetchone()[0] + conn.close() + if result == "ok": + logger.info("Database repair successful - integrity verified") + return + logger.critical( + "Database repair completed but integrity still failing: %s. " + "Continuing anyway; consider manual repair or fresh DB.", + result, + ) + except Exception as e: + logger.critical("Database repair verification failed: %s. Continuing anyway.", e) + return + except Exception as e: + from qBitrr.db_recovery import DatabaseRecoveryError + logger.critical( - "Automatic database repair failed. " - "Consider stopping qBitrr and running scripts/repair_database.py manually." + "Automatic database repair failed: %s. " + "Consider stopping qBitrr and running scripts/repair_database.py manually, " + "or remove/rename the database file to start with a fresh database (data loss).", + e, ) + if isinstance(e, DatabaseRecoveryError) and db_path.exists(): + _offer_fresh_start(db_path) def get_database() -> SqliteDatabase: From 5cc1d2d127d0251f64d0b930402da2ceff5abb10 Mon Sep 17 00:00:00 2001 From: Feramance Date: Thu, 26 Feb 2026 14:30:35 +0100 Subject: [PATCH 10/66] fix: reduce DB corruption on unclean shutdown; delete corrupt DB on failed repair - Docker: add tini as PID 1 so SIGTERM reaches Python (enables checkpoint on stop) - docker-compose: stop_grace_period: 30s so cleanup runs before SIGKILL - database: on failed repair delete corrupt DB (not move) so fresh DB is created - docs: document why corruption happens, tini/stop_grace_period, prevention --- Dockerfile | 7 ++++++- docker-compose.yml | 1 + docs/troubleshooting/database.md | 21 +++++++++++---------- docs/troubleshooting/docker.md | 25 +++++++++++++++++++++++++ qBitrr/database.py | 15 +++++---------- 5 files changed, 48 insertions(+), 21 deletions(-) diff --git a/Dockerfile b/Dockerfile index ceeb46e65..aea06a58d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,10 @@ LABEL Maintainer="feramance" LABEL Version="5.9.1" LABEL org.opencontainers.image.source=https://github.com/feramance/qbitrr +# Install tini so PID 1 forwards SIGTERM to Python (enables graceful shutdown and DB checkpoint) +RUN apt-get update && apt-get install -y --no-install-recommends tini \ + && rm -rf /var/lib/apt/lists/* + # Env used by the script to determine if it's inside a docker - # if this is set to 69420 it will change the working dir for docker specific values ENV QBITRR_DOCKER_RUNNING=69420 @@ -31,4 +35,5 @@ WORKDIR /config EXPOSE 6969 -ENTRYPOINT ["python", "-m", "qBitrr.main"] +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["python", "-m", "qBitrr.main"] diff --git a/docker-compose.yml b/docker-compose.yml index ea2327cff..5c2152dfa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: - PGID=${USER_ID} restart: always tty: true + stop_grace_period: 30s volumes: - /mnt/media/Torrents:/torrents - .config:/config diff --git a/docs/troubleshooting/database.md b/docs/troubleshooting/database.md index 22c7be092..4151b7f01 100644 --- a/docs/troubleshooting/database.md +++ b/docs/troubleshooting/database.md @@ -314,14 +314,14 @@ sqlite3.OperationalError: database is locked ``` sqlite3.DatabaseError: database disk image is malformed sqlite3.OperationalError: disk I/O error +PRAGMA integrity_check: Freelist: size is 1 but should be 3 / btreeInitPage() returns error code 11 ``` -**Causes**: +**Why this happens**: -- Unexpected shutdown (power loss, container crash) -- Disk full during write operation -- Hardware failures (bad sectors, memory errors) -- File system corruption (ext4, btrfs, zfs) +- **Unclean shutdown** — Container or process killed (e.g. `docker kill`, OOM, or Docker stop before cleanup runs) so the database WAL is never checkpointed. This is the most common cause in Docker when the main process is PID 1 and does not receive SIGTERM. +- **Storage or filesystem** — NFS/CIFS, failing disk, full disk, or power loss during a write. +- **Multiple writers / locking** — Rare if only qBitrr uses the DB; ensure no other app touches `qbitrr.db`. **Automatic Recovery**: @@ -396,11 +396,12 @@ qBitrr automatically attempts recovery when corruption is detected: Follow these practices to minimize corruption risk: -1. **Use local storage** — SQLite on NFS/CIFS is unreliable due to file locking issues -2. **Graceful shutdown** — Always use `docker stop` (not `docker kill`); set `stop_grace_period: 30s` in Docker Compose -3. **Ensure adequate disk space** — WAL operations need temporary space; configure `FreeSpace` in config -4. **Regular backups** — Set up daily backups of `qbitrr.db` (see [Backup & Restore](#database-backup-restore)) -5. **Proper permissions** — Ensure the qBitrr user owns the database files (`chown 1000:1000 /config/qBitManager/qbitrr.db`) +1. **Use local storage** — SQLite on NFS/CIFS is unreliable due to file locking and sync semantics. Keep `qbitrr.db` on a local disk or a volume backed by local storage. +2. **Graceful shutdown** — Always use `docker stop` (not `docker kill`). Use a stop grace period so the app has time to run its shutdown handler and checkpoint the DB (see Docker section below). +3. **Docker: init process and stop_grace_period** — The official image uses **tini** as PID 1 so that when Docker sends SIGTERM, it is forwarded to the Python process. That allows the app to run its cleanup (checkpoint WAL, then exit). Set `stop_grace_period: 30s` (or more) in your Compose file so Docker does not send SIGKILL before cleanup finishes. +4. **Ensure adequate disk space** — WAL operations need temporary space; configure `FreeSpace` in config. +5. **Regular backups** — Set up daily backups of `qbitrr.db` (see [Backup & Restore](#database-backup-restore)). +6. **Proper permissions** — Ensure the qBitrr user owns the database files (`chown 1000:1000 /config/qBitManager/qbitrr.db`). !!! note "Historical Fix: synchronous Setting" Prior to the fix in commit `465c306d`, qBitrr used `PRAGMA synchronous=0` (OFF), which traded data integrity for write speed. This was changed to `synchronous=1` (NORMAL), which prevents corruption from power loss/crashes with minimal performance impact (~5-10% write latency increase, mitigated by WAL mode). diff --git a/docs/troubleshooting/docker.md b/docs/troubleshooting/docker.md index 3451b298f..e6328760a 100644 --- a/docs/troubleshooting/docker.md +++ b/docs/troubleshooting/docker.md @@ -308,6 +308,31 @@ docker exec qbitrr printenv | grep -i qbit ## Container Health +### Database Corruption After Restart (Docker) + +**Symptom:** After `docker stop` / `docker restart`, logs show "database disk image is malformed" or integrity check failures. + +**Cause:** The main process did not receive SIGTERM (or was killed before cleanup), so the database WAL was never checkpointed. This used to happen when Python was PID 1 in the container. + +**What qBitrr does to prevent it:** + +- The official image runs **tini** as PID 1 so SIGTERM is forwarded to the Python process. On SIGTERM, qBitrr checkpoints the database WAL and then exits. +- Use **`stop_grace_period: 30s`** (or more) in your Compose file so Docker waits long enough for cleanup before sending SIGKILL. + +**Example:** + +```yaml +services: + qbitrr: + image: feramance/qbitrr:latest + stop_grace_period: 30s # Give time for DB checkpoint on stop + # ... rest of config +``` + +**Do not:** Use `docker kill` or `docker stop -t 0`; that skips graceful shutdown and can corrupt the database. + +--- + ### Container Constantly Restarting **Check restart policy:** diff --git a/qBitrr/database.py b/qBitrr/database.py index c113b9693..43e0ea47a 100644 --- a/qBitrr/database.py +++ b/qBitrr/database.py @@ -4,7 +4,6 @@ import logging import sqlite3 -import time from pathlib import Path from peewee import SqliteDatabase @@ -33,19 +32,16 @@ def _offer_fresh_start(db_path: Path) -> None: """ - Move corrupt database aside and allow a fresh DB to be created on connect. + Delete corrupt database so a fresh one is created on connect. Use only after repair has failed; causes loss of existing DB data. """ try: - ts = int(time.time()) - corrupt_path = db_path.parent / f"qbitrr.db.corrupt.{ts}" - db_path.rename(corrupt_path) + db_path.unlink() logger.warning( - "Moved corrupt database to %s; a fresh database will be created on connect.", - corrupt_path.name, + "Deleted corrupt database; a fresh database will be created on connect.", ) except Exception as e: - logger.debug("Could not move corrupt database aside: %s", e) + logger.debug("Could not delete corrupt database: %s", e) def _startup_integrity_check_and_repair(db_path: Path) -> None: @@ -111,8 +107,7 @@ def _startup_integrity_check_and_repair(db_path: Path) -> None: logger.critical( "Automatic database repair failed: %s. " - "Consider stopping qBitrr and running scripts/repair_database.py manually, " - "or remove/rename the database file to start with a fresh database (data loss).", + "Corrupt database will be removed and a fresh one created on connect (data loss).", e, ) if isinstance(e, DatabaseRecoveryError) and db_path.exists(): From e5179f8121b478dc38545e83aae314a139932ff1 Mon Sep 17 00:00:00 2001 From: Feramance Date: Thu, 26 Feb 2026 14:37:54 +0100 Subject: [PATCH 11/66] fix: FreeSpaceManager monitor qBit-managed categories too Pass arr_categories | qbit_managed_categories to FreeSpaceManager so torrents in ManagedCategories (e.g. autobrr) are included in free-space checks and can be paused when disk is low. --- qBitrr/arss.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/qBitrr/arss.py b/qBitrr/arss.py index 5d66af758..984b44815 100755 --- a/qBitrr/arss.py +++ b/qBitrr/arss.py @@ -7953,13 +7953,15 @@ def build_arr_instances(self): # Validate category assignments after all Arr instances are initialized self._validate_category_assignments() + # FreeSpaceManager monitors both Arr-managed and qBit-managed categories + all_monitored_categories = self.arr_categories | self.qbit_managed_categories if ( FREE_SPACE != "-1" and AUTO_PAUSE_RESUME and not QBIT_DISABLED - and len(self.arr_categories) > 0 + and len(all_monitored_categories) > 0 ): - managed_object = FreeSpaceManager(self.arr_categories, self) + managed_object = FreeSpaceManager(all_monitored_categories, self) self.managed_objects["FreeSpaceManager"] = managed_object for cat in self.special_categories: managed_object = PlaceHolderArr(cat, self) From c4ff7ec8b4754f5744b199419f0b808d66a8be64 Mon Sep 17 00:00:00 2001 From: Feramance Date: Thu, 26 Feb 2026 14:57:46 +0100 Subject: [PATCH 12/66] docs: fix MkDocs build; document FSM category monitoring - requirements.docs.txt: pin mkdocs<2.0 for Material compatibility - mkdocs.yml: use logo SVG, update copyright to 2026 - disk-space.md: document Arr + qBit-managed categories for FSM; add troubleshooting step for unmonitored categories --- docs/features/disk-space.md | 16 +++++++++++++++- mkdocs.yml | 4 ++-- requirements.docs.txt | 2 +- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/features/disk-space.md b/docs/features/disk-space.md index 6692708ad..c687be918 100644 --- a/docs/features/disk-space.md +++ b/docs/features/disk-space.md @@ -53,6 +53,15 @@ graph TD - Automatically resumes paused downloads - Continues normal operation +**Which torrents are monitored:** + +The Free Space Manager monitors torrents in **all categories** that qBitrr manages: + +- **Arr-managed categories** — The `Category` setting for each Radarr, Sonarr, and Lidarr instance (e.g. `radarr`, `sonarr`, `radarr4k`). +- **qBit-managed categories** — Categories listed in `[qBit].ManagedCategories` (e.g. `autobrr`). + +Torrents in any of these categories are included in free-space checks and can be paused when disk is low. Torrents in other qBittorrent categories (not used by any Arr instance and not in `ManagedCategories`) are not monitored. + --- ## Configuration @@ -456,9 +465,14 @@ Check paused torrents in qBittorrent: df -h /data/downloads ``` -4. **Check logs for errors:** +4. **Ensure torrents are in monitored categories:** + Free Space Manager only considers torrents in Arr-managed categories (each instance's `Category`) and in `[qBit].ManagedCategories`. If your torrents use a different qBittorrent category that is not in either set, they will not be paused. Add that category to an Arr instance's `Category` or to `qBit.ManagedCategories`. + +5. **Check logs for errors:** ```bash grep -i "space\|error" ~/logs/Main.log + # Or FreeSpaceManager log + tail -f ~/config/logs/FreeSpaceManager.log ``` --- diff --git a/mkdocs.yml b/mkdocs.yml index a071c25b8..09e2bfdd7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,13 +12,13 @@ exclude_docs: | includes/abbreviations.md # Copyright -copyright: Copyright © 2025 Feramance +copyright: Copyright © 2026 Feramance # Configuration theme: name: material language: en - logo: assets/logov2-clean.png + logo: assets/logov2-clean.svg favicon: assets/favicon.ico icon: repo: fontawesome/brands/github diff --git a/requirements.docs.txt b/requirements.docs.txt index 84d563ab6..94a34b5c2 100644 --- a/requirements.docs.txt +++ b/requirements.docs.txt @@ -1,5 +1,5 @@ markdown-include>=0.8.0 -mkdocs>=1.5.3 +mkdocs>=1.5.3,<2.0 mkdocs-git-revision-date-localized-plugin>=1.2.0 mkdocs-include-markdown-plugin>=6.0.0 mkdocs-material>=9.5.0 From a7967ef8440b286a9600f02f8d1e1a809ad346de Mon Sep 17 00:00:00 2001 From: Feramance Date: Thu, 26 Feb 2026 15:11:36 +0100 Subject: [PATCH 13/66] ci: fix PR and docs workflows - pull_requests: skip docker_image job on fork PRs (no secrets); use brew install sdl2 on macOS instead of full Homebrew installer - mkdocs: remove missing favicon reference so --strict build passes in CI - requirements.docs: pin mkdocs<1.6 to avoid 2.x incompatibility warning --- .github/workflows/pull_requests.yml | 4 +++- mkdocs.yml | 1 - requirements.docs.txt | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pull_requests.yml b/.github/workflows/pull_requests.yml index ed2210578..e6ba24bef 100644 --- a/.github/workflows/pull_requests.yml +++ b/.github/workflows/pull_requests.yml @@ -43,7 +43,8 @@ jobs: ref: ${{ github.event.pull_request.head.ref }} - name: Install Homebrew dependencies if: runner.os == 'macOS' - run: "/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"\n" + run: | + brew install sdl2 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v6 with: @@ -102,6 +103,7 @@ jobs: docker_image: name: Build Docker Images runs-on: ubuntu-latest + if: github.event.pull_request.head.repo.full_name == github.repository permissions: packages: write contents: read diff --git a/mkdocs.yml b/mkdocs.yml index 09e2bfdd7..949cfd7c5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -19,7 +19,6 @@ theme: name: material language: en logo: assets/logov2-clean.svg - favicon: assets/favicon.ico icon: repo: fontawesome/brands/github palette: diff --git a/requirements.docs.txt b/requirements.docs.txt index 94a34b5c2..efaf3e9a3 100644 --- a/requirements.docs.txt +++ b/requirements.docs.txt @@ -1,5 +1,5 @@ markdown-include>=0.8.0 -mkdocs>=1.5.3,<2.0 +mkdocs>=1.5.3,<1.6 mkdocs-git-revision-date-localized-plugin>=1.2.0 mkdocs-include-markdown-plugin>=6.0.0 mkdocs-material>=9.5.0 From b2ed922b25b27cfb627e39c0d33f2e7948017c51 Mon Sep 17 00:00:00 2001 From: Feramance Date: Thu, 26 Feb 2026 16:28:56 +0100 Subject: [PATCH 14/66] fix: docs nav for strict build, ConfigView parse types, Configuration on home --- config.example.toml | 7 +- docs/configuration/seeding.md | 47 +++++-------- mkdocs.yml | 4 +- qBitrr/arss.py | 24 +++++-- qBitrr/database.py | 27 ++++--- qBitrr/gen_config.py | 121 ++++++++++++++++++++------------ qBitrr/main.py | 6 +- qBitrr/qbit_category_manager.py | 24 ++----- webui/src/config/tooltips.ts | 8 +-- webui/src/pages/ConfigView.tsx | 18 +++-- 10 files changed, 143 insertions(+), 143 deletions(-) diff --git a/config.example.toml b/config.example.toml index 8c630b94c..71daf0a1c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -155,11 +155,8 @@ MaxSeedingTime = -1 # 4 = Remove when both conditions are met (AND) RemoveTorrent = -1 -# Enable Hit and Run protection for managed category torrents -HitAndRunMode = false - -# HnR clear mode: and = require both ratio and time; or = either; disabled = no HnR -HitAndRunClearMode = "disabled" +# Hit and Run mode: and = require both ratio and time; or = either clears; disabled = no HnR +HitAndRunMode = "disabled" # Minimum seed ratio before removal allowed (HnR protection) MinSeedRatio = 1.0 diff --git a/docs/configuration/seeding.md b/docs/configuration/seeding.md index f2559564c..ddee25849 100644 --- a/docs/configuration/seeding.md +++ b/docs/configuration/seeding.md @@ -370,7 +370,7 @@ RemoveIfExists = false SuperSeedMode = false # Hit and Run protection -HitAndRunMode = true +HitAndRunMode = "and" MinSeedRatio = 1.0 MinSeedingTimeDays = 5 TrackerUpdateBuffer = 3600 @@ -603,36 +603,21 @@ Automatically add tags to torrents with this tracker. #### HitAndRunMode ```toml -HitAndRunMode = true -``` - -**Type:** Boolean -**Default:** `false` - -Enable Hit and Run (HnR) protection for this tracker. When `true`, torrents with this tracker will **not** be removed until HnR obligations are met, regardless of `RemoveTorrent` settings. See [Hit and Run Protection](#hit-and-run-protection) for details on how obligations are calculated. - -**Backwards compatibility:** If `HitAndRunClearMode` is not set, `HitAndRunMode = true` is treated as clear mode `"and"`. - ---- - -#### HitAndRunClearMode - -```toml -HitAndRunClearMode = "and" # or "or", "disabled" +HitAndRunMode = "and" # or "or", "disabled" ``` **Type:** String: `"and"` | `"or"` | `"disabled"` -**Default:** `"disabled"` (no HnR). Legacy: `HitAndRunMode = true` migrates to `"and"`. +**Default:** `"disabled"` (no HnR) -Controls **how** ratio and time clear HnR for full downloads (when both `MinSeedRatio` and `MinSeedingTimeDays` are set): +Single setting for Hit and Run (HnR) protection for this tracker: | Value | Meaning | |-------|---------| -| `"and"` | HnR clears only when **both** minimum ratio **and** minimum time are met | -| `"or"` | HnR clears when **either** minimum ratio **or** minimum time is met | +| `"and"` | HnR enabled; clears only when **both** minimum ratio **and** minimum time are met | +| `"or"` | HnR enabled; clears when **either** minimum ratio **or** minimum time is met | | `"disabled"` | No HnR protection; torrents can be removed per `RemoveTorrent` only | -When only one of ratio or time is set (e.g. `MinSeedingTimeDays = 0`), that single condition is used regardless of clear mode. Partial downloads always use `HitAndRunPartialSeedRatio` only. +When `"and"` or `"or"`, torrents with this tracker will **not** be removed until HnR obligations are met, regardless of `RemoveTorrent` settings. When only one of ratio or time is set (e.g. `MinSeedingTimeDays = 0`), that single condition is used. Partial downloads always use `HitAndRunPartialSeedRatio` only. --- @@ -645,7 +630,7 @@ MinSeedRatio = 1.0 **Type:** Float **Default:** `1.0` -Minimum upload ratio required to clear HnR. A value of `1.0` means you must upload at least as much as you downloaded. For full downloads, whether **both** ratio and time are required or **either** clears the obligation is controlled by [`HitAndRunClearMode`](#hitandrunclearmode) (`"and"` vs `"or"`). +Minimum upload ratio required to clear HnR. A value of `1.0` means you must upload at least as much as you downloaded. For full downloads, whether **both** ratio and time are required or **either** clears the obligation is controlled by [`HitAndRunMode`](#hitandrunmode) (`"and"` vs `"or"`). --- @@ -749,7 +734,7 @@ Priority = 10 URI = "https://tracker.beyond-hd.me/announce" MaxUploadRatio = 1.5 MaxSeedingTime = 432000 # 5 days -HitAndRunMode = true +HitAndRunMode = "and" MinSeedRatio = 1.0 MinSeedingTimeDays = 5 AddTags = ["private", "BeyondHD"] @@ -787,7 +772,7 @@ Priority = 10 URI = "https://flacsfor.me/announce" MaxUploadRatio = -1 # Never remove based on ratio MaxSeedingTime = 259200 # 72 hours -HitAndRunMode = true +HitAndRunMode = "and" MinSeedRatio = 1.0 MinSeedingTimeDays = 3 AddTags = ["RED", "music", "lossless"] @@ -1158,7 +1143,7 @@ qBitrr's HnR protection acts as a **safety layer** on top of the existing seedin - **Downloads below [`HitAndRunMinimumDownloadPercent`](#hitandrunminimumdownloadpercent)%** (default 10%): Not subject to HnR (most trackers don't count these) - **[`TrackerUpdateBuffer`](#trackerupdatebuffer)**: Extra seconds subtracted from seeding time to account for tracker stats lag (~30 min behind the client) -When [`HitAndRunMode`](#hitandrunmode) = `false` (default), behavior is identical to previous versions. +When [`HitAndRunMode`](#hitandrunmode) = `"disabled"` (default), behavior is identical to previous versions. **Automatic HnR bypass:** If a tracker reports the torrent as unregistered, unauthorized, or not found, HnR protection is automatically bypassed — the torrent no longer exists on the tracker, so seeding obligations no longer apply. @@ -1192,7 +1177,7 @@ RemoveTorrent = 3 # Remove when either met Name = "TorrentLeech" URI = "tracker.torrentleech.org" Priority = 10 -HitAndRunMode = true +HitAndRunMode = "and" MinSeedRatio = 1.0 MinSeedingTimeDays = 10 # Registered class requirement HitAndRunMinimumDownloadPercent = 10 # TL counts HnR at >=10% downloaded @@ -1212,7 +1197,7 @@ RemoveTorrent = 3 Name = "TorrentLeech" URI = "tracker.torrentleech.org" Priority = 10 -HitAndRunMode = true +HitAndRunMode = "and" MinSeedRatio = 1.0 MinSeedingTimeDays = 4 # TL God class requirement TrackerUpdateBuffer = 3600 @@ -1231,7 +1216,7 @@ RemoveTorrent = 3 Name = "TorrentLeech" URI = "tracker.torrentleech.org" Priority = 10 -HitAndRunMode = true +HitAndRunMode = "and" MinSeedRatio = 1.0 MinSeedingTimeDays = 10 TrackerUpdateBuffer = 3600 @@ -1241,7 +1226,7 @@ TrackerUpdateBuffer = 3600 Name = "IPTorrents" URI = "tracker.iptorrents.com" Priority = 8 -HitAndRunMode = true +HitAndRunMode = "and" MinSeedRatio = 1.0 MinSeedingTimeDays = 4 TrackerUpdateBuffer = 3600 @@ -1251,7 +1236,7 @@ TrackerUpdateBuffer = 3600 Name = "Public" URI = "udp://open.stealth.si:80/announce" Priority = 1 -HitAndRunMode = false +HitAndRunMode = "disabled" ``` --- diff --git a/mkdocs.yml b/mkdocs.yml index 949cfd7c5..d6a0e800c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -191,12 +191,12 @@ nav: - Home: - index.md - Getting Started: getting-started/index.md + - Configuration: configuration/index.md - Features: features/index.md - FAQ: faq.md - Changelog: changelog.md - Getting Started: - - getting-started/index.md - Installation: - getting-started/installation/index.md - getting-started/installation/docker.md @@ -207,7 +207,6 @@ nav: - Migration Guide: getting-started/migration.md - Configuration: - - configuration/index.md - Configuration File: configuration/config-file.md - qBittorrent: configuration/qbittorrent.md - Arr Instances: @@ -227,7 +226,6 @@ nav: - Environment Variables: configuration/environment.md - Features: - - features/index.md - Torrent Health Monitoring: features/health-monitoring.md - Instant Imports: features/instant-imports.md - Automated Search: features/automated-search.md diff --git a/qBitrr/arss.py b/qBitrr/arss.py index 984b44815..f9d0077a8 100755 --- a/qBitrr/arss.py +++ b/qBitrr/arss.py @@ -5740,11 +5740,14 @@ def _get_most_important_tracker_and_tags( return max_item, set(itertools.chain.from_iterable(_list_of_tags)) def _resolve_hnr_clear_mode(self, tracker_or_config: dict) -> str: - """Resolve HnR clear mode: 'and' | 'or' | 'disabled'. Backwards compat: HitAndRunMode true -> 'and'.""" - raw = tracker_or_config.get("HitAndRunClearMode") + """Resolve HnR mode from single HitAndRunMode key: 'and' | 'or' | 'disabled'.""" + raw = tracker_or_config.get("HitAndRunMode") if isinstance(raw, str) and raw.strip().lower() in ("and", "or", "disabled"): return raw.strip().lower() - return "and" if tracker_or_config.get("HitAndRunMode", False) else "disabled" + # Legacy: boolean HitAndRunMode (pre-migration) + if raw is True: + return "and" + return "disabled" def _get_torrent_limit_meta(self, torrent: qbittorrentapi.TorrentDictionary): _, monitored_trackers = self._get_torrent_important_trackers(torrent) @@ -5795,7 +5798,6 @@ def _get_torrent_limit_meta(self, torrent: qbittorrentapi.TorrentDictionary): ), "super_seeding": most_important_tracker.get("SuperSeedMode", torrent.super_seeding), "max_eta": most_important_tracker.get("MaximumETA", self.maximum_eta), - "hnr_mode": most_important_tracker.get("HitAndRunMode", False), "hnr_clear_mode": self._resolve_hnr_clear_mode(most_important_tracker), "hnr_min_seed_ratio": most_important_tracker.get("MinSeedRatio", 1.0), "hnr_min_seeding_time_days": most_important_tracker.get("MinSeedingTimeDays", 0), @@ -7625,9 +7627,11 @@ def __init__(self, categories: set[str], manager: ArrManager): # Track search setup state to cooperate with Arr.register_search_mode self.search_setup_completed = False if FREE_SPACE_FOLDER == "CHANGE_ME": - self.completed_folder = pathlib.Path(COMPLETED_DOWNLOAD_FOLDER).joinpath( - next(iter(self.categories)) - ) + # Prefer an Arr-managed category so the path exists (Arr uses category subdirs). + # qBit-managed-only categories may have no subdir under COMPLETED_DOWNLOAD_FOLDER. + arr_cats = self.categories & self.manager.arr_categories + chosen = next(iter(arr_cats), None) or next(iter(self.categories)) + self.completed_folder = pathlib.Path(COMPLETED_DOWNLOAD_FOLDER).joinpath(chosen) else: self.completed_folder = pathlib.Path(FREE_SPACE_FOLDER) self.min_free_space = FREE_SPACE @@ -7635,6 +7639,12 @@ def __init__(self, categories: set[str], manager: ArrManager): self._min_free_space_bytes = ( parse_size(self.min_free_space) if self.min_free_space != "-1" else 0 ) + if not self.completed_folder.exists(): + # Fallback to parent when chosen category subdir doesn't exist (e.g. qBit-only). + parent = pathlib.Path(COMPLETED_DOWNLOAD_FOLDER) + if parent.exists(): + self.completed_folder = parent + # else: keep completed_folder and let disk_usage raise so the user sees the error self.current_free_space = ( shutil.disk_usage(self.completed_folder).free - self._min_free_space_bytes ) diff --git a/qBitrr/database.py b/qBitrr/database.py index 43e0ea47a..990a5b158 100644 --- a/qBitrr/database.py +++ b/qBitrr/database.py @@ -53,11 +53,10 @@ def _startup_integrity_check_and_repair(db_path: Path) -> None: return result = "unknown" try: - conn = sqlite3.connect(str(db_path), timeout=10.0) - cursor = conn.cursor() - cursor.execute("PRAGMA integrity_check") - result = cursor.fetchone()[0] - conn.close() + with sqlite3.connect(str(db_path), timeout=10.0) as conn: + cursor = conn.cursor() + cursor.execute("PRAGMA integrity_check") + result = cursor.fetchone()[0] if result == "ok": return except Exception as e: @@ -71,11 +70,10 @@ def _startup_integrity_check_and_repair(db_path: Path) -> None: if checkpoint_wal(db_path, logger_override=logger): try: - conn = sqlite3.connect(str(db_path), timeout=10.0) - cursor = conn.cursor() - cursor.execute("PRAGMA integrity_check") - result = cursor.fetchone()[0] - conn.close() + with sqlite3.connect(str(db_path), timeout=10.0) as conn: + cursor = conn.cursor() + cursor.execute("PRAGMA integrity_check") + result = cursor.fetchone()[0] if result == "ok": logger.info("WAL checkpoint resolved database issue - integrity ok") return @@ -86,11 +84,10 @@ def _startup_integrity_check_and_repair(db_path: Path) -> None: try: if repair_database(db_path, backup=True, logger_override=logger): try: - conn = sqlite3.connect(str(db_path), timeout=10.0) - cursor = conn.cursor() - cursor.execute("PRAGMA integrity_check") - result = cursor.fetchone()[0] - conn.close() + with sqlite3.connect(str(db_path), timeout=10.0) as conn: + cursor = conn.cursor() + cursor.execute("PRAGMA integrity_check") + result = cursor.fetchone()[0] if result == "ok": logger.info("Database repair successful - integrity verified") return diff --git a/qBitrr/gen_config.py b/qBitrr/gen_config.py index 48d45c2fd..27919996b 100755 --- a/qBitrr/gen_config.py +++ b/qBitrr/gen_config.py @@ -343,21 +343,12 @@ def _add_qbit_section(config: TOMLDocument): "RemoveTorrent", -1, ) - _gen_default_line( - category_seeding, - "Enable Hit and Run protection for managed category torrents", - "HitAndRunMode", - False, - ) _gen_default_line( category_seeding, [ - "HnR clear mode: when ratio/time can remove HnR obligation.", - " and = require both MinSeedRatio AND MinSeedingTimeDays (when both set)", - " or = require either ratio OR time", - " disabled = no HnR protection. Legacy: HitAndRunMode true is migrated to and.", + "Hit and Run mode: and = require both ratio and time; or = either clears; disabled = no HnR.", ], - "HitAndRunClearMode", + "HitAndRunMode", "disabled", ) _gen_default_line( @@ -1245,8 +1236,7 @@ def _migrate_qbit_category_settings(config: MyConfig) -> bool: seeding["MaxUploadRatio"] = -1 seeding["MaxSeedingTime"] = -1 seeding["RemoveTorrent"] = -1 - seeding["HitAndRunMode"] = False - seeding["HitAndRunClearMode"] = "disabled" + seeding["HitAndRunMode"] = "disabled" seeding["MinSeedRatio"] = 1.0 seeding["MinSeedingTimeDays"] = 0 seeding["HitAndRunMinimumDownloadPercent"] = 10 @@ -1272,7 +1262,7 @@ def _migrate_qbit_category_settings(config: MyConfig) -> bool: seeding["MaxUploadRatio"] = -1 seeding["MaxSeedingTime"] = -1 seeding["RemoveTorrent"] = -1 - seeding["HitAndRunMode"] = False + seeding["HitAndRunMode"] = "disabled" seeding["MinSeedRatio"] = 1.0 seeding["MinSeedingTimeDays"] = 0 seeding["HitAndRunMinimumDownloadPercent"] = 10 @@ -1313,8 +1303,7 @@ def _migrate_hnr_settings(config: MyConfig) -> bool: changes_made = False arr_types = ["Radarr", "Sonarr", "Lidarr", "Animarr"] hnr_seeding_defaults = { - "HitAndRunMode": False, - "HitAndRunClearMode": "disabled", + "HitAndRunMode": "disabled", "MinSeedRatio": 1.0, "MinSeedingTimeDays": 0, "HitAndRunMinimumDownloadPercent": 10, @@ -1451,52 +1440,98 @@ def _migrate_hnr_settings(config: MyConfig) -> bool: return changes_made -def _migrate_hnr_clear_mode(config: MyConfig) -> bool: +def _migrate_hnr_single_key(config: MyConfig) -> bool: """ - Add HitAndRunClearMode and migrate HitAndRunMode true -> "and". + Consolidate HitAndRunMode (bool) + HitAndRunClearMode (string) into single HitAndRunMode string. - Migration runs if ConfigVersion < "5.9.2". - Sets HitAndRunClearMode = "and" where HitAndRunMode is true and key missing; - otherwise sets HitAndRunClearMode = "disabled" when key missing. + Runs when ConfigVersion < "5.9.2" or when already 5.9.2 but HitAndRunClearMode is present. + Sets HitAndRunMode = "and" | "or" | "disabled", removes HitAndRunClearMode. Returns True if any change was made. """ from qBitrr.config_version import _parse_version, get_config_version current_version = _parse_version(get_config_version(config)) - if current_version >= _parse_version("5.9.2"): + valid_modes = ("and", "or", "disabled") + + def _has_hnr_clear_mode_anywhere() -> bool: + for key in list(config.config.keys()): + section = config.config.get(str(key)) + if not isinstance(section, dict): + continue + if "CategorySeeding" in section: + cs = section["CategorySeeding"] + if isinstance(cs, dict) and "HitAndRunClearMode" in cs: + return True + if "Trackers" in section and isinstance(section["Trackers"], list): + for t in section["Trackers"]: + if isinstance(t, dict) and "HitAndRunClearMode" in t: + return True + if "Torrent" in section and isinstance(section["Torrent"], dict): + tt = section["Torrent"] + if "Trackers" in tt and isinstance(tt["Trackers"], list): + for t in tt["Trackers"]: + if isinstance(t, dict) and "HitAndRunClearMode" in t: + return True + return False + + if current_version >= _parse_version("5.9.2") and not _has_hnr_clear_mode_anywhere(): return False + def _resolve(d: dict) -> str: + raw_clear = d.get("HitAndRunClearMode") + if isinstance(raw_clear, str) and raw_clear.strip().lower() in valid_modes: + return raw_clear.strip().lower() + raw_mode = d.get("HitAndRunMode") + if isinstance(raw_mode, str) and raw_mode.strip().lower() in valid_modes: + return raw_mode.strip().lower() + # Legacy boolean HitAndRunMode + if raw_mode is True: + return "and" + return "disabled" + changes_made = False for key in list(config.config.keys()): section = config.config[str(key)] if not isinstance(section, dict): continue - # CategorySeeding if "CategorySeeding" in section: cs = section["CategorySeeding"] - if isinstance(cs, dict) and "HitAndRunClearMode" not in cs: - cs["HitAndRunClearMode"] = "and" if cs.get("HitAndRunMode", False) else "disabled" - changes_made = True - # Trackers list + if isinstance(cs, dict): + had_clear = "HitAndRunClearMode" in cs + had_bool = isinstance(cs.get("HitAndRunMode"), bool) + resolved = _resolve(cs) + cs["HitAndRunMode"] = resolved + if had_clear: + del cs["HitAndRunClearMode"] + if had_clear or had_bool: + changes_made = True if "Trackers" in section and isinstance(section["Trackers"], list): for tracker in section["Trackers"]: - if isinstance(tracker, dict) and "HitAndRunClearMode" not in tracker: - tracker["HitAndRunClearMode"] = ( - "and" if tracker.get("HitAndRunMode", False) else "disabled" - ) - changes_made = True + if isinstance(tracker, dict): + had_clear = "HitAndRunClearMode" in tracker + had_bool = isinstance(tracker.get("HitAndRunMode"), bool) + resolved = _resolve(tracker) + tracker["HitAndRunMode"] = resolved + if had_clear: + del tracker["HitAndRunClearMode"] + if had_clear or had_bool: + changes_made = True if "Torrent" in section and isinstance(section["Torrent"], dict): tt = section["Torrent"] if "Trackers" in tt and isinstance(tt["Trackers"], list): for tracker in tt["Trackers"]: - if isinstance(tracker, dict) and "HitAndRunClearMode" not in tracker: - tracker["HitAndRunClearMode"] = ( - "and" if tracker.get("HitAndRunMode", False) else "disabled" - ) - changes_made = True + if isinstance(tracker, dict): + had_clear = "HitAndRunClearMode" in tracker + had_bool = isinstance(tracker.get("HitAndRunMode"), bool) + resolved = _resolve(tracker) + tracker["HitAndRunMode"] = resolved + if had_clear: + del tracker["HitAndRunClearMode"] + if had_clear or had_bool: + changes_made = True if changes_made: - print("Migration 5.9.1→5.9.2: Added HitAndRunClearMode (and/or/disabled)") + print("Migration 5.9.x→5.9.2: Consolidated HitAndRunMode to single key (and/or/disabled)") return changes_made @@ -1671,8 +1706,7 @@ def ensure_value(config_section: str, key: str, default_value: Any) -> bool: # Validate HnR fields on CategorySeeding and Tracker sections hnr_category_defaults = { - "HitAndRunMode": False, - "HitAndRunClearMode": "disabled", + "HitAndRunMode": "disabled", "MinSeedRatio": 1.0, "MinSeedingTimeDays": 0, "HitAndRunMinimumDownloadPercent": 10, @@ -1680,8 +1714,7 @@ def ensure_value(config_section: str, key: str, default_value: Any) -> bool: "TrackerUpdateBuffer": 0, } hnr_tracker_defaults = { - "HitAndRunMode": False, - "HitAndRunClearMode": "disabled", + "HitAndRunMode": "disabled", "MinSeedRatio": 1.0, "MinSeedingTimeDays": 0, "HitAndRunMinimumDownloadPercent": 10, @@ -1786,8 +1819,8 @@ def apply_config_migrations(config: MyConfig) -> None: if _migrate_hnr_settings(config): changes_made = True - # Add HitAndRunClearMode and migrate HitAndRunMode true -> "and" (< 5.9.2) - if _migrate_hnr_clear_mode(config): + # Consolidate HitAndRunMode to single key and/or/disabled (< 5.9.2 or 5.9.2 with ClearMode) + if _migrate_hnr_single_key(config): changes_made = True # Validate and fill config (this also ensures ConfigVersion field exists) diff --git a/qBitrr/main.py b/qBitrr/main.py index 85a26fc21..72e7de518 100644 --- a/qBitrr/main.py +++ b/qBitrr/main.py @@ -565,8 +565,7 @@ def _init_instance(self, section_name: str, instance_name: str) -> None: # Load HnR protection settings hnr_keys = { - "HitAndRunMode": False, - "HitAndRunClearMode": "disabled", + "HitAndRunMode": "disabled", "MinSeedRatio": 1.0, "MinSeedingTimeDays": 0, "HitAndRunPartialSeedRatio": 1.0, @@ -697,8 +696,7 @@ def _reload_qbit_category_configs(self) -> None: "RemoveTorrent", ] hnr_keys = { - "HitAndRunMode": False, - "HitAndRunClearMode": "disabled", + "HitAndRunMode": "disabled", "MinSeedRatio": 1.0, "MinSeedingTimeDays": 0, "HitAndRunPartialSeedRatio": 1.0, diff --git a/qBitrr/qbit_category_manager.py b/qBitrr/qbit_category_manager.py index bc7a71fcd..27e5e1e92 100644 --- a/qBitrr/qbit_category_manager.py +++ b/qBitrr/qbit_category_manager.py @@ -157,7 +157,6 @@ def _process_single_torrent(self, torrent: TorrentDictionary, category: str): "Name", "AddTags", "HitAndRunMode", - "HitAndRunClearMode", "MinSeedRatio", "MinSeedingTimeDays", "HitAndRunMinimumDownloadPercent", @@ -340,13 +339,9 @@ def _should_remove_torrent(self, torrent: TorrentDictionary, config: dict) -> bo return should_remove def _hnr_clear_mode_enabled(self, config: dict) -> bool: - """True if HnR protection is enabled (clear mode is not 'disabled').""" - mode = (config.get("HitAndRunClearMode") or "").strip().lower() - if mode in ("and", "or"): - return True - if mode == "disabled": - return False - return bool(config.get("HitAndRunMode", False)) # Legacy: HitAndRunMode true -> enabled + """True if HnR protection is enabled (HitAndRunMode is 'and' or 'or').""" + mode = (config.get("HitAndRunMode") or "").strip().lower() + return mode in ("and", "or") def _get_tracker_config(self, torrent: TorrentDictionary) -> dict | None: """Find the highest-priority matching tracker config for this torrent.""" @@ -425,16 +420,9 @@ def _hnr_safe_to_remove(self, torrent: TorrentDictionary, config: dict) -> bool: Returns: True if HnR obligations are met (safe to remove), False otherwise """ - clear_mode = (config.get("HitAndRunClearMode") or "").strip().lower() - if clear_mode == "disabled": - if not config.get("HitAndRunMode", False): - return True - # Legacy: HitAndRunMode true with no HitAndRunClearMode -> treat as "and" - clear_mode = "and" - elif clear_mode not in ("and", "or"): - clear_mode = "and" if config.get("HitAndRunMode", False) else "disabled" - if clear_mode == "disabled": - return True + clear_mode = (config.get("HitAndRunMode") or "disabled").strip().lower() + if clear_mode not in ("and", "or"): + return True # Disabled or invalid: safe to remove min_ratio = config.get("MinSeedRatio", 1.0) min_time_secs = config.get("MinSeedingTimeDays", 0) * 86400 diff --git a/webui/src/config/tooltips.ts b/webui/src/config/tooltips.ts index 90f71fe9d..53906f303 100644 --- a/webui/src/config/tooltips.ts +++ b/webui/src/config/tooltips.ts @@ -164,9 +164,7 @@ export const FIELD_TOOLTIPS: Record = { "Torrent.SeedingMode.RemoveTorrent": "Removal policy: -1 do not remove, 1 remove on ratio, 2 remove on time, 3 remove on ratio or time, 4 remove on ratio and time.", "CategorySeeding.HitAndRunMode": - "Enable Hit and Run protection for managed category torrents.", - "CategorySeeding.HitAndRunClearMode": - "When HnR is enabled: and = require both ratio and time to clear; or = either clears; disabled = no HnR.", + "Hit and Run mode: and = require both ratio and time to clear; or = either clears; disabled = no HnR.", "CategorySeeding.MinSeedRatio": "Minimum seed ratio before removal allowed for managed categories (HnR protection).", "CategorySeeding.MinSeedingTimeDays": @@ -178,9 +176,7 @@ export const FIELD_TOOLTIPS: Record = { "CategorySeeding.TrackerUpdateBuffer": "Extra seconds buffer for tracker stats lag in managed categories.", HitAndRunMode: - "Enable Hit and Run protection for this tracker. Prevents torrent removal until seeding obligations are met.", - HitAndRunClearMode: - "When HnR is enabled: and = require both ratio and time; or = either clears; disabled = no HnR.", + "Hit and Run mode: and = require both ratio and time; or = either clears; disabled = no HnR.", MinSeedRatio: "Minimum seed ratio before HnR obligation is cleared (e.g. 1.0 for 1:1 ratio).", MinSeedingTimeDays: diff --git a/webui/src/pages/ConfigView.tsx b/webui/src/pages/ConfigView.tsx index e02794e29..767cee372 100644 --- a/webui/src/pages/ConfigView.tsx +++ b/webui/src/pages/ConfigView.tsx @@ -455,13 +455,12 @@ const QBIT_FIELDS: FieldDefinition[] = [ { label: "Hit and Run Mode", path: ["CategorySeeding", "HitAndRunMode"], - type: "checkbox", - }, - { - label: "HnR Clear Mode", - path: ["CategorySeeding", "HitAndRunClearMode"], type: "select", options: ["and", "or", "disabled"], + format: (v: unknown) => + v === true ? "and" : v === false ? "disabled" : (v as string), + parse: (v: string | boolean) => + typeof v === "string" ? v : v ? "and" : "disabled", }, { label: "Min Seed Ratio", @@ -1082,13 +1081,12 @@ const ARR_TRACKER_FIELDS: FieldDefinition[] = [ { label: "Hit and Run Mode", path: ["HitAndRunMode"], - type: "checkbox", - }, - { - label: "HnR Clear Mode", - path: ["HitAndRunClearMode"], type: "select", options: ["and", "or", "disabled"], + format: (v: unknown) => + v === true ? "and" : v === false ? "disabled" : (v as string), + parse: (v: string | boolean) => + typeof v === "string" ? v : v ? "and" : "disabled", }, { label: "Min Seed Ratio", From c47b9d888dc2b33dfd6a3f2f079fe2bb6a5d9583 Mon Sep 17 00:00:00 2001 From: Feramance Date: Thu, 26 Feb 2026 16:46:47 +0100 Subject: [PATCH 15/66] fix(docs): disable parallel processing in git-revision-date plugin to fix CI AssertionError --- mkdocs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/mkdocs.yml b/mkdocs.yml index d6a0e800c..28c1313ff 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -122,6 +122,7 @@ plugins: - git-revision-date-localized: enable_creation_date: true type: timeago + enable_parallel_processing: false - minify: minify_html: true minify_js: true From 4363e1ac391e6b7fbafeeecd176074005e502649 Mon Sep 17 00:00:00 2001 From: Feramance Date: Thu, 26 Feb 2026 16:53:36 +0100 Subject: [PATCH 16/66] fix(docs): include root CHANGELOG in docs/changelog.md for MkDocs build --- docs/changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 60b7a6ed9..e137197fb 120000 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1 +1 @@ -../CHANGELOG.md +{% include-markdown "../CHANGELOG.md" %} From bf3f298fd254017abbd83b06c0556b617a8ddc7f Mon Sep 17 00:00:00 2001 From: Feramance Date: Thu, 26 Feb 2026 16:57:44 +0100 Subject: [PATCH 17/66] fix(docs): ensure changelog.md exists in CI by copying CHANGELOG.md when missing --- .github/workflows/docs.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c656e2c4c..a547d7b89 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -55,6 +55,10 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" + - name: Ensure changelog page exists + run: | + if [ ! -f docs/changelog.md ]; then cp CHANGELOG.md docs/changelog.md; fi + - name: Build documentation run: mkdocs build --strict --verbose @@ -99,6 +103,10 @@ jobs: pip install -r requirements.docs.txt pip install linkchecker + - name: Ensure changelog page exists + run: | + if [ ! -f docs/changelog.md ]; then cp CHANGELOG.md docs/changelog.md; fi + - name: Build docs run: mkdocs build From 157e897f9656eb89705bc0902e2f7888b44cfad4 Mon Sep 17 00:00:00 2001 From: Feramance Date: Thu, 26 Feb 2026 17:02:08 +0100 Subject: [PATCH 18/66] fix(docs): remove symlink before copying changelog in CI --- .github/workflows/docs.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a547d7b89..b78523dda 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -57,7 +57,10 @@ jobs: - name: Ensure changelog page exists run: | - if [ ! -f docs/changelog.md ]; then cp CHANGELOG.md docs/changelog.md; fi + if [ ! -f docs/changelog.md ] || [ -L docs/changelog.md ]; then + rm -f docs/changelog.md + cp CHANGELOG.md docs/changelog.md + fi - name: Build documentation run: mkdocs build --strict --verbose @@ -105,7 +108,10 @@ jobs: - name: Ensure changelog page exists run: | - if [ ! -f docs/changelog.md ]; then cp CHANGELOG.md docs/changelog.md; fi + if [ ! -f docs/changelog.md ] || [ -L docs/changelog.md ]; then + rm -f docs/changelog.md + cp CHANGELOG.md docs/changelog.md + fi - name: Build docs run: mkdocs build From c15292c2eb8189c277300c149de401636615b897 Mon Sep 17 00:00:00 2001 From: Feramance Date: Thu, 26 Feb 2026 17:16:35 +0100 Subject: [PATCH 19/66] webui: qBit process view - remove badges, one card per instance - Remove kind badges from qBit process cards and Arr/qBit badge from category chips - Ensure a card is shown for every qBit instance in status, even with no processes - Show instance alive status when a qBit card has no processes - Hide Restart All footer when card has no processes --- webui/src/pages/ProcessesView.tsx | 44 +++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/webui/src/pages/ProcessesView.tsx b/webui/src/pages/ProcessesView.tsx index b2fcf8d0b..29cd70d2c 100644 --- a/webui/src/pages/ProcessesView.tsx +++ b/webui/src/pages/ProcessesView.tsx @@ -287,6 +287,24 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element { instances.get(instanceKey)!.push(proc); }); + // Ensure a qBittorrent card exists for every defined qBit instance (even with no processes) + const qbitInstanceNames = statusData?.qbitInstances + ? Object.keys(statusData.qbitInstances) + : []; + if (qbitInstanceNames.length > 0) { + if (!appBuckets.has("qBittorrent")) appBuckets.set("qBittorrent", new Map()); + const qbitInstances = appBuckets.get("qBittorrent")!; + for (const instanceName of qbitInstanceNames) { + const displayName = + instanceName.toLowerCase().startsWith("qbit") + ? instanceName + : `qBit-${instanceName}`; + if (!qbitInstances.has(displayName)) { + qbitInstances.set(displayName, []); + } + } + } + const appOrder = ["Radarr", "Sonarr", "Lidarr", "qBittorrent", "Other"]; const result: AppGroup[] = Array.from(appBuckets.entries()) @@ -356,10 +374,27 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element { : runningCount === 0 ? "status-indicator--bad" : ""; + // For qBittorrent with no processes, use instance alive from status + const qbitInstanceKey = + app === "qBittorrent" && totalCount === 0 + ? (instanceName.toLowerCase().startsWith("qbit-") + ? instanceName.slice(5) + : instanceName) + : null; + const qbitInstanceAlive = + qbitInstanceKey != null + ? statusData?.qbitInstances?.[qbitInstanceKey]?.alive ?? false + : null; const statusClass = ["status-indicator"]; if (tone) statusClass.push(tone); + else if (qbitInstanceAlive !== null) + statusClass.push(qbitInstanceAlive ? "status-indicator--ok" : "status-indicator--bad"); const statusLabel = - totalCount === 0 + totalCount === 0 && qbitInstanceAlive !== null + ? qbitInstanceAlive + ? "Instance running" + : "Instance stopped" + : totalCount === 0 ? "No processes" : runningCount === totalCount ? "All running" @@ -382,7 +417,7 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element {
{displayName}
{summaryLabel}
- {filteredKinds.length ? ( + {app !== "qBittorrent" && filteredKinds.length ? (
{filteredKinds.map((kind) => ( @@ -464,9 +499,6 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element {
{cat.category} - - {cat.managedBy === "arr" ? "Arr" : "qBit"} -
@@ -475,6 +507,7 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element { ); })}
+ {(items.length > 0 && (
+ )) || null}
); }); From d29785e42e7df1558ff17259e48777752fe36218 Mon Sep 17 00:00:00 2001 From: Feramance Date: Thu, 26 Feb 2026 18:10:54 +0100 Subject: [PATCH 20/66] docs: fix health endpoint to /health and update Built With stack - Correct all references from /api/health to /health (actual route is GET /health) - Update index Built With: replace Mantine with custom CSS (no external UI runtime) - Update webui/index.md intro to say TypeScript and custom CSS --- docs/advanced/index.md | 2 +- docs/features/process-management.md | 8 ++++---- docs/getting-started/installation/docker.md | 4 ++-- docs/getting-started/migration.md | 2 +- docs/getting-started/quickstart.md | 2 +- docs/index.md | 2 +- docs/troubleshooting/common-issues.md | 2 +- docs/troubleshooting/docker.md | 4 ++-- docs/troubleshooting/index.md | 2 +- docs/webui/index.md | 10 +++++----- 10 files changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/advanced/index.md b/docs/advanced/index.md index a72d7ec73..7c0f7ee1b 100644 --- a/docs/advanced/index.md +++ b/docs/advanced/index.md @@ -345,7 +345,7 @@ External monitoring can check: ```bash # Check if qBitrr is running -curl http://localhost:6969/api/health +curl http://localhost:6969/health # Expected response: {"status": "healthy", "version": "5.5.5"} diff --git a/docs/features/process-management.md b/docs/features/process-management.md index 1716663ec..acec34864 100644 --- a/docs/features/process-management.md +++ b/docs/features/process-management.md @@ -369,7 +369,7 @@ If a process doesn't terminate within 30 seconds, it's forcefully killed (SIGKIL 1. **Enable Auto-Restart**: `AutoRestartProcesses = true` 2. **Set Reasonable Limits**: Default settings (5 restarts in 5 minutes) work for most cases 3. **Monitor Logs**: Set up log aggregation or alerts for restart events -4. **Use External Health Checks**: Monitor qBitrr's WebUI endpoint (`/api/health`) with Uptime Kuma, Healthchecks.io, etc. +4. **Use External Health Checks**: Monitor qBitrr's WebUI endpoint (`/health`) with Uptime Kuma, Healthchecks.io, etc. --- @@ -441,7 +441,7 @@ services: image: feramance/qbitrr:latest restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:6969/api/health"] + test: ["CMD", "curl", "-f", "http://localhost:6969/health"] interval: 30s timeout: 10s retries: 3 @@ -463,14 +463,14 @@ services: # Monitor qBitrr WebUI - name: qBitrr type: HTTP - url: http://localhost:6969/api/health + url: http://localhost:6969/health interval: 60 ``` **Healthchecks.io:** ```bash # Cron job to ping healthchecks.io if qBitrr is running -*/5 * * * * curl -fsS --retry 3 http://localhost:6969/api/health && curl -fsS https://hc-ping.com/YOUR-UUID +*/5 * * * * curl -fsS --retry 3 http://localhost:6969/health && curl -fsS https://hc-ping.com/YOUR-UUID ``` --- diff --git a/docs/getting-started/installation/docker.md b/docs/getting-started/installation/docker.md index b503df875..97c22b50e 100644 --- a/docs/getting-started/installation/docker.md +++ b/docs/getting-started/installation/docker.md @@ -325,7 +325,7 @@ Add a health check: services: qbitrr: healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:6969/api/health"] + test: ["CMD", "curl", "-f", "http://localhost:6969/health"] interval: 30s timeout: 10s retries: 3 @@ -583,7 +583,7 @@ services: services: qbitrr: healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:6969/api/health"] + test: ["CMD", "curl", "-f", "http://localhost:6969/health"] interval: 30s timeout: 10s retries: 3 diff --git a/docs/getting-started/migration.md b/docs/getting-started/migration.md index 4b7ba281f..f8d07b686 100644 --- a/docs/getting-started/migration.md +++ b/docs/getting-started/migration.md @@ -531,7 +531,7 @@ tail -f ~/logs/Main.log # Native docker logs -f qbitrr # Docker # Test API -curl http://localhost:6969/api/health +curl http://localhost:6969/health # Test qBittorrent connection curl http://localhost:6969/api/qbittorrent/status diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index fa64fb111..d7ae6191e 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -544,7 +544,7 @@ curl -H "X-Api-Key: your-api-key" http://sonarr:8989/api/v3/system/status ### Test qBitrr WebUI ```bash -curl http://localhost:6969/api/health +curl http://localhost:6969/health # Expected: {"status":"healthy","version":"5.5.5"} ``` diff --git a/docs/index.md b/docs/index.md index 45e6a0519..58bd5fed4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -457,7 +457,7 @@ copies of the Software. - [Python](https://python.org) - Backend language - [Flask](https://flask.palletsprojects.com/) - API framework - [React](https://react.dev/) - WebUI framework -- [Mantine](https://mantine.dev/) - UI component library +- Custom CSS - Theming and layout (no external UI runtime) **Thanks To**: diff --git a/docs/troubleshooting/common-issues.md b/docs/troubleshooting/common-issues.md index 7ce4e2fcc..7c41fdcae 100644 --- a/docs/troubleshooting/common-issues.md +++ b/docs/troubleshooting/common-issues.md @@ -676,7 +676,7 @@ The #1 reason qBitrr doesn't process torrents is **mismatched categories**. 2. **Test connection:** ```bash - curl http://localhost:6969/api/health + curl http://localhost:6969/health ``` 3. **Check firewall:** diff --git a/docs/troubleshooting/docker.md b/docs/troubleshooting/docker.md index e6328760a..12a83bdc9 100644 --- a/docs/troubleshooting/docker.md +++ b/docs/troubleshooting/docker.md @@ -166,7 +166,7 @@ services: **Test from host:** ```bash -curl http://localhost:6969/api/health +curl http://localhost:6969/health ``` **If using custom port:** @@ -559,7 +559,7 @@ docker exec qbitrr ps aux | grep qbitrr ```bash # From host to container -curl http://localhost:6969/api/health +curl http://localhost:6969/health # From container to qBittorrent docker exec qbitrr curl -v http://qbittorrent:8080/api/v2/app/version diff --git a/docs/troubleshooting/index.md b/docs/troubleshooting/index.md index 92ffcd4eb..ecf028189 100644 --- a/docs/troubleshooting/index.md +++ b/docs/troubleshooting/index.md @@ -312,7 +312,7 @@ top -p $(pidof qbitrr) 2. **Check API response times:** ```bash - time curl http://localhost:6969/api/health +time curl http://localhost:6969/health ``` 3. **Database optimization:** diff --git a/docs/webui/index.md b/docs/webui/index.md index 0fe6edfef..9edfd5241 100644 --- a/docs/webui/index.md +++ b/docs/webui/index.md @@ -1,6 +1,6 @@ # WebUI Overview -qBitrr includes a modern, React-based web interface for real-time monitoring, configuration management, and system control. Built with TypeScript and Mantine UI components, the WebUI provides a responsive, intuitive interface for managing your qBitrr instance. +qBitrr includes a modern, React-based web interface for real-time monitoring, configuration management, and system control. Built with TypeScript and custom CSS, the WebUI provides a responsive, intuitive interface for managing your qBitrr instance. --- @@ -655,7 +655,7 @@ GET /api/sonarr/series # List Sonarr series GET /api/lidarr/albums # List Lidarr albums GET /api/config # Get configuration POST /api/config # Update configuration -GET /api/health # Health check +GET /health # Health check ``` ### Authentication @@ -737,10 +737,10 @@ All responses are JSON: ```bash # From server - curl http://localhost:6969/api/health + curl http://localhost:6969/health # From remote machine - curl http://SERVER_IP:6969/api/health + curl http://SERVER_IP:6969/health ``` 5. **Check firewall rules:** @@ -883,7 +883,7 @@ All responses are JSON: ```bash # Test API response time - time curl http://localhost:6969/api/health + time curl http://localhost:6969/health ``` 5. **Database size:** From 1166131a64a00a20daf4160a21e2e360404364b8 Mon Sep 17 00:00:00 2001 From: Feramance Date: Thu, 26 Feb 2026 18:46:07 +0100 Subject: [PATCH 21/66] docs: full doc vs code consistency review - config-file: ConfigVersion string/default, FreeSpaceFolder default CHANGE_ME, WebUI ViewDensity, qBit ManagedCategories, minimal config without ConfigVersion - config-editor: ViewDensity key fields/reload table, rate limits KB/s, MaximumDeletablePercentage decimal 0-1 - environment: SearchLoopDelay -1 = 30s delay not disabled, note on process/WebUI env - api: document GET /web/qbit/categories (public only) - disk-space: clarify pause only managed categories - health-monitoring: note [Settings.FFprobe] not in gen-config - process-management: fix env var name, shutdown wording, process API example - tooltips: add WebUI.ViewDensity - gen_config: IgnoreTorrentsYoungerThan default 180 in settings_defaults --- docs/configuration/config-file.md | 33 +++++++++++++++++++++-------- docs/configuration/environment.md | 8 ++++--- docs/features/disk-space.md | 18 ++++++++++++---- docs/features/health-monitoring.md | 3 +++ docs/features/process-management.md | 27 ++++++++++++++++------- docs/webui/api.md | 20 +++++++++++++++++ docs/webui/config-editor.md | 16 +++++++++----- qBitrr/gen_config.py | 14 +++++++++++- webui/src/config/tooltips.ts | 6 ++++++ 9 files changed, 115 insertions(+), 30 deletions(-) diff --git a/docs/configuration/config-file.md b/docs/configuration/config-file.md index b07b5fc77..dfbd28d73 100644 --- a/docs/configuration/config-file.md +++ b/docs/configuration/config-file.md @@ -69,8 +69,8 @@ The `[Settings]` section contains global configuration that applies to all qBitr ```toml [Settings] -# Internal config schema version - DO NOT MODIFY -ConfigVersion = 3 +# Internal config schema version - DO NOT MODIFY (managed automatically) +# ConfigVersion = "5.9.2" # Logging ConsoleLevel = "INFO" @@ -119,11 +119,11 @@ ProcessRestartDelay = 5 ### ConfigVersion ```toml -ConfigVersion = 3 +ConfigVersion = "5.9.2" ``` -**Type:** Integer -**Default:** `3` +**Type:** String +**Default:** Set automatically by qBitrr (e.g. `"5.9.2"`). Legacy integer values 1–4 are accepted and mapped to semver strings. **Required:** Yes (managed automatically) Internal configuration schema version. **DO NOT MODIFY** this value manually. qBitrr uses it to detect when config migrations are needed. @@ -270,7 +270,7 @@ FreeSpaceFolder = "/data/downloads" ``` **Type:** String (path) -**Default:** Same as `CompletedDownloadFolder` +**Default:** `"CHANGE_ME"` in generated config. Must be set explicitly when `FreeSpace != "-1"`. Folder to monitor for free space checks. Usually the same as `CompletedDownloadFolder`. @@ -736,6 +736,7 @@ LiveArr = true GroupSonarr = true GroupLidarr = true Theme = "Dark" +ViewDensity = "Comfortable" ``` --- @@ -933,6 +934,20 @@ WebUI color theme. --- +### ViewDensity + +```toml +ViewDensity = "Comfortable" +``` + +**Type:** String +**Default:** `"Comfortable"` +**Options:** `Comfortable`, `Compact` + +List view density in the WebUI (e.g. Arr views, process list). `Comfortable` uses more spacing; `Compact` fits more rows on screen. + +--- + ## qBit Section The `[qBit]` section configures the connection to qBittorrent. @@ -946,9 +961,10 @@ Host = "localhost" Port = 8080 UserName = "admin" Password = "adminpass" +ManagedCategories = [] ``` -For detailed qBittorrent configuration, see the [qBittorrent Configuration Guide](qbittorrent.md). +For detailed qBittorrent configuration, including `[qBit.CategorySeeding]` for per-category seeding settings, see the [qBittorrent Configuration Guide](qbittorrent.md). --- @@ -1170,11 +1186,10 @@ Host = "localhost" # Required ## Complete Minimal Config -Absolute minimum configuration to get started: +Absolute minimum configuration to get started. `ConfigVersion` is managed automatically by qBitrr; omit it or leave as set by `--gen-config`. ```toml [Settings] -ConfigVersion = 3 CompletedDownloadFolder = "/data/downloads" [WebUI] diff --git a/docs/configuration/environment.md b/docs/configuration/environment.md index cb16d36dc..e49a1f3cc 100644 --- a/docs/configuration/environment.md +++ b/docs/configuration/environment.md @@ -169,9 +169,9 @@ QBITRR_SETTINGS_LOOP_SLEEP_TIMER=10 ### QBITRR_SETTINGS_SEARCH_LOOP_DELAY **Type:** Integer -**Default:** `-1` (disabled) +**Default:** `-1` (use internal 30-second delay between each search command) -Seconds to sleep between posting search commands. +Seconds between posting individual search commands when processing a batch. When `-1`, qBitrr uses an internal 30-second delay. ```bash QBITRR_SETTINGS_SEARCH_LOOP_DELAY=300 @@ -841,6 +841,8 @@ QBITRR_QBIT_PASSWORD: ### Complete Environment Variable List +The following environment variables are supported by qBitrr. Process management and WebUI settings (e.g. `AutoRestartProcesses`, `WebUI.Port`, `WebUI.Token`) are not currently overridable via environment variables; use `config.toml` for those. + | Environment Variable | Type | Default | Description | |---------------------|------|---------|-------------| | `QBITRR_SETTINGS_CONSOLE_LEVEL` | String | `INFO` | Console logging level | @@ -851,7 +853,7 @@ QBITRR_QBIT_PASSWORD: | `QBITRR_SETTINGS_AUTO_PAUSE_RESUME` | Boolean | `true` | Auto pause/resume torrents | | `QBITRR_SETTINGS_NO_INTERNET_SLEEP_TIMER` | Integer | `15` | Sleep seconds when no internet | | `QBITRR_SETTINGS_LOOP_SLEEP_TIMER` | Integer | `5` | Sleep seconds between loops | -| `QBITRR_SETTINGS_SEARCH_LOOP_DELAY` | Integer | `-1` | Delay between search commands | +| `QBITRR_SETTINGS_SEARCH_LOOP_DELAY` | Integer | `-1` | Seconds between search commands (-1 = internal 30s delay) | | `QBITRR_SETTINGS_FAILED_CATEGORY` | String | `failed` | Failed torrents category | | `QBITRR_SETTINGS_RECHECK_CATEGORY` | String | `recheck` | Recheck torrents category | | `QBITRR_SETTINGS_TAGLESS` | Boolean | `false` | Process all torrents (ignore tags) | diff --git a/docs/features/disk-space.md b/docs/features/disk-space.md index c687be918..fe28a0902 100644 --- a/docs/features/disk-space.md +++ b/docs/features/disk-space.md @@ -31,7 +31,7 @@ graph TD A[qBitrr monitoring loop] --> B{Check free space} B --> C{Space < FreeSpace?} C -->|No| D[Continue normally] - C -->|Yes| E[Pause all downloads] + C -->|Yes| E[Pause downloads in managed categories] E --> F[Log warning] F --> G[Keep seeding active] G --> H[Wait for next loop] @@ -46,7 +46,7 @@ graph TD 1. qBitrr checks free space every `LoopSleepTimer` seconds (default: 5) 2. Compares available space to `FreeSpace` threshold 3. If space < threshold: - - Pauses all downloading torrents + - Pauses **downloading** torrents in **managed** categories (Arr-managed and qBit-managed) so that free space would not fall below the threshold - Keeps seeding torrents active - Logs warning message 4. When space frees up (files imported/deleted): @@ -139,7 +139,7 @@ FreeSpaceFolder = "/data/downloads" ``` **Type:** String (path) -**Default:** Same as `CompletedDownloadFolder` +**Default:** `"CHANGE_ME"` in generated config; must be set when `FreeSpace != "-1"` Folder to monitor for free space. @@ -170,6 +170,8 @@ CompletedDownloadFolder = "/downloads" FreeSpaceFolder = "/downloads" ``` +**Docker:** The path is **inside the container**. You must mount your host torrent directory at that path (e.g. `-v /host/torrents:/torrents`). If the path does not exist in the container, qBitrr falls back to `/` and reports the container root filesystem's free space (usually very small), which will trigger constant pausing. Ensure your compose/run mounts the volume at the same path as `FreeSpaceFolder`. + --- ### AutoPauseResume @@ -307,7 +309,7 @@ State: qBitrr Actions: 1. Detects: 48 GB < 50 GB threshold -2. Pauses all downloading torrents +2. Pauses downloading torrents in managed categories (see "Which torrents are monitored" below) 3. Logs: "Disk space low, pausing downloads" 4. Keeps seeding torrents active 5. Waits for space to free up @@ -512,6 +514,14 @@ Check paused torrents in qBittorrent: FreeSpaceFolder = "/downloads" ``` +4. **Windows: Use the actual drive path** + If `FreeSpaceFolder` is set to a Unix-style path (e.g. `/torrents`) and qBitrr runs natively on Windows, that path is resolved to the current drive (e.g. `C:\torrents`). If that path does not exist, qBitrr falls back to the drive root (e.g. `C:\`) and reports that drive's free space. Set `FreeSpaceFolder` (and `CompletedDownloadFolder`) to the real path where torrents are stored, for example: + ```toml + CompletedDownloadFolder = "D:\\torrents" + FreeSpaceFolder = "D:\\torrents" + ``` + Check the FreeSpaceManager log (TRACE) for the line `Path: ...` to see which path is being used for the free-space check. + --- ### Imports Failing Due to Space diff --git a/docs/features/health-monitoring.md b/docs/features/health-monitoring.md index 26f012d9c..bc93de1e9 100644 --- a/docs/features/health-monitoring.md +++ b/docs/features/health-monitoring.md @@ -807,6 +807,9 @@ FFprobe is pre-installed in the official qBitrr Docker image. ### Validation Rules +!!! note "Advanced / optional" + The `[Settings.FFprobe]` section and the options below are **not** generated by `qbitrr --gen-config`. The only FFprobe-related setting in the generated config is `Settings.FFprobeAutoUpdate`. If your codebase reads additional FFprobe options (e.g. `FFprobePath`, `CheckVideoCodec`, `Timeout`), add them manually to `config.toml`. See the [Configuration File Reference](../configuration/config-file.md) for supported keys. + #### Video Validation ```toml diff --git a/docs/features/process-management.md b/docs/features/process-management.md index acec34864..f214ffde7 100644 --- a/docs/features/process-management.md +++ b/docs/features/process-management.md @@ -267,7 +267,7 @@ qBitrr implements graceful shutdown to ensure data consistency: ### Shutdown Timeout -If a process doesn't terminate within 30 seconds, it's forcefully killed (SIGKILL). +If a process doesn't terminate within a short period after receiving SIGTERM, it is forcefully killed (SIGKILL). The exact delay is implementation-defined (e.g. a few seconds in recovery paths). --- @@ -391,7 +391,7 @@ services: image: feramance/qbitrr:latest restart: unless-stopped # Container-level restart environment: - - QBITRR_SETTINGS__AUTORESTARTPROCESSES=true # Process-level restart + - QBITRR_SETTINGS_AUTO_RESTART_PROCESSES=true # Process-level restart ``` **Layered Restart:** @@ -447,7 +447,7 @@ services: retries: 3 start_period: 40s environment: - - QBITRR_SETTINGS__AUTORESTARTPROCESSES=true + - QBITRR_SETTINGS_AUTO_RESTART_PROCESSES=true ``` --- @@ -529,13 +529,24 @@ GET /api/processes { "processes": [ { - "name": "Radarr-Movies", "category": "radarr-movies", - "status": "running", + "name": "Radarr-Movies", + "kind": "search", "pid": 12345, - "uptime": 3600, - "restart_count": 2, - "last_restart": "2024-01-15T10:30:00Z" + "alive": true, + "rebuilding": false, + "searchSummary": "Found 12 missing movies, searched 5", + "searchTimestamp": "2025-11-27T12:00:00Z" + }, + { + "category": "radarr-movies", + "name": "Radarr-Movies", + "kind": "torrent", + "pid": 12346, + "alive": true, + "rebuilding": false, + "queueCount": 3, + "categoryCount": 8 } ] } diff --git a/docs/webui/api.md b/docs/webui/api.md index d722969bf..12ee9f506 100644 --- a/docs/webui/api.md +++ b/docs/webui/api.md @@ -514,6 +514,26 @@ Restart specific Arr instance (both search and torrent processes). --- +### Get qBit Categories + +Get qBittorrent categories managed by qBitrr (qBit-managed and Arr-managed) with seeding statistics. + +**Endpoint**: +- `GET /web/qbit/categories` (public only; no `/api/` variant) + +**Authentication**: None (public endpoint). + +**Response**: Array of category objects, each including: +- `category` - Category name +- `instance` - qBittorrent instance name +- `managedBy` - `"qbit"` or `"arr"` +- `torrentCount`, `seedingCount`, `totalSize`, `avgRatio`, `avgSeedingTime` +- `seedingConfig` - Per-category seeding limits (e.g. `maxRatio`, `maxTime`, `removeMode`, `downloadLimit`, `uploadLimit`) + +**Use Case**: Category management UI, seeding stats display. + +--- + ### Get Torrent Distribution Get torrent distribution statistics across all categories. diff --git a/docs/webui/config-editor.md b/docs/webui/config-editor.md index a6905f229..4bd3d4567 100644 --- a/docs/webui/config-editor.md +++ b/docs/webui/config-editor.md @@ -99,7 +99,9 @@ AutoUpdateCron = "0 3 * * 0" - **WebUI Token**: Optional bearer token for API/UI authentication (auto-generated if empty) - **Live Arr**: Enable real-time Arr data (bypasses database cache, increases API load) - **Group Sonarr by Series**: Group episodes by series and seasons in collapsible sections +- **Group Lidarr by Artist**: Group Lidarr albums by artist in collapsible sections - **Theme**: Visual theme (`Light` or `Dark`) — **changes apply immediately** +- **View Density**: List density (`Comfortable` or `Compact`) — applied on next load **Example**: ```toml @@ -109,7 +111,9 @@ Port = 6969 Token = "abc123def456..." LiveArr = false GroupSonarr = true +GroupLidarr = true Theme = "Dark" +ViewDensity = "Comfortable" ``` **Validation**: @@ -117,10 +121,12 @@ Theme = "Dark" - `Host` must not be empty - `Port` must be between 1 and 65535 - `Token` accepts any string (empty = no authentication) +- `ViewDensity` must be `Comfortable` or `Compact` **Special Behavior**: - **Theme**: Changes apply immediately via JavaScript (no save required) +- **ViewDensity**: Persisted to config; applied when the page is loaded - **Host/Port/Token**: Trigger WebUI restart after save --- @@ -398,7 +404,7 @@ RemoveTrackerWithMessage = ["skipping tracker announce (unreachable)", "No such **Validation**: - All numeric fields must be non-negative -- `MaximumDeletablePercentage` must be between 0 and 100 +- `MaximumDeletablePercentage` must be a decimal between 0 and 1 (e.g. 0.99 = 99%) --- @@ -408,8 +414,8 @@ Configure per-instance seeding policies. **Key Fields**: -- **Download Rate Limit Per Torrent**: Bytes/s download limit (-1 = unlimited) -- **Upload Rate Limit Per Torrent**: Bytes/s upload limit (-1 = unlimited) +- **Download Rate Limit Per Torrent**: KB/s download limit (-1 = unlimited). qBitrr converts to bytes/s for qBittorrent. +- **Upload Rate Limit Per Torrent**: KB/s upload limit (-1 = unlimited). qBitrr converts to bytes/s for qBittorrent. - **Max Upload Ratio**: Maximum upload ratio (-1 = unlimited) - **Max Seeding Time (s)**: Maximum seeding duration (-1 = unlimited) - **Remove Torrent (policy)**: Removal policy: @@ -584,7 +590,7 @@ The editor validates fields **on change** and **before save**, displaying inline | `Arr.Category` | Must not be empty | | `EntrySearch.SearchLimit` | Must be ≥ 1 | | `AutoUpdateCron` | Must contain 5 or 6 space-separated fields | -| `Torrent.MaximumDeletablePercentage` | Must be 0-100 | +| `Torrent.MaximumDeletablePercentage` | Decimal 0–1 (e.g. 0.99 = 99%) | | `Torrent.SeedingMode.RemoveTorrent` | Must be -1, 1, 2, 3, or 4 | ### Error Display @@ -623,7 +629,7 @@ The backend uses **intelligent reload detection** to minimize disruption: | Change Type | Reload Type | Behavior | |-------------|-------------|----------| -| **Frontend-only** (`WebUI.Theme`, `WebUI.LiveArr`, `WebUI.GroupSonarr`) | `frontend` | No reload (changes apply in browser) | +| **Frontend-only** (`WebUI.Theme`, `WebUI.LiveArr`, `WebUI.GroupSonarr`, `WebUI.GroupLidarr`, `WebUI.ViewDensity`) | `frontend` | No backend reload (Theme applies immediately; ViewDensity applies on next load) | | **WebUI Server** (`WebUI.Host`, `WebUI.Port`, `WebUI.Token`) | `webui` | Restart WebUI server (brief downtime) | | **Single Arr Instance** (e.g., `Radarr-4K.*`) | `single_arr` | Reload only that Arr instance | | **Multiple Arr Instances** (e.g., `Radarr-4K.*` + `Sonarr-TV.*`) | `multi_arr` | Reload each affected instance sequentially | diff --git a/qBitrr/gen_config.py b/qBitrr/gen_config.py index 27919996b..7ff30023d 100755 --- a/qBitrr/gen_config.py +++ b/qBitrr/gen_config.py @@ -381,6 +381,18 @@ def _add_qbit_section(config: TOMLDocument): "TrackerUpdateBuffer", 0, ) + _gen_default_line( + category_seeding, + "Maximum time stalled downloads can sit before removal, in minutes (-1 = disabled, 0 = infinite).", + "StalledDelay", + -1, + ) + _gen_default_line( + category_seeding, + "Ignore torrents younger than this (seconds). Stalled removal also requires last_activity older than this.", + "IgnoreTorrentsYoungerThan", + 600, + ) _gen_qbit_tracker_tables(qbit) qbit.add("CategorySeeding", category_seeding) @@ -1613,7 +1625,7 @@ def ensure_value(config_section: str, key: str, default_value: Any) -> bool: ("FailedCategory", "failed"), ("RecheckCategory", "recheck"), ("Tagless", False), - ("IgnoreTorrentsYoungerThan", 600), + ("IgnoreTorrentsYoungerThan", 180), ("PingURLS", ["one.one.one.one", "dns.google.com"]), ("FFprobeAutoUpdate", True), ("AutoUpdateEnabled", False), diff --git a/webui/src/config/tooltips.ts b/webui/src/config/tooltips.ts index 53906f303..6b17c6802 100644 --- a/webui/src/config/tooltips.ts +++ b/webui/src/config/tooltips.ts @@ -48,6 +48,8 @@ export const FIELD_TOOLTIPS: Record = { "WebUI.GroupSonarr": "Group Sonarr views by series and seasons in collapsible sections.", "WebUI.GroupLidarr": "Group Lidarr views by artist and albums in collapsible sections.", "WebUI.Theme": "Choose the visual theme for the WebUI (light or dark).", + "WebUI.ViewDensity": + "View density for lists in the WebUI: Comfortable (more spacing) or Compact (more rows on screen).", "qBit.Trackers": "Tracker configs shared by all Arr instances on this qBit instance. Define tracker-specific rate limits, HnR protection, and management rules here. Arr instances inherit these and can optionally override per-tracker settings.", @@ -175,6 +177,10 @@ export const FIELD_TOOLTIPS: Record = { "Minimum ratio for partial downloads in managed categories (HnR protection).", "CategorySeeding.TrackerUpdateBuffer": "Extra seconds buffer for tracker stats lag in managed categories.", + "CategorySeeding.StalledDelay": + "Minutes to allow stalled downloads before removal for qBit-managed categories (-1 = disabled, 0 = infinite).", + "CategorySeeding.IgnoreTorrentsYoungerThan": + "Ignore torrents younger than this many seconds for stalled removal; last_activity must also be older than this.", HitAndRunMode: "Hit and Run mode: and = require both ratio and time; or = either clears; disabled = no HnR.", MinSeedRatio: From b5045c285660291c4519e51c0ff446ef9b16053d Mon Sep 17 00:00:00 2001 From: Feramance Date: Thu, 26 Feb 2026 20:09:54 +0100 Subject: [PATCH 22/66] fix: premature torrent removals in HNR clear mode --- docker-compose.yml | 10 +++- mkdocs.yml | 1 + qBitrr/arss.py | 103 +++++++++++++++++++++++--------- qBitrr/main.py | 16 +++++ qBitrr/qbit_category_manager.py | 57 +++++++++++++++++- webui/src/pages/ConfigView.tsx | 26 ++++++++ 6 files changed, 183 insertions(+), 30 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 5c2152dfa..98e436a19 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,7 @@ services: tty: true stop_grace_period: 30s volumes: - - /mnt/media/Torrents:/torrents + - torrents:/torrents - .config:/config logging: driver: json-file @@ -22,3 +22,11 @@ services: max-file: 1 ports: - 6969:6969 + +volumes: + torrents: + driver: local + driver_opts: + type: cifs + device: ${CIFS_PATH} + o: username=${CIFS_USER},password=${CIFS_PASS},vers=3.1.1,iocharset=utf8 diff --git a/mkdocs.yml b/mkdocs.yml index 28c1313ff..bd48b07a1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -119,6 +119,7 @@ markdown_extensions: plugins: - search: separator: '[\s\-,:!=\[\]()"`/]+|\.(?!\d)|&[lg]t;|(?!\b)(?=[A-Z][a-z])' +- include-markdown - git-revision-date-localized: enable_creation_date: true type: timeago diff --git a/qBitrr/arss.py b/qBitrr/arss.py index f9d0077a8..94b99ac1d 100755 --- a/qBitrr/arss.py +++ b/qBitrr/arss.py @@ -20,6 +20,7 @@ import qbittorrentapi import qbittorrentapi.exceptions import requests +from jaraco.docker import is_docker from packaging import version as version_parser from peewee import DatabaseError, Model, OperationalError, SqliteDatabase from pyarr import LidarrAPI, RadarrAPI, SonarrAPI @@ -5278,15 +5279,16 @@ def _process_single_torrent_stalled_torrent( torrent.added_on < time.time() - self.ignore_torrents_younger_than and torrent.last_activity < (time.time() - self.ignore_torrents_younger_than) ): - if self._hnr_allows_delete(torrent, "stale torrent deletion"): - self._mark_for_deletion(torrent, "stale torrent deletion") + if self._hnr_allows_delete(torrent, extra): + self._mark_for_deletion(torrent, extra) else: self.logger.trace( - "Ignoring Stale torrent: " + "Ignoring Stale torrent (%s): " "[Progress: %s%%][Added On: %s]" "[Availability: %s%%][Time Left: %s]" "[Last active: %s] " "| [%s] | %s (%s)", + extra, round(torrent.progress * 100, 2), datetime.fromtimestamp(torrent.added_on), round(torrent.availability * 100, 2), @@ -5540,15 +5542,24 @@ def _process_single_torrent_delete_cfunmet( if self._hnr_allows_delete(torrent, "CF unmet deletion"): self._mark_for_deletion(torrent, "CF unmet deletion") - def _process_single_torrent_delete_ratio_seed(self, torrent: qbittorrentapi.TorrentDictionary): - data_settings, data_torrent = self._get_torrent_limit_meta(torrent) + def _process_single_torrent_delete_ratio_seed( + self, + torrent: qbittorrentapi.TorrentDictionary, + limit_meta: tuple[dict, dict] | None = None, + ): + if limit_meta is not None: + data_settings, data_torrent = limit_meta + else: + data_settings, data_torrent = self._get_torrent_limit_meta(torrent) r_dat = data_settings.get("ratio_limit", -5) r_tor = data_torrent.get("ratio_limit", -5) t_dat = data_settings.get("seeding_time_limit", -5) t_tor = data_torrent.get("seeding_time_limit", -5) ratio_limit = max(r_dat, r_tor) if (r_dat > 0 or r_tor > 0) else -5 seeding_time_limit = max(t_dat, t_tor) if (t_dat > 0 or t_tor > 0) else -5 - if self._hnr_allows_delete(torrent, "ratio/seed limit deletion"): + if self._hnr_allows_delete( + torrent, "ratio/seed limit deletion", data_settings=data_settings + ): self._mark_for_deletion( torrent, "ratio/seed limit deletion", @@ -5823,7 +5834,7 @@ def _should_leave_alone( return_value = True remove_torrent = False if torrent.super_seeding or torrent.state_enum == TorrentStates.FORCED_UPLOAD: - return return_value, -1, remove_torrent + return return_value, -1, remove_torrent, None, None is_uploading = torrent.state_enum in ( TorrentStates.UPLOADING, @@ -5903,6 +5914,8 @@ def _should_leave_alone( return_value, data_settings.get("max_eta", self.maximum_eta), remove_torrent, + data_settings, + data_torrent, ) def _process_single_torrent_trackers( @@ -5994,15 +6007,14 @@ def _stalled_check( if not self.allowed_stalled: self.logger.trace("Stalled check: Stalled delay disabled") return False + stalled_delay_seconds = int(timedelta(minutes=self.stalled_delay).total_seconds()) if time_now < torrent.added_on + self.ignore_torrents_younger_than: self.logger.trace( "Stalled check: In recent queue %s [Current:%s][Added:%s][Starting:%s]", torrent.name, datetime.fromtimestamp(time_now), datetime.fromtimestamp(torrent.added_on), - datetime.fromtimestamp( - torrent.added_on + timedelta(minutes=self.stalled_delay).seconds - ), + datetime.fromtimestamp(torrent.added_on + stalled_delay_seconds), ) return True if self.stalled_delay == 0: @@ -6018,9 +6030,7 @@ def _stalled_check( torrent.name, datetime.fromtimestamp(time_now), datetime.fromtimestamp(torrent.last_activity), - datetime.fromtimestamp( - torrent.last_activity + timedelta(minutes=self.stalled_delay).seconds - ), + datetime.fromtimestamp(torrent.last_activity + stalled_delay_seconds), ) if ( ( @@ -6039,8 +6049,7 @@ def _stalled_check( ) and self.allowed_stalled: if ( self.stalled_delay > 0 - and time_now - >= torrent.last_activity + timedelta(minutes=self.stalled_delay).seconds + and time_now >= torrent.last_activity + stalled_delay_seconds ): stalled_ignore = False self.logger.trace("Process stalled, delay expired: %s", torrent.name) @@ -6068,9 +6077,7 @@ def _stalled_check( torrent.name, datetime.fromtimestamp(time_now), datetime.fromtimestamp(torrent.last_activity), - datetime.fromtimestamp( - torrent.last_activity + timedelta(minutes=self.stalled_delay).seconds - ), + datetime.fromtimestamp(torrent.last_activity + stalled_delay_seconds), ) elif self.in_tags(torrent, "qBitrr-allowed_stalled", instance_name): @@ -6090,8 +6097,8 @@ def _process_single_torrent( self._process_single_torrent_trackers(torrent, instance_name) self.manager.qbit_manager.name_cache[torrent.hash] = torrent.name time_now = time.time() - leave_alone, _tracker_max_eta, remove_torrent = self._should_leave_alone( - torrent, instance_name + leave_alone, _tracker_max_eta, remove_torrent, _data_settings, _data_torrent = ( + self._should_leave_alone(torrent, instance_name) ) self.logger.trace( "Torrent [%s]: Leave Alone (allow seeding): %s, Max ETA: %s, State[%s]", @@ -6124,7 +6131,9 @@ def _process_single_torrent( ): self._process_single_torrent_delete_cfunmet(torrent, instance_name) elif remove_torrent and not leave_alone and torrent.amount_left == 0: - self._process_single_torrent_delete_ratio_seed(torrent) + self._process_single_torrent_delete_ratio_seed( + torrent, limit_meta=(_data_settings, _data_torrent) + ) elif torrent.category == FAILED_CATEGORY: # Bypass everything if manually marked as failed self._process_single_torrent_failed_cat(torrent) @@ -6348,7 +6357,13 @@ def custom_format_unmet_check(self, torrent: qbittorrentapi.TorrentDictionary) - except Exception: return False - def _hnr_allows_delete(self, torrent: qbittorrentapi.TorrentDictionary, reason: str) -> bool: + def _hnr_allows_delete( + self, + torrent: qbittorrentapi.TorrentDictionary, + reason: str, + *, + data_settings: dict | None = None, + ) -> bool: """Check if HnR obligations allow deleting this torrent. Fetches tracker metadata and checks HnR. Returns True if deletion @@ -6366,7 +6381,8 @@ def _hnr_allows_delete(self, torrent: qbittorrentapi.TorrentDictionary, reason: ) return True - data_settings, _ = self._get_torrent_limit_meta(torrent) + if data_settings is None: + data_settings, _ = self._get_torrent_limit_meta(torrent) if self._hnr_safe_to_remove(torrent, data_settings): return True self.logger.info( @@ -7632,24 +7648,50 @@ def __init__(self, categories: set[str], manager: ArrManager): arr_cats = self.categories & self.manager.arr_categories chosen = next(iter(arr_cats), None) or next(iter(self.categories)) self.completed_folder = pathlib.Path(COMPLETED_DOWNLOAD_FOLDER).joinpath(chosen) + # Use the main completed-download folder for disk usage so we report the same + # filesystem the user expects (doc default: "Same as CompletedDownloadFolder"). + # A category subdir may be a different mount and report much less free space. + self._disk_usage_path = pathlib.Path(COMPLETED_DOWNLOAD_FOLDER).resolve() else: self.completed_folder = pathlib.Path(FREE_SPACE_FOLDER) + self._disk_usage_path = pathlib.Path(FREE_SPACE_FOLDER).resolve() self.min_free_space = FREE_SPACE # Parse once to avoid repeated conversions self._min_free_space_bytes = ( parse_size(self.min_free_space) if self.min_free_space != "-1" else 0 ) - if not self.completed_folder.exists(): + if FREE_SPACE_FOLDER == "CHANGE_ME" and not self.completed_folder.exists(): # Fallback to parent when chosen category subdir doesn't exist (e.g. qBit-only). parent = pathlib.Path(COMPLETED_DOWNLOAD_FOLDER) if parent.exists(): self.completed_folder = parent # else: keep completed_folder and let disk_usage raise so the user sees the error + # Path for disk usage: use resolved path; if it does not exist, use first existing + # parent so we still report the correct volume (e.g. /torrents missing -> use /). + self._path_for_disk_usage = self._disk_usage_path + _p = self._disk_usage_path + while _p and not _p.exists(): + _p = _p.parent + if _p: + self._path_for_disk_usage = _p + if self._path_for_disk_usage != self._disk_usage_path: + self.logger.warning( + "FreeSpaceFolder path does not exist, using parent for disk space check | " + "Configured: %s | Using: %s%s", + self._disk_usage_path, + self._path_for_disk_usage, + ( + " | In Docker: ensure the host volume is mounted at the configured path (e.g. -v /host/torrents:/torrents)" + if is_docker() + else "" + ), + ) self.current_free_space = ( - shutil.disk_usage(self.completed_folder).free - self._min_free_space_bytes + shutil.disk_usage(self._path_for_disk_usage).free - self._min_free_space_bytes ) self.logger.trace( - "Free space monitor initialized | Available: %s | Threshold: %s", + "Free space monitor initialized | Path: %s | Available: %s | Threshold: %s", + self._path_for_disk_usage, format_bytes(self.current_free_space + self._min_free_space_bytes), format_bytes(self._min_free_space_bytes), ) @@ -7811,8 +7853,15 @@ def process_torrents(self): raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="internet") if self.manager.qbit_manager.should_delay_torrent_scan: raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="delay") + # Re-resolve path each loop so we use the configured path once it appears + # (e.g. Docker volume mounted after process start). + _p = self._disk_usage_path + while _p and not _p.exists(): + _p = _p.parent + if _p: + self._path_for_disk_usage = _p self.current_free_space = ( - shutil.disk_usage(self.completed_folder).free - self._min_free_space_bytes + shutil.disk_usage(self._path_for_disk_usage).free - self._min_free_space_bytes ) self.logger.trace( "Processing torrents | Available: %s | Threshold: %s | Usable: %s | Torrents: %d | Paused for space: %d", diff --git a/qBitrr/main.py b/qBitrr/main.py index 72e7de518..3c74e2019 100644 --- a/qBitrr/main.py +++ b/qBitrr/main.py @@ -587,12 +587,21 @@ def _init_instance(self, section_name: str, instance_name: str) -> None: # Load qBit-level shared trackers instance_trackers = CONFIG.get(f"{section_name}.Trackers", fallback=[]) + # Stalled handling for qBit-managed categories (same semantics as Arr) + stalled_delay = CONFIG.get(f"{section_name}.CategorySeeding.StalledDelay", fallback=-1) + ignore_younger = CONFIG.get( + f"{section_name}.CategorySeeding.IgnoreTorrentsYoungerThan", + fallback=CONFIG.get("Settings.IgnoreTorrentsYoungerThan", fallback=600), + ) + # Store config for later initialization self.qbit_category_configs[instance_name] = { "managed_categories": managed_categories, "default_seeding": default_seeding, "category_overrides": category_overrides, "trackers": instance_trackers, + "stalled_delay": stalled_delay, + "ignore_torrents_younger_than": ignore_younger, } self.logger.debug( "Loaded qBit category config for '%s': %d managed categories", @@ -723,11 +732,18 @@ def _load_category_config(section_name: str, instance_name: str): if isinstance(cat_config, dict) and "Name" in cat_config: category_overrides[cat_config["Name"]] = cat_config trackers = CONFIG.get(f"{section_name}.Trackers", fallback=[]) + stalled_delay = CONFIG.get(f"{section_name}.CategorySeeding.StalledDelay", fallback=-1) + ignore_younger = CONFIG.get( + f"{section_name}.CategorySeeding.IgnoreTorrentsYoungerThan", + fallback=CONFIG.get("Settings.IgnoreTorrentsYoungerThan", fallback=600), + ) self.qbit_category_configs[instance_name] = { "managed_categories": managed_categories, "default_seeding": default_seeding, "category_overrides": category_overrides, "trackers": trackers, + "stalled_delay": stalled_delay, + "ignore_torrents_younger_than": ignore_younger, } for section in CONFIG.sections(): diff --git a/qBitrr/qbit_category_manager.py b/qBitrr/qbit_category_manager.py index 27e5e1e92..0675b1f7b 100644 --- a/qBitrr/qbit_category_manager.py +++ b/qBitrr/qbit_category_manager.py @@ -4,6 +4,7 @@ import logging import time +from datetime import timedelta from typing import TYPE_CHECKING from qbittorrentapi import TorrentStates @@ -34,6 +35,9 @@ def __init__(self, instance_name: str, qbit_manager: qBitManager, config: dict): - managed_categories: List of category names - default_seeding: Default seeding settings - category_overrides: Per-category seeding overrides + - trackers: Tracker config list + - stalled_delay: Minutes before removing stalled downloads (-1 = disabled) + - ignore_torrents_younger_than: Seconds; don't remove torrents younger than this """ self.instance_name = instance_name self.qbit_manager = qbit_manager @@ -41,6 +45,9 @@ def __init__(self, instance_name: str, qbit_manager: qBitManager, config: dict): self.default_seeding = config.get("default_seeding", {}) self.category_overrides = config.get("category_overrides", {}) self.trackers = self._build_merged_trackers(config.get("trackers", [])) + self.stalled_delay = config.get("stalled_delay", -1) + self.ignore_torrents_younger_than = config.get("ignore_torrents_younger_than", 600) + self.allowed_stalled = self.stalled_delay != -1 self.logger = logging.getLogger(f"qBitrr.qBitCategory.{instance_name}") self.logger.info( @@ -133,7 +140,7 @@ def process_torrents(self): def _process_single_torrent(self, torrent: TorrentDictionary, category: str): """ - Process a single torrent - apply seeding settings and check removal. + Process a single torrent - apply seeding settings, stalled removal, and ratio/time removal. Args: torrent: qBittorrent torrent object @@ -173,7 +180,12 @@ def _process_single_torrent(self, torrent: TorrentDictionary, category: str): # Apply seeding limits using effective (possibly tracker-overridden) config self._apply_seeding_limits(torrent, effective_config) - # Check if torrent should be removed + # Stalled download removal (same semantics as Arr: IgnoreTorrentsYoungerThan gates both) + if self._should_remove_stalled(torrent): + self._remove_stalled_torrent(torrent, category) + return + + # Check if torrent should be removed (seeding ratio/time) if self._should_remove_torrent(torrent, effective_config): self._remove_torrent(torrent, category) @@ -186,6 +198,47 @@ def _process_single_torrent(self, torrent: TorrentDictionary, category: str): exc_info=True, ) + def _should_remove_stalled(self, torrent: TorrentDictionary) -> bool: + """ + Return True if this torrent is stalled (STALLED_DOWNLOAD/METADATA_DOWNLOAD) and has + exceeded StalledDelay, and passes IgnoreTorrentsYoungerThan (intentional gate on both + added_on and last_activity). + """ + if not self.allowed_stalled or self.stalled_delay <= 0: + return False + if torrent.state_enum not in ( + TorrentStates.STALLED_DOWNLOAD, + TorrentStates.METADATA_DOWNLOAD, + ): + return False + time_now = time.time() + stalled_delay_seconds = int(timedelta(minutes=self.stalled_delay).total_seconds()) + if time_now < torrent.added_on + self.ignore_torrents_younger_than: + return False + if time_now < torrent.last_activity + stalled_delay_seconds: + return False + if torrent.last_activity > time_now - self.ignore_torrents_younger_than: + return False + return True + + def _remove_stalled_torrent(self, torrent: TorrentDictionary, category: str): + """Remove a stalled download (no progress within StalledDelay, past IgnoreTorrentsYoungerThan).""" + try: + self.logger.info( + "Removing stalled torrent '%s' from category '%s' (no progress for %d min)", + torrent.name, + category, + self.stalled_delay, + ) + torrent.delete(delete_files=True) + except Exception as e: + self.logger.error( + "Failed to remove stalled torrent '%s': %s", + torrent.name, + e, + exc_info=True, + ) + def _apply_tags(self, torrent: TorrentDictionary, tags: list): """ Apply tags to a torrent if not already present. diff --git a/webui/src/pages/ConfigView.tsx b/webui/src/pages/ConfigView.tsx index 767cee372..d45862f1e 100644 --- a/webui/src/pages/ConfigView.tsx +++ b/webui/src/pages/ConfigView.tsx @@ -532,6 +532,32 @@ const QBIT_FIELDS: FieldDefinition[] = [ return undefined; }, }, + { + label: "Stalled Delay (min)", + path: ["CategorySeeding", "StalledDelay"], + type: "number", + placeholder: "-1 (disabled), 0 (infinite), or minutes before removing stalled downloads", + validate: (value) => { + const num = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(num) || num < -1) { + return "Stalled Delay must be -1 or greater."; + } + return undefined; + }, + }, + { + label: "Ignore Torrents Younger Than (s)", + path: ["CategorySeeding", "IgnoreTorrentsYoungerThan"], + type: "number", + placeholder: "Seconds; stalled removal also requires last_activity older than this", + validate: (value) => { + const num = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(num) || num < 0) { + return "Ignore Torrents Younger Than must be a non-negative number."; + } + return undefined; + }, + }, ]; const ARR_GENERAL_FIELDS: FieldDefinition[] = [ From 071be707eda633702696bc63d47cade6ed44731a Mon Sep 17 00:00:00 2001 From: Feramance Date: Thu, 26 Feb 2026 20:26:54 +0100 Subject: [PATCH 23/66] Update qbit_category_manager --- qBitrr/qbit_category_manager.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/qBitrr/qbit_category_manager.py b/qBitrr/qbit_category_manager.py index 0675b1f7b..966b8c700 100644 --- a/qBitrr/qbit_category_manager.py +++ b/qBitrr/qbit_category_manager.py @@ -180,8 +180,28 @@ def _process_single_torrent(self, torrent: TorrentDictionary, category: str): # Apply seeding limits using effective (possibly tracker-overridden) config self._apply_seeding_limits(torrent, effective_config) - # Stalled download removal (same semantics as Arr: IgnoreTorrentsYoungerThan gates both) + # Stalled download removal (same semantics as Arr: IgnoreTorrentsYoungerThan gates both; + # respect HnR before deleting with delete_files=True to avoid Hit and Run violations) if self._should_remove_stalled(torrent): + hnr_config = tracker_config if tracker_config else config + if self._hnr_clear_mode_enabled(hnr_config) and self._hnr_tracker_is_dead( + torrent, hnr_config + ): + self.logger.debug( + "HnR bypass: tracker reports torrent as unregistered/dead '%s'", + torrent.name, + ) + elif self._hnr_clear_mode_enabled(hnr_config) and not self._hnr_safe_to_remove( + torrent, hnr_config + ): + self.logger.info( + "HnR protection: blocking stalled removal of [%s] (ratio=%.2f, seeding=%s, progress=%.1f%%)", + torrent.name, + torrent.ratio, + timedelta(seconds=torrent.seeding_time), + torrent.progress * 100, + ) + return self._remove_stalled_torrent(torrent, category) return From 35f49c28d751c5869cc5f0b0b93dfd79a0e1623b Mon Sep 17 00:00:00 2001 From: Feramance Date: Thu, 26 Feb 2026 20:42:27 +0100 Subject: [PATCH 24/66] fix: correct _should_leave_alone return type to 5-tuple (bool, int, bool, dict|None, dict|None) --- qBitrr/arss.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qBitrr/arss.py b/qBitrr/arss.py index 94b99ac1d..0371e001d 100755 --- a/qBitrr/arss.py +++ b/qBitrr/arss.py @@ -5830,7 +5830,7 @@ def _get_torrent_limit_meta(self, torrent: qbittorrentapi.TorrentDictionary): def _should_leave_alone( self, torrent: qbittorrentapi.TorrentDictionary, instance_name: str = "default" - ) -> tuple[bool, int, bool]: + ) -> tuple[bool, int, bool, dict | None, dict | None]: return_value = True remove_torrent = False if torrent.super_seeding or torrent.state_enum == TorrentStates.FORCED_UPLOAD: From a10d3b8181a669e213abd0d98a5b484b3841016b Mon Sep 17 00:00:00 2001 From: Feramance Date: Thu, 26 Feb 2026 22:27:47 +0100 Subject: [PATCH 25/66] Unify torrent behaviour for all categories; qBit-managed categories use PlaceHolderArr and qBit seeding config - Add PlaceHolderArr for each qBit-managed category (e.g. autobrr) so they get full state machine (recheck, missing files, stalled, ratio/time removal) - PlaceHolderArr for qBit-managed categories loads [qBit] CategorySeeding and Trackers so seeding config is respected - Remove duplicate stalled and ratio/time removal from qBit category manager; it only applies seeding limits and tracker tags - Docs: note that failed/recheck categories get same qBit-side handling as Arr-managed categories --- docs/configuration/config-file.md | 4 + qBitrr/arss.py | 187 ++++++++++++++++++++++++------ qBitrr/qbit_category_manager.py | 31 +---- 3 files changed, 156 insertions(+), 66 deletions(-) diff --git a/docs/configuration/config-file.md b/docs/configuration/config-file.md index dfbd28d73..e4dbaf557 100644 --- a/docs/configuration/config-file.md +++ b/docs/configuration/config-file.md @@ -412,6 +412,8 @@ qBittorrent category for manually marking torrents as failed. - Wrong content - Manual intervention needed +Torrents in this category receive the same qBit-side behaviour as Arr-managed categories (recheck on error, missing files handling, stalled detection, etc.). Only research and blocklisting (notifying Arr, re-search) are Arr-specific; for the `failed` category, qBitrr still marks the release as failed in Arr and removes the torrent when so configured. + --- ### RecheckCategory @@ -438,6 +440,8 @@ qBittorrent category for forcing torrent recheck. - After restoring from backup - After moving download location +Torrents in this category receive the same qBit-side behaviour as Arr-managed categories (recheck on error, missing files handling, stalled detection, etc.). Only research and blocklisting remain Arr-specific; for the `recheck` category, qBitrr forces a full recheck and moves the torrent back to its original category. + --- ### Tagless diff --git a/qBitrr/arss.py b/qBitrr/arss.py index 0371e001d..64ef7663c 100755 --- a/qBitrr/arss.py +++ b/qBitrr/arss.py @@ -7435,11 +7435,39 @@ def __init__(self, name: str, manager: ArrManager): "Settings.IgnoreTorrentsYoungerThan", fallback=600 ) self.timed_ignore_cache = ExpiringSet(max_age_seconds=self.ignore_torrents_younger_than) + self.timed_ignore_cache_2 = ExpiringSet( + max_age_seconds=self.ignore_torrents_younger_than * 2 + ) self.timed_skip = ExpiringSet(max_age_seconds=self.ignore_torrents_younger_than) self.tracker_delay = ExpiringSet(max_age_seconds=600) + self.special_casing_file_check = ExpiringSet(max_age_seconds=10) + self.cleaned_torrents = set() + self.missing_files_post_delete = set() + self.downloads_with_bad_error_message_blocklist = set() + self.needs_cleanup = False + self._warned_no_seeding_limits = False + self.custom_format_unmet_search = False + self.do_not_remove_slow = False + self.maximum_eta = CONFIG.get("Settings.Torrent.MaximumETA", fallback=86400) + self.monitored_trackers = [] + self._host_to_config_uri = {} + self._add_trackers_if_missing = set() + self.seeding_mode_global_remove_torrent = -1 + self.seeding_mode_global_max_upload_ratio = -1 + self.seeding_mode_global_max_seeding_time = -1 + self.seeding_mode_global_download_limit = -1 + self.seeding_mode_global_upload_limit = -1 + self.seeding_mode_global_bad_tracker_msg = [] + self.completed_folder = pathlib.Path(COMPLETED_DOWNLOAD_FOLDER).joinpath(self.category) self._LOG_LEVEL = self.manager.qbit_manager.logger.level self.logger = logging.getLogger(f"qBitrr.{self._name}") run_logs(self.logger, self._name) + self.manager.completed_folders.add(self.completed_folder) + self.manager.category_allowlist.add(self.category) + self.stalled_delay = -1 + self.allowed_stalled = False + if self.category in self.manager.qbit_managed_categories: + self._apply_qbit_seeding_config() self.search_missing = False self.session = None self.search_setup_completed = False @@ -7450,6 +7478,78 @@ def __init__(self, name: str, manager: ArrManager): self.free_space_tagged_count: int = 0 self.logger.hnotice("Starting %s monitor", self._name) + def custom_format_unmet_check(self, torrent: qbittorrentapi.TorrentDictionary) -> bool: + """PlaceHolderArr does not use Arr queue; never trigger custom-format branch.""" + return False + + def _apply_qbit_seeding_config(self) -> None: + """Load [qBit] CategorySeeding and Trackers so this PlaceHolderArr respects qBit seeding config.""" + section = "qBit" + seeding_keys = [ + "DownloadRateLimitPerTorrent", + "UploadRateLimitPerTorrent", + "MaxUploadRatio", + "MaxSeedingTime", + "RemoveTorrent", + ] + default_seeding = {} + for key in seeding_keys: + default_seeding[key] = CONFIG.get(f"{section}.CategorySeeding.{key}", fallback=-1) + for key, fallback in ( + ("HitAndRunMode", "disabled"), + ("MinSeedRatio", 1.0), + ("MinSeedingTimeDays", 0), + ("HitAndRunPartialSeedRatio", 1.0), + ("TrackerUpdateBuffer", 0), + ): + default_seeding[key] = CONFIG.get( + f"{section}.CategorySeeding.{key}", fallback=fallback + ) + category_overrides = {} + for cat_config in CONFIG.get(f"{section}.CategorySeeding.Categories", fallback=[]): + if isinstance(cat_config, dict) and "Name" in cat_config: + category_overrides[cat_config["Name"]] = cat_config + effective = dict(default_seeding) + if self.category in category_overrides: + effective.update(category_overrides[self.category]) + self.seeding_mode_global_remove_torrent = effective.get("RemoveTorrent", -1) + self.seeding_mode_global_max_upload_ratio = effective.get("MaxUploadRatio", -1) + self.seeding_mode_global_max_seeding_time = effective.get("MaxSeedingTime", -1) + self.seeding_mode_global_download_limit = effective.get("DownloadRateLimitPerTorrent", -1) + self.seeding_mode_global_upload_limit = effective.get("UploadRateLimitPerTorrent", -1) + self.stalled_delay = CONFIG.get(f"{section}.CategorySeeding.StalledDelay", fallback=-1) + self.allowed_stalled = self.stalled_delay != -1 + self.monitored_trackers = CONFIG.get(f"{section}.Trackers", fallback=[]) + self._remove_trackers_if_exists = { + uri + for i in self.monitored_trackers + if i.get("RemoveIfExists") is True + and (uri := (i.get("URI") or "").strip().rstrip("/")) + } + self._monitored_tracker_urls = { + uri + for i in self.monitored_trackers + if (uri := (i.get("URI") or "").strip().rstrip("/")) + and uri not in self._remove_trackers_if_exists + } + self._add_trackers_if_missing = { + uri + for i in self.monitored_trackers + if i.get("AddTrackerIfMissing") is True + and (uri := (i.get("URI") or "").strip().rstrip("/")) + } + self._host_to_config_uri = {} + for _uri in self._monitored_tracker_urls: + _host = _extract_tracker_host(_uri) + if _host: + self._host_to_config_uri[_host] = _uri + self.logger.debug( + "Applied qBit seeding config for category '%s': RemoveTorrent=%s, StalledDelay=%s", + self.category, + self.seeding_mode_global_remove_torrent, + self.stalled_delay, + ) + def _process_errored(self): # Recheck all torrents marked for rechecking. if not self.recheck: @@ -7491,10 +7591,28 @@ def _process_errored(self): self.recheck.clear() def _process_failed(self): - if not (self.delete or self.skip_blacklist): + to_delete_all = self.delete.union( + self.missing_files_post_delete, self.downloads_with_bad_error_message_blocklist + ) + skip_blacklist = { + i.upper() for i in self.skip_blacklist.union(self.missing_files_post_delete) + } + if not (to_delete_all or self.remove_from_qbit or self.skip_blacklist): return - to_delete_all = self.delete - skip_blacklist = {i.upper() for i in self.skip_blacklist} + n_delete = len(self.delete) + n_missing = len(self.missing_files_post_delete) + n_bad_msg = len(self.downloads_with_bad_error_message_blocklist) + n_remove = len(self.remove_from_qbit) + n_skip = len(self.skip_blacklist) + self.logger.info( + "Deletion summary: delete=%d, missing_files=%d, bad_error_blocklist=%d, " + "remove_from_qbit=%d, skip_blacklist=%d", + n_delete, + n_missing, + n_bad_msg, + n_remove, + n_skip, + ) if to_delete_all: for arr in self.manager.managed_objects.values(): if payload := arr.process_entries(to_delete_all): @@ -7539,10 +7657,15 @@ def _process_failed(self): ) to_delete_all = to_delete_all.union(temp_to_delete) for h in to_delete_all: + self.cleaned_torrents.discard(h) + self.sent_to_scan_hashes.discard(h) if h in self.manager.qbit_manager.name_cache: del self.manager.qbit_manager.name_cache[h] if h in self.manager.qbit_manager.cache: del self.manager.qbit_manager.cache[h] + if self.missing_files_post_delete or self.downloads_with_bad_error_message_blocklist: + self.missing_files_post_delete.clear() + self.downloads_with_bad_error_message_blocklist.clear() self.skip_blacklist.clear() self.remove_from_qbit.clear() self.delete.clear() @@ -7554,49 +7677,33 @@ def process(self): def process_torrents(self): try: try: - _client = self.manager.qbit_manager.client - if _client is None: - raise DelayLoopException(length=LOOP_SLEEP_TIMER, type="no_downloads") while True: try: - torrents = with_retry( - lambda: _client.torrents.info( - status_filter="all", - category=self.category, - sort="added_on", - reverse=False, - ), - retries=3, - backoff=0.5, - max_backoff=3, - exceptions=( - qbittorrentapi.exceptions.APIError, - qbittorrentapi.exceptions.APIConnectionError, - requests.exceptions.RequestException, - ), - ) + torrents_with_instances = self._get_torrents_from_all_instances() break - except qbittorrentapi.exceptions.APIError: - continue - torrents = [t for t in torrents if hasattr(t, "category")] - self.category_torrent_count = len(torrents) - if not len(torrents): + except (qbittorrentapi.exceptions.APIError, JSONDecodeError) as e: + if "JSONDecodeError" in str(e): + continue + raise qbittorrentapi.exceptions.APIError + + torrents_with_instances = [ + (instance, t) + for instance, t in torrents_with_instances + if hasattr(t, "category") + ] + self.category_torrent_count = len(torrents_with_instances) + if not torrents_with_instances: raise DelayLoopException(length=LOOP_SLEEP_TIMER, type="no_downloads") + if not has_internet(self.manager.qbit_manager.client): self.manager.qbit_manager.should_delay_torrent_scan = True raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="internet") if self.manager.qbit_manager.should_delay_torrent_scan: raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="delay") - for torrent in torrents: - if torrent.category != RECHECK_CATEGORY: - self.manager.qbit_manager.cache[torrent.hash] = torrent.category - self.manager.qbit_manager.name_cache[torrent.hash] = torrent.name - if torrent.category == FAILED_CATEGORY: - # Bypass everything if manually marked as failed - self._process_single_torrent_failed_cat(torrent) - elif torrent.category == RECHECK_CATEGORY: - # Bypass everything else if manually marked for rechecking - self._process_single_torrent_recheck_cat(torrent) + + for instance_name, torrent in torrents_with_instances: + with contextlib.suppress(qbittorrentapi.NotFound404Error): + self._process_single_torrent(torrent, instance_name=instance_name) self.process() except NoConnectionrException as e: self.logger.error(e.message) @@ -8025,4 +8132,10 @@ def build_arr_instances(self): for cat in self.special_categories: managed_object = PlaceHolderArr(cat, self) self.managed_objects[cat] = managed_object + # qBit-managed categories get the same torrent behaviour (recheck, missing files, + # stalled, etc.) via PlaceHolderArr when not already an Arr category. + for cat in self.qbit_managed_categories: + if cat not in self.managed_objects: + managed_object = PlaceHolderArr(cat, self) + self.managed_objects[cat] = managed_object return self diff --git a/qBitrr/qbit_category_manager.py b/qBitrr/qbit_category_manager.py index 966b8c700..ba50d9c37 100644 --- a/qBitrr/qbit_category_manager.py +++ b/qBitrr/qbit_category_manager.py @@ -180,35 +180,8 @@ def _process_single_torrent(self, torrent: TorrentDictionary, category: str): # Apply seeding limits using effective (possibly tracker-overridden) config self._apply_seeding_limits(torrent, effective_config) - # Stalled download removal (same semantics as Arr: IgnoreTorrentsYoungerThan gates both; - # respect HnR before deleting with delete_files=True to avoid Hit and Run violations) - if self._should_remove_stalled(torrent): - hnr_config = tracker_config if tracker_config else config - if self._hnr_clear_mode_enabled(hnr_config) and self._hnr_tracker_is_dead( - torrent, hnr_config - ): - self.logger.debug( - "HnR bypass: tracker reports torrent as unregistered/dead '%s'", - torrent.name, - ) - elif self._hnr_clear_mode_enabled(hnr_config) and not self._hnr_safe_to_remove( - torrent, hnr_config - ): - self.logger.info( - "HnR protection: blocking stalled removal of [%s] (ratio=%.2f, seeding=%s, progress=%.1f%%)", - torrent.name, - torrent.ratio, - timedelta(seconds=torrent.seeding_time), - torrent.progress * 100, - ) - return - self._remove_stalled_torrent(torrent, category) - return - - # Check if torrent should be removed (seeding ratio/time) - if self._should_remove_torrent(torrent, effective_config): - self._remove_torrent(torrent, category) - + # PlaceHolderArr runs the full state machine for qBit-managed categories (stalled, + # ratio/time removal, missing files, recheck). No duplicate removal here. except Exception as e: self.logger.error( "Error processing torrent '%s' in category '%s': %s", From 368eccd66be0b8c7a3dc3ebd6580584bd6227b0f Mon Sep 17 00:00:00 2001 From: Feramance Date: Thu, 26 Feb 2026 23:42:49 +0100 Subject: [PATCH 26/66] fix(database): wrap ArrInstance index creation in db.atomic() to fix commit error Error creating ArrInstance indexes: cannot commit - no transaction is active. Peewee uses autocommit; calling db.commit() after raw DDL had no active transaction. Use db.atomic() so Peewee manages begin/commit. --- qBitrr/database.py | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/qBitrr/database.py b/qBitrr/database.py index 990a5b158..5c0bca084 100644 --- a/qBitrr/database.py +++ b/qBitrr/database.py @@ -214,25 +214,24 @@ def _create_arrinstance_indexes(db: SqliteDatabase, models: list) -> None: which is done on every WebUI page load. """ try: - cursor = db.cursor() - for model in models: - if hasattr(model, "ArrInstance"): - table_name = model._meta.table_name - index_name = f"idx_arrinstance_{table_name}" - - # Check if index already exists - cursor.execute( - "SELECT name FROM sqlite_master WHERE type='index' AND name=?", - (index_name,), - ) - if cursor.fetchone(): - continue # Index already exists - - # Create index - cursor.execute(f"CREATE INDEX {index_name} ON {table_name}(ArrInstance)") - logger.info("Created index: %s on %s.ArrInstance", index_name, table_name) + with db.atomic(): + cursor = db.cursor() + for model in models: + if hasattr(model, "ArrInstance"): + table_name = model._meta.table_name + index_name = f"idx_arrinstance_{table_name}" + + # Check if index already exists + cursor.execute( + "SELECT name FROM sqlite_master WHERE type='index' AND name=?", + (index_name,), + ) + if cursor.fetchone(): + continue # Index already exists - db.commit() + # Create index + cursor.execute(f"CREATE INDEX {index_name} ON {table_name}(ArrInstance)") + logger.info("Created index: %s on %s.ArrInstance", index_name, table_name) except Exception as e: logger.error("Error creating ArrInstance indexes: %s", e) From e83d3333fdf417d11e6af25cadf4d6fb832fa8b9 Mon Sep 17 00:00:00 2001 From: Feramance Date: Thu, 26 Feb 2026 23:52:25 +0100 Subject: [PATCH 27/66] fix(arss): missing-files handling and per-instance removal for HnR/clear mode - Add _is_missing_files_torrent() for MISSING_FILES and ERROR/missingFiles state - Route missing-files torrents earlier and pass instance_name to removal - Track remove_from_qbit_by_instance so deletion targets correct qBit instance - Add _process_failed() to delete from correct instance and log failures - Add maximum_deletable_percentage config support --- qBitrr/arss.py | 191 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 187 insertions(+), 4 deletions(-) diff --git a/qBitrr/arss.py b/qBitrr/arss.py index 64ef7663c..8af256276 100755 --- a/qBitrr/arss.py +++ b/qBitrr/arss.py @@ -905,6 +905,19 @@ def is_ignored_state(torrent: TorrentDictionary) -> bool: TorrentStates.QUEUED_DOWNLOAD, ) + @staticmethod + def _is_missing_files_torrent(torrent: TorrentDictionary) -> bool: + """True if torrent is in missing-files state (delete from client, no blacklist).""" + if torrent.state_enum == TorrentStates.MISSING_FILES: + return True + if torrent.state_enum == TorrentStates.ERROR: + raw = getattr(torrent, "state", None) + if raw is None and hasattr(torrent, "get"): + raw = torrent.get("state") + if isinstance(raw, str): + return raw == "missingFiles" or "missing" in raw.lower() + return False + @staticmethod def is_uploading_state(torrent: TorrentDictionary) -> bool: return torrent.state_enum in ( @@ -5438,7 +5451,11 @@ def _process_single_torrent_fully_completed_torrent( self.files_to_cleanup.add((torrent.hash, torrent_folder)) self.import_torrents.append((torrent, instance_name)) - def _process_single_torrent_missing_files(self, torrent: qbittorrentapi.TorrentDictionary): + def _process_single_torrent_missing_files( + self, + torrent: qbittorrentapi.TorrentDictionary, + instance_name: str = "default", + ): # Sometimes Sonarr/Radarr does not automatically remove the # torrent for some reason, # this ensures that we can safely remove it if the client is reporting @@ -6140,6 +6157,9 @@ def _process_single_torrent( elif torrent.category == RECHECK_CATEGORY: # Bypass everything else if manually marked for rechecking self._process_single_torrent_recheck_cat(torrent) + elif self._is_missing_files_torrent(torrent): + # Handle missing-files state (and ERROR state when API reports missingFiles) + self._process_single_torrent_missing_files(torrent, instance_name) elif self.is_ignored_state(torrent): self._process_single_torrent_ignored(torrent) elif ( @@ -6211,7 +6231,7 @@ def _process_single_torrent( self._process_single_torrent_already_sent_to_scan(torrent) # Sometimes torrents will error, this causes them to be rechecked so they - # complete downloading. + # complete downloading. (Missing-files case handled earlier.) elif torrent.state_enum == TorrentStates.ERROR: self._process_single_torrent_errored(torrent) # If a torrent was not just added, @@ -6227,8 +6247,6 @@ def _process_single_torrent( and torrent.completion_on < time_now - 60 ): self._process_single_torrent_fully_completed_torrent(torrent, leave_alone) - elif torrent.state_enum == TorrentStates.MISSING_FILES: - self._process_single_torrent_missing_files(torrent) # If a torrent is Uploading Pause it, as long as its not being Forced Uploaded. elif ( self.is_uploading_state(torrent) @@ -7428,6 +7446,7 @@ def __init__(self, name: str, manager: ArrManager): self.pause = set() self.skip_blacklist = set() self.remove_from_qbit = set() + self.remove_from_qbit_by_instance: dict[str, set[str]] = {} self.delete = set() self.resume = set() self.expiring_bool = ExpiringSet(max_age_seconds=10) @@ -7449,6 +7468,9 @@ def __init__(self, name: str, manager: ArrManager): self.custom_format_unmet_search = False self.do_not_remove_slow = False self.maximum_eta = CONFIG.get("Settings.Torrent.MaximumETA", fallback=86400) + self.maximum_deletable_percentage = CONFIG.get( + "Settings.Torrent.MaximumDeletablePercentage", fallback=0.95 + ) self.monitored_trackers = [] self._host_to_config_uri = {} self._add_trackers_if_missing = set() @@ -7478,6 +7500,15 @@ def __init__(self, name: str, manager: ArrManager): self.free_space_tagged_count: int = 0 self.logger.hnotice("Starting %s monitor", self._name) + def _process_single_torrent_missing_files( + self, + torrent: qbittorrentapi.TorrentDictionary, + instance_name: str = "default", + ) -> None: + """Track which qBit instance the torrent is on so we delete from the correct client.""" + super()._process_single_torrent_missing_files(torrent, instance_name) + self.remove_from_qbit_by_instance.setdefault(instance_name, set()).add(torrent.hash) + def custom_format_unmet_check(self, torrent: qbittorrentapi.TorrentDictionary) -> bool: """PlaceHolderArr does not use Arr queue; never trigger custom-format branch.""" return False @@ -7550,6 +7581,157 @@ def _apply_qbit_seeding_config(self) -> None: self.stalled_delay, ) + def _process_failed(self) -> None: + """Delete torrents from the correct qBit instance and log any delete failures.""" + to_delete_all = self.delete.union( + self.missing_files_post_delete, self.downloads_with_bad_error_message_blocklist + ) + skip_blacklist = { + i.upper() for i in self.skip_blacklist.union(self.missing_files_post_delete) + } + if not (to_delete_all or self.remove_from_qbit or self.skip_blacklist): + return + n_delete = len(self.delete) + n_missing = len(self.missing_files_post_delete) + n_bad_msg = len(self.downloads_with_bad_error_message_blocklist) + n_remove = len(self.remove_from_qbit) + n_skip = len(self.skip_blacklist) + self.logger.info( + "Deletion summary: delete=%d, missing_files=%d, bad_error_blocklist=%d, " + "remove_from_qbit=%d, skip_blacklist=%d", + n_delete, + n_missing, + n_bad_msg, + n_remove, + n_skip, + ) + if to_delete_all: + for arr in self.manager.managed_objects.values(): + if payload := arr.process_entries(to_delete_all): + for entry, hash_ in payload: + if hash_ in arr.cache: + arr._process_failed_individual( + hash_=hash_, entry=entry, skip_blacklist=skip_blacklist + ) + deleted_hashes: set[str] = set() + qbit_manager = self.manager.qbit_manager + # Delete per-instance (e.g. missing-files) so we use the correct qBit client. + for instance_name, hashes in self.remove_from_qbit_by_instance.items(): + client = qbit_manager.get_client(instance_name) + if client is None: + self.logger.warning( + "Cannot delete %d torrent(s) from qBit instance '%s': no client", + len(hashes), + instance_name, + ) + continue + try: + with_retry( + lambda h=hashes: client.torrents_delete(hashes=h, delete_files=True), + retries=3, + backoff=0.5, + max_backoff=3, + exceptions=( + qbittorrentapi.exceptions.APIError, + qbittorrentapi.exceptions.APIConnectionError, + requests.exceptions.RequestException, + ), + ) + deleted_hashes.update(hashes) + except ( + qbittorrentapi.exceptions.APIError, + qbittorrentapi.exceptions.APIConnectionError, + requests.exceptions.RequestException, + ) as e: + self.logger.error( + "Failed to delete %d torrent(s) from qBit instance '%s': %s", + len(hashes), + instance_name, + e, + ) + # Remaining remove_from_qbit/skip_blacklist and to_delete_all via default client. + if self.remove_from_qbit or self.skip_blacklist or to_delete_all: + temp_to_delete = set() + if to_delete_all: + if self.manager.qbit: + try: + with_retry( + lambda: self.manager.qbit.torrents_delete( + hashes=to_delete_all, delete_files=True + ), + retries=3, + backoff=0.5, + max_backoff=3, + exceptions=( + qbittorrentapi.exceptions.APIError, + qbittorrentapi.exceptions.APIConnectionError, + requests.exceptions.RequestException, + ), + ) + temp_to_delete.update(to_delete_all) + except ( + qbittorrentapi.exceptions.APIError, + qbittorrentapi.exceptions.APIConnectionError, + requests.exceptions.RequestException, + ) as e: + self.logger.error( + "Failed to delete %d torrent(s) from qBit (to_delete_all): %s", + len(to_delete_all), + e, + ) + else: + self.logger.warning( + "Cannot delete to_delete_all: no qBit client (manager.qbit is None)" + ) + if self.remove_from_qbit or self.skip_blacklist: + rest = (self.remove_from_qbit.union(self.skip_blacklist)) - deleted_hashes + if rest and self.manager.qbit: + try: + with_retry( + lambda: self.manager.qbit.torrents_delete( + hashes=rest, delete_files=True + ), + retries=3, + backoff=0.5, + max_backoff=3, + exceptions=( + qbittorrentapi.exceptions.APIError, + qbittorrentapi.exceptions.APIConnectionError, + requests.exceptions.RequestException, + ), + ) + temp_to_delete.update(rest) + except ( + qbittorrentapi.exceptions.APIError, + qbittorrentapi.exceptions.APIConnectionError, + requests.exceptions.RequestException, + ) as e: + self.logger.error( + "Failed to delete %d torrent(s) from qBit (remove/blacklist): %s", + len(rest), + e, + ) + elif rest: + self.logger.warning( + "Cannot delete %d torrent(s): no qBit client (manager.qbit is None)", + len(rest), + ) + to_delete_all = to_delete_all.union(temp_to_delete).union(deleted_hashes) + for h in to_delete_all: + self.cleaned_torrents.discard(h) + self.sent_to_scan_hashes.discard(h) + if h in self.manager.qbit_manager.name_cache: + del self.manager.qbit_manager.name_cache[h] + if h in self.manager.qbit_manager.cache: + del self.manager.qbit_manager.cache[h] + if self.missing_files_post_delete or self.downloads_with_bad_error_message_blocklist: + self.missing_files_post_delete.clear() + self.downloads_with_bad_error_message_blocklist.clear() + self.skip_blacklist.clear() + self.remove_from_qbit.clear() + self.remove_from_qbit_by_instance.clear() + self.delete.clear() + def _process_errored(self): # Recheck all torrents marked for rechecking. if not self.recheck: @@ -7736,6 +7918,7 @@ def __init__(self, categories: set[str], manager: ArrManager): self.logger = logging.getLogger(f"qBitrr.{self._name}") self._LOG_LEVEL = self.manager.qbit_manager.logger.level run_logs(self.logger, self._name) + self.cache = {} self.categories = categories self.logger.trace("Categories: %s", self.categories) self.pause = set() From 05b9be72ec48fdfcfefa3d06ee0db631fec09e95 Mon Sep 17 00:00:00 2001 From: Feramance Date: Fri, 27 Feb 2026 00:22:16 +0100 Subject: [PATCH 28/66] fix: premature torrent removals in HnR clear mode (arss + category manager) --- qBitrr/arss.py | 95 ++++++--------------------------- qBitrr/qbit_category_manager.py | 42 --------------- 2 files changed, 15 insertions(+), 122 deletions(-) diff --git a/qBitrr/arss.py b/qBitrr/arss.py index 8af256276..af7c5dca0 100755 --- a/qBitrr/arss.py +++ b/qBitrr/arss.py @@ -7471,9 +7471,21 @@ def __init__(self, name: str, manager: ArrManager): self.maximum_deletable_percentage = CONFIG.get( "Settings.Torrent.MaximumDeletablePercentage", fallback=0.95 ) + self.folder_exclusion_regex = None + self.file_name_exclusion_regex = None + self.file_extension_allowlist = None + self.folder_exclusion_regex_re = None + self.file_name_exclusion_regex_re = None + self.file_extension_allowlist_re = None + self.re_search_stalled = False self.monitored_trackers = [] self._host_to_config_uri = {} self._add_trackers_if_missing = set() + self._remove_trackers_if_exists = set() + self._monitored_tracker_urls = set() + self.remove_dead_trackers = False + self._remove_tracker_hosts = set() + self._normalized_bad_tracker_msgs = set() self.seeding_mode_global_remove_torrent = -1 self.seeding_mode_global_max_upload_ratio = -1 self.seeding_mode_global_max_seeding_time = -1 @@ -7574,6 +7586,9 @@ def _apply_qbit_seeding_config(self) -> None: _host = _extract_tracker_host(_uri) if _host: self._host_to_config_uri[_host] = _uri + self._remove_tracker_hosts = { + h for u in self._remove_trackers_if_exists if (h := _extract_tracker_host(u)) + } self.logger.debug( "Applied qBit seeding config for category '%s': RemoveTorrent=%s, StalledDelay=%s", self.category, @@ -7772,86 +7787,6 @@ def _process_errored(self): self.timed_ignore_cache.add(k) self.recheck.clear() - def _process_failed(self): - to_delete_all = self.delete.union( - self.missing_files_post_delete, self.downloads_with_bad_error_message_blocklist - ) - skip_blacklist = { - i.upper() for i in self.skip_blacklist.union(self.missing_files_post_delete) - } - if not (to_delete_all or self.remove_from_qbit or self.skip_blacklist): - return - n_delete = len(self.delete) - n_missing = len(self.missing_files_post_delete) - n_bad_msg = len(self.downloads_with_bad_error_message_blocklist) - n_remove = len(self.remove_from_qbit) - n_skip = len(self.skip_blacklist) - self.logger.info( - "Deletion summary: delete=%d, missing_files=%d, bad_error_blocklist=%d, " - "remove_from_qbit=%d, skip_blacklist=%d", - n_delete, - n_missing, - n_bad_msg, - n_remove, - n_skip, - ) - if to_delete_all: - for arr in self.manager.managed_objects.values(): - if payload := arr.process_entries(to_delete_all): - for entry, hash_ in payload: - if hash_ in arr.cache: - arr._process_failed_individual( - hash_=hash_, entry=entry, skip_blacklist=skip_blacklist - ) - if self.remove_from_qbit or self.skip_blacklist or to_delete_all: - # Remove all bad torrents from the Client. - temp_to_delete = set() - if to_delete_all: - with contextlib.suppress(Exception): - with_retry( - lambda: self.manager.qbit.torrents_delete( - hashes=to_delete_all, delete_files=True - ), - retries=3, - backoff=0.5, - max_backoff=3, - exceptions=( - qbittorrentapi.exceptions.APIError, - qbittorrentapi.exceptions.APIConnectionError, - requests.exceptions.RequestException, - ), - ) - if self.remove_from_qbit or self.skip_blacklist: - temp_to_delete = self.remove_from_qbit.union(self.skip_blacklist) - with contextlib.suppress(Exception): - with_retry( - lambda: self.manager.qbit.torrents_delete( - hashes=temp_to_delete, delete_files=True - ), - retries=3, - backoff=0.5, - max_backoff=3, - exceptions=( - qbittorrentapi.exceptions.APIError, - qbittorrentapi.exceptions.APIConnectionError, - requests.exceptions.RequestException, - ), - ) - to_delete_all = to_delete_all.union(temp_to_delete) - for h in to_delete_all: - self.cleaned_torrents.discard(h) - self.sent_to_scan_hashes.discard(h) - if h in self.manager.qbit_manager.name_cache: - del self.manager.qbit_manager.name_cache[h] - if h in self.manager.qbit_manager.cache: - del self.manager.qbit_manager.cache[h] - if self.missing_files_post_delete or self.downloads_with_bad_error_message_blocklist: - self.missing_files_post_delete.clear() - self.downloads_with_bad_error_message_blocklist.clear() - self.skip_blacklist.clear() - self.remove_from_qbit.clear() - self.delete.clear() - def process(self): self._process_errored() self._process_failed() diff --git a/qBitrr/qbit_category_manager.py b/qBitrr/qbit_category_manager.py index ba50d9c37..541150165 100644 --- a/qBitrr/qbit_category_manager.py +++ b/qBitrr/qbit_category_manager.py @@ -4,7 +4,6 @@ import logging import time -from datetime import timedelta from typing import TYPE_CHECKING from qbittorrentapi import TorrentStates @@ -191,47 +190,6 @@ def _process_single_torrent(self, torrent: TorrentDictionary, category: str): exc_info=True, ) - def _should_remove_stalled(self, torrent: TorrentDictionary) -> bool: - """ - Return True if this torrent is stalled (STALLED_DOWNLOAD/METADATA_DOWNLOAD) and has - exceeded StalledDelay, and passes IgnoreTorrentsYoungerThan (intentional gate on both - added_on and last_activity). - """ - if not self.allowed_stalled or self.stalled_delay <= 0: - return False - if torrent.state_enum not in ( - TorrentStates.STALLED_DOWNLOAD, - TorrentStates.METADATA_DOWNLOAD, - ): - return False - time_now = time.time() - stalled_delay_seconds = int(timedelta(minutes=self.stalled_delay).total_seconds()) - if time_now < torrent.added_on + self.ignore_torrents_younger_than: - return False - if time_now < torrent.last_activity + stalled_delay_seconds: - return False - if torrent.last_activity > time_now - self.ignore_torrents_younger_than: - return False - return True - - def _remove_stalled_torrent(self, torrent: TorrentDictionary, category: str): - """Remove a stalled download (no progress within StalledDelay, past IgnoreTorrentsYoungerThan).""" - try: - self.logger.info( - "Removing stalled torrent '%s' from category '%s' (no progress for %d min)", - torrent.name, - category, - self.stalled_delay, - ) - torrent.delete(delete_files=True) - except Exception as e: - self.logger.error( - "Failed to remove stalled torrent '%s': %s", - torrent.name, - e, - exc_info=True, - ) - def _apply_tags(self, torrent: TorrentDictionary, tags: list): """ Apply tags to a torrent if not already present. From 76d331a3fa894ff1d1b227dfc3e340a7230ef6d2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 27 Feb 2026 06:01:32 +0000 Subject: [PATCH 29/66] chore: remove unused qBit category removal helpers Applied via @cursor push command --- qBitrr/qbit_category_manager.py | 106 -------------------------------- 1 file changed, 106 deletions(-) diff --git a/qBitrr/qbit_category_manager.py b/qBitrr/qbit_category_manager.py index 541150165..664fc7241 100644 --- a/qBitrr/qbit_category_manager.py +++ b/qBitrr/qbit_category_manager.py @@ -6,8 +6,6 @@ import time from typing import TYPE_CHECKING -from qbittorrentapi import TorrentStates - from qBitrr.arss import _extract_tracker_host from qBitrr.errors import DelayLoopException @@ -264,84 +262,6 @@ def _apply_seeding_limits(self, torrent: TorrentDictionary, config: dict): e, ) - def _should_remove_torrent(self, torrent: TorrentDictionary, config: dict) -> bool: - """ - Check if torrent meets removal conditions. - - - Seeding limits (ratio/time) only apply to COMPLETED/UPLOADING torrents - - HnR protection only applies to DOWNLOADING torrents - - Args: - torrent: qBittorrent torrent object - config: Seeding configuration dict - - Returns: - True if torrent should be removed, False otherwise - """ - remove_mode = config.get("RemoveTorrent", -1) - - if remove_mode == -1: - return False - - is_uploading = torrent.state_enum in ( - TorrentStates.UPLOADING, - TorrentStates.STALLED_UPLOAD, - TorrentStates.QUEUED_UPLOAD, - TorrentStates.PAUSED_UPLOAD, - TorrentStates.FORCED_UPLOAD, - ) - is_downloading = torrent.state_enum in ( - TorrentStates.DOWNLOADING, - TorrentStates.STALLED_DOWNLOAD, - TorrentStates.QUEUED_DOWNLOAD, - TorrentStates.PAUSED_DOWNLOAD, - TorrentStates.FORCED_DOWNLOAD, - TorrentStates.METADATA_DOWNLOAD, - ) - - if not is_uploading: - return False - - ratio_limit = config.get("MaxUploadRatio", -1) - time_limit = config.get("MaxSeedingTime", -1) - - ratio_met = ratio_limit > 0 and torrent.ratio >= ratio_limit - time_met = time_limit > 0 and torrent.seeding_time >= time_limit - - should_remove = False - if remove_mode == 1: - should_remove = ratio_met - elif remove_mode == 2: - should_remove = time_met - elif remove_mode == 3: - should_remove = ratio_met or time_met - elif remove_mode == 4: - should_remove = ratio_met and time_met - - if should_remove: - tracker_config = self._get_tracker_config(torrent) - hnr_config = tracker_config if tracker_config else config - - if self._hnr_clear_mode_enabled(hnr_config) and self._hnr_tracker_is_dead( - torrent, hnr_config - ): - self.logger.debug( - "HnR bypass: tracker reports torrent as unregistered/dead '%s'", - torrent.name, - ) - return True - - if is_downloading and not self._hnr_safe_to_remove(torrent, hnr_config): - self.logger.debug( - "HnR protection: keeping downloading torrent '%s' (ratio=%.2f, seeding=%ds)", - torrent.name, - torrent.ratio, - torrent.seeding_time, - ) - return False - - return should_remove - def _hnr_clear_mode_enabled(self, config: dict) -> bool: """True if HnR protection is enabled (HitAndRunMode is 'and' or 'or').""" mode = (config.get("HitAndRunMode") or "").strip().lower() @@ -463,32 +383,6 @@ def _hnr_safe_to_remove(self, torrent: TorrentDictionary, config: dict) -> bool: return True return True - def _remove_torrent(self, torrent: TorrentDictionary, category: str): - """ - Remove torrent that met seeding goals. - - Args: - torrent: qBittorrent torrent object - category: Category name - """ - try: - self.logger.info( - "Removing torrent '%s' from category '%s' " "(ratio: %.2f, seeding time: %ds)", - torrent.name, - category, - torrent.ratio, - torrent.seeding_time, - ) - # Remove from qBit but keep files - torrent.delete(delete_files=False) - except Exception as e: - self.logger.error( - "Failed to remove torrent '%s': %s", - torrent.name, - e, - exc_info=True, - ) - def _create_dedicated_client(self): """Create a dedicated qBit client for this process to avoid HTTP session sharing.""" import qbittorrentapi From 319bc47557a506b4f24b896a6de60595cbabffb5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 27 Feb 2026 06:15:02 +0000 Subject: [PATCH 30/66] chore: remove unused HnR helpers in category manager Applied via @cursor push command --- qBitrr/qbit_category_manager.py | 81 --------------------------------- 1 file changed, 81 deletions(-) diff --git a/qBitrr/qbit_category_manager.py b/qBitrr/qbit_category_manager.py index 664fc7241..2b39da38b 100644 --- a/qBitrr/qbit_category_manager.py +++ b/qBitrr/qbit_category_manager.py @@ -262,11 +262,6 @@ def _apply_seeding_limits(self, torrent: TorrentDictionary, config: dict): e, ) - def _hnr_clear_mode_enabled(self, config: dict) -> bool: - """True if HnR protection is enabled (HitAndRunMode is 'and' or 'or').""" - mode = (config.get("HitAndRunMode") or "").strip().lower() - return mode in ("and", "or") - def _get_tracker_config(self, torrent: TorrentDictionary) -> dict | None: """Find the highest-priority matching tracker config for this torrent.""" if not self.trackers: @@ -307,82 +302,6 @@ def _get_tracker_config(self, torrent: TorrentDictionary) -> dict | None: best_priority = priority return best - def _hnr_tracker_is_dead(self, torrent: TorrentDictionary, config: dict) -> bool: - """Check if the HnR-enabled tracker reports the torrent as unregistered.""" - _dead_keywords = { - "unregistered torrent", - "torrent not registered", - "info hash is not authorized", - "torrent is not authorized", - "not found", - "torrent not found", - } - uri = (config.get("URI") or "").strip().rstrip("/") - cfg_host = _extract_tracker_host(uri) - if not cfg_host: - return False - try: - for tracker in torrent.trackers: - tracker_url = (getattr(tracker, "url", None) or "").rstrip("/") - if _extract_tracker_host(tracker_url) != cfg_host: - continue - message_text = (getattr(tracker, "msg", "") or "").lower() - if any(keyword in message_text for keyword in _dead_keywords): - return True - except Exception: - pass - return False - - def _hnr_safe_to_remove(self, torrent: TorrentDictionary, config: dict) -> bool: - """ - Check if Hit and Run obligations are met for this torrent. - - Args: - torrent: qBittorrent torrent object - config: Seeding configuration dict - - Returns: - True if HnR obligations are met (safe to remove), False otherwise - """ - clear_mode = (config.get("HitAndRunMode") or "disabled").strip().lower() - if clear_mode not in ("and", "or"): - return True # Disabled or invalid: safe to remove - - min_ratio = config.get("MinSeedRatio", 1.0) - min_time_secs = config.get("MinSeedingTimeDays", 0) * 86400 - min_dl_pct = config.get("HitAndRunMinimumDownloadPercent", 10) / 100.0 - partial_ratio = config.get("HitAndRunPartialSeedRatio", 1.0) - buffer_secs = config.get("TrackerUpdateBuffer", 0) - - is_partial = torrent.progress < 1.0 and torrent.progress >= min_dl_pct - effective_seeding_time = torrent.seeding_time - buffer_secs - - if torrent.progress < min_dl_pct: - return True # Below minimum download threshold, no HnR obligation - if is_partial: - return torrent.ratio >= partial_ratio # Partial: ratio only - - ratio_met = torrent.ratio >= min_ratio if min_ratio > 0 else False - time_met = effective_seeding_time >= min_time_secs if min_time_secs > 0 else False - - if clear_mode == "and": - if min_ratio > 0 and min_time_secs > 0: - return ratio_met and time_met - if min_ratio > 0: - return ratio_met - if min_time_secs > 0: - return time_met - return True - if clear_mode == "or": - if min_ratio > 0 and min_time_secs > 0: - return ratio_met or time_met - if min_ratio > 0: - return ratio_met - if min_time_secs > 0: - return time_met - return True - return True - def _create_dedicated_client(self): """Create a dedicated qBit client for this process to avoid HTTP session sharing.""" import qbittorrentapi From 4943bc94dce883e4fe903e90c853628ec8a8cb63 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 27 Feb 2026 07:12:00 +0000 Subject: [PATCH 31/66] fix: resolve placeholder section mapping and windows path loop Applied via @cursor push command --- qBitrr/arss.py | 52 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/qBitrr/arss.py b/qBitrr/arss.py index af7c5dca0..76bad6c6c 100755 --- a/qBitrr/arss.py +++ b/qBitrr/arss.py @@ -7526,8 +7526,8 @@ def custom_format_unmet_check(self, torrent: qbittorrentapi.TorrentDictionary) - return False def _apply_qbit_seeding_config(self) -> None: - """Load [qBit] CategorySeeding and Trackers so this PlaceHolderArr respects qBit seeding config.""" - section = "qBit" + """Load qBit CategorySeeding/Trackers for this category's owning qBit section.""" + section = self.manager.qbit_managed_category_sections.get(self.category, "qBit") seeding_keys = [ "DownloadRateLimitPerTorrent", "UploadRateLimitPerTorrent", @@ -7590,7 +7590,9 @@ def _apply_qbit_seeding_config(self) -> None: h for u in self._remove_trackers_if_exists if (h := _extract_tracker_host(u)) } self.logger.debug( - "Applied qBit seeding config for category '%s': RemoveTorrent=%s, StalledDelay=%s", + "Applied qBit seeding config from section '%s' for category '%s': " + "RemoveTorrent=%s, StalledDelay=%s", + section, self.category, self.seeding_mode_global_remove_torrent, self.stalled_delay, @@ -7894,9 +7896,7 @@ def __init__(self, categories: set[str], manager: ArrManager): # Path for disk usage: use resolved path; if it does not exist, use first existing # parent so we still report the correct volume (e.g. /torrents missing -> use /). self._path_for_disk_usage = self._disk_usage_path - _p = self._disk_usage_path - while _p and not _p.exists(): - _p = _p.parent + _p = self._first_existing_parent(self._disk_usage_path) if _p: self._path_for_disk_usage = _p if self._path_for_disk_usage != self._disk_usage_path: @@ -7949,6 +7949,17 @@ def __init__(self, categories: set[str], manager: ArrManager): ) ) + @staticmethod + def _first_existing_parent(path: pathlib.Path) -> pathlib.Path: + """Return the nearest existing parent path without looping at filesystem root.""" + current = path + while not current.exists(): + parent = current.parent + if parent == current: + break + current = parent + return current + def _get_models( self, ) -> tuple[ @@ -8080,9 +8091,7 @@ def process_torrents(self): raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="delay") # Re-resolve path each loop so we use the configured path once it appears # (e.g. Docker volume mounted after process start). - _p = self._disk_usage_path - while _p and not _p.exists(): - _p = _p.parent + _p = self._first_existing_parent(self._disk_usage_path) if _p: self._path_for_disk_usage = _p self.current_free_space = ( @@ -8136,6 +8145,7 @@ def __init__(self, qbitmanager: qBitManager): self.special_categories: set[str] = {FAILED_CATEGORY, RECHECK_CATEGORY} self.arr_categories: set[str] = set() self.qbit_managed_categories: set[str] = set() + self.qbit_managed_category_sections: dict[str, str] = {} self.category_allowlist: set[str] = self.special_categories.copy() self.completed_folders: set[pathlib.Path] = set() self.managed_objects: dict[str, Arr] = {} @@ -8162,12 +8172,25 @@ def _validate_category_assignments(self): ValueError: If any category is managed by both Arr and qBit """ # Collect qBit-managed categories from all instances + self.qbit_managed_categories.clear() + self.qbit_managed_category_sections.clear() for section in CONFIG.sections(): # Check default qBit section if section == "qBit": managed_cats = CONFIG.get("qBit.ManagedCategories", fallback=[]) if managed_cats: self.qbit_managed_categories.update(managed_cats) + for category in managed_cats: + owner = self.qbit_managed_category_sections.setdefault(category, section) + if owner != section: + self.logger.warning( + "Category '%s' is managed by both '%s' and '%s'; " + "PlaceHolderArr will use '%s' seeding config", + category, + owner, + section, + owner, + ) self.logger.debug( "qBit instance 'default' manages categories: %s", ", ".join(managed_cats), @@ -8178,6 +8201,17 @@ def _validate_category_assignments(self): managed_cats = CONFIG.get(f"{section}.ManagedCategories", fallback=[]) if managed_cats: self.qbit_managed_categories.update(managed_cats) + for category in managed_cats: + owner = self.qbit_managed_category_sections.setdefault(category, section) + if owner != section: + self.logger.warning( + "Category '%s' is managed by both '%s' and '%s'; " + "PlaceHolderArr will use '%s' seeding config", + category, + owner, + section, + owner, + ) self.logger.debug( "qBit instance '%s' manages categories: %s", instance_name, From 252d742ed71c73497fc852d5bbcfdbbb9be2cc58 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 27 Feb 2026 07:44:21 +0000 Subject: [PATCH 32/66] fix: apply placeholder qbit actions and cap compose log size Applied via @cursor push command --- docker-compose.yml | 2 +- qBitrr/arss.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 98e436a19..ad4f09cb6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,7 +18,7 @@ services: logging: driver: json-file options: - max-size: 5g + max-size: 50m max-file: 1 ports: - 6969:6969 diff --git a/qBitrr/arss.py b/qBitrr/arss.py index 76bad6c6c..cbe458dfb 100755 --- a/qBitrr/arss.py +++ b/qBitrr/arss.py @@ -7790,7 +7790,10 @@ def _process_errored(self): self.recheck.clear() def process(self): + self._process_resume() + self._process_paused() self._process_errored() + self._process_file_priority() self._process_failed() def process_torrents(self): From 0d84d36538f38717bb1ad4de58efb9553ba765c5 Mon Sep 17 00:00:00 2001 From: Feramance Date: Fri, 27 Feb 2026 09:21:04 +0100 Subject: [PATCH 33/66] fix: rate limit vars (dl_r/ul_r) in arss; dedupe Configuration nav in mkdocs --- mkdocs.yml | 2 +- qBitrr/arss.py | 40 ++++++++++++++++++++-------------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index bd48b07a1..17d3d4886 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -193,7 +193,6 @@ nav: - Home: - index.md - Getting Started: getting-started/index.md - - Configuration: configuration/index.md - Features: features/index.md - FAQ: faq.md - Changelog: changelog.md @@ -209,6 +208,7 @@ nav: - Migration Guide: getting-started/migration.md - Configuration: + - Overview: configuration/index.md - Configuration File: configuration/config-file.md - qBittorrent: configuration/qbittorrent.md - Arr Instances: diff --git a/qBitrr/arss.py b/qBitrr/arss.py index cbe458dfb..6c30131eb 100755 --- a/qBitrr/arss.py +++ b/qBitrr/arss.py @@ -5976,21 +5976,19 @@ def _process_single_torrent_trackers( monitored_trackers, _remove_urls ) if monitored_trackers and most_important_tracker: - if ( - r := most_important_tracker.get( - "DownloadRateLimit", self.seeding_mode_global_download_limit - ) - ) != 0 and torrent.dl_limit != r: - torrent.set_download_limit(limit=r) - elif r < 0: + dl_r = most_important_tracker.get( + "DownloadRateLimit", self.seeding_mode_global_download_limit + ) + if dl_r != 0 and torrent.dl_limit != dl_r: + torrent.set_download_limit(limit=dl_r) + elif dl_r < 0: torrent.set_download_limit(limit=-1) - if ( - r := most_important_tracker.get( - "UploadRateLimit", self.seeding_mode_global_upload_limit - ) - ) != 0 and torrent.up_limit != r: - torrent.set_upload_limit(limit=r) - elif r < 0: + ul_r = most_important_tracker.get( + "UploadRateLimit", self.seeding_mode_global_upload_limit + ) + if ul_r != 0 and torrent.up_limit != ul_r: + torrent.set_upload_limit(limit=ul_r) + elif ul_r < 0: torrent.set_upload_limit(limit=-1) if ( r := most_important_tracker.get("SuperSeedMode", False) @@ -5999,13 +5997,15 @@ def _process_single_torrent_trackers( torrent.set_super_seeding(enabled=r) else: - if (r := self.seeding_mode_global_download_limit) != 0 and torrent.dl_limit != r: - torrent.set_download_limit(limit=r) - elif r < 0: + dl_r = self.seeding_mode_global_download_limit + if dl_r != 0 and torrent.dl_limit != dl_r: + torrent.set_download_limit(limit=dl_r) + elif dl_r < 0: torrent.set_download_limit(limit=-1) - if (r := self.seeding_mode_global_upload_limit) != 0 and torrent.up_limit != r: - torrent.set_upload_limit(limit=r) - elif r < 0: + ul_r = self.seeding_mode_global_upload_limit + if ul_r != 0 and torrent.up_limit != ul_r: + torrent.set_upload_limit(limit=ul_r) + elif ul_r < 0: torrent.set_upload_limit(limit=-1) if unique_tags: From 55ef11eeaffcfa5a3638d26da49b22a6cc84df54 Mon Sep 17 00:00:00 2001 From: Feramance Date: Fri, 27 Feb 2026 15:13:47 +0100 Subject: [PATCH 34/66] fix: FreeSpaceFolder, PlaceHolderArr category, ERROR/missing-files, _process_failed retry and cache cleanup; add CLAUDE.md - FreeSpaceFolder: apply first-existing-parent fallback only when FREE_SPACE_FOLDER is CHANGE_ME; explicit path missing now errors instead of using wrong filesystem - PlaceHolderArr.process_torrents: filter by getattr(t, 'category', None) == self.category so only this instance's category is processed - _process_failed (Arr): wrap manager.qbit.torrents_delete in with_retry and try/except; track only successfully deleted hashes in deleted_hashes for cache cleanup - Torrent handling: missing-files check moved to immediately after RECHECK_CATEGORY so missing-files bypass all other processing and go straight to deletion - .gitignore: stop ignoring CLAUDE.md; add CLAUDE.md (sync with AGENTS.md per workspace rules) --- .gitignore | 1 - CLAUDE.md | 434 +++++++++++++++++++++++++++++++++++++++++++ qBitrr/arss.py | 184 +++++++++++++----- qBitrr/gen_config.py | 2 +- qBitrr/main.py | 4 +- 5 files changed, 579 insertions(+), 46 deletions(-) create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index c07a666e3..d88d30dcf 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,5 @@ test_webui.sh /scripts/temp/ # Development -CLAUDE.md opencodetmp/ .claude diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..227cb123c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,434 @@ +# AGENTS.md – Agent Coding Guide + +> **Purpose**: qBitrr orchestrates qBittorrent ↔ Radarr/Sonarr/Lidarr communication, handling torrent health checks, instant imports, smart cleanup, and request automation. This guide ensures AI agents/contributors maintain consistency across Python backend and React frontend. + +## Project Overview +- **Language**: Python 3.12+ (backend), TypeScript + React 18 (WebUI) +- **Architecture**: Multi-threaded event loops per Arr instance; Flask/Waitress REST API; Peewee ORM (SQLite) +- **Entry Point**: `qBitrr.main:run` → spawns WebUI, ArrManager loops, auto-update watchers +- **Key Modules**: + - `qBitrr/main.py` – orchestrates multiprocessing, launches arr managers and WebUI + - `qBitrr/arss.py` – ArrManager classes (Radarr/Sonarr/Lidarr) with health checks & import logic + - `qBitrr/config.py` – TOML config parsing, validation, migrations + - `qBitrr/webui.py` – Flask routes for `/api/*` (token-protected) and `/web/*` (helpers) + - `qBitrr/ffprobe.py` – media file verification via ffprobe + - `qBitrr/tables.py` – Peewee models for persistent state (downloads, searches, expiry) + - `webui/src/` – React dashboard with @mantine/core UI, react-hook-form, @tanstack/react-table +- **Config**: `~/config/config.toml` (native) or `/config/config.toml` (Docker). Generated on first run via `gen_config.py` +- **Logging**: Structured logs in `~/logs/` or `/config/logs`; `Main.log`, `WebUI.log`, per-Arr logs +- **Deployment**: PyPI package (`qBitrr2`), Docker image (`feramance/qbitrr:latest`), or source install + +## Build/Lint/Test Workflow + +### Python Backend +1. **Create Environment**: `make newenv` (or `python -m venv .venv && source .venv/bin/activate`) +2. **Install Dependencies**: `make syncenv` (installs `.[all]` from setup.cfg, includes dev deps + WebUI build) +3. **Format & Lint**: `make reformat` → runs pre-commit hooks: + - `black` (99-char line length, py312 target) + - `isort` (black profile, known_third_party listed in pyproject.toml) + - `autoflake` (removes unused imports/variables) + - `pyupgrade` (modernizes syntax to py38+) + - `check-yaml`, `check-toml`, `check-json`, `detect-private-key`, `end-of-file-fixer`, `trailing-whitespace`, `mixed-line-ending` +4. **Manual Testing**: No pytest suite; test against live qBittorrent + Arr instances or use Docker Compose setup +5. **Version Bump**: `bump2version patch|minor|major` (updates `.bumpversion.cfg`, `setup.cfg`, `pyproject.toml`, tags commit) +6. **Build Package**: `python setup.py sdist bdist_wheel` → dist/qBitrr2-*.whl +7. **Docker Build**: `docker build -t feramance/qbitrr:test .` (multi-stage: Node build → Python install) + +### TypeScript/React WebUI +1. **Install**: `cd webui && npm ci` (package-lock.json locked to exact versions) +2. **Dev Server**: `npm run dev` → http://localhost:5173 with HMR (Vite) +3. **Lint**: `npm run lint` → ESLint with `@eslint/js`, `typescript-eslint`, `react-hooks`, `react-refresh` +4. **Type Check**: `tsc -b` (tsconfig.app.json + tsconfig.node.json) +5. **Build**: `npm run build` → outputs to `webui/dist/`, copied to `qBitrr/static/` for bundling +6. **Preview**: `npm run preview` → serve production build locally + +### CI/CD +- **pre-commit.ci**: Auto-formats PRs, runs weekly autoupdates +- **CodeQL**: `.github/workflows/codeql.yml` scans for security issues +- **Nightly**: `.github/workflows/nightly.yml` builds Docker image, publishes to `feramance/qbitrr:nightly` +- **Release**: `.github/workflows/release.yml` publishes PyPI package, Docker tags (`latest`, semver) +- **Dependabot**: `.github/dependabot.yml` updates GitHub Actions weekly + +## Code Style + +### Python (PEP 8 + Black) +- **Formatting**: Black (99-char max), isort (black profile), 4-space indentation +- **Type Hints**: Required for all function signatures; use `from __future__ import annotations` for forward refs +- **Naming**: + - `snake_case` for functions, variables, module names + - `PascalCase` for classes, exceptions + - `SCREAMING_SNAKE_CASE` for module-level constants +- **Exceptions**: Inherit from `qBitManagerError` (qBitrr/errors.py). Common subclasses: + - `ConfigException` – config parsing/validation + - `ArrManagerException` – Arr API failures + - `SkipException` – skip torrent processing without error + - `NoConnectionrException` – connection failures (typo preserved for compatibility) + - `DelayLoopException`, `RestartLoopException` – control flow for event loops + - `RequireConfigValue` – missing required config key +- **Docstrings**: Required for all classes and public functions; use triple-quotes, describe purpose + params + return +- **Imports**: Absolute imports (`from qBitrr.config import CONFIG`), grouped by stdlib → third-party → local +- **Line Breaks**: LF only (enforced by pre-commit `mixed-line-ending --fix lf`) + +### TypeScript/React +- **Style**: ESLint `recommended` + `typescript-eslint` strict, functional components only +- **Type Safety**: Explicit return types, interfaces for all component props, no `any` (use `unknown` if needed) +- **Naming**: + - `camelCase` for variables, functions + - `PascalCase` for components, interfaces, types +- **Import Order**: React → node_modules/@-scoped → local modules → local icons (SVGs) +- **Hooks**: Declare dependencies correctly; use `useCallback`/`useMemo` for expensive ops +- **State Management**: Context API (`SearchContext`, `ToastContext`, `WebUIContext`) for global state +- **UI Library**: @mantine/core v8 for components; follow Mantine conventions (e.g., `sx` prop for inline styles) + +### General +- **Indentation**: 4 spaces (Python), 2 spaces (JS/TS/JSON/YAML) +- **Line Endings**: LF (`\n`) enforced by pre-commit +- **Trailing Whitespace**: None (auto-fixed by pre-commit) +- **EOF Newline**: Required (enforced by `end-of-file-fixer`) +- **Unused Code**: No unused imports, variables, or functions (autoflake removes them) + +## Error Handling & Logging +- **Custom Exceptions**: Always inherit from `qBitManagerError`; include context in `__init__` (e.g., `RequireConfigValue(config_class, config_key)`) +- **Logging**: Use `logging.getLogger("qBitrr")` or subloggers (`qBitrr.arr`, `qBitrr.webui`); call `run_logs(logger, "ModuleName")` to write to file +- **Log Levels**: DEBUG (verbose state), INFO (user-facing), WARNING (recoverable issue), ERROR (failure), CRITICAL (fatal) +- **User Messages**: Provide actionable error messages; reference config keys, Arr instance names, torrent hashes + +## Architecture & Patterns +- **Multiprocessing**: `pathos.multiprocessing` for cross-platform support; each Arr instance runs in a separate process +- **Threading**: WebUI runs in main thread; auto-update, network monitor, and FFprobe downloads in background threads +- **Database**: Peewee ORM with SQLite (thread-safe via `db_lock.py`); tables: `DownloadsModel`, `SearchModel`, `EntryExpiry` +- **Event Loops**: Each ArrManager has a main loop checking qBit torrents every N seconds, triggering health checks, imports, cleanup +- **Config Migrations**: `apply_config_migrations()` upgrades old configs; bump `CURRENT_CONFIG_VERSION` when schema changes +- **API Routes**: + - `/api/*` – token-protected (check `Settings.WebUIToken`), returns JSON + - `/web/*` – public helpers (serve UI, version info) + - `/ui` → serves React SPA from `qBitrr/static/` + +## Development Tips +- **Config Changes**: Edit `qBitrr/gen_config.py` (MyConfig class); regenerate example via `qbitrr --gen-config` +- **WebUI Changes**: Run `npm run dev` in webui/, API requests proxy to http://localhost:6969 +- **Database Schema**: Modify `qBitrr/tables.py`, add migration logic in `config.py:apply_config_migrations()` +- **New Arr Type**: Subclass `ArrManagerBase` in `arss.py`, implement `_process_failed_individual()`, register in `main.py` +- **Pre-commit Bypass**: `git commit --no-verify` (discouraged; use for emergency hotfixes only) + +## Testing & Validation +- **Manual Testing**: Launch qBittorrent + Radarr/Sonarr locally or via Docker Compose; trigger downloads, check logs +- **Health Checks**: Test stalled torrents (pause + resume), failed imports (bad file perms), blacklisting +- **Config Validation**: Test invalid TOML, missing required keys, edge cases (e.g., empty categories) +- **WebUI Testing**: Check all tabs (Processes, Logs, Radarr/Sonarr/Lidarr, Config), test CRUD ops + +## Pre-Merge/Release Cleanup + +**CRITICAL**: Before merging PRs or creating releases, clean up ALL temporary files to keep the repository tidy. + +### Temporary Files to Remove + +**Always remove these file patterns:** +- `*_TEST*.md` - Test plans, test results, test summaries +- `*_RESULTS*.md` - Test results, benchmark results +- `READY_FOR_*.md` - Merge checklists, release checklists +- `*_PLAN.md` - Implementation plans, migration plans +- `*_SUMMARY.md` - Development summaries, decision logs +- `*_NOTES.md` - Development notes, meeting notes +- `TODO.md`, `TASKS.md` - Temporary task lists (unless permanent project management) + +**Files to KEEP:** +- `README.md` - Main project readme +- `CHANGELOG.md` - Release history +- `AGENTS.md` - AI agent coding guide +- `.github/*.md` - GitHub templates (PR, issue, discussion) +- `docs/**/*.md` - All permanent documentation (includes API docs, contribution guide, systemd guide) + +### Cleanup Commands + +```bash +# 1. Find all temporary markdown files +find . -name "*_TEST*.md" -o -name "*_RESULTS*.md" -o -name "READY_FOR_*.md" -o -name "*_PLAN.md" -o -name "*_SUMMARY.md" -o -name "*_NOTES.md" + +# 2. Review the list to ensure no permanent docs are caught + +# 3. Remove temporary files +find . -name "*_TEST*.md" -o -name "*_RESULTS*.md" -o -name "READY_FOR_*.md" -o -name "*_PLAN.md" | xargs git rm + +# 4. Verify only permanent docs remain +git ls-files "*.md" | grep -v "^docs/" + +# Expected output: +# AGENTS.md +# CHANGELOG.md +# README.md +# .github/pull_request_template.md +# (plus any other permanent root-level docs) + +# 5. Commit cleanup +git commit -m "chore: Remove temporary documentation files" +``` + +### Why This Matters + +1. **Repository Hygiene**: Keeps git history clean and focused on permanent documentation +2. **Release Quality**: Prevents shipping development artifacts to end users +3. **Search Clarity**: Reduces noise when searching documentation +4. **Professional Image**: Shows attention to detail and proper project management +5. **Storage Efficiency**: Reduces repository size over time + +### When to Clean Up + +- **Before merging feature branches to master** +- **Before creating release tags** +- **After completing major development work** +- **During code review** (reviewer should check for temporary files) + +### Pre-Merge Checklist + +Before submitting a PR for final merge: + +- [ ] All temporary `*_TEST*.md`, `*_RESULTS*.md` files removed +- [ ] All `READY_FOR_*.md` checklists removed +- [ ] All `*_PLAN.md` planning docs removed +- [ ] Only permanent documentation remains in root +- [ ] `docs/` directory contains only user-facing documentation +- [ ] Commit message references cleanup: `chore: Remove temporary documentation files` + +## Release Process + +### Overview +qBitrr uses **semantic versioning** (MAJOR.MINOR.PATCH) and automated release workflows. Releases are triggered by **commit message prefixes** on the `master` branch — not by local version bumps or tags. + +### How Releases Are Triggered + +The `.github/workflows/release.yml` workflow runs on every push to `master`, but the release jobs **only execute** when the head commit message starts with one of these prefixes: + +| Prefix | Bump | Example | +|--------|------|---------| +| `[patch]` | x.x.**X** | Bug fixes, minor improvements | +| `[minor]` | x.**X**.0 | New features, non-breaking changes | +| `[major]` | **X**.0.0 | Breaking changes | + +The workflow can also be triggered manually via `workflow_dispatch` on GitHub Actions. + +**Do NOT run `bump2version` locally.** The CI workflow runs `bump2version` itself, commits the version bump (with `[skip ci]`), updates the bundled git hash, generates the changelog, and publishes the release. + +### Release Steps + +1. Ensure all PRs are merged to `master` and CI checks pass +2. Create a commit on `master` with the appropriate prefix: + ```bash + git checkout master + git pull origin master + git commit --allow-empty -m "[patch] Short description of the release" + git push origin master + ``` +3. The release workflow automatically: + - Runs `bump2version` to update version in `setup.cfg`, `pyproject.toml`, `.bumpversion.cfg`, `bundled_data.py`, `Dockerfile`, `docs/index.md` + - Commits the version bump and bundled git hash (`[skip ci]`) + - Creates a draft GitHub release + - Builds and pushes Docker images (`latest`, `nightly`, `v{version}`) to Docker Hub and GHCR + - Builds platform binaries (Windows x64, macOS arm64, Linux x64) and uploads to the release + - Builds and publishes the Python package to PyPI + - Generates a changelog entry in `CHANGELOG.md` from commits since the last tag + - Publishes the GitHub release with the changelog as release notes + +### Version Numbering Guidelines + +**MAJOR** (X.0.0) - Breaking changes: +- Config schema changes requiring migration +- Removed features or breaking API changes +- Python version requirement changes +- Database schema breaking changes + +**MINOR** (x.X.0) - New features: +- New Arr instance types +- New search features +- New WebUI pages/features +- Non-breaking config additions + +**PATCH** (x.x.X) - Bug fixes: +- Bug fixes +- Performance improvements +- Documentation updates +- Dependency updates (non-breaking) + +### Hotfix Release Process + +For urgent fixes to production: + +1. **Create Hotfix Branch** + ```bash + git checkout master + git checkout -b hotfix/fix-critical-bug + ``` + +2. **Make Fix and Test** + - Implement minimal fix + - Test thoroughly + +3. **Merge and Release** + ```bash + git checkout master + git merge hotfix/fix-critical-bug + git commit --allow-empty -m "[patch] Fix critical bug description" + git push origin master + ``` + +### Rollback Procedure + +If a release has critical issues: + +1. **Create Rollback Tag** + ```bash + # Revert to previous version + git revert + git push origin master + ``` + +2. **Notify Users** + - Update GitHub Release with warning + - Post in GitHub Discussions + - Recommend downgrade: `pip install qBitrr2==5.8.0` + +### CI/CD Workflows + +- **`.github/workflows/release.yml`**: Triggered on tag push (v*) +- **`.github/workflows/nightly.yml`**: Builds `nightly` Docker tag daily +- **`.github/workflows/codeql.yml`**: Security scanning on push +- **`.github/dependabot.yml`**: Weekly dependency updates + +### Release Artifacts + +Each release produces: +- **PyPI Package**: `qBitrr2-{version}.tar.gz`, `qBitrr2-{version}-py3-none-any.whl` +- **Docker Images**: Multi-architecture (amd64, arm64, armv7) +- **GitHub Release**: Changelog + attachments +- **Git Tag**: `v{version}` (e.g., v5.8.1) + +## Common Pitfalls +- **Qbittorrent API**: Both qBittorrent 4.x and 5.x are automatically detected and supported +- **Tagging**: Radarr/Sonarr downloads MUST have tags matching the category configured for qBitrr to track them +- **Paths**: qBit's "Save Path" must be accessible to Radarr/Sonarr (common issue in Docker with mismatched volumes) +- **WebUI Token**: If `Settings.WebUIToken` is set, all `/api/*` calls require `X-API-Token` header +- **Database Locks**: Use `db_lock.py:locked_database()` context manager for all Peewee queries to avoid conflicts + +## Documentation Maintenance + +**CRITICAL**: When implementing new features or modifying existing functionality, documentation MUST be updated simultaneously to maintain accuracy. + +### Required Documentation Updates + +When making code changes, update the following documentation as applicable: + +#### 1. User-Facing Documentation (`docs/` directory) +- **Configuration files** (`docs/configuration/*.md`): + - Add/update config option descriptions + - Provide example values and use cases + - Document defaults and valid ranges + - Include troubleshooting tips + +- **Feature documentation** (`docs/features/*.md`): + - Explain how the feature works + - Provide step-by-step setup instructions + - Add FAQ entries for common questions + - Include screenshots/examples where helpful + +- **Specific files requiring updates**: + - `docs/configuration/quality-profiles.md` – for temp profile changes + - `docs/features/automated-search.md` – for search behavior changes + - `docs/configuration/arr/radarr.md`, `sonarr.md`, `lidarr.md` – Arr-specific features + +#### 2. Code Documentation +- **Docstrings**: Add/update for all new public functions and classes +- **Inline comments**: Explain complex logic, design decisions, edge cases +- **Type hints**: Required for all function signatures + +#### 3. Configuration Examples +- **`config.example.toml`**: Update with new config options and examples +- **`qBitrr/gen_config.py`**: Add new config fields with descriptions + +#### 4. API Documentation +- **`docs/reference/api.md`**: Update if adding/changing WebUI API endpoints +- **OpenAPI/Swagger**: Update specs if applicable + +### Documentation Quality Standards + +- **Accuracy**: Docs must reflect actual behavior (test before documenting) +- **Completeness**: Cover all config options, edge cases, and common scenarios +- **Clarity**: Use clear language; avoid jargon; provide examples +- **Maintenance**: Remove outdated info; mark deprecated features +- **Searchability**: Use consistent terminology; include keywords + +### Documentation Workflow + +1. **During Development**: + - Draft documentation as you write code + - Note design decisions and edge cases + - Create examples for common use cases + +2. **Before PR**: + - Review all affected documentation files + - Test examples and code snippets + - Check for broken links or references + - Ensure consistency with existing docs + +3. **PR Review**: + - Documentation changes are reviewed alongside code + - Reviewers verify accuracy and completeness + - Update based on feedback + +### Examples of Required Updates + +**Adding a new config option:** +```markdown +# In docs/configuration/quality-profiles.md + +## ForceResetTempProfiles + +**Type:** Boolean +**Default:** `false` +**Added in:** v5.x.x + +Resets all items using temporary profiles back to their main profiles when qBitrr starts. + +**Example:** +```toml +[Radarr.EntrySearch] +ForceResetTempProfiles = true +``` + +**Use Case:** Useful after testing temp profiles or resolving profile mapping issues. +``` + +**Updating feature behavior:** +```markdown +# In docs/features/automated-search.md + +## Temporary Profile Switching + +**How it works:** +- Items without files are automatically switched to a temporary quality profile +- This increases the chances of finding content (lower quality = more available releases) +- Once content is found, items are switched back to the main profile +- Supports timeout-based automatic resets and startup resets + +**FAQ:** +Q: Why is my Sonarr series on a temp profile when some episodes have files? +A: Missing episodes take priority. If ANY episodes are missing, the entire series uses the temp profile to maximize chances of finding the missing content. +``` + +### Documentation Checklist + +Before submitting changes, verify: + +- [ ] All new config options documented in `docs/configuration/` +- [ ] Feature behavior explained in `docs/features/` +- [ ] Examples added to `config.example.toml` +- [ ] Docstrings added for new functions/classes +- [ ] Inline comments explain complex logic +- [ ] API changes documented in `docs/reference/api.md` +- [ ] README.md updated if user-visible changes +- [ ] CHANGELOG.md or release notes prepared + +**Remember:** Outdated documentation is worse than no documentation. Keep it current! + +--- + +> **Note**: Follow this guide for all contributions. Questions? Check the [documentation](https://feramance.github.io/qBitrr/), README.md, or open a [feature request](.github/ISSUE_TEMPLATE/feature_request.yml). diff --git a/qBitrr/arss.py b/qBitrr/arss.py index 6c30131eb..dbb964bdd 100755 --- a/qBitrr/arss.py +++ b/qBitrr/arss.py @@ -570,6 +570,7 @@ def __init__( self.delete = set() self.resume = set() self.remove_from_qbit = set() + self.remove_from_qbit_by_instance: dict[str, set[str]] = {} self.overseerr_requests_release_cache = {} self.files_to_explicitly_delete: Iterator = iter([]) self.files_to_cleanup = set() @@ -1714,19 +1715,26 @@ def _process_failed(self) -> None: skip_blacklist = { i.upper() for i in self.skip_blacklist.union(self.missing_files_post_delete) } - if to_delete_all or self.remove_from_qbit or self.skip_blacklist: + if ( + to_delete_all + or self.remove_from_qbit + or self.skip_blacklist + or self.remove_from_qbit_by_instance + ): n_delete = len(self.delete) n_missing = len(self.missing_files_post_delete) n_bad_msg = len(self.downloads_with_bad_error_message_blocklist) n_remove = len(self.remove_from_qbit) + n_remove_by_inst = sum(len(s) for s in self.remove_from_qbit_by_instance.values()) n_skip = len(self.skip_blacklist) self.logger.info( "Deletion summary: delete=%d, missing_files=%d, bad_error_blocklist=%d, " - "remove_from_qbit=%d, skip_blacklist=%d", + "remove_from_qbit=%d, remove_by_instance=%d, skip_blacklist=%d", n_delete, n_missing, n_bad_msg, n_remove, + n_remove_by_inst, n_skip, ) if to_delete_all and self.logger.isEnabledFor(10): # DEBUG @@ -1744,17 +1752,97 @@ def _process_failed(self) -> None: self._process_failed_individual( hash_=hash_, entry=entry, skip_blacklist=skip_blacklist ) + # Delete missing-files torrents from the correct qBit instance (multi-instance). + qbit_manager = self.manager.qbit_manager + for inst_name, hashes in self.remove_from_qbit_by_instance.items(): + client = qbit_manager.get_client(inst_name) + if client is None: + self.logger.warning( + "Cannot delete %d torrent(s) from qBit instance '%s': no client", + len(hashes), + inst_name, + ) + continue + try: + client.torrents_delete(hashes=list(hashes), delete_files=True) + for h in hashes: + self.cleaned_torrents.discard(h) + self.sent_to_scan_hashes.discard(h) + if h in qbit_manager.name_cache: + del qbit_manager.name_cache[h] + if h in qbit_manager.cache: + del qbit_manager.cache[h] + except ( + qbittorrentapi.exceptions.APIError, + qbittorrentapi.exceptions.APIConnectionError, + requests.exceptions.RequestException, + ) as e: + self.logger.error( + "Failed to delete %d torrent(s) from qBit instance '%s': %s", + len(hashes), + inst_name, + e, + ) + self.remove_from_qbit_by_instance.clear() if self.remove_from_qbit or self.skip_blacklist or to_delete_all: # Remove all bad torrents from the Client. - temp_to_delete = set() + deleted_hashes: set[str] = set() if to_delete_all: - self.manager.qbit.torrents_delete(hashes=to_delete_all, delete_files=True) + try: + with_retry( + lambda: self.manager.qbit.torrents_delete( + hashes=to_delete_all, delete_files=True + ), + retries=3, + backoff=0.5, + max_backoff=3, + exceptions=( + qbittorrentapi.exceptions.APIError, + qbittorrentapi.exceptions.APIConnectionError, + requests.exceptions.RequestException, + ), + ) + except ( + qbittorrentapi.exceptions.APIError, + qbittorrentapi.exceptions.APIConnectionError, + requests.exceptions.RequestException, + ) as e: + self.logger.error( + "Failed to delete %d torrent(s) from qBit: %s", + len(to_delete_all), + e, + ) + else: + deleted_hashes.update(to_delete_all) if self.remove_from_qbit or self.skip_blacklist: temp_to_delete = self.remove_from_qbit.union(self.skip_blacklist) - self.manager.qbit.torrents_delete(hashes=temp_to_delete, delete_files=True) - - to_delete_all = to_delete_all.union(temp_to_delete) - for h in to_delete_all: + try: + with_retry( + lambda: self.manager.qbit.torrents_delete( + hashes=temp_to_delete, delete_files=True + ), + retries=3, + backoff=0.5, + max_backoff=3, + exceptions=( + qbittorrentapi.exceptions.APIError, + qbittorrentapi.exceptions.APIConnectionError, + requests.exceptions.RequestException, + ), + ) + except ( + qbittorrentapi.exceptions.APIError, + qbittorrentapi.exceptions.APIConnectionError, + requests.exceptions.RequestException, + ) as e: + self.logger.error( + "Failed to delete %d torrent(s) from qBit: %s", + len(temp_to_delete), + e, + ) + else: + deleted_hashes.update(temp_to_delete) + for h in deleted_hashes: self.cleaned_torrents.discard(h) self.sent_to_scan_hashes.discard(h) if h in self.manager.qbit_manager.name_cache: @@ -4861,7 +4949,7 @@ def process_torrents(self): torrents_with_instances = self._get_torrents_from_all_instances() break except (qbittorrentapi.exceptions.APIError, JSONDecodeError) as e: - if "JSONDecodeError" in str(e): + if isinstance(e, JSONDecodeError): continue else: raise qbittorrentapi.exceptions.APIError @@ -5476,7 +5564,7 @@ def _process_single_torrent_missing_files( torrent.hash, ) # We do not want to blacklist these!! - self.remove_from_qbit.add(torrent.hash) + self.remove_from_qbit_by_instance.setdefault(instance_name, set()).add(torrent.hash) def _process_single_torrent_uploading( self, torrent: qbittorrentapi.TorrentDictionary, leave_alone: bool @@ -6031,7 +6119,7 @@ def _stalled_check( torrent.name, datetime.fromtimestamp(time_now), datetime.fromtimestamp(torrent.added_on), - datetime.fromtimestamp(torrent.added_on + stalled_delay_seconds), + datetime.fromtimestamp(torrent.added_on + self.ignore_torrents_younger_than), ) return True if self.stalled_delay == 0: @@ -6158,7 +6246,7 @@ def _process_single_torrent( # Bypass everything else if manually marked for rechecking self._process_single_torrent_recheck_cat(torrent) elif self._is_missing_files_torrent(torrent): - # Handle missing-files state (and ERROR state when API reports missingFiles) + # Missing-files (and ERROR+missingFiles): bypass all other processing, delete from client. self._process_single_torrent_missing_files(torrent, instance_name) elif self.is_ignored_state(torrent): self._process_single_torrent_ignored(torrent) @@ -6231,7 +6319,7 @@ def _process_single_torrent( self._process_single_torrent_already_sent_to_scan(torrent) # Sometimes torrents will error, this causes them to be rechecked so they - # complete downloading. (Missing-files case handled earlier.) + # complete downloading. elif torrent.state_enum == TorrentStates.ERROR: self._process_single_torrent_errored(torrent) # If a torrent was not just added, @@ -7519,7 +7607,6 @@ def _process_single_torrent_missing_files( ) -> None: """Track which qBit instance the torrent is on so we delete from the correct client.""" super()._process_single_torrent_missing_files(torrent, instance_name) - self.remove_from_qbit_by_instance.setdefault(instance_name, set()).add(torrent.hash) def custom_format_unmet_check(self, torrent: qbittorrentapi.TorrentDictionary) -> bool: """PlaceHolderArr does not use Arr queue; never trigger custom-format branch.""" @@ -7606,20 +7693,27 @@ def _process_failed(self) -> None: skip_blacklist = { i.upper() for i in self.skip_blacklist.union(self.missing_files_post_delete) } - if not (to_delete_all or self.remove_from_qbit or self.skip_blacklist): + if not ( + to_delete_all + or self.remove_from_qbit + or self.skip_blacklist + or self.remove_from_qbit_by_instance + ): return n_delete = len(self.delete) n_missing = len(self.missing_files_post_delete) n_bad_msg = len(self.downloads_with_bad_error_message_blocklist) n_remove = len(self.remove_from_qbit) + n_remove_by_inst = sum(len(s) for s in self.remove_from_qbit_by_instance.values()) n_skip = len(self.skip_blacklist) self.logger.info( "Deletion summary: delete=%d, missing_files=%d, bad_error_blocklist=%d, " - "remove_from_qbit=%d, skip_blacklist=%d", + "remove_from_qbit=%d, remove_by_instance=%d, skip_blacklist=%d", n_delete, n_missing, n_bad_msg, n_remove, + n_remove_by_inst, n_skip, ) if to_delete_all: @@ -7804,14 +7898,14 @@ def process_torrents(self): torrents_with_instances = self._get_torrents_from_all_instances() break except (qbittorrentapi.exceptions.APIError, JSONDecodeError) as e: - if "JSONDecodeError" in str(e): + if isinstance(e, JSONDecodeError): continue raise qbittorrentapi.exceptions.APIError torrents_with_instances = [ (instance, t) for instance, t in torrents_with_instances - if hasattr(t, "category") + if getattr(t, "category", None) == self.category ] self.category_torrent_count = len(torrents_with_instances) if not torrents_with_instances: @@ -7885,6 +7979,7 @@ def __init__(self, categories: set[str], manager: ArrManager): else: self.completed_folder = pathlib.Path(FREE_SPACE_FOLDER) self._disk_usage_path = pathlib.Path(FREE_SPACE_FOLDER).resolve() + self._free_space_folder_is_auto = FREE_SPACE_FOLDER == "CHANGE_ME" self.min_free_space = FREE_SPACE # Parse once to avoid repeated conversions self._min_free_space_bytes = ( @@ -7896,24 +7991,27 @@ def __init__(self, categories: set[str], manager: ArrManager): if parent.exists(): self.completed_folder = parent # else: keep completed_folder and let disk_usage raise so the user sees the error - # Path for disk usage: use resolved path; if it does not exist, use first existing - # parent so we still report the correct volume (e.g. /torrents missing -> use /). + # Path for disk usage: use resolved path. Only when path was auto-chosen (CHANGE_ME), + # allow falling back to first existing parent so we still report the correct volume; + # when the user explicitly set FreeSpaceFolder, do not substitute—missing path should + # error so they fix typo/mount instead of monitoring the wrong filesystem. self._path_for_disk_usage = self._disk_usage_path - _p = self._first_existing_parent(self._disk_usage_path) - if _p: - self._path_for_disk_usage = _p - if self._path_for_disk_usage != self._disk_usage_path: - self.logger.warning( - "FreeSpaceFolder path does not exist, using parent for disk space check | " - "Configured: %s | Using: %s%s", - self._disk_usage_path, - self._path_for_disk_usage, - ( - " | In Docker: ensure the host volume is mounted at the configured path (e.g. -v /host/torrents:/torrents)" - if is_docker() - else "" - ), - ) + if self._free_space_folder_is_auto: + _p = self._first_existing_parent(self._disk_usage_path) + if _p: + self._path_for_disk_usage = _p + if self._path_for_disk_usage != self._disk_usage_path: + self.logger.warning( + "FreeSpaceFolder path does not exist, using parent for disk space check | " + "Configured: %s | Using: %s%s", + self._disk_usage_path, + self._path_for_disk_usage, + ( + " | In Docker: ensure the host volume is mounted at the configured path (e.g. -v /host/torrents:/torrents)" + if is_docker() + else "" + ), + ) self.current_free_space = ( shutil.disk_usage(self._path_for_disk_usage).free - self._min_free_space_bytes ) @@ -7953,13 +8051,13 @@ def __init__(self, categories: set[str], manager: ArrManager): ) @staticmethod - def _first_existing_parent(path: pathlib.Path) -> pathlib.Path: - """Return the nearest existing parent path without looping at filesystem root.""" + def _first_existing_parent(path: pathlib.Path) -> pathlib.Path | None: + """Return the nearest existing parent path, or None if none exist (e.g. path below root).""" current = path while not current.exists(): parent = current.parent if parent == current: - break + return None current = parent return current @@ -8093,10 +8191,12 @@ def process_torrents(self): if self.manager.qbit_manager.should_delay_torrent_scan: raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="delay") # Re-resolve path each loop so we use the configured path once it appears - # (e.g. Docker volume mounted after process start). - _p = self._first_existing_parent(self._disk_usage_path) - if _p: - self._path_for_disk_usage = _p + # (e.g. Docker volume mounted after process start). Only when path was + # auto-chosen (CHANGE_ME); explicit FreeSpaceFolder must exist or we error. + if self._free_space_folder_is_auto: + _p = self._first_existing_parent(self._disk_usage_path) + if _p: + self._path_for_disk_usage = _p self.current_free_space = ( shutil.disk_usage(self._path_for_disk_usage).free - self._min_free_space_bytes ) diff --git a/qBitrr/gen_config.py b/qBitrr/gen_config.py index 7ff30023d..064a56f79 100755 --- a/qBitrr/gen_config.py +++ b/qBitrr/gen_config.py @@ -391,7 +391,7 @@ def _add_qbit_section(config: TOMLDocument): category_seeding, "Ignore torrents younger than this (seconds). Stalled removal also requires last_activity older than this.", "IgnoreTorrentsYoungerThan", - 600, + 180, ) _gen_qbit_tracker_tables(qbit) diff --git a/qBitrr/main.py b/qBitrr/main.py index 3c74e2019..6b53e6739 100644 --- a/qBitrr/main.py +++ b/qBitrr/main.py @@ -591,7 +591,7 @@ def _init_instance(self, section_name: str, instance_name: str) -> None: stalled_delay = CONFIG.get(f"{section_name}.CategorySeeding.StalledDelay", fallback=-1) ignore_younger = CONFIG.get( f"{section_name}.CategorySeeding.IgnoreTorrentsYoungerThan", - fallback=CONFIG.get("Settings.IgnoreTorrentsYoungerThan", fallback=600), + fallback=CONFIG.get("Settings.IgnoreTorrentsYoungerThan", fallback=180), ) # Store config for later initialization @@ -735,7 +735,7 @@ def _load_category_config(section_name: str, instance_name: str): stalled_delay = CONFIG.get(f"{section_name}.CategorySeeding.StalledDelay", fallback=-1) ignore_younger = CONFIG.get( f"{section_name}.CategorySeeding.IgnoreTorrentsYoungerThan", - fallback=CONFIG.get("Settings.IgnoreTorrentsYoungerThan", fallback=600), + fallback=CONFIG.get("Settings.IgnoreTorrentsYoungerThan", fallback=180), ) self.qbit_category_configs[instance_name] = { "managed_categories": managed_categories, From e07f2b1839683df1005df807391b9e3787554f10 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 27 Feb 2026 15:08:27 +0000 Subject: [PATCH 35/66] fix: align placeholder defaults and retry deletion paths Applied via @cursor push command --- qBitrr/arss.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/qBitrr/arss.py b/qBitrr/arss.py index dbb964bdd..25c85cefb 100755 --- a/qBitrr/arss.py +++ b/qBitrr/arss.py @@ -1764,7 +1764,19 @@ def _process_failed(self) -> None: ) continue try: - client.torrents_delete(hashes=list(hashes), delete_files=True) + with_retry( + lambda c=client, h=list(hashes): c.torrents_delete( + hashes=h, delete_files=True + ), + retries=3, + backoff=0.5, + max_backoff=3, + exceptions=( + qbittorrentapi.exceptions.APIError, + qbittorrentapi.exceptions.APIConnectionError, + requests.exceptions.RequestException, + ), + ) for h in hashes: self.cleaned_torrents.discard(h) self.sent_to_scan_hashes.discard(h) @@ -7539,7 +7551,7 @@ def __init__(self, name: str, manager: ArrManager): self.resume = set() self.expiring_bool = ExpiringSet(max_age_seconds=10) self.ignore_torrents_younger_than = CONFIG.get( - "Settings.IgnoreTorrentsYoungerThan", fallback=600 + "Settings.IgnoreTorrentsYoungerThan", fallback=180 ) self.timed_ignore_cache = ExpiringSet(max_age_seconds=self.ignore_torrents_younger_than) self.timed_ignore_cache_2 = ExpiringSet( @@ -7738,7 +7750,7 @@ def _process_failed(self) -> None: continue try: with_retry( - lambda h=hashes: client.torrents_delete(hashes=h, delete_files=True), + lambda c=client, h=hashes: c.torrents_delete(hashes=h, delete_files=True), retries=3, backoff=0.5, max_backoff=3, @@ -7889,6 +7901,9 @@ def process(self): self._process_errored() self._process_file_priority() self._process_failed() + self.import_torrents.clear() + with contextlib.suppress(AttributeError): + self.files_to_cleanup.clear() def process_torrents(self): try: @@ -7907,6 +7922,7 @@ def process_torrents(self): for instance, t in torrents_with_instances if getattr(t, "category", None) == self.category ] + self._warned_no_seeding_limits = False self.category_torrent_count = len(torrents_with_instances) if not torrents_with_instances: raise DelayLoopException(length=LOOP_SLEEP_TIMER, type="no_downloads") @@ -7959,7 +7975,7 @@ def __init__(self, categories: set[str], manager: ArrManager): self.resume = set() self.expiring_bool = ExpiringSet(max_age_seconds=10) self.ignore_torrents_younger_than = CONFIG.get( - "Settings.IgnoreTorrentsYoungerThan", fallback=600 + "Settings.IgnoreTorrentsYoungerThan", fallback=180 ) self.timed_ignore_cache = ExpiringSet(max_age_seconds=self.ignore_torrents_younger_than) self.needs_cleanup = False From f82d1bbd26af59b2a6d203a30c9ecd9a7794d5d9 Mon Sep 17 00:00:00 2001 From: Feramance Date: Fri, 27 Feb 2026 16:09:49 +0100 Subject: [PATCH 36/66] fix(ci): correct python-version expression in PR workflow --- .github/workflows/pull_requests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pull_requests.yml b/.github/workflows/pull_requests.yml index e6ba24bef..b4704f5da 100644 --- a/.github/workflows/pull_requests.yml +++ b/.github/workflows/pull_requests.yml @@ -48,7 +48,7 @@ jobs: - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v6 with: - python-version: ${{ matrix.python}} + python-version: ${{ matrix.python }} architecture: ${{ matrix.arch }} cache: pip - name: Install APT dependencies From f01b295974e6ec247a23f5bc7284902e978158fa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 27 Feb 2026 15:36:26 +0000 Subject: [PATCH 37/66] fix: remove accidental CLAUDE guide and init cleanup set Applied via @cursor push command --- .gitignore | 1 + CLAUDE.md | 434 ------------------------------------------------- qBitrr/arss.py | 1 + 3 files changed, 2 insertions(+), 434 deletions(-) delete mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index d88d30dcf..c5db24e38 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,4 @@ test_webui.sh # Development opencodetmp/ .claude +CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 227cb123c..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,434 +0,0 @@ -# AGENTS.md – Agent Coding Guide - -> **Purpose**: qBitrr orchestrates qBittorrent ↔ Radarr/Sonarr/Lidarr communication, handling torrent health checks, instant imports, smart cleanup, and request automation. This guide ensures AI agents/contributors maintain consistency across Python backend and React frontend. - -## Project Overview -- **Language**: Python 3.12+ (backend), TypeScript + React 18 (WebUI) -- **Architecture**: Multi-threaded event loops per Arr instance; Flask/Waitress REST API; Peewee ORM (SQLite) -- **Entry Point**: `qBitrr.main:run` → spawns WebUI, ArrManager loops, auto-update watchers -- **Key Modules**: - - `qBitrr/main.py` – orchestrates multiprocessing, launches arr managers and WebUI - - `qBitrr/arss.py` – ArrManager classes (Radarr/Sonarr/Lidarr) with health checks & import logic - - `qBitrr/config.py` – TOML config parsing, validation, migrations - - `qBitrr/webui.py` – Flask routes for `/api/*` (token-protected) and `/web/*` (helpers) - - `qBitrr/ffprobe.py` – media file verification via ffprobe - - `qBitrr/tables.py` – Peewee models for persistent state (downloads, searches, expiry) - - `webui/src/` – React dashboard with @mantine/core UI, react-hook-form, @tanstack/react-table -- **Config**: `~/config/config.toml` (native) or `/config/config.toml` (Docker). Generated on first run via `gen_config.py` -- **Logging**: Structured logs in `~/logs/` or `/config/logs`; `Main.log`, `WebUI.log`, per-Arr logs -- **Deployment**: PyPI package (`qBitrr2`), Docker image (`feramance/qbitrr:latest`), or source install - -## Build/Lint/Test Workflow - -### Python Backend -1. **Create Environment**: `make newenv` (or `python -m venv .venv && source .venv/bin/activate`) -2. **Install Dependencies**: `make syncenv` (installs `.[all]` from setup.cfg, includes dev deps + WebUI build) -3. **Format & Lint**: `make reformat` → runs pre-commit hooks: - - `black` (99-char line length, py312 target) - - `isort` (black profile, known_third_party listed in pyproject.toml) - - `autoflake` (removes unused imports/variables) - - `pyupgrade` (modernizes syntax to py38+) - - `check-yaml`, `check-toml`, `check-json`, `detect-private-key`, `end-of-file-fixer`, `trailing-whitespace`, `mixed-line-ending` -4. **Manual Testing**: No pytest suite; test against live qBittorrent + Arr instances or use Docker Compose setup -5. **Version Bump**: `bump2version patch|minor|major` (updates `.bumpversion.cfg`, `setup.cfg`, `pyproject.toml`, tags commit) -6. **Build Package**: `python setup.py sdist bdist_wheel` → dist/qBitrr2-*.whl -7. **Docker Build**: `docker build -t feramance/qbitrr:test .` (multi-stage: Node build → Python install) - -### TypeScript/React WebUI -1. **Install**: `cd webui && npm ci` (package-lock.json locked to exact versions) -2. **Dev Server**: `npm run dev` → http://localhost:5173 with HMR (Vite) -3. **Lint**: `npm run lint` → ESLint with `@eslint/js`, `typescript-eslint`, `react-hooks`, `react-refresh` -4. **Type Check**: `tsc -b` (tsconfig.app.json + tsconfig.node.json) -5. **Build**: `npm run build` → outputs to `webui/dist/`, copied to `qBitrr/static/` for bundling -6. **Preview**: `npm run preview` → serve production build locally - -### CI/CD -- **pre-commit.ci**: Auto-formats PRs, runs weekly autoupdates -- **CodeQL**: `.github/workflows/codeql.yml` scans for security issues -- **Nightly**: `.github/workflows/nightly.yml` builds Docker image, publishes to `feramance/qbitrr:nightly` -- **Release**: `.github/workflows/release.yml` publishes PyPI package, Docker tags (`latest`, semver) -- **Dependabot**: `.github/dependabot.yml` updates GitHub Actions weekly - -## Code Style - -### Python (PEP 8 + Black) -- **Formatting**: Black (99-char max), isort (black profile), 4-space indentation -- **Type Hints**: Required for all function signatures; use `from __future__ import annotations` for forward refs -- **Naming**: - - `snake_case` for functions, variables, module names - - `PascalCase` for classes, exceptions - - `SCREAMING_SNAKE_CASE` for module-level constants -- **Exceptions**: Inherit from `qBitManagerError` (qBitrr/errors.py). Common subclasses: - - `ConfigException` – config parsing/validation - - `ArrManagerException` – Arr API failures - - `SkipException` – skip torrent processing without error - - `NoConnectionrException` – connection failures (typo preserved for compatibility) - - `DelayLoopException`, `RestartLoopException` – control flow for event loops - - `RequireConfigValue` – missing required config key -- **Docstrings**: Required for all classes and public functions; use triple-quotes, describe purpose + params + return -- **Imports**: Absolute imports (`from qBitrr.config import CONFIG`), grouped by stdlib → third-party → local -- **Line Breaks**: LF only (enforced by pre-commit `mixed-line-ending --fix lf`) - -### TypeScript/React -- **Style**: ESLint `recommended` + `typescript-eslint` strict, functional components only -- **Type Safety**: Explicit return types, interfaces for all component props, no `any` (use `unknown` if needed) -- **Naming**: - - `camelCase` for variables, functions - - `PascalCase` for components, interfaces, types -- **Import Order**: React → node_modules/@-scoped → local modules → local icons (SVGs) -- **Hooks**: Declare dependencies correctly; use `useCallback`/`useMemo` for expensive ops -- **State Management**: Context API (`SearchContext`, `ToastContext`, `WebUIContext`) for global state -- **UI Library**: @mantine/core v8 for components; follow Mantine conventions (e.g., `sx` prop for inline styles) - -### General -- **Indentation**: 4 spaces (Python), 2 spaces (JS/TS/JSON/YAML) -- **Line Endings**: LF (`\n`) enforced by pre-commit -- **Trailing Whitespace**: None (auto-fixed by pre-commit) -- **EOF Newline**: Required (enforced by `end-of-file-fixer`) -- **Unused Code**: No unused imports, variables, or functions (autoflake removes them) - -## Error Handling & Logging -- **Custom Exceptions**: Always inherit from `qBitManagerError`; include context in `__init__` (e.g., `RequireConfigValue(config_class, config_key)`) -- **Logging**: Use `logging.getLogger("qBitrr")` or subloggers (`qBitrr.arr`, `qBitrr.webui`); call `run_logs(logger, "ModuleName")` to write to file -- **Log Levels**: DEBUG (verbose state), INFO (user-facing), WARNING (recoverable issue), ERROR (failure), CRITICAL (fatal) -- **User Messages**: Provide actionable error messages; reference config keys, Arr instance names, torrent hashes - -## Architecture & Patterns -- **Multiprocessing**: `pathos.multiprocessing` for cross-platform support; each Arr instance runs in a separate process -- **Threading**: WebUI runs in main thread; auto-update, network monitor, and FFprobe downloads in background threads -- **Database**: Peewee ORM with SQLite (thread-safe via `db_lock.py`); tables: `DownloadsModel`, `SearchModel`, `EntryExpiry` -- **Event Loops**: Each ArrManager has a main loop checking qBit torrents every N seconds, triggering health checks, imports, cleanup -- **Config Migrations**: `apply_config_migrations()` upgrades old configs; bump `CURRENT_CONFIG_VERSION` when schema changes -- **API Routes**: - - `/api/*` – token-protected (check `Settings.WebUIToken`), returns JSON - - `/web/*` – public helpers (serve UI, version info) - - `/ui` → serves React SPA from `qBitrr/static/` - -## Development Tips -- **Config Changes**: Edit `qBitrr/gen_config.py` (MyConfig class); regenerate example via `qbitrr --gen-config` -- **WebUI Changes**: Run `npm run dev` in webui/, API requests proxy to http://localhost:6969 -- **Database Schema**: Modify `qBitrr/tables.py`, add migration logic in `config.py:apply_config_migrations()` -- **New Arr Type**: Subclass `ArrManagerBase` in `arss.py`, implement `_process_failed_individual()`, register in `main.py` -- **Pre-commit Bypass**: `git commit --no-verify` (discouraged; use for emergency hotfixes only) - -## Testing & Validation -- **Manual Testing**: Launch qBittorrent + Radarr/Sonarr locally or via Docker Compose; trigger downloads, check logs -- **Health Checks**: Test stalled torrents (pause + resume), failed imports (bad file perms), blacklisting -- **Config Validation**: Test invalid TOML, missing required keys, edge cases (e.g., empty categories) -- **WebUI Testing**: Check all tabs (Processes, Logs, Radarr/Sonarr/Lidarr, Config), test CRUD ops - -## Pre-Merge/Release Cleanup - -**CRITICAL**: Before merging PRs or creating releases, clean up ALL temporary files to keep the repository tidy. - -### Temporary Files to Remove - -**Always remove these file patterns:** -- `*_TEST*.md` - Test plans, test results, test summaries -- `*_RESULTS*.md` - Test results, benchmark results -- `READY_FOR_*.md` - Merge checklists, release checklists -- `*_PLAN.md` - Implementation plans, migration plans -- `*_SUMMARY.md` - Development summaries, decision logs -- `*_NOTES.md` - Development notes, meeting notes -- `TODO.md`, `TASKS.md` - Temporary task lists (unless permanent project management) - -**Files to KEEP:** -- `README.md` - Main project readme -- `CHANGELOG.md` - Release history -- `AGENTS.md` - AI agent coding guide -- `.github/*.md` - GitHub templates (PR, issue, discussion) -- `docs/**/*.md` - All permanent documentation (includes API docs, contribution guide, systemd guide) - -### Cleanup Commands - -```bash -# 1. Find all temporary markdown files -find . -name "*_TEST*.md" -o -name "*_RESULTS*.md" -o -name "READY_FOR_*.md" -o -name "*_PLAN.md" -o -name "*_SUMMARY.md" -o -name "*_NOTES.md" - -# 2. Review the list to ensure no permanent docs are caught - -# 3. Remove temporary files -find . -name "*_TEST*.md" -o -name "*_RESULTS*.md" -o -name "READY_FOR_*.md" -o -name "*_PLAN.md" | xargs git rm - -# 4. Verify only permanent docs remain -git ls-files "*.md" | grep -v "^docs/" - -# Expected output: -# AGENTS.md -# CHANGELOG.md -# README.md -# .github/pull_request_template.md -# (plus any other permanent root-level docs) - -# 5. Commit cleanup -git commit -m "chore: Remove temporary documentation files" -``` - -### Why This Matters - -1. **Repository Hygiene**: Keeps git history clean and focused on permanent documentation -2. **Release Quality**: Prevents shipping development artifacts to end users -3. **Search Clarity**: Reduces noise when searching documentation -4. **Professional Image**: Shows attention to detail and proper project management -5. **Storage Efficiency**: Reduces repository size over time - -### When to Clean Up - -- **Before merging feature branches to master** -- **Before creating release tags** -- **After completing major development work** -- **During code review** (reviewer should check for temporary files) - -### Pre-Merge Checklist - -Before submitting a PR for final merge: - -- [ ] All temporary `*_TEST*.md`, `*_RESULTS*.md` files removed -- [ ] All `READY_FOR_*.md` checklists removed -- [ ] All `*_PLAN.md` planning docs removed -- [ ] Only permanent documentation remains in root -- [ ] `docs/` directory contains only user-facing documentation -- [ ] Commit message references cleanup: `chore: Remove temporary documentation files` - -## Release Process - -### Overview -qBitrr uses **semantic versioning** (MAJOR.MINOR.PATCH) and automated release workflows. Releases are triggered by **commit message prefixes** on the `master` branch — not by local version bumps or tags. - -### How Releases Are Triggered - -The `.github/workflows/release.yml` workflow runs on every push to `master`, but the release jobs **only execute** when the head commit message starts with one of these prefixes: - -| Prefix | Bump | Example | -|--------|------|---------| -| `[patch]` | x.x.**X** | Bug fixes, minor improvements | -| `[minor]` | x.**X**.0 | New features, non-breaking changes | -| `[major]` | **X**.0.0 | Breaking changes | - -The workflow can also be triggered manually via `workflow_dispatch` on GitHub Actions. - -**Do NOT run `bump2version` locally.** The CI workflow runs `bump2version` itself, commits the version bump (with `[skip ci]`), updates the bundled git hash, generates the changelog, and publishes the release. - -### Release Steps - -1. Ensure all PRs are merged to `master` and CI checks pass -2. Create a commit on `master` with the appropriate prefix: - ```bash - git checkout master - git pull origin master - git commit --allow-empty -m "[patch] Short description of the release" - git push origin master - ``` -3. The release workflow automatically: - - Runs `bump2version` to update version in `setup.cfg`, `pyproject.toml`, `.bumpversion.cfg`, `bundled_data.py`, `Dockerfile`, `docs/index.md` - - Commits the version bump and bundled git hash (`[skip ci]`) - - Creates a draft GitHub release - - Builds and pushes Docker images (`latest`, `nightly`, `v{version}`) to Docker Hub and GHCR - - Builds platform binaries (Windows x64, macOS arm64, Linux x64) and uploads to the release - - Builds and publishes the Python package to PyPI - - Generates a changelog entry in `CHANGELOG.md` from commits since the last tag - - Publishes the GitHub release with the changelog as release notes - -### Version Numbering Guidelines - -**MAJOR** (X.0.0) - Breaking changes: -- Config schema changes requiring migration -- Removed features or breaking API changes -- Python version requirement changes -- Database schema breaking changes - -**MINOR** (x.X.0) - New features: -- New Arr instance types -- New search features -- New WebUI pages/features -- Non-breaking config additions - -**PATCH** (x.x.X) - Bug fixes: -- Bug fixes -- Performance improvements -- Documentation updates -- Dependency updates (non-breaking) - -### Hotfix Release Process - -For urgent fixes to production: - -1. **Create Hotfix Branch** - ```bash - git checkout master - git checkout -b hotfix/fix-critical-bug - ``` - -2. **Make Fix and Test** - - Implement minimal fix - - Test thoroughly - -3. **Merge and Release** - ```bash - git checkout master - git merge hotfix/fix-critical-bug - git commit --allow-empty -m "[patch] Fix critical bug description" - git push origin master - ``` - -### Rollback Procedure - -If a release has critical issues: - -1. **Create Rollback Tag** - ```bash - # Revert to previous version - git revert - git push origin master - ``` - -2. **Notify Users** - - Update GitHub Release with warning - - Post in GitHub Discussions - - Recommend downgrade: `pip install qBitrr2==5.8.0` - -### CI/CD Workflows - -- **`.github/workflows/release.yml`**: Triggered on tag push (v*) -- **`.github/workflows/nightly.yml`**: Builds `nightly` Docker tag daily -- **`.github/workflows/codeql.yml`**: Security scanning on push -- **`.github/dependabot.yml`**: Weekly dependency updates - -### Release Artifacts - -Each release produces: -- **PyPI Package**: `qBitrr2-{version}.tar.gz`, `qBitrr2-{version}-py3-none-any.whl` -- **Docker Images**: Multi-architecture (amd64, arm64, armv7) -- **GitHub Release**: Changelog + attachments -- **Git Tag**: `v{version}` (e.g., v5.8.1) - -## Common Pitfalls -- **Qbittorrent API**: Both qBittorrent 4.x and 5.x are automatically detected and supported -- **Tagging**: Radarr/Sonarr downloads MUST have tags matching the category configured for qBitrr to track them -- **Paths**: qBit's "Save Path" must be accessible to Radarr/Sonarr (common issue in Docker with mismatched volumes) -- **WebUI Token**: If `Settings.WebUIToken` is set, all `/api/*` calls require `X-API-Token` header -- **Database Locks**: Use `db_lock.py:locked_database()` context manager for all Peewee queries to avoid conflicts - -## Documentation Maintenance - -**CRITICAL**: When implementing new features or modifying existing functionality, documentation MUST be updated simultaneously to maintain accuracy. - -### Required Documentation Updates - -When making code changes, update the following documentation as applicable: - -#### 1. User-Facing Documentation (`docs/` directory) -- **Configuration files** (`docs/configuration/*.md`): - - Add/update config option descriptions - - Provide example values and use cases - - Document defaults and valid ranges - - Include troubleshooting tips - -- **Feature documentation** (`docs/features/*.md`): - - Explain how the feature works - - Provide step-by-step setup instructions - - Add FAQ entries for common questions - - Include screenshots/examples where helpful - -- **Specific files requiring updates**: - - `docs/configuration/quality-profiles.md` – for temp profile changes - - `docs/features/automated-search.md` – for search behavior changes - - `docs/configuration/arr/radarr.md`, `sonarr.md`, `lidarr.md` – Arr-specific features - -#### 2. Code Documentation -- **Docstrings**: Add/update for all new public functions and classes -- **Inline comments**: Explain complex logic, design decisions, edge cases -- **Type hints**: Required for all function signatures - -#### 3. Configuration Examples -- **`config.example.toml`**: Update with new config options and examples -- **`qBitrr/gen_config.py`**: Add new config fields with descriptions - -#### 4. API Documentation -- **`docs/reference/api.md`**: Update if adding/changing WebUI API endpoints -- **OpenAPI/Swagger**: Update specs if applicable - -### Documentation Quality Standards - -- **Accuracy**: Docs must reflect actual behavior (test before documenting) -- **Completeness**: Cover all config options, edge cases, and common scenarios -- **Clarity**: Use clear language; avoid jargon; provide examples -- **Maintenance**: Remove outdated info; mark deprecated features -- **Searchability**: Use consistent terminology; include keywords - -### Documentation Workflow - -1. **During Development**: - - Draft documentation as you write code - - Note design decisions and edge cases - - Create examples for common use cases - -2. **Before PR**: - - Review all affected documentation files - - Test examples and code snippets - - Check for broken links or references - - Ensure consistency with existing docs - -3. **PR Review**: - - Documentation changes are reviewed alongside code - - Reviewers verify accuracy and completeness - - Update based on feedback - -### Examples of Required Updates - -**Adding a new config option:** -```markdown -# In docs/configuration/quality-profiles.md - -## ForceResetTempProfiles - -**Type:** Boolean -**Default:** `false` -**Added in:** v5.x.x - -Resets all items using temporary profiles back to their main profiles when qBitrr starts. - -**Example:** -```toml -[Radarr.EntrySearch] -ForceResetTempProfiles = true -``` - -**Use Case:** Useful after testing temp profiles or resolving profile mapping issues. -``` - -**Updating feature behavior:** -```markdown -# In docs/features/automated-search.md - -## Temporary Profile Switching - -**How it works:** -- Items without files are automatically switched to a temporary quality profile -- This increases the chances of finding content (lower quality = more available releases) -- Once content is found, items are switched back to the main profile -- Supports timeout-based automatic resets and startup resets - -**FAQ:** -Q: Why is my Sonarr series on a temp profile when some episodes have files? -A: Missing episodes take priority. If ANY episodes are missing, the entire series uses the temp profile to maximize chances of finding the missing content. -``` - -### Documentation Checklist - -Before submitting changes, verify: - -- [ ] All new config options documented in `docs/configuration/` -- [ ] Feature behavior explained in `docs/features/` -- [ ] Examples added to `config.example.toml` -- [ ] Docstrings added for new functions/classes -- [ ] Inline comments explain complex logic -- [ ] API changes documented in `docs/reference/api.md` -- [ ] README.md updated if user-visible changes -- [ ] CHANGELOG.md or release notes prepared - -**Remember:** Outdated documentation is worse than no documentation. Keep it current! - ---- - -> **Note**: Follow this guide for all contributions. Questions? Check the [documentation](https://feramance.github.io/qBitrr/), README.md, or open a [feature request](.github/ISSUE_TEMPLATE/feature_request.yml). diff --git a/qBitrr/arss.py b/qBitrr/arss.py index 25c85cefb..7dc6ec361 100755 --- a/qBitrr/arss.py +++ b/qBitrr/arss.py @@ -7540,6 +7540,7 @@ def __init__(self, name: str, manager: ArrManager): self.sent_to_scan = set() self.sent_to_scan_hashes = set() self.files_probed = set() + self.files_to_cleanup = set() self.import_torrents = [] self.change_priority = {} self.recheck = set() From a4c5deb6d47ccfb57d9a9a391b034613c9a7da1a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 27 Feb 2026 15:49:48 +0000 Subject: [PATCH 38/66] fix: remove stale sqlite wal and shm on fresh start Applied via @cursor push command --- qBitrr/database.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/qBitrr/database.py b/qBitrr/database.py index 5c0bca084..858539378 100644 --- a/qBitrr/database.py +++ b/qBitrr/database.py @@ -36,12 +36,21 @@ def _offer_fresh_start(db_path: Path) -> None: Use only after repair has failed; causes loss of existing DB data. """ try: - db_path.unlink() + for candidate in ( + db_path, + db_path.parent / f"{db_path.name}-wal", + db_path.parent / f"{db_path.name}-shm", + ): + try: + candidate.unlink() + except FileNotFoundError: + continue logger.warning( - "Deleted corrupt database; a fresh database will be created on connect.", + "Deleted corrupt database and SQLite WAL/SHM files; " + "a fresh database will be created on connect.", ) except Exception as e: - logger.debug("Could not delete corrupt database: %s", e) + logger.debug("Could not delete corrupt database files: %s", e) def _startup_integrity_check_and_repair(db_path: Path) -> None: From 173415f404138a4cb33be0e2a7334c0240c55471 Mon Sep 17 00:00:00 2001 From: Feramance Date: Fri, 27 Feb 2026 21:44:51 +0100 Subject: [PATCH 39/66] fix(webui): defer setState in NumberInput effect to satisfy react-hooks/set-state-in-effect --- webui/src/pages/ConfigView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webui/src/pages/ConfigView.tsx b/webui/src/pages/ConfigView.tsx index d45862f1e..a1f97f89b 100644 --- a/webui/src/pages/ConfigView.tsx +++ b/webui/src/pages/ConfigView.tsx @@ -2254,7 +2254,7 @@ function NumberInput({ useEffect(() => { if (!isEditing.current) { - setLocalValue(externalStr); + queueMicrotask(() => setLocalValue(externalStr)); } }, [externalStr]); From 934a3c3a96a84ddc50bf112863f189582f4f908e Mon Sep 17 00:00:00 2001 From: Feramance Date: Fri, 27 Feb 2026 21:49:57 +0100 Subject: [PATCH 40/66] Removed unecessary test --- tests/__init__.py | 1 - tests/unit/__init__.py | 1 - tests/unit/test_torrent_limit_check.py | 114 ------------------------- 3 files changed, 116 deletions(-) delete mode 100644 tests/__init__.py delete mode 100644 tests/unit/__init__.py delete mode 100644 tests/unit/test_torrent_limit_check.py diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index 66173aec4..000000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Test package diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py deleted file mode 100644 index a0291f069..000000000 --- a/tests/unit/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Unit tests diff --git a/tests/unit/test_torrent_limit_check.py b/tests/unit/test_torrent_limit_check.py deleted file mode 100644 index 1fab148bc..000000000 --- a/tests/unit/test_torrent_limit_check.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Unit tests for torrent_limit_check (seeding removal modes with valid/unset limits).""" - -from __future__ import annotations - -from unittest.mock import MagicMock - - -def _make_torrent(ratio: float = 0.0, seeding_time: int = 0): - t = MagicMock() - t.ratio = ratio - t.seeding_time = seeding_time - return t - - -def _make_arr(remove_torrent: int, warned: bool = False): - """Minimal Arr-like object with only attributes used by torrent_limit_check.""" - arr = MagicMock() - arr.seeding_mode_global_remove_torrent = remove_torrent - arr._warned_no_seeding_limits = warned - return arr - - -def torrent_limit_check_standalone(arr, torrent, seeding_time_limit, ratio_limit) -> bool: - """Replicate torrent_limit_check logic for testing (avoids importing full Arr).""" - if arr.seeding_mode_global_remove_torrent == -1: - return False - - ratio_limit_valid = ratio_limit is not None and ratio_limit > 0 - time_limit_valid = seeding_time_limit is not None and seeding_time_limit > 0 - ratio_met = ratio_limit_valid and torrent.ratio >= ratio_limit - time_met = time_limit_valid and torrent.seeding_time >= seeding_time_limit - - mode = arr.seeding_mode_global_remove_torrent - if mode in (1, 2, 3, 4) and not ratio_limit_valid and not time_limit_valid: - return False - - if mode == 4: - return ratio_met and time_met - if mode == 3: - return ratio_met or time_met - if mode == 2: - return time_met - if mode == 1: - return ratio_met - return False - - -class TestTorrentLimitCheck: - """Test removal modes with set/unset ratio and time limits.""" - - def test_remove_torrent_never_returns_false_regardless_of_limits(self): - arr = _make_arr(-1) - t = _make_torrent(ratio=10.0, seeding_time=100000) - assert torrent_limit_check_standalone(arr, t, 100, 2.0) is False - assert torrent_limit_check_standalone(arr, t, -5, -5) is False - - def test_mode_1_ratio_only_requires_set_ratio_limit(self): - arr = _make_arr(1) - t = _make_torrent(ratio=2.0, seeding_time=0) - # Limit set and met - assert torrent_limit_check_standalone(arr, t, -5, 2.0) is True - assert torrent_limit_check_standalone(arr, t, -5, 1.5) is True - # Limit set but not met - assert torrent_limit_check_standalone(arr, t, -5, 3.0) is False - # Time limit unset is ignored for mode 1 - assert torrent_limit_check_standalone(arr, t, -5, 2.0) is True - # Both unset -> no removal - assert torrent_limit_check_standalone(arr, t, -5, -5) is False - - def test_mode_2_time_only_requires_set_time_limit(self): - arr = _make_arr(2) - t = _make_torrent(ratio=0.0, seeding_time=1000) - # Time limit set and met - assert torrent_limit_check_standalone(arr, t, 500, -5) is True - assert torrent_limit_check_standalone(arr, t, 1000, -5) is True - # Time limit set but not met - assert torrent_limit_check_standalone(arr, t, 2000, -5) is False - # Ratio unset is ignored for mode 2 - assert torrent_limit_check_standalone(arr, t, 500, -5) is True - # Both unset -> no removal - assert torrent_limit_check_standalone(arr, t, -5, -5) is False - - def test_mode_3_or_requires_at_least_one_set_and_met(self): - arr = _make_arr(3) - t = _make_torrent(ratio=2.0, seeding_time=1000) - # Ratio set and met, time unset - assert torrent_limit_check_standalone(arr, t, -5, 2.0) is True - # Time set and met, ratio unset - assert torrent_limit_check_standalone(arr, t, 500, -5) is True - # Both set, one met - assert torrent_limit_check_standalone(arr, t, 500, 3.0) is True # time met - assert torrent_limit_check_standalone(arr, t, 2000, 1.5) is True # ratio met - # Both set and met - assert torrent_limit_check_standalone(arr, t, 500, 1.0) is True - # Both unset -> no removal - assert torrent_limit_check_standalone(arr, t, -5, -5) is False - # Both set but neither met - assert torrent_limit_check_standalone(arr, t, 2000, 5.0) is False - - def test_mode_4_and_requires_both_set_and_both_met(self): - arr = _make_arr(4) - t = _make_torrent(ratio=2.0, seeding_time=1000) - # Both set and met - assert torrent_limit_check_standalone(arr, t, 500, 1.5) is True - # Ratio met, time not met - assert torrent_limit_check_standalone(arr, t, 2000, 1.5) is False - # Time met, ratio not met - assert torrent_limit_check_standalone(arr, t, 500, 5.0) is False - # Ratio unset -> cannot remove (ratio_met is False) - assert torrent_limit_check_standalone(arr, t, 500, -5) is False - # Time unset -> cannot remove (time_met is False) - assert torrent_limit_check_standalone(arr, t, -5, 1.5) is False - # Both unset -> no removal - assert torrent_limit_check_standalone(arr, t, -5, -5) is False From d0fb505a9a7009bf4b92d2566d126d5b05eecc01 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 20:51:42 +0000 Subject: [PATCH 41/66] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f65dff01a..24bdedad5 100644 --- a/README.md +++ b/README.md @@ -186,4 +186,4 @@ Released under the [MIT License](LICENSE). Use it, modify it, share it—commerc Star History Chart - \ No newline at end of file + From b8e558bc1a6ba96b0877076aedf99ac80d364464 Mon Sep 17 00:00:00 2001 From: Feramance Date: Fri, 27 Feb 2026 21:52:22 +0100 Subject: [PATCH 42/66] Fix pre-commit --- .github/workflows/pre-commit.yaml | 2 ++ README.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index 3894c28c2..78c127dc9 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -14,4 +14,6 @@ jobs: uses: actions/setup-python@v6 with: python-version: '3.12' + - name: Install project (for absolufy-imports and import resolution) + run: pip install -e . - uses: pre-commit/action@v3.0.1 diff --git a/README.md b/README.md index f65dff01a..24bdedad5 100644 --- a/README.md +++ b/README.md @@ -186,4 +186,4 @@ Released under the [MIT License](LICENSE). Use it, modify it, share it—commerc Star History Chart - \ No newline at end of file + From 94851524d896428a78752541f66dcbbfd39b89c5 Mon Sep 17 00:00:00 2001 From: Feramance Date: Fri, 27 Feb 2026 22:09:22 +0100 Subject: [PATCH 43/66] Removed redundant egg info --- qBitrr2.egg-info/PKG-INFO | 274 -------------------------- qBitrr2.egg-info/SOURCES.txt | 69 ------- qBitrr2.egg-info/dependency_links.txt | 0 qBitrr2.egg-info/entry_points.txt | 2 - qBitrr2.egg-info/requires.txt | 44 ----- qBitrr2.egg-info/top_level.txt | 1 - 6 files changed, 390 deletions(-) delete mode 100644 qBitrr2.egg-info/PKG-INFO delete mode 100644 qBitrr2.egg-info/SOURCES.txt delete mode 100644 qBitrr2.egg-info/dependency_links.txt delete mode 100644 qBitrr2.egg-info/entry_points.txt delete mode 100644 qBitrr2.egg-info/requires.txt delete mode 100644 qBitrr2.egg-info/top_level.txt diff --git a/qBitrr2.egg-info/PKG-INFO b/qBitrr2.egg-info/PKG-INFO deleted file mode 100644 index 48d44d186..000000000 --- a/qBitrr2.egg-info/PKG-INFO +++ /dev/null @@ -1,274 +0,0 @@ -Metadata-Version: 2.4 -Name: qBitrr2 -Version: 5.4.5 -Summary: Intelligent automation for qBittorrent and *Arr apps (Radarr/Sonarr/Lidarr) - health monitoring, instant imports, quality upgrades, request integration -Home-page: https://github.com/Feramance/qBitrr -Author: Feramance -Author-email: fera@fera.wtf -License: MIT -Project-URL: Homepage, https://github.com/Feramance/qBitrr -Project-URL: Documentation, https://github.com/Feramance/qBitrr/blob/master/API_DOCUMENTATION.md -Project-URL: Issue Tracker, https://github.com/Feramance/qBitrr/issues -Project-URL: Source Code, https://github.com/Feramance/qBitrr -Project-URL: Changelog, https://github.com/Feramance/qBitrr/blob/master/CHANGELOG.md -Project-URL: Docker Hub, https://hub.docker.com/r/feramance/qbitrr -Project-URL: PyPI, https://pypi.org/project/qBitrr2/ -Project-URL: Systemd Guide, https://github.com/Feramance/qBitrr/blob/master/SYSTEMD_SERVICE.md -Keywords: qbittorrent,radarr,sonarr,lidarr,arr,automation,torrent,media,plex,jellyfin,overseerr,ombi -Classifier: Development Status :: 5 - Production/Stable -Classifier: Intended Audience :: Developers -Classifier: Intended Audience :: End Users/Desktop -Classifier: Intended Audience :: System Administrators -Classifier: License :: OSI Approved :: MIT License -Classifier: Natural Language :: English -Classifier: Operating System :: MacOS :: MacOS X -Classifier: Operating System :: Microsoft :: Windows -Classifier: Operating System :: POSIX :: Linux -Classifier: Programming Language :: Python :: 3 :: Only -Classifier: Programming Language :: Python :: 3.11 -Classifier: Programming Language :: Python :: 3.12 -Classifier: Programming Language :: Python :: Implementation :: CPython -Classifier: Programming Language :: Python :: Implementation :: PyPy -Classifier: Topic :: Communications -Classifier: Topic :: Internet -Classifier: Topic :: Multimedia :: Video -Classifier: Topic :: System :: Monitoring -Classifier: Topic :: Terminals -Classifier: Topic :: Utilities -Classifier: Typing :: Typed -Requires-Python: <4,>=3.11 -Description-Content-Type: text/markdown -License-File: LICENSE -Requires-Dist: cachetools -Requires-Dist: colorama -Requires-Dist: coloredlogs -Requires-Dist: flask -Requires-Dist: environ-config -Requires-Dist: ffmpeg-python -Requires-Dist: jaraco.docker -Requires-Dist: packaging -Requires-Dist: pathos -Requires-Dist: peewee -Requires-Dist: ping3 -Requires-Dist: pyarr -Requires-Dist: qbittorrent-api -Requires-Dist: requests -Requires-Dist: tomlkit -Requires-Dist: waitress -Requires-Dist: croniter -Provides-Extra: dev -Requires-Dist: black==24.3.0; extra == "dev" -Requires-Dist: bump2version==1.0.1; extra == "dev" -Requires-Dist: isort==5.10.1; extra == "dev" -Requires-Dist: pip-tools==7.3.0; extra == "dev" -Requires-Dist: pre-commit==3.3.3; extra == "dev" -Requires-Dist: pyinstaller==5.13.1; extra == "dev" -Requires-Dist: pyupgrade==2.31.0; extra == "dev" -Requires-Dist: twine==3.7.1; extra == "dev" -Requires-Dist: ujson==5.10.0; extra == "dev" -Requires-Dist: upgrade-pip==0.1.4; extra == "dev" -Provides-Extra: fast -Requires-Dist: ujson==5.10.0; extra == "fast" -Provides-Extra: all -Requires-Dist: black==24.3.0; extra == "all" -Requires-Dist: bump2version==1.0.1; extra == "all" -Requires-Dist: isort==5.10.1; extra == "all" -Requires-Dist: pip-tools==7.3.0; extra == "all" -Requires-Dist: pre-commit==3.3.3; extra == "all" -Requires-Dist: pyinstaller==5.13.1; extra == "all" -Requires-Dist: pyupgrade==2.31.0; extra == "all" -Requires-Dist: twine==3.7.1; extra == "all" -Requires-Dist: ujson==5.10.0; extra == "all" -Requires-Dist: upgrade-pip==0.1.4; extra == "all" -Requires-Dist: ujson==5.10.0; extra == "all" -Dynamic: license-file - -# qBitrr Logo qBitrr - -[![PyPI](https://img.shields.io/pypi/v/qBitrr2?label=PyPI)](https://pypi.org/project/qBitrr2/) -[![Downloads](https://img.shields.io/pypi/dm/qBitrr2)](https://pypi.org/project/qBitrr2/) -[![Docker Pulls](https://img.shields.io/docker/pulls/feramance/qbitrr.svg)](https://hub.docker.com/r/feramance/qbitrr) -[![CodeQL](https://github.com/Feramance/qBitrr/actions/workflows/codeql.yml/badge.svg?branch=master)](https://github.com/Feramance/qBitrr/actions/workflows/codeql.yml) -[![Nightly Build](https://github.com/Feramance/qBitrr/actions/workflows/nightly.yml/badge.svg?branch=master)](https://github.com/Feramance/qBitrr/actions/workflows/nightly.yml) -[![pre-commit.ci](https://results.pre-commit.ci/badge/github/Feramance/qBitrr/master.svg)](https://results.pre-commit.ci/latest/github/Feramance/qBitrr/master) -[![License: MIT](https://img.shields.io/pypi/l/qbitrr)](LICENSE) - -> 🧩 The intelligent glue between qBittorrent and the *Arr ecosystem (Radarr, Sonarr, Lidarr). Monitors torrent health, triggers instant imports, automates quality upgrades, manages disk space, integrates with request systems (Overseerr/Ombi), and provides a modern React dashboard for complete visibility and control. - -## 📚 Documentation - -**Full documentation is available at: https://feramance.github.io/qBitrr/** - -- [Getting Started](https://feramance.github.io/qBitrr/getting-started/) – Installation guides for pip, Docker, and native setups -- [Configuration](https://feramance.github.io/qBitrr/configuration/) – qBittorrent, Arr instances, quality profiles, and more -- [Features](https://feramance.github.io/qBitrr/features/) – Health monitoring, automated search, quality management, disk space, auto-updates -- [WebUI](https://feramance.github.io/qBitrr/webui/) – Built-in React dashboard with live monitoring and config editor -- [Troubleshooting](https://feramance.github.io/qBitrr/troubleshooting/) – Common issues and debug logging -- [API Reference](https://feramance.github.io/qBitrr/reference/api/) – REST API documentation - -## ⚡ Quick Start - -### 🐍 Install with pip -```bash -python -m venv .venv -source .venv/bin/activate # Windows: .venv\Scripts\activate -pip install qBitrr2 - -# First run creates ~/config/config.toml -qbitrr -``` - -### 🐳 Run with Docker -```bash -docker run -d \ - --name qbitrr \ - --tty \ - -e TZ=Europe/London \ - -p 6969:6969 \ - -v /path/to/appdata/qbitrr:/config \ - -v /path/to/completed/downloads:/completed_downloads:rw \ - --restart unless-stopped \ - feramance/qbitrr:latest -``` - -**Docker Compose:** -```yaml -services: - qbitrr: - image: feramance/qbitrr:latest - container_name: qbitrr - restart: unless-stopped - tty: true - environment: - TZ: Europe/London - ports: - - "6969:6969" - volumes: - - /path/to/appdata/qbitrr:/config - - /path/to/completed/downloads:/completed_downloads:rw -``` - -Access the WebUI at `http://:6969/ui` after startup. - -## ✨ Key Features - -- **🚀 Multi-qBittorrent Support (v5.7.x+)** – Manage torrents across multiple qBittorrent instances for load balancing, redundancy, and VPN isolation -- **🚑 Torrent Health Monitoring** – Detect stalled/failed downloads, auto-blacklist, trigger re-searches -- **🔍 Automated Search** – Missing media, quality upgrades, custom format scoring -- **🎯 Request Integration** – Pull requests from Overseerr/Ombi, prioritize user-requested media -- **📊 Quality Management** – RSS sync, queue refresh, profile switching, custom format enforcement -- **🌱 Seeding Control** – Per-tracker settings, ratio/time limits, tracker injection -- **🛡️ Hit and Run Protection** – Automatic HnR obligation tracking with configurable thresholds, partial download handling, and dead tracker bypass -- **💾 Disk Space Management** – Auto-pause when low on space, configurable thresholds -- **🔄 Auto-Updates** – GitHub release-based updates with scheduled cron support -- **💻 Modern WebUI** – Live process monitoring, log viewer, Arr insights, config editor - -## 🛠️ Essential Configuration - -1. **Configure qBittorrent** in `~/config/config.toml`: - ```toml - [qBit] - Host = "localhost" - Port = 8080 - UserName = "admin" - Password = "adminpass" - ``` - -2. **Add Arr instances**: - ```toml - [Radarr-Movies] - URI = "http://localhost:7878" - APIKey = "your-radarr-api-key" - Category = "radarr-movies" - ``` - -3. **Set completed folder**: - ```toml - [Settings] - CompletedDownloadFolder = "/path/to/completed" - ``` - -### 🆕 Multi-qBittorrent (v5.7.x+) - -Manage torrents across multiple qBittorrent instances: - -```toml -[qBit] # Default instance (required) -Host = "localhost" -Port = 8080 -UserName = "admin" -Password = "password" - -[qBit-seedbox] # Additional instance (optional) -Host = "192.168.1.100" -Port = 8080 -UserName = "admin" -Password = "seedboxpass" -``` - -See [Multi-qBittorrent Guide](MULTI_QBIT_V3_USER_GUIDE.md) for complete documentation. - ---- - -See [Configuration Guide](https://feramance.github.io/qBitrr/configuration/) and [config.example.toml](config.example.toml) for all available options. - -## 📖 Resources - -- **Documentation:** https://feramance.github.io/qBitrr/ -- **PyPI Package:** https://pypi.org/project/qBitrr2/ -- **Docker Hub:** https://hub.docker.com/r/feramance/qbitrr -- **Example Config:** [config.example.toml](config.example.toml) -- **API Documentation:** [docs/reference/api.md](docs/reference/api.md) -- **Systemd Setup:** [docs/getting-started/installation/systemd.md](docs/getting-started/installation/systemd.md) - -## 🐛 Issues & Support - -- **Report Bugs:** [Bug Report Template](.github/ISSUE_TEMPLATE/bug_report.yml) -- **Request Features:** [Feature Request Template](.github/ISSUE_TEMPLATE/feature_request.yml) -- **Discussions:** [GitHub Discussions](https://github.com/Feramance/qBitrr/discussions) -- **Troubleshooting:** [Common Issues](https://feramance.github.io/qBitrr/troubleshooting/) - -## 🤝 Contributing - -Contributions welcome! See [docs/development/contributing.md](docs/development/contributing.md) for coding guidelines and development setup. - -**Development setup:** -```bash -# Python backend -make newenv && make syncenv -make reformat # Format and lint - -# WebUI -cd webui && npm ci -npm run dev # Dev server at localhost:5173 -``` - -## ❤️ Support - -If qBitrr saves you time and headaches: -- ⭐ **Star the repo** – helps others discover qBitrr -- 💰 **Sponsor:** [Patreon](https://patreon.com/qBitrr) | [PayPal](https://www.paypal.me/feramance) - -## 📄 License - -Released under the [MIT License](LICENSE). Use it, modify it, share it—commercially or personally. - ---- - -
- -**Made with ❤️ by the qBitrr community** - -[Documentation](https://feramance.github.io/qBitrr/) • [PyPI](https://pypi.org/project/qBitrr2/) • [Docker](https://hub.docker.com/r/feramance/qbitrr) • [GitHub](https://github.com/Feramance/qBitrr) - -
- -## Star History - - - - - - Star History Chart - - diff --git a/qBitrr2.egg-info/SOURCES.txt b/qBitrr2.egg-info/SOURCES.txt deleted file mode 100644 index d29af816d..000000000 --- a/qBitrr2.egg-info/SOURCES.txt +++ /dev/null @@ -1,69 +0,0 @@ -LICENSE -MANIFEST.in -README.md -config.example.toml -pyproject.toml -setup.cfg -setup.py -qBitrr/__init__.py -qBitrr/arss.py -qBitrr/auto_update.py -qBitrr/bundled_data.py -qBitrr/config.py -qBitrr/config_version.py -qBitrr/db_lock.py -qBitrr/env_config.py -qBitrr/errors.py -qBitrr/ffprobe.py -qBitrr/gen_config.py -qBitrr/home_path.py -qBitrr/logger.py -qBitrr/main.py -qBitrr/search_activity_store.py -qBitrr/tables.py -qBitrr/utils.py -qBitrr/versioning.py -qBitrr/webui.py -qBitrr/static/index.html -qBitrr/static/manifest.json -qBitrr/static/sw.js -qBitrr/static/vite.svg -qBitrr/static/assets/ArrView.js -qBitrr/static/assets/ArrView.js.map -qBitrr/static/assets/ConfigView.js -qBitrr/static/assets/ConfigView.js.map -qBitrr/static/assets/LogsView.js -qBitrr/static/assets/LogsView.js.map -qBitrr/static/assets/ProcessesView.js -qBitrr/static/assets/ProcessesView.js.map -qBitrr/static/assets/app.css -qBitrr/static/assets/app.js -qBitrr/static/assets/app.js.map -qBitrr/static/assets/build.svg -qBitrr/static/assets/check-mark.svg -qBitrr/static/assets/close.svg -qBitrr/static/assets/download.svg -qBitrr/static/assets/gear.svg -qBitrr/static/assets/lidarr.svg -qBitrr/static/assets/live-streaming.svg -qBitrr/static/assets/log.svg -qBitrr/static/assets/plus.svg -qBitrr/static/assets/process.svg -qBitrr/static/assets/react-select.esm.js -qBitrr/static/assets/react-select.esm.js.map -qBitrr/static/assets/refresh-arrow.svg -qBitrr/static/assets/table.js -qBitrr/static/assets/table.js.map -qBitrr/static/assets/trash.svg -qBitrr/static/assets/up-arrow.svg -qBitrr/static/assets/useInterval.js -qBitrr/static/assets/useInterval.js.map -qBitrr/static/assets/vendor.js -qBitrr/static/assets/vendor.js.map -qBitrr/static/assets/visibility.svg -qBitrr2.egg-info/PKG-INFO -qBitrr2.egg-info/SOURCES.txt -qBitrr2.egg-info/dependency_links.txt -qBitrr2.egg-info/entry_points.txt -qBitrr2.egg-info/requires.txt -qBitrr2.egg-info/top_level.txt diff --git a/qBitrr2.egg-info/dependency_links.txt b/qBitrr2.egg-info/dependency_links.txt deleted file mode 100644 index e69de29bb..000000000 diff --git a/qBitrr2.egg-info/entry_points.txt b/qBitrr2.egg-info/entry_points.txt deleted file mode 100644 index 1169dee4f..000000000 --- a/qBitrr2.egg-info/entry_points.txt +++ /dev/null @@ -1,2 +0,0 @@ -[console_scripts] -qbitrr = qBitrr.main:run diff --git a/qBitrr2.egg-info/requires.txt b/qBitrr2.egg-info/requires.txt deleted file mode 100644 index 4fecb58e2..000000000 --- a/qBitrr2.egg-info/requires.txt +++ /dev/null @@ -1,44 +0,0 @@ -cachetools -colorama -coloredlogs -flask -environ-config -ffmpeg-python -jaraco.docker -packaging -pathos -peewee -ping3 -pyarr -qbittorrent-api -requests -tomlkit -waitress -croniter - -[all] -black==24.3.0 -bump2version==1.0.1 -isort==5.10.1 -pip-tools==7.3.0 -pre-commit==3.3.3 -pyinstaller==5.13.1 -pyupgrade==2.31.0 -twine==3.7.1 -ujson==5.10.0 -upgrade-pip==0.1.4 - -[dev] -black==24.3.0 -bump2version==1.0.1 -isort==5.10.1 -pip-tools==7.3.0 -pre-commit==3.3.3 -pyinstaller==5.13.1 -pyupgrade==2.31.0 -twine==3.7.1 -ujson==5.10.0 -upgrade-pip==0.1.4 - -[fast] -ujson==5.10.0 diff --git a/qBitrr2.egg-info/top_level.txt b/qBitrr2.egg-info/top_level.txt deleted file mode 100644 index 9a7700f4c..000000000 --- a/qBitrr2.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -qBitrr From 06a1e747acb9837f134664e7a6f92ff5b74d0999 Mon Sep 17 00:00:00 2001 From: Feramance Date: Fri, 27 Feb 2026 22:09:38 +0100 Subject: [PATCH 44/66] Updated git ignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c5db24e38..3b755f5e1 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ __pycache__/ *.so *.egg *.egg-info/ +qBitrr2.egg-info/ .Python # Build artifacts From 6f1df0f4d2614649cc66b93fb7959e5abf496811 Mon Sep 17 00:00:00 2001 From: Feramance Date: Fri, 27 Feb 2026 23:04:42 +0100 Subject: [PATCH 45/66] feat(scripts): add post-merge hook to rebuild and deploy on git pull --- scripts/README.md | 42 +++++++++++++++++++++++++++++- scripts/install-post-merge-hook.sh | 24 +++++++++++++++++ scripts/post-merge.hook | 6 +++++ scripts/rebuild-and-deploy.sh | 39 +++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 scripts/install-post-merge-hook.sh create mode 100644 scripts/post-merge.hook create mode 100644 scripts/rebuild-and-deploy.sh diff --git a/scripts/README.md b/scripts/README.md index b80083e83..32a896774 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,6 +1,46 @@ # qBitrr Database Recovery Scripts -This directory contains utility scripts for database maintenance and recovery. +This directory contains utility scripts for database maintenance, recovery, and deployment. + +## Rebuild and deploy on git sync + +To **rebuild and deploy qBitrr automatically after every `git pull`**, install the post-merge hook: + +```bash +# From repo root (Linux/macOS or Git Bash on Windows) +bash scripts/install-post-merge-hook.sh +``` + +After installation, each time you run `git pull` (and a merge happens), the hook will: + +- **If `docker-compose.yml` exists:** run `docker compose build --no-cache` and `docker compose up -d`. +- **Otherwise:** run `make syncenv` (sync venv and build WebUI). Restart qBitrr manually if you use systemd. + +**Override behaviour** with `DEPLOY_MODE`: + +- `DEPLOY_MODE=docker` — force Docker Compose rebuild and deploy. +- `DEPLOY_MODE=native` — force `make syncenv` only (no Docker). + +Example (before pulling): + +```bash +export DEPLOY_MODE=native +git pull +``` + +**Manual run** (without git): + +```bash +bash scripts/rebuild-and-deploy.sh +``` + +**Files:** + +- `scripts/rebuild-and-deploy.sh` — Rebuild and deploy logic. +- `scripts/post-merge.hook` — Hook content (installed into `.git/hooks/post-merge` by the installer). +- `scripts/install-post-merge-hook.sh` — One-time installer for the hook. + +--- ## Scripts diff --git a/scripts/install-post-merge-hook.sh b/scripts/install-post-merge-hook.sh new file mode 100644 index 000000000..7210c7306 --- /dev/null +++ b/scripts/install-post-merge-hook.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Install the post-merge hook so that 'git pull' triggers rebuild and deploy. +# Run once from repo root: bash scripts/install-post-merge-hook.sh + +set -e + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +HOOK_SRC="$REPO_ROOT/scripts/post-merge.hook" +HOOK_DST="$REPO_ROOT/.git/hooks/post-merge" + +if [ ! -d "$REPO_ROOT/.git" ]; then + echo "Not a git repo: $REPO_ROOT" >&2 + exit 1 +fi + +if [ ! -f "$HOOK_SRC" ]; then + echo "Hook template missing: $HOOK_SRC" >&2 + exit 1 +fi + +cp "$HOOK_SRC" "$HOOK_DST" +chmod +x "$HOOK_DST" +echo "Installed post-merge hook at .git/hooks/post-merge" +echo "After 'git pull', qBitrr will rebuild and deploy (Docker or native per DEPLOY_MODE)." diff --git a/scripts/post-merge.hook b/scripts/post-merge.hook new file mode 100644 index 000000000..fe5b118c6 --- /dev/null +++ b/scripts/post-merge.hook @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Git post-merge hook: rebuild and deploy qBitrr after every git pull (merge). +# Install with: scripts/install-post-merge-hook.sh + +REPO_ROOT="$(git rev-parse --show-toplevel)" +exec "$REPO_ROOT/scripts/rebuild-and-deploy.sh" diff --git a/scripts/rebuild-and-deploy.sh b/scripts/rebuild-and-deploy.sh new file mode 100644 index 000000000..6489fc93c --- /dev/null +++ b/scripts/rebuild-and-deploy.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Rebuild and deploy qBitrr (for use after git pull / sync). +# Supports: Docker Compose deploy, or native make syncenv only. +# Set DEPLOY_MODE=docker (default if docker-compose.yml present) or DEPLOY_MODE=native. + +set -e + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +echo "[rebuild-and-deploy] Working in $REPO_ROOT" + +# Default: use docker if docker-compose.yml exists +if [ -z "${DEPLOY_MODE}" ]; then + if [ -f "docker-compose.yml" ]; then + DEPLOY_MODE=docker + else + DEPLOY_MODE=native + fi +fi + +case "${DEPLOY_MODE}" in + docker) + echo "[rebuild-and-deploy] Rebuilding and deploying with Docker Compose..." + docker compose build --no-cache + docker compose up -d + echo "[rebuild-and-deploy] Done. Container status:" + docker compose ps + ;; + native) + echo "[rebuild-and-deploy] Syncing environment and building WebUI (make syncenv)..." + make syncenv + echo "[rebuild-and-deploy] Done. Restart qBitrr manually if needed (e.g. systemctl restart qbitrr)." + ;; + *) + echo "[rebuild-and-deploy] Unknown DEPLOY_MODE=${DEPLOY_MODE}. Use 'docker' or 'native'." >&2 + exit 1 + ;; +esac From 810e1d64dc4d202c5fb4f5c78c0f09061c10c44f Mon Sep 17 00:00:00 2001 From: Feramance Date: Sat, 28 Feb 2026 10:14:08 +0100 Subject: [PATCH 46/66] feat(config): human-friendly duration in config and Web UI - Backend: duration_config.py (s/m/h/d/w/M), get_duration in gen_config, migrate call sites - Frontend: DurationInput, duration field type, durationUtils.ts - Docs: config-file time values, config-editor duration input --- docs/configuration/config-file.md | 15 ++ docs/webui/config-editor.md | 3 + qBitrr/arss.py | 52 +++-- qBitrr/config.py | 11 +- qBitrr/duration_config.py | 95 +++++++++ qBitrr/gen_config.py | 13 ++ qBitrr/main.py | 71 +++++-- webui/src/config/durationUtils.ts | 155 +++++++++++++++ webui/src/pages/ConfigView.tsx | 319 +++++++++++++++++++++--------- webui/src/styles.css | 13 ++ 10 files changed, 615 insertions(+), 132 deletions(-) create mode 100644 qBitrr/duration_config.py create mode 100644 webui/src/config/durationUtils.ts diff --git a/docs/configuration/config-file.md b/docs/configuration/config-file.md index e4dbaf557..a26bf5586 100644 --- a/docs/configuration/config-file.md +++ b/docs/configuration/config-file.md @@ -46,6 +46,21 @@ name = "first" name = "second" ``` +### Time values (durations) + +Many settings accept **time durations**. You can use: + +- **Integers (legacy)**: Plain numbers in the key's base unit (seconds or minutes, depending on the setting). Example: `LoopSleepTimer = 5` (5 seconds), `StalledDelay = 15` (15 minutes). +- **Suffixed strings**: A number followed by a unit letter for human-readable values: + - `s` = seconds + - `m` = minutes + - `h` = hours + - `d` = days + - `w` = weeks + - `M` = months (30 days; uppercase to distinguish from minutes) + +Examples: `MaxSeedingTime = "1w"` (1 week in seconds), `StalledDelay = "1440m"` (1440 minutes). Unsupplied or empty suffix is treated as the key's base unit (seconds or minutes). Use `-1` or `"-1"` where a setting supports "disabled". + --- ## Configuration Sections diff --git a/docs/webui/config-editor.md b/docs/webui/config-editor.md index 4bd3d4567..02ac704a9 100644 --- a/docs/webui/config-editor.md +++ b/docs/webui/config-editor.md @@ -507,6 +507,9 @@ Numeric field with spinner controls. ``` +### Duration Input +Time fields use a **number input plus unit dropdown** (s, m, h, d, w, M) instead of raw seconds or minutes. When you change a duration and save, the config may store it as a suffixed string (e.g. `MaxSeedingTime = "1w"`, `StalledDelay = "60m"`) for readability; the backend accepts both integers and suffixed strings. Disabled values (e.g. `-1`) appear as "Disabled" where supported. + ### Checkbox Toggle boolean values. diff --git a/qBitrr/arss.py b/qBitrr/arss.py index 7dc6ec361..dcbf972b6 100755 --- a/qBitrr/arss.py +++ b/qBitrr/arss.py @@ -228,11 +228,15 @@ def __init__( self.import_mode = CONFIG.get(f"{name}.importMode", fallback="Auto") if self.import_mode == "Hardlink": self.import_mode = "Auto" - self.refresh_downloads_timer = CONFIG.get(f"{name}.RefreshDownloadsTimer", fallback=1) + self.refresh_downloads_timer = CONFIG.get_duration( + f"{name}.RefreshDownloadsTimer", fallback=1, unit="minutes" + ) self.arr_error_codes_to_blocklist = CONFIG.get( f"{name}.ArrErrorCodesToBlocklist", fallback=[] ) - self.rss_sync_timer = CONFIG.get(f"{name}.RssSyncTimer", fallback=15) + self.rss_sync_timer = CONFIG.get_duration( + f"{name}.RssSyncTimer", fallback=15, unit="minutes" + ) self.case_sensitive_matches = CONFIG.get( f"{name}.Torrent.CaseSensitiveMatches", fallback=False @@ -264,7 +268,7 @@ def __init__( self.seeding_mode_global_max_upload_ratio = CONFIG.get( f"{name}.Torrent.SeedingMode.MaxUploadRatio", fallback=-1 ) - self.seeding_mode_global_max_seeding_time = CONFIG.get( + self.seeding_mode_global_max_seeding_time = CONFIG.get_duration( f"{name}.Torrent.SeedingMode.MaxSeedingTime", fallback=-1 ) self.seeding_mode_global_remove_torrent = CONFIG.get( @@ -340,10 +344,10 @@ def __init__( f"{name}.EntrySearch.ForceMinimumCustomFormat", fallback=False ) - self.ignore_torrents_younger_than = CONFIG.get( + self.ignore_torrents_younger_than = CONFIG.get_duration( f"{name}.Torrent.IgnoreTorrentsYoungerThan", fallback=600 ) - self.maximum_eta = CONFIG.get(f"{name}.Torrent.MaximumETA", fallback=86400) + self.maximum_eta = CONFIG.get_duration(f"{name}.Torrent.MaximumETA", fallback=86400) self.maximum_deletable_percentage = CONFIG.get( f"{name}.Torrent.MaximumDeletablePercentage", fallback=0.95 ) @@ -362,7 +366,9 @@ def __init__( self.do_not_remove_slow = CONFIG.get(f"{name}.Torrent.DoNotRemoveSlow", fallback=False) self.re_search_stalled = CONFIG.get(f"{name}.Torrent.ReSearchStalled", fallback=False) - self.stalled_delay = CONFIG.get(f"{name}.Torrent.StalledDelay", fallback=15) + self.stalled_delay = CONFIG.get_duration( + f"{name}.Torrent.StalledDelay", fallback=15, unit="minutes" + ) self.allowed_stalled = True if self.stalled_delay != -1 else False self.search_current_year = None @@ -413,7 +419,7 @@ def __init__( self.overseerr_approved_only = CONFIG.get( f"{name}.EntrySearch.Overseerr.ApprovedOnly", fallback=True ) - self.search_requests_every_x_seconds = CONFIG.get( + self.search_requests_every_x_seconds = CONFIG.get_duration( f"{name}.EntrySearch.SearchRequestsEvery", fallback=300 ) self._temp_overseer_request_cache: dict[str, set[int | str]] = defaultdict(set) @@ -521,8 +527,8 @@ def __init__( self.profile_switch_retry_attempts = CONFIG.get( f"{name}.EntrySearch.ProfileSwitchRetryAttempts", fallback=3 ) - self.temp_profile_timeout_minutes = CONFIG.get( - f"{name}.EntrySearch.TempProfileResetTimeoutMinutes", fallback=0 + self.temp_profile_timeout_minutes = CONFIG.get_duration( + f"{name}.EntrySearch.TempProfileResetTimeoutMinutes", fallback=0, unit="minutes" ) self.logger.info( "Parsed quality profile mappings: %s", @@ -7551,7 +7557,7 @@ def __init__(self, name: str, manager: ArrManager): self.delete = set() self.resume = set() self.expiring_bool = ExpiringSet(max_age_seconds=10) - self.ignore_torrents_younger_than = CONFIG.get( + self.ignore_torrents_younger_than = CONFIG.get_duration( "Settings.IgnoreTorrentsYoungerThan", fallback=180 ) self.timed_ignore_cache = ExpiringSet(max_age_seconds=self.ignore_torrents_younger_than) @@ -7568,7 +7574,7 @@ def __init__(self, name: str, manager: ArrManager): self._warned_no_seeding_limits = False self.custom_format_unmet_search = False self.do_not_remove_slow = False - self.maximum_eta = CONFIG.get("Settings.Torrent.MaximumETA", fallback=86400) + self.maximum_eta = CONFIG.get_duration("Settings.Torrent.MaximumETA", fallback=86400) self.maximum_deletable_percentage = CONFIG.get( "Settings.Torrent.MaximumDeletablePercentage", fallback=0.95 ) @@ -7637,7 +7643,12 @@ def _apply_qbit_seeding_config(self) -> None: ] default_seeding = {} for key in seeding_keys: - default_seeding[key] = CONFIG.get(f"{section}.CategorySeeding.{key}", fallback=-1) + if key == "MaxSeedingTime": + default_seeding[key] = CONFIG.get_duration( + f"{section}.CategorySeeding.{key}", fallback=-1 + ) + else: + default_seeding[key] = CONFIG.get(f"{section}.CategorySeeding.{key}", fallback=-1) for key, fallback in ( ("HitAndRunMode", "disabled"), ("MinSeedRatio", 1.0), @@ -7645,9 +7656,14 @@ def _apply_qbit_seeding_config(self) -> None: ("HitAndRunPartialSeedRatio", 1.0), ("TrackerUpdateBuffer", 0), ): - default_seeding[key] = CONFIG.get( - f"{section}.CategorySeeding.{key}", fallback=fallback - ) + if key == "TrackerUpdateBuffer": + default_seeding[key] = CONFIG.get_duration( + f"{section}.CategorySeeding.{key}", fallback=fallback + ) + else: + default_seeding[key] = CONFIG.get( + f"{section}.CategorySeeding.{key}", fallback=fallback + ) category_overrides = {} for cat_config in CONFIG.get(f"{section}.CategorySeeding.Categories", fallback=[]): if isinstance(cat_config, dict) and "Name" in cat_config: @@ -7660,7 +7676,9 @@ def _apply_qbit_seeding_config(self) -> None: self.seeding_mode_global_max_seeding_time = effective.get("MaxSeedingTime", -1) self.seeding_mode_global_download_limit = effective.get("DownloadRateLimitPerTorrent", -1) self.seeding_mode_global_upload_limit = effective.get("UploadRateLimitPerTorrent", -1) - self.stalled_delay = CONFIG.get(f"{section}.CategorySeeding.StalledDelay", fallback=-1) + self.stalled_delay = CONFIG.get_duration( + f"{section}.CategorySeeding.StalledDelay", fallback=-1, unit="minutes" + ) self.allowed_stalled = self.stalled_delay != -1 self.monitored_trackers = CONFIG.get(f"{section}.Trackers", fallback=[]) self._remove_trackers_if_exists = { @@ -7975,7 +7993,7 @@ def __init__(self, categories: set[str], manager: ArrManager): self.pause = set() self.resume = set() self.expiring_bool = ExpiringSet(max_age_seconds=10) - self.ignore_torrents_younger_than = CONFIG.get( + self.ignore_torrents_younger_than = CONFIG.get_duration( "Settings.IgnoreTorrentsYoungerThan", fallback=180 ) self.timed_ignore_cache = ExpiringSet(max_age_seconds=self.ignore_torrents_younger_than) diff --git a/qBitrr/config.py b/qBitrr/config.py index 9bc9362e0..2d9ec423d 100644 --- a/qBitrr/config.py +++ b/qBitrr/config.py @@ -135,13 +135,13 @@ def process_flags() -> argparse.Namespace | bool: if FREE_SPACE != "-1" else None ) -NO_INTERNET_SLEEP_TIMER = ENVIRO_CONFIG.settings.no_internet_sleep_timer or CONFIG.get( +NO_INTERNET_SLEEP_TIMER = ENVIRO_CONFIG.settings.no_internet_sleep_timer or CONFIG.get_duration( "Settings.NoInternetSleepTimer", fallback=15 ) -LOOP_SLEEP_TIMER = ENVIRO_CONFIG.settings.loop_sleep_timer or CONFIG.get( +LOOP_SLEEP_TIMER = ENVIRO_CONFIG.settings.loop_sleep_timer or CONFIG.get_duration( "Settings.LoopSleepTimer", fallback=5 ) -SEARCH_LOOP_DELAY = ENVIRO_CONFIG.settings.search_loop_delay or CONFIG.get( +SEARCH_LOOP_DELAY = ENVIRO_CONFIG.settings.search_loop_delay or CONFIG.get_duration( "Settings.SearchLoopDelay", fallback=-1 ) AUTO_PAUSE_RESUME = ENVIRO_CONFIG.settings.auto_pause_resume or CONFIG.get( @@ -150,8 +150,9 @@ def process_flags() -> argparse.Namespace | bool: PING_URLS = ENVIRO_CONFIG.settings.ping_urls or CONFIG.get( "Settings.PingURLS", fallback=["one.one.one.one", "dns.google.com"] ) -IGNORE_TORRENTS_YOUNGER_THAN = ENVIRO_CONFIG.settings.ignore_torrents_younger_than or CONFIG.get( - "Settings.IgnoreTorrentsYoungerThan", fallback=180 +IGNORE_TORRENTS_YOUNGER_THAN = ( + ENVIRO_CONFIG.settings.ignore_torrents_younger_than + or CONFIG.get_duration("Settings.IgnoreTorrentsYoungerThan", fallback=180) ) diff --git a/qBitrr/duration_config.py b/qBitrr/duration_config.py new file mode 100644 index 000000000..2c4bf0eb7 --- /dev/null +++ b/qBitrr/duration_config.py @@ -0,0 +1,95 @@ +""" +Parse duration values from config: integers (legacy) or suffixed strings (e.g. "1w", "60m"). + +Used by MyConfig.get_duration() so time-related keys can be stored as human-readable +strings (s/m/h/d/w/M) while remaining backwards compatible with plain numbers. +""" + +from __future__ import annotations + +import re +from typing import Any + +# Multipliers for suffix -> seconds +SUFFIX_TO_SECONDS = { + "s": 1, + "m": 60, + "h": 3600, + "d": 86400, + "w": 604800, + "M": 2592000, # 30 days +} + +# Same suffixes -> minutes (for keys that use minutes as base unit) +SUFFIX_TO_MINUTES = { + "s": 1 / 60, + "m": 1, + "h": 60, + "d": 1440, + "w": 10080, + "M": 43200, # 30 days +} + +_DURATION_PATTERN = re.compile(r"^\s*(-?\d+)\s*([smhdwM]?)\s*$", re.IGNORECASE) + + +def parse_duration_to_seconds(value: Any, fallback: int = -1) -> int: + """ + Parse a config value to seconds. Accepts int (return as-is) or str with optional suffix. + + Suffixes: s=seconds, m=minutes, h=hours, d=days, w=weeks, M=months (30 days). + Plain number or unsuffixed string is treated as seconds (backwards compatibility). + -1 / "-1" is allowed for "disabled" semantics. + """ + if value is None: + return fallback + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + s = str(value).strip() + if not s: + return fallback + m = _DURATION_PATTERN.match(s) + if not m: + try: + return int(float(s)) + except (ValueError, TypeError): + return fallback + num = int(m.group(1)) + raw_suffix = (m.group(2) or "s").strip() + # Uppercase M = month (30 days); lowercase m = minute + if raw_suffix == "M": + mult = SUFFIX_TO_SECONDS["M"] + else: + mult = SUFFIX_TO_SECONDS.get(raw_suffix.lower(), 1) + return num * mult + + +def parse_duration_to_minutes(value: Any, fallback: int = -1) -> int: + """ + Parse a config value to minutes. Same rules as parse_duration_to_seconds but + returns minutes (for keys like StalledDelay, RssSyncTimer). + """ + if value is None: + return fallback + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + s = str(value).strip() + if not s: + return fallback + m = _DURATION_PATTERN.match(s) + if not m: + try: + return int(float(s)) + except (ValueError, TypeError): + return fallback + num = int(m.group(1)) + raw_suffix = (m.group(2) or "m").strip() + if raw_suffix == "M": + mult = SUFFIX_TO_MINUTES["M"] + else: + mult = SUFFIX_TO_MINUTES.get(raw_suffix.lower(), 1) + return int(num * mult) diff --git a/qBitrr/gen_config.py b/qBitrr/gen_config.py index 064a56f79..314b00f6e 100755 --- a/qBitrr/gen_config.py +++ b/qBitrr/gen_config.py @@ -8,6 +8,7 @@ from tomlkit.items import Table from tomlkit.toml_document import TOMLDocument +from qBitrr.duration_config import parse_duration_to_minutes, parse_duration_to_seconds from qBitrr.env_config import ENVIRO_CONFIG from qBitrr.home_path import APPDATA_FOLDER, HOME_PATH @@ -1011,6 +1012,18 @@ def _value_error(self, err, arg1): def get(self, section: str, fallback: Any = None) -> T: return self._deep_get(section, default=fallback) + def get_duration(self, dotted_key: str, fallback: int = -1, unit: str = "seconds") -> int: + """ + Get a time value in seconds or minutes. Accepts int or suffixed string (e.g. "1w", "60m"). + Plain numbers are treated as the key's base unit (seconds or minutes). + """ + raw = self._deep_get(dotted_key, default=fallback) + if raw is ... or raw is None: + return fallback + if unit == "minutes": + return parse_duration_to_minutes(raw, fallback) + return parse_duration_to_seconds(raw, fallback) + def get_or_raise(self, section: str) -> T: if (r := self._deep_get(section, default=KeyError)) is KeyError: raise KeyError(f"{section} does not exist") diff --git a/qBitrr/main.py b/qBitrr/main.py index 6b53e6739..7f6f48bb1 100644 --- a/qBitrr/main.py +++ b/qBitrr/main.py @@ -142,8 +142,12 @@ def __init__(self): self._pending_spawns: list[tuple] = [] # (arr_instance, meta) tuples to retry self.auto_restart_enabled = CONFIG.get("Settings.AutoRestartProcesses", fallback=True) self.max_process_restarts = CONFIG.get("Settings.MaxProcessRestarts", fallback=5) - self.process_restart_window = CONFIG.get("Settings.ProcessRestartWindow", fallback=300) - self.process_restart_delay = CONFIG.get("Settings.ProcessRestartDelay", fallback=5) + self.process_restart_window = CONFIG.get_duration( + "Settings.ProcessRestartWindow", fallback=300 + ) + self.process_restart_delay = CONFIG.get_duration( + "Settings.ProcessRestartDelay", fallback=5 + ) # Start WebUI immediately, before any blocking network calls try: web_port = int(CONFIG.get("WebUI.Port", fallback=6969) or 6969) @@ -330,7 +334,12 @@ def _restart(): def _prepare_arr_processes(self, arr, timeout_seconds: int = 30) -> None: timeout = max( - 1, int(CONFIG.get("Settings.ProcessSpawnTimeoutSeconds", fallback=timeout_seconds)) + 1, + int( + CONFIG.get_duration( + "Settings.ProcessSpawnTimeoutSeconds", fallback=timeout_seconds + ) + ), ) result_queue: SimpleQueue = SimpleQueue() @@ -560,7 +569,12 @@ def _init_instance(self, section_name: str, instance_name: str) -> None: "RemoveTorrent", ] for key in seeding_keys: - value = CONFIG.get(f"{section_name}.CategorySeeding.{key}", fallback=-1) + if key == "MaxSeedingTime": + value = CONFIG.get_duration( + f"{section_name}.CategorySeeding.{key}", fallback=-1 + ) + else: + value = CONFIG.get(f"{section_name}.CategorySeeding.{key}", fallback=-1) default_seeding[key] = value # Load HnR protection settings @@ -572,9 +586,14 @@ def _init_instance(self, section_name: str, instance_name: str) -> None: "TrackerUpdateBuffer": 0, } for key, fallback in hnr_keys.items(): - default_seeding[key] = CONFIG.get( - f"{section_name}.CategorySeeding.{key}", fallback=fallback - ) + if key == "TrackerUpdateBuffer": + default_seeding[key] = CONFIG.get_duration( + f"{section_name}.CategorySeeding.{key}", fallback=fallback + ) + else: + default_seeding[key] = CONFIG.get( + f"{section_name}.CategorySeeding.{key}", fallback=fallback + ) # Load per-category overrides category_overrides = {} @@ -588,10 +607,12 @@ def _init_instance(self, section_name: str, instance_name: str) -> None: instance_trackers = CONFIG.get(f"{section_name}.Trackers", fallback=[]) # Stalled handling for qBit-managed categories (same semantics as Arr) - stalled_delay = CONFIG.get(f"{section_name}.CategorySeeding.StalledDelay", fallback=-1) - ignore_younger = CONFIG.get( + stalled_delay = CONFIG.get_duration( + f"{section_name}.CategorySeeding.StalledDelay", fallback=-1, unit="minutes" + ) + ignore_younger = CONFIG.get_duration( f"{section_name}.CategorySeeding.IgnoreTorrentsYoungerThan", - fallback=CONFIG.get("Settings.IgnoreTorrentsYoungerThan", fallback=180), + fallback=CONFIG.get_duration("Settings.IgnoreTorrentsYoungerThan", fallback=180), ) # Store config for later initialization @@ -718,13 +739,23 @@ def _load_category_config(section_name: str, instance_name: str): return default_seeding = {} for key in seeding_keys: - default_seeding[key] = CONFIG.get( - f"{section_name}.CategorySeeding.{key}", fallback=-1 - ) + if key == "MaxSeedingTime": + default_seeding[key] = CONFIG.get_duration( + f"{section_name}.CategorySeeding.{key}", fallback=-1 + ) + else: + default_seeding[key] = CONFIG.get( + f"{section_name}.CategorySeeding.{key}", fallback=-1 + ) for key, fallback in hnr_keys.items(): - default_seeding[key] = CONFIG.get( - f"{section_name}.CategorySeeding.{key}", fallback=fallback - ) + if key == "TrackerUpdateBuffer": + default_seeding[key] = CONFIG.get_duration( + f"{section_name}.CategorySeeding.{key}", fallback=fallback + ) + else: + default_seeding[key] = CONFIG.get( + f"{section_name}.CategorySeeding.{key}", fallback=fallback + ) category_overrides = {} for cat_config in CONFIG.get( f"{section_name}.CategorySeeding.Categories", fallback=[] @@ -732,10 +763,12 @@ def _load_category_config(section_name: str, instance_name: str): if isinstance(cat_config, dict) and "Name" in cat_config: category_overrides[cat_config["Name"]] = cat_config trackers = CONFIG.get(f"{section_name}.Trackers", fallback=[]) - stalled_delay = CONFIG.get(f"{section_name}.CategorySeeding.StalledDelay", fallback=-1) - ignore_younger = CONFIG.get( + stalled_delay = CONFIG.get_duration( + f"{section_name}.CategorySeeding.StalledDelay", fallback=-1, unit="minutes" + ) + ignore_younger = CONFIG.get_duration( f"{section_name}.CategorySeeding.IgnoreTorrentsYoungerThan", - fallback=CONFIG.get("Settings.IgnoreTorrentsYoungerThan", fallback=180), + fallback=CONFIG.get_duration("Settings.IgnoreTorrentsYoungerThan", fallback=180), ) self.qbit_category_configs[instance_name] = { "managed_categories": managed_categories, diff --git a/webui/src/config/durationUtils.ts b/webui/src/config/durationUtils.ts new file mode 100644 index 000000000..5fd7b4f62 --- /dev/null +++ b/webui/src/config/durationUtils.ts @@ -0,0 +1,155 @@ +/** + * Duration parsing/formatting for config time values. + * Supports integers (legacy) and suffixed strings (e.g. "1w", "60m"). + * Used by ConfigView duration fields and must match backend (duration_config.py). + */ + +export type DurationUnit = "s" | "m" | "h" | "d" | "w" | "M"; + +const SUFFIX_TO_SECONDS: Record = { + s: 1, + m: 60, + h: 3600, + d: 86400, + w: 604800, + M: 2592000, // 30 days +}; + +const SUFFIX_TO_MINUTES: Record = { + s: 1 / 60, + m: 1, + h: 60, + d: 1440, + w: 10080, + M: 43200, // 30 days +}; + +const DURATION_PATTERN = /^\s*(-?\d+)\s*([smhdwM]?)\s*$/i; + +export const DURATION_UNITS: { value: DurationUnit; label: string }[] = [ + { value: "s", label: "s" }, + { value: "m", label: "m" }, + { value: "h", label: "h" }, + { value: "d", label: "d" }, + { value: "w", label: "w" }, + { value: "M", label: "M" }, +]; + +function parseSuffixed( + value: unknown, + toSeconds: boolean, + fallback: number +): number { + if (value === null || value === undefined) return fallback; + if (typeof value === "number" && Number.isFinite(value)) return value; + const s = String(value).trim(); + if (!s) return fallback; + const m = s.match(DURATION_PATTERN); + if (!m) { + const n = Number(s); + return Number.isFinite(n) ? n : fallback; + } + const num = parseInt(m[1], 10); + const rawSuffix = (m[2] || (toSeconds ? "s" : "m")).trim(); + const suffix = rawSuffix === "M" ? "M" : rawSuffix.toLowerCase(); + const mult = toSeconds + ? SUFFIX_TO_SECONDS[suffix] ?? 1 + : SUFFIX_TO_MINUTES[suffix] ?? 1; + return num * mult; +} + +export function parseDurationToSeconds(value: unknown, fallback = -1): number { + return parseSuffixed(value, true, fallback); +} + +export function parseDurationToMinutes(value: unknown, fallback = -1): number { + return Math.floor(parseSuffixed(value, false, fallback)); +} + +function bestUnitForTotal( + total: number, + baseUnit: "seconds" | "minutes" +): DurationUnit { + if (total < 0) return "s"; + const abs = Math.abs(total); + const inSeconds = baseUnit === "seconds" ? abs : abs * 60; + if (inSeconds >= 2592000) return "M"; + if (inSeconds >= 604800) return "w"; + if (inSeconds >= 86400) return "d"; + if (inSeconds >= 3600) return "h"; + if (inSeconds >= 60) return "m"; + return "s"; +} + +function totalToNumberAndUnit( + total: number, + baseUnit: "seconds" | "minutes" +): { number: number; unit: DurationUnit } { + if (total < 0) return { number: -1, unit: "s" }; + const unit = bestUnitForTotal(total, baseUnit); + const mult = + baseUnit === "seconds" + ? SUFFIX_TO_SECONDS[unit] + : SUFFIX_TO_MINUTES[unit]; + const n = total / mult; + return { number: Math.round(n * 100) / 100, unit }; +} + +function numberAndUnitToTotal( + num: number, + unit: DurationUnit, + baseUnit: "seconds" | "minutes" +): number { + const mult = + baseUnit === "seconds" + ? SUFFIX_TO_SECONDS[unit] + : SUFFIX_TO_MINUTES[unit]; + return num * mult; +} + +function toSuffixed( + total: number, + baseUnit: "seconds" | "minutes" +): string | number { + if (total < 0) return -1; + const unit = bestUnitForTotal(total, baseUnit); + const mult = + baseUnit === "seconds" + ? SUFFIX_TO_SECONDS[unit] + : SUFFIX_TO_MINUTES[unit]; + const n = Math.round(total / mult); + if (unit === "s" && baseUnit === "seconds") return n; + if (unit === "m" && baseUnit === "minutes") return n; + return `${n}${unit}`; +} + +export interface DurationDisplay { + number: number; + unit: DurationUnit; + total: number; +} + +export function parseDurationDisplay( + value: unknown, + baseUnit: "seconds" | "minutes", + fallback = -1 +): DurationDisplay { + const total = + baseUnit === "seconds" + ? parseDurationToSeconds(value, fallback) + : parseDurationToMinutes(value, fallback); + const { number: n, unit } = totalToNumberAndUnit(total, baseUnit); + return { number: n, unit, total }; +} + +export function durationDisplayToValue( + number: number, + unit: DurationUnit, + baseUnit: "seconds" | "minutes", + allowNegative: boolean +): string | number { + if (allowNegative && number === -1) return -1; + const total = numberAndUnitToTotal(number, unit, baseUnit); + const out = toSuffixed(total, baseUnit); + return typeof out === "number" ? out : out; +} diff --git a/webui/src/pages/ConfigView.tsx b/webui/src/pages/ConfigView.tsx index a1f97f89b..ae76b1c86 100644 --- a/webui/src/pages/ConfigView.tsx +++ b/webui/src/pages/ConfigView.tsx @@ -7,6 +7,14 @@ import type { ConfigDocument } from "../api/types"; import { useToast } from "../context/ToastContext"; import { useWebUI } from "../context/WebUIContext"; import { getTooltip } from "../config/tooltips"; +import { + DURATION_UNITS, + durationDisplayToValue, + parseDurationDisplay, + parseDurationToMinutes, + parseDurationToSeconds, + type DurationUnit, +} from "../config/durationUtils"; import { IconImage } from "../components/IconImage"; import { TagInput } from "../components/TagInput"; import Select from "react-select"; @@ -20,7 +28,7 @@ import SaveIcon from "../icons/check-mark.svg"; import DeleteIcon from "../icons/trash.svg"; import CloseIcon from "../icons/close.svg"; -type FieldType = "text" | "number" | "checkbox" | "password" | "select" | "tags"; +type FieldType = "text" | "number" | "checkbox" | "password" | "select" | "tags" | "duration"; interface ValidationContext { root: ConfigDocument; @@ -44,6 +52,10 @@ interface FieldDefinition { required?: boolean; validate?: FieldValidator; fullWidth?: boolean; + /** For type "duration": base unit for the config key (seconds or minutes). */ + nativeUnit?: "seconds" | "minutes"; + /** For type "duration: allow -1 (disabled). */ + allowNegative?: boolean; } interface ValidationError { @@ -204,37 +216,42 @@ const SETTINGS_FIELDS: FieldDefinition[] = [ }, { label: "Auto Pause/Resume", path: ["Settings", "AutoPauseResume"], type: "checkbox" }, { - label: "No Internet Sleep (s)", + label: "No Internet Sleep", path: ["Settings", "NoInternetSleepTimer"], - type: "number", + type: "duration", + nativeUnit: "seconds", validate: (value) => { - const num = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(num) || num < 0) { - return "No Internet Sleep must be a non-negative number."; + const total = parseDurationToSeconds(value, -1); + if (!Number.isFinite(total) || total < 0) { + return "No Internet Sleep must be a non-negative duration."; } return undefined; }, }, { - label: "Loop Sleep (s)", + label: "Loop Sleep", path: ["Settings", "LoopSleepTimer"], - type: "number", + type: "duration", + nativeUnit: "seconds", validate: (value) => { - const num = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(num) || num < 0) { - return "Loop Sleep must be a non-negative number."; + const total = parseDurationToSeconds(value, -1); + if (!Number.isFinite(total) || total < 0) { + return "Loop Sleep must be a non-negative duration."; } return undefined; }, }, { - label: "Search Loop Delay (s)", + label: "Search Loop Delay", path: ["Settings", "SearchLoopDelay"], - type: "number", + type: "duration", + nativeUnit: "seconds", + allowNegative: true, validate: (value) => { - const num = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(num) || num < 0) { - return "Search Loop Delay must be a non-negative number."; + const total = parseDurationToSeconds(value, -2); + if (total === -1) return undefined; + if (!Number.isFinite(total) || total < 0) { + return "Search Loop Delay must be -1 (disabled) or a non-negative duration."; } return undefined; }, @@ -245,11 +262,12 @@ const SETTINGS_FIELDS: FieldDefinition[] = [ { label: "Ignore Torrents Younger Than", path: ["Settings", "IgnoreTorrentsYoungerThan"], - type: "number", + type: "duration", + nativeUnit: "seconds", validate: (value) => { - const num = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(num) || num < 0) { - return "Ignore Torrents Younger Than must be a non-negative number."; + const total = parseDurationToSeconds(value, -1); + if (!Number.isFinite(total) || total < 0) { + return "Ignore Torrents Younger Than must be a non-negative duration."; } return undefined; }, @@ -305,25 +323,27 @@ const SETTINGS_FIELDS: FieldDefinition[] = [ }, }, { - label: "Process Restart Window (s)", + label: "Process Restart Window", path: ["Settings", "ProcessRestartWindow"], - type: "number", + type: "duration", + nativeUnit: "seconds", validate: (value) => { - const num = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(num) || num < 1) { + const total = parseDurationToSeconds(value, 0); + if (!Number.isFinite(total) || total < 1) { return "Process Restart Window must be at least 1 second."; } return undefined; }, }, { - label: "Process Restart Delay (s)", + label: "Process Restart Delay", path: ["Settings", "ProcessRestartDelay"], - type: "number", + type: "duration", + nativeUnit: "seconds", validate: (value) => { - const num = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(num) || num < 0) { - return "Process Restart Delay must be a non-negative number."; + const total = parseDurationToSeconds(value, -1); + if (!Number.isFinite(total) || total < 0) { + return "Process Restart Delay must be a non-negative duration."; } return undefined; }, @@ -420,10 +440,12 @@ const QBIT_FIELDS: FieldDefinition[] = [ placeholder: "-1 (disabled), or positive number", }, { - label: "Max Seeding Time (seconds)", + label: "Max Seeding Time", path: ["CategorySeeding", "MaxSeedingTime"], - type: "number", - placeholder: "-1 (disabled), or positive number", + type: "duration", + nativeUnit: "seconds", + allowNegative: true, + placeholder: "-1 (disabled), or positive duration", }, { label: "Remove Torrent (policy)", @@ -519,41 +541,46 @@ const QBIT_FIELDS: FieldDefinition[] = [ }, }, { - label: "Tracker Update Buffer (s)", + label: "Tracker Update Buffer", path: ["CategorySeeding", "TrackerUpdateBuffer"], - type: "number", + type: "duration", + nativeUnit: "seconds", required: false, validate: (value) => { if (value === null || value === undefined || value === "") return undefined; - const num = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(num) || num < 0) { + const total = parseDurationToSeconds(value, -1); + if (!Number.isFinite(total) || total < 0) { return "Tracker Update Buffer must be 0 or greater."; } return undefined; }, }, { - label: "Stalled Delay (min)", + label: "Stalled Delay", path: ["CategorySeeding", "StalledDelay"], - type: "number", + type: "duration", + nativeUnit: "minutes", + allowNegative: true, placeholder: "-1 (disabled), 0 (infinite), or minutes before removing stalled downloads", validate: (value) => { - const num = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(num) || num < -1) { + const total = parseDurationToMinutes(value, -2); + if (total === -1) return undefined; + if (!Number.isFinite(total) || total < -1) { return "Stalled Delay must be -1 or greater."; } return undefined; }, }, { - label: "Ignore Torrents Younger Than (s)", + label: "Ignore Torrents Younger Than", path: ["CategorySeeding", "IgnoreTorrentsYoungerThan"], - type: "number", + type: "duration", + nativeUnit: "seconds", placeholder: "Seconds; stalled removal also requires last_activity older than this", validate: (value) => { - const num = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(num) || num < 0) { - return "Ignore Torrents Younger Than must be a non-negative number."; + const total = parseDurationToSeconds(value, -1); + if (!Number.isFinite(total) || total < 0) { + return "Ignore Torrents Younger Than must be a non-negative duration."; } return undefined; }, @@ -620,25 +647,27 @@ const ARR_GENERAL_FIELDS: FieldDefinition[] = [ required: true, }, { - label: "RSS Sync Timer (min)", + label: "RSS Sync Timer", path: ["RssSyncTimer"], - type: "number", + type: "duration", + nativeUnit: "minutes", validate: (value) => { - const num = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(num) || num < 0) { - return "RSS Sync Timer must be a non-negative number."; + const total = parseDurationToMinutes(value, -1); + if (!Number.isFinite(total) || total < 0) { + return "RSS Sync Timer must be a non-negative duration."; } return undefined; }, }, { - label: "Refresh Downloads Timer (min)", + label: "Refresh Downloads Timer", path: ["RefreshDownloadsTimer"], - type: "number", + type: "duration", + nativeUnit: "minutes", validate: (value) => { - const num = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(num) || num < 0) { - return "Refresh Downloads Timer must be a non-negative number."; + const total = parseDurationToMinutes(value, -1); + if (!Number.isFinite(total) || total < 0) { + return "Refresh Downloads Timer must be a non-negative duration."; } return undefined; }, @@ -711,12 +740,13 @@ const ARR_ENTRY_SEARCH_FIELDS: FieldDefinition[] = [ type: "checkbox", }, { - label: "Search Requests Every (s)", + label: "Search Requests Every", path: ["EntrySearch", "SearchRequestsEvery"], - type: "number", + type: "duration", + nativeUnit: "seconds", validate: (value) => { - const num = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(num) || num < 1) { + const total = parseDurationToSeconds(value, 0); + if (!Number.isFinite(total) || total < 1) { return "Search Requests Every must be at least 1 second."; } return undefined; @@ -744,9 +774,10 @@ const ARR_ENTRY_SEARCH_FIELDS: FieldDefinition[] = [ description: "Reset all items using temp profiles to their original main profile on qBitrr startup", }, { - label: "Temp Profile Reset Timeout (Minutes)", + label: "Temp Profile Reset Timeout", path: ["EntrySearch", "TempProfileResetTimeoutMinutes"], - type: "number", + type: "duration", + nativeUnit: "minutes", description: "Timeout in minutes after which items with temp profiles are automatically reset to main profile (0 = disabled)", }, { @@ -875,25 +906,29 @@ const ARR_TORRENT_FIELDS: FieldDefinition[] = [ type: "checkbox", }, { - label: "Ignore Torrents Younger Than (s)", + label: "Ignore Torrents Younger Than", path: ["Torrent", "IgnoreTorrentsYoungerThan"], - type: "number", + type: "duration", + nativeUnit: "seconds", validate: (value) => { - const num = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(num) || num < 0) { - return "Ignore Torrents Younger Than must be a non-negative number."; + const total = parseDurationToSeconds(value, -1); + if (!Number.isFinite(total) || total < 0) { + return "Ignore Torrents Younger Than must be a non-negative duration."; } return undefined; }, }, { - label: "Maximum ETA (s)", + label: "Maximum ETA", path: ["Torrent", "MaximumETA"], - type: "number", + type: "duration", + nativeUnit: "seconds", + allowNegative: true, validate: (value) => { - const num = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(num) || num < -1) { - return "Maximum ETA must be -1 or a non-negative number."; + const total = parseDurationToSeconds(value, -2); + if (total === -1) return undefined; + if (!Number.isFinite(total) || total < -1) { + return "Maximum ETA must be -1 or a non-negative duration."; } return undefined; }, @@ -916,13 +951,14 @@ const ARR_TORRENT_FIELDS: FieldDefinition[] = [ type: "checkbox", }, { - label: "Stalled Delay (min)", + label: "Stalled Delay", path: ["Torrent", "StalledDelay"], - type: "number", + type: "duration", + nativeUnit: "minutes", validate: (value) => { - const num = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(num) || num < 0) { - return "Stalled Delay must be a non-negative number."; + const total = parseDurationToMinutes(value, -1); + if (!Number.isFinite(total) || total < 0) { + return "Stalled Delay must be a non-negative duration."; } return undefined; }, @@ -972,12 +1008,15 @@ const ARR_SEEDING_FIELDS: FieldDefinition[] = [ }, }, { - label: "Max Seeding Time (s)", + label: "Max Seeding Time", path: ["Torrent", "SeedingMode", "MaxSeedingTime"], - type: "number", + type: "duration", + nativeUnit: "seconds", + allowNegative: true, validate: (value) => { - const num = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(num) || num < -1) { + const total = parseDurationToSeconds(value, -2); + if (total === -1) return undefined; + if (!Number.isFinite(total) || total < -1) { return "Max Seeding Time must be -1 or greater."; } return undefined; @@ -1033,11 +1072,14 @@ const ARR_TRACKER_FIELDS: FieldDefinition[] = [ { label: "Maximum ETA", path: ["MaximumETA"], - type: "number", + type: "duration", + nativeUnit: "seconds", + allowNegative: true, validate: (value) => { - const num = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(num) || num < -1) { - return "Maximum ETA must be -1 or a non-negative number."; + const total = parseDurationToSeconds(value, -2); + if (total === -1) return undefined; + if (!Number.isFinite(total) || total < -1) { + return "Maximum ETA must be -1 or a non-negative duration."; } return undefined; }, @@ -1081,10 +1123,13 @@ const ARR_TRACKER_FIELDS: FieldDefinition[] = [ { label: "Max Seeding Time", path: ["MaxSeedingTime"], - type: "number", + type: "duration", + nativeUnit: "seconds", + allowNegative: true, validate: (value) => { - const num = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(num) || num < -1) { + const total = parseDurationToSeconds(value, -2); + if (total === -1) return undefined; + if (!Number.isFinite(total) || total < -1) { return "Max Seeding Time must be -1 or greater."; } return undefined; @@ -1171,14 +1216,15 @@ const ARR_TRACKER_FIELDS: FieldDefinition[] = [ }, }, { - label: "Tracker Update Buffer (s)", + label: "Tracker Update Buffer", path: ["TrackerUpdateBuffer"], - type: "number", + type: "duration", + nativeUnit: "seconds", required: false, validate: (value) => { if (value === null || value === undefined || value === "") return undefined; - const num = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(num) || num < 0) { + const total = parseDurationToSeconds(value, -1); + if (!Number.isFinite(total) || total < 0) { return "Tracker Update Buffer must be 0 or greater."; } return undefined; @@ -2278,6 +2324,82 @@ function NumberInput({ ); } +function DurationInput({ + value, + onChange, + placeholder, + nativeUnit = "seconds", + allowNegative = false, +}: { + value: unknown; + onChange: (v: string | number) => void; + placeholder?: string; + nativeUnit?: "seconds" | "minutes"; + allowNegative?: boolean; +}): JSX.Element { + const fallback = allowNegative ? -1 : 0; + const display = parseDurationDisplay(value, nativeUnit, fallback); + const [num, setNum] = useState(display.number); + const [unit, setUnit] = useState(display.unit); + const isEditing = useRef(false); + + useEffect(() => { + if (!isEditing.current) { + const d = parseDurationDisplay(value, nativeUnit, fallback); + queueMicrotask(() => { + setNum(d.number); + setUnit(d.unit); + }); + } + }, [value, nativeUnit, fallback]); + + const handleNumChange = (raw: string) => { + const n = raw.trim() === "" ? (allowNegative ? -1 : 0) : Number(raw); + if (!Number.isFinite(n)) return; + setNum(n); + const out = durationDisplayToValue(n, unit, nativeUnit, allowNegative); + onChange(out); + }; + + const handleUnitChange = (newUnit: DurationUnit) => { + setUnit(newUnit); + const out = durationDisplayToValue(num, newUnit, nativeUnit, allowNegative); + onChange(out); + }; + + const displayVal = num === -1 && allowNegative ? "Disabled" : String(num); + return ( +
+ { + isEditing.current = true; + }} + onBlur={() => { + isEditing.current = false; + const d = parseDurationDisplay(value, nativeUnit, fallback); + setNum(d.number); + setUnit(d.unit); + }} + onChange={(e) => handleNumChange(e.target.value)} + placeholder={placeholder} + /> + +
+ ); +} + function FieldGroup({ title, fields, @@ -2620,6 +2742,21 @@ function FieldGroup({
); } + if (field.type === "duration") { + return ( +
+ + onChange(path, field, v)} + placeholder={field.placeholder} + nativeUnit={field.nativeUnit ?? "seconds"} + allowNegative={field.allowNegative ?? false} + /> + {description &&
{description}
} +
+ ); + } if (field.type === "password") { return (
diff --git a/webui/src/styles.css b/webui/src/styles.css index bb8ee6675..4ec16b70b 100644 --- a/webui/src/styles.css +++ b/webui/src/styles.css @@ -935,6 +935,19 @@ body { .field input[type="checkbox"] { width: auto; } +.duration-input { + display: flex; + gap: 8px; + align-items: center; +} +.duration-input input { + flex: 1; + min-width: 0; +} +.duration-input select { + flex: 0 0 auto; + width: 56px; +} .field.mobile-instance-select { display: none; } From b48b537724f7efdfeb519c8ed710a04fa54f10cc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Feb 2026 09:31:38 +0000 Subject: [PATCH 47/66] fix: handle sub-minute durations in minute parser --- qBitrr/duration_config.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/qBitrr/duration_config.py b/qBitrr/duration_config.py index 2c4bf0eb7..819088418 100644 --- a/qBitrr/duration_config.py +++ b/qBitrr/duration_config.py @@ -30,7 +30,7 @@ "M": 43200, # 30 days } -_DURATION_PATTERN = re.compile(r"^\s*(-?\d+)\s*([smhdwM]?)\s*$", re.IGNORECASE) +_DURATION_PATTERN = re.compile(r"^\s*(-?\d+)\s*([sSmMhHdDwWM]?)\s*$") def parse_duration_to_seconds(value: Any, fallback: int = -1) -> int: @@ -92,4 +92,7 @@ def parse_duration_to_minutes(value: Any, fallback: int = -1) -> int: mult = SUFFIX_TO_MINUTES["M"] else: mult = SUFFIX_TO_MINUTES.get(raw_suffix.lower(), 1) - return int(num * mult) + minutes = num * mult + if 0 < minutes < 1: + return 1 + return int(minutes) From a528989accacf30cc65dedbeaa1e25a0c609c459 Mon Sep 17 00:00:00 2001 From: Feramance Date: Sat, 28 Feb 2026 18:08:24 +0100 Subject: [PATCH 48/66] chore: cross-platform rebuild script and WebUI duration labels - Add scripts/rebuild_and_deploy.py (Python) for Windows/Linux/macOS - Post-merge hook prefers Python script, falls back to bash - WebUI duration dropdown: show full unit names (seconds, minutes, etc.) --- scripts/README.md | 17 ++++++--- scripts/install-post-merge-hook.sh | 1 + scripts/post-merge.hook | 13 +++++-- scripts/rebuild_and_deploy.py | 58 ++++++++++++++++++++++++++++++ webui/src/config/durationUtils.ts | 12 +++---- 5 files changed, 89 insertions(+), 12 deletions(-) create mode 100644 scripts/rebuild_and_deploy.py diff --git a/scripts/README.md b/scripts/README.md index 32a896774..5270ea883 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -4,7 +4,7 @@ This directory contains utility scripts for database maintenance, recovery, and ## Rebuild and deploy on git sync -To **rebuild and deploy qBitrr automatically after every `git pull`**, install the post-merge hook: +To **rebuild and deploy qBitrr automatically after every `git pull`**, install the post-merge hook. The hook runs the Python rebuild script when available (works on Windows, Linux, macOS); otherwise it falls back to the bash script (requires bash, e.g. Git Bash on Windows). ```bash # From repo root (Linux/macOS or Git Bash on Windows) @@ -28,16 +28,25 @@ export DEPLOY_MODE=native git pull ``` -**Manual run** (without git): +**Manual run** (without git), any environment: ```bash +# Cross-platform (Windows, Linux, macOS) — use when bash is not available +python scripts/rebuild_and_deploy.py +# or +python3 scripts/rebuild_and_deploy.py +``` + +```bash +# Linux/macOS or Git Bash on Windows bash scripts/rebuild-and-deploy.sh ``` **Files:** -- `scripts/rebuild-and-deploy.sh` — Rebuild and deploy logic. -- `scripts/post-merge.hook` — Hook content (installed into `.git/hooks/post-merge` by the installer). +- `scripts/rebuild_and_deploy.py` — Rebuild and deploy logic (Python; works in any environment). +- `scripts/rebuild-and-deploy.sh` — Same logic for bash (Linux/macOS/Git Bash). +- `scripts/post-merge.hook` — Hook content (installed into `.git/hooks/post-merge`; runs Python script when available, else bash). - `scripts/install-post-merge-hook.sh` — One-time installer for the hook. --- diff --git a/scripts/install-post-merge-hook.sh b/scripts/install-post-merge-hook.sh index 7210c7306..a0766b5c3 100644 --- a/scripts/install-post-merge-hook.sh +++ b/scripts/install-post-merge-hook.sh @@ -22,3 +22,4 @@ cp "$HOOK_SRC" "$HOOK_DST" chmod +x "$HOOK_DST" echo "Installed post-merge hook at .git/hooks/post-merge" echo "After 'git pull', qBitrr will rebuild and deploy (Docker or native per DEPLOY_MODE)." +echo "Hook uses Python script when available (any OS), else bash script." diff --git a/scripts/post-merge.hook b/scripts/post-merge.hook index fe5b118c6..54ed60298 100644 --- a/scripts/post-merge.hook +++ b/scripts/post-merge.hook @@ -1,6 +1,15 @@ -#!/usr/bin/env bash +#!/bin/sh # Git post-merge hook: rebuild and deploy qBitrr after every git pull (merge). # Install with: scripts/install-post-merge-hook.sh +# Uses Python script when available (cross-platform); falls back to bash script. REPO_ROOT="$(git rev-parse --show-toplevel)" -exec "$REPO_ROOT/scripts/rebuild-and-deploy.sh" +cd "$REPO_ROOT" || exit 1 + +if command -v python3 >/dev/null 2>&1; then + exec python3 scripts/rebuild_and_deploy.py +fi +if command -v python >/dev/null 2>&1; then + exec python scripts/rebuild_and_deploy.py +fi +exec bash scripts/rebuild-and-deploy.sh diff --git a/scripts/rebuild_and_deploy.py b/scripts/rebuild_and_deploy.py new file mode 100644 index 000000000..14d345aa9 --- /dev/null +++ b/scripts/rebuild_and_deploy.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +""" +Rebuild and deploy qBitrr (for use after git pull / sync). +Cross-platform: runs on Windows, Linux, and macOS without bash. + +Supports: Docker Compose deploy, or native make syncenv only. +Set DEPLOY_MODE=docker (default if docker-compose.yml present) or DEPLOY_MODE=native. +""" + +from __future__ import annotations + +import os +import subprocess +import sys + + +def main() -> int: + repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + os.chdir(repo_root) + + print(f"[rebuild-and-deploy] Working in {repo_root}") + + deploy_mode = os.environ.get("DEPLOY_MODE", "").strip().lower() + if not deploy_mode: + deploy_mode = "docker" if os.path.isfile("docker-compose.yml") else "native" + + if deploy_mode == "docker": + print("[rebuild-and-deploy] Rebuilding and deploying with Docker Compose...") + for cmd in [ + ["docker", "compose", "build", "--no-cache"], + ["docker", "compose", "up", "-d"], + ]: + ret = subprocess.run(cmd) + if ret.returncode != 0: + return ret.returncode + print("[rebuild-and-deploy] Done. Container status:") + subprocess.run(["docker", "compose", "ps"]) + elif deploy_mode == "native": + print("[rebuild-and-deploy] Syncing environment and building WebUI (make syncenv)...") + ret = subprocess.run(["make", "syncenv"]) + if ret.returncode != 0: + return ret.returncode + print( + "[rebuild-and-deploy] Done. Restart qBitrr manually if needed " + "(e.g. systemctl restart qbitrr)." + ) + else: + print( + f"[rebuild-and-deploy] Unknown DEPLOY_MODE={deploy_mode}. Use 'docker' or 'native'.", + file=sys.stderr, + ) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/webui/src/config/durationUtils.ts b/webui/src/config/durationUtils.ts index 5fd7b4f62..c0941b7d1 100644 --- a/webui/src/config/durationUtils.ts +++ b/webui/src/config/durationUtils.ts @@ -27,12 +27,12 @@ const SUFFIX_TO_MINUTES: Record = { const DURATION_PATTERN = /^\s*(-?\d+)\s*([smhdwM]?)\s*$/i; export const DURATION_UNITS: { value: DurationUnit; label: string }[] = [ - { value: "s", label: "s" }, - { value: "m", label: "m" }, - { value: "h", label: "h" }, - { value: "d", label: "d" }, - { value: "w", label: "w" }, - { value: "M", label: "M" }, + { value: "s", label: "seconds" }, + { value: "m", label: "minutes" }, + { value: "h", label: "hours" }, + { value: "d", label: "days" }, + { value: "w", label: "weeks" }, + { value: "M", label: "months" }, ]; function parseSuffixed( From 6ef76f53dffe5b13d796d36fcb7ff0e1c9c82dfd Mon Sep 17 00:00:00 2001 From: Feramance Date: Sat, 28 Feb 2026 18:21:11 +0100 Subject: [PATCH 49/66] fix: PlaceHolderArr TAGLESS crash and duration unit dropdown width - PlaceHolderArr: set self.type, _get_models(), register_search_mode() so self.torrents is set when TAGLESS (fixes AttributeError in in_tags) - Config duration unit select: min-width 7.5em so full labels (seconds, minutes, etc.) show --- qBitrr/arss.py | 14 ++++++++++++++ webui/src/styles.css | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/qBitrr/arss.py b/qBitrr/arss.py index dcbf972b6..0b9ace569 100755 --- a/qBitrr/arss.py +++ b/qBitrr/arss.py @@ -7537,6 +7537,7 @@ class PlaceHolderArr(Arr): def __init__(self, name: str, manager: ArrManager): if name in manager.groups: raise OSError(f"Group '{name}' has already been registered.") + self.type = "placeholder" self._name = name.title() self.category = name self.manager = manager @@ -7617,8 +7618,21 @@ def __init__(self, name: str, manager: ArrManager): self.queue_active_count: int = 0 self.category_torrent_count: int = 0 self.free_space_tagged_count: int = 0 + self.register_search_mode() self.logger.hnotice("Starting %s monitor", self._name) + def _get_models( + self, + ) -> tuple[ + None, + None, + None, + None, + type[TorrentLibrary] | None, + ]: + """PlaceHolderArr has no file/queue models; only TorrentLibrary when TAGLESS.""" + return None, None, None, None, (TorrentLibrary if TAGLESS else None) + def _process_single_torrent_missing_files( self, torrent: qbittorrentapi.TorrentDictionary, diff --git a/webui/src/styles.css b/webui/src/styles.css index 4ec16b70b..1041f4fab 100644 --- a/webui/src/styles.css +++ b/webui/src/styles.css @@ -946,7 +946,8 @@ body { } .duration-input select { flex: 0 0 auto; - width: 56px; + min-width: 7.5em; + width: auto; } .field.mobile-instance-select { display: none; From a1e0284856b8fab10340d69bbbe7cd9eb2f81039 Mon Sep 17 00:00:00 2001 From: Feramance Date: Sat, 28 Feb 2026 22:41:56 +0100 Subject: [PATCH 50/66] fix: comprehensive code review fixes across backend and frontend Backend (Python): - Fix infinite while True retry loops with with_retry (exponential backoff) - Fix dead condition (and->or) in movie year filter skipping year-0 movies - Fix late-binding closure bugs in lambda callbacks inside loops - Fix missing ArrInstance filter in episode search state reset - Fix exception classes not calling super().__init__() (empty str(e)) - Fix /web/* routes bypassing authentication on all mutation endpoints - Fix timing-attack-vulnerable token comparison (use secrets.compare_digest) - Fix gen_config.py or-fallback treating False/0 as missing values - Fix ExpiringSet.__update__ only removing first expired item - Fix resource leaks in db_recovery, db_lock, ffprobe (unclosed connections) - Fix kill() called before terminate() in process cleanup - Fix BaseException catches swallowing KeyboardInterrupt - Fix double order_by in episode query (second replaces first) - Fix ExpiringSet TOCTOU race condition in is_alive - Fix _report_config_issues only validating Radarr sections - Fix SQL identifier validation in index creation - Fix overly permissive chmod 0o777->0o755 for ffprobe - Fix deprecated datetime.utcnow() -> datetime.now(timezone.utc) - Fix silent exception swallowing in search_activity_store - Fix validate_and_return_torrent_file confusing nested retry logic - Fix parameter name shadowing builtin type in error classes - Add primary_key=True to MoviesFilesModel and AlbumFilesModel - Remove dead code (no-op expressions, duplicate imports) Frontend (React/TypeScript): - Fix WebUIContext re-fetching config on every toast (depend on push only) - Fix missing React Error Boundary (white-screen crash on render errors) - Fix stale aggSummary closure in all 3 Arr view components - Fix token not cleaned from URL after extraction (stays in history) - Fix LogsView bypassing auth layer with raw fetch and token in URL - Fix infinite effect loop risk from instancePage dependency - Fix two useReactTable instances always computed (pass empty data) - Fix JSON.stringify on large arrays every poll (use lightweight signature) - Fix timer not cleared on unmount in CopyButton - Fix array index used as key in TagInput (use tag value) - Fix WebUIContext value not memoized (unnecessary re-renders) - Fix react-select styles not reactive to theme changes - Fix ConfirmDialog missing ARIA attributes (role, aria-modal) - Fix toasts not keyboard accessible (role=alert, tabIndex, onKeyDown) - Fix unnecessary async on sync handlers in ProcessesView - Fix duplicate SVG imports across multiple views - Fix describeError unnecessarily in useCallback - Remove commented-out dead code blocks - Remove unused onRefresh prop and misleading variable alias --- docs/advanced/architecture.md | 2 +- qBitrr/arss.py | 1468 +++++++++++++----------- qBitrr/database.py | 105 +- qBitrr/db_lock.py | 14 +- qBitrr/db_recovery.py | 107 +- qBitrr/errors.py | 11 +- qBitrr/ffprobe.py | 9 +- qBitrr/gen_config.py | 46 +- qBitrr/main.py | 13 +- qBitrr/search_activity_store.py | 36 +- qBitrr/tables.py | 4 +- qBitrr/utils.py | 31 +- qBitrr/webui.py | 28 +- webui/src/App.tsx | 52 +- webui/src/api/client.ts | 3 + webui/src/components/ConfirmDialog.tsx | 5 +- webui/src/components/CopyButton.tsx | 10 +- webui/src/components/TagInput.tsx | 2 +- webui/src/context/ToastContext.tsx | 4 +- webui/src/context/WebUIContext.tsx | 12 +- webui/src/pages/ConfigView.tsx | 13 +- webui/src/pages/LidarrView.tsx | 25 +- webui/src/pages/LogsView.tsx | 80 +- webui/src/pages/ProcessesView.tsx | 13 +- webui/src/pages/RadarrView.tsx | 16 +- webui/src/pages/SonarrView.tsx | 34 +- 26 files changed, 1117 insertions(+), 1026 deletions(-) diff --git a/docs/advanced/architecture.md b/docs/advanced/architecture.md index b5eb332f8..2958f91c3 100644 --- a/docs/advanced/architecture.md +++ b/docs/advanced/architecture.md @@ -975,7 +975,7 @@ class DelayLoopException(qBitManagerError): self.length = length # Seconds to delay # Usage: raised when qBittorrent or an Arr API is unreachable -raise DelayLoopException(length=60, type="qbittorrent_offline") +raise DelayLoopException(length=60, error_type="qbittorrent_offline") ``` **RestartLoopException** -- restart from the beginning after a config reload: diff --git a/qBitrr/arss.py b/qBitrr/arss.py index 0b9ace569..2718ee49f 100755 --- a/qBitrr/arss.py +++ b/qBitrr/arss.py @@ -217,7 +217,7 @@ def __init__( try: self.completed_folder.mkdir(parents=True, exist_ok=True) self.completed_folder.chmod(mode=0o777) - except BaseException: + except Exception: self.logger.warning( "%s completed folder is a soft requirement. The specified folder does not exist %s and cannot be created. This will disable all file monitoring.", self._name, @@ -600,6 +600,7 @@ def __init__( self.special_casing_file_check = ExpiringSet(max_age_seconds=10) self.expiring_bool = ExpiringSet(max_age_seconds=10) self.session = requests.Session() + atexit.register(self.session.close) self.cleaned_torrents = set() self.search_api_command = None @@ -895,7 +896,7 @@ def is_alive(self) -> bool: except requests.RequestException: self.logger.warning("Could not connect to %s", self.uri) # Clear the cache to ensure we retry on next check - if 1 in self.expiring_bool.container: + with contextlib.suppress(KeyError): self.expiring_bool.remove(1) return False @@ -1473,56 +1474,58 @@ def _process_failed_individual( self.logger.trace("Requeue cache entry list: %s", object_ids) if self.series_search: series_id = None - while True: - try: - data = self.client.get_series(object_ids[0]) - name = data["title"] - series_id = data["id"] - if name: - year = data.get("year", 0) - tvdbId = data.get("tvdbId", 0) - self.logger.notice( - "Re-Searching series: %s (%s) | [tvdbId=%s|id=%s]", - name, - year, - tvdbId, - series_id, - ) - else: - self.logger.notice("Re-Searching series: %s", series_id) - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue - except PyarrResourceNotFound as e: - self.logger.warning( - "Series %s not found in Sonarr (likely removed): %s", - object_ids[0], - str(e), + try: + data = with_retry( + lambda: self.client.get_series(object_ids[0]), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ContentDecodingError, + requests.exceptions.ConnectionError, + JSONDecodeError, + ), + ) + name = data["title"] + series_id = data["id"] + if name: + year = data.get("year", 0) + tvdbId = data.get("tvdbId", 0) + self.logger.notice( + "Re-Searching series: %s (%s) | [tvdbId=%s|id=%s]", + name, + year, + tvdbId, + series_id, ) - break + else: + self.logger.notice("Re-Searching series: %s", series_id) + except PyarrResourceNotFound as e: + self.logger.warning( + "Series %s not found in Sonarr (likely removed): %s", + object_ids[0], + str(e), + ) for object_id in object_ids: if object_id in self.queue_file_ids: self.queue_file_ids.remove(object_id) if series_id: self.logger.trace("Research series id: %s", series_id) - while True: - try: - self.client.post_command( - self.search_api_command, seriesId=series_id - ) - break - except ( + with_retry( + lambda: self.client.post_command( + self.search_api_command, seriesId=series_id + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError, requests.exceptions.ConnectionError, JSONDecodeError, - ): - continue + ), + ) if self.persistent_queue: self.persistent_queue.insert( EntryId=series_id, ArrInstance=self._name @@ -1530,69 +1533,71 @@ def _process_failed_individual( else: for object_id in object_ids: episode_found = False - while True: - try: - data = self.client.get_episode(object_id) - name = data.get("title") - series_id = data.get("series", {}).get("id") - if name: - episodeNumber = data.get("episodeNumber", 0) - absoluteEpisodeNumber = data.get("absoluteEpisodeNumber", 0) - seasonNumber = data.get("seasonNumber", 0) - seriesTitle = data.get("series", {}).get("title") - year = data.get("series", {}).get("year", 0) - tvdbId = data.get("series", {}).get("tvdbId", 0) - self.logger.notice( - "Re-Searching episode: %s (%s) | " - "S%02dE%03d " - "(E%04d) | " - "%s | " - "[tvdbId=%s|id=%s]", - seriesTitle, - year, - seasonNumber, - episodeNumber, - absoluteEpisodeNumber, - name, - tvdbId, - object_id, - ) - else: - self.logger.notice("Re-Searching episode: %s", object_id) - episode_found = True - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - AttributeError, - ): - continue - except PyarrResourceNotFound as e: - self.logger.warning( - "Episode %s not found in Sonarr (likely removed): %s", + try: + data = with_retry( + lambda oid=object_id: self.client.get_episode(oid), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ContentDecodingError, + requests.exceptions.ConnectionError, + JSONDecodeError, + AttributeError, + ), + ) + name = data.get("title") + series_id = data.get("series", {}).get("id") + if name: + episodeNumber = data.get("episodeNumber", 0) + absoluteEpisodeNumber = data.get("absoluteEpisodeNumber", 0) + seasonNumber = data.get("seasonNumber", 0) + seriesTitle = data.get("series", {}).get("title") + year = data.get("series", {}).get("year", 0) + tvdbId = data.get("series", {}).get("tvdbId", 0) + self.logger.notice( + "Re-Searching episode: %s (%s) | " + "S%02dE%03d " + "(E%04d) | " + "%s | " + "[tvdbId=%s|id=%s]", + seriesTitle, + year, + seasonNumber, + episodeNumber, + absoluteEpisodeNumber, + name, + tvdbId, object_id, - str(e), ) - break + else: + self.logger.notice("Re-Searching episode: %s", object_id) + episode_found = True + except PyarrResourceNotFound as e: + self.logger.warning( + "Episode %s not found in Sonarr (likely removed): %s", + object_id, + str(e), + ) if object_id in self.queue_file_ids: self.queue_file_ids.remove(object_id) if episode_found: - while True: - try: - self.client.post_command( - "EpisodeSearch", episodeIds=[object_id] - ) - break - except ( + with_retry( + lambda oid=object_id: self.client.post_command( + "EpisodeSearch", episodeIds=[oid] + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError, requests.exceptions.ConnectionError, JSONDecodeError, - ): - continue + ), + ) if self.persistent_queue: self.persistent_queue.insert( EntryId=object_id, ArrInstance=self._name @@ -1600,51 +1605,53 @@ def _process_failed_individual( elif self.type == "radarr": self.logger.trace("Requeue cache entry: %s", object_id) movie_found = False - while True: - try: - data = self.client.get_movie(object_id) - name = data.get("title") - if name: - year = data.get("year", 0) - tmdbId = data.get("tmdbId", 0) - self.logger.notice( - "Re-Searching movie: %s (%s) | [tmdbId=%s|id=%s]", - name, - year, - tmdbId, - object_id, - ) - else: - self.logger.notice("Re-Searching movie: %s", object_id) - movie_found = True - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - AttributeError, - ): - continue - except PyarrResourceNotFound as e: - self.logger.warning( - "Movie %s not found in Radarr (likely removed): %s", object_id, str(e) + try: + data = with_retry( + lambda: self.client.get_movie(object_id), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ContentDecodingError, + requests.exceptions.ConnectionError, + JSONDecodeError, + AttributeError, + ), + ) + name = data.get("title") + if name: + year = data.get("year", 0) + tmdbId = data.get("tmdbId", 0) + self.logger.notice( + "Re-Searching movie: %s (%s) | [tmdbId=%s|id=%s]", + name, + year, + tmdbId, + object_id, ) - break + else: + self.logger.notice("Re-Searching movie: %s", object_id) + movie_found = True + except PyarrResourceNotFound as e: + self.logger.warning( + "Movie %s not found in Radarr (likely removed): %s", object_id, str(e) + ) if object_id in self.queue_file_ids: self.queue_file_ids.remove(object_id) if movie_found: - while True: - try: - self.client.post_command("MoviesSearch", movieIds=[object_id]) - break - except ( + with_retry( + lambda: self.client.post_command("MoviesSearch", movieIds=[object_id]), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError, requests.exceptions.ConnectionError, JSONDecodeError, - ): - continue + ), + ) if self.persistent_queue: self.persistent_queue.insert( EntryId=object_id, ArrInstance=self._name @@ -1652,51 +1659,53 @@ def _process_failed_individual( elif self.type == "lidarr": self.logger.trace("Requeue cache entry: %s", object_id) album_found = False - while True: - try: - data = self.client.get_album(object_id) - name = data.get("title") - if name: - artist_title = data.get("artist", {}).get("artistName", "") - foreign_album_id = data.get("foreignAlbumId", "") - self.logger.notice( - "Re-Searching album: %s - %s | [foreignAlbumId=%s|id=%s]", - artist_title, - name, - foreign_album_id, - object_id, - ) - else: - self.logger.notice("Re-Searching album: %s", object_id) - album_found = True - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - AttributeError, - ): - continue - except PyarrResourceNotFound as e: - self.logger.warning( - "Album %s not found in Lidarr (likely removed): %s", object_id, str(e) + try: + data = with_retry( + lambda: self.client.get_album(object_id), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ContentDecodingError, + requests.exceptions.ConnectionError, + JSONDecodeError, + AttributeError, + ), + ) + name = data.get("title") + if name: + artist_title = data.get("artist", {}).get("artistName", "") + foreign_album_id = data.get("foreignAlbumId", "") + self.logger.notice( + "Re-Searching album: %s - %s | [foreignAlbumId=%s|id=%s]", + artist_title, + name, + foreign_album_id, + object_id, ) - break + else: + self.logger.notice("Re-Searching album: %s", object_id) + album_found = True + except PyarrResourceNotFound as e: + self.logger.warning( + "Album %s not found in Lidarr (likely removed): %s", object_id, str(e) + ) if object_id in self.queue_file_ids: self.queue_file_ids.remove(object_id) if album_found: - while True: - try: - self.client.post_command("AlbumSearch", albumIds=[object_id]) - break - except ( + with_retry( + lambda: self.client.post_command("AlbumSearch", albumIds=[object_id]), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError, requests.exceptions.ConnectionError, JSONDecodeError, - ): - continue + ), + ) if self.persistent_queue: self.persistent_queue.insert( EntryId=object_id, ArrInstance=self._name @@ -1759,6 +1768,9 @@ def _process_failed(self) -> None: hash_=hash_, entry=entry, skip_blacklist=skip_blacklist ) # Delete missing-files torrents from the correct qBit instance (multi-instance). + per_instance_hashes = set() + for hashes in self.remove_from_qbit_by_instance.values(): + per_instance_hashes.update(hashes) qbit_manager = self.manager.qbit_manager for inst_name, hashes in self.remove_from_qbit_by_instance.items(): client = qbit_manager.get_client(inst_name) @@ -1802,6 +1814,7 @@ def _process_failed(self) -> None: e, ) self.remove_from_qbit_by_instance.clear() + to_delete_all = to_delete_all - per_instance_hashes if self.remove_from_qbit or self.skip_blacklist or to_delete_all: # Remove all bad torrents from the Client. deleted_hashes: set[str] = set() @@ -1963,21 +1976,21 @@ def arr_db_query_commands_count(self) -> int: search_commands = 0 if not (self.search_missing or self.do_upgrade_search): return 0 - while True: - try: - commands = self.client.get_command() - for command in commands: - # Count all active search commands (including MissingEpisodeSearch) - if command["name"].endswith("Search") and command["status"] != "completed": - search_commands = search_commands + 1 - break - except ( + commands = with_retry( + lambda: self.client.get_command(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError, requests.exceptions.ConnectionError, JSONDecodeError, - ): - continue + ), + ) + for command in commands: + if command["name"].endswith("Search") and command["status"] != "completed": + search_commands = search_commands + 1 return search_commands @@ -2102,19 +2115,20 @@ def db_reset__series_searched_state(self): (self.series_file_model.Searched == True) & (self.series_file_model.ArrInstance == self._name) ).execute() - while True: - try: - series = self.client.get_series() - for s in series: - ids.append(s["id"]) - break - except ( + series = with_retry( + lambda: self.client.get_series(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError, requests.exceptions.ConnectionError, JSONDecodeError, - ): - continue + ), + ) + for s in series: + ids.append(s["id"]) self.series_file_model.delete().where( (self.series_file_model.EntryId.not_in(ids)) & (self.series_file_model.ArrInstance == self._name) @@ -2128,23 +2142,35 @@ def db_reset__episode_searched_state(self): self.loop_completed is True and self.reset_on_completion ): # Only wipe if a loop completed was tagged self.model_file.update(Searched=False, Upgrade=False).where( - self.model_file.Searched == True + (self.model_file.Searched == True) & (self.model_file.ArrInstance == self._name) ).execute() - while True: - try: - series = self.client.get_series() - for s in series: - episodes = self.client.get_episode(s["id"], True) - for e in episodes: - ids.append(e["id"]) - break - except ( + series = with_retry( + lambda: self.client.get_series(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError, requests.exceptions.ConnectionError, JSONDecodeError, - ) as e: - continue + ), + ) + for s in series: + episodes = with_retry( + lambda s=s: self.client.get_episode(s["id"], True), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ContentDecodingError, + requests.exceptions.ConnectionError, + JSONDecodeError, + ), + ) + for e in episodes: + ids.append(e["id"]) self.model_file.delete().where( (self.model_file.EntryId.not_in(ids)) & (self.model_file.ArrInstance == self._name) ).execute() @@ -2159,19 +2185,20 @@ def db_reset__movie_searched_state(self): self.model_file.update(Searched=False, Upgrade=False).where( (self.model_file.Searched == True) & (self.model_file.ArrInstance == self._name) ).execute() - while True: - try: - movies = self.client.get_movie() - for m in movies: - ids.append(m["id"]) - break - except ( + movies = with_retry( + lambda: self.client.get_movie(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError, requests.exceptions.ConnectionError, JSONDecodeError, - ): - continue + ), + ) + for m in movies: + ids.append(m["id"]) self.model_file.delete().where( (self.model_file.EntryId.not_in(ids)) & (self.model_file.ArrInstance == self._name) ).execute() @@ -2186,21 +2213,33 @@ def db_reset__album_searched_state(self): self.model_file.update(Searched=False, Upgrade=False).where( (self.model_file.Searched == True) & (self.model_file.ArrInstance == self._name) ).execute() - while True: - try: - artists = self.client.get_artist() - for artist in artists: - albums = self.client.get_album(artistId=artist["id"]) - for album in albums: - ids.append(album["id"]) - break - except ( + artists = with_retry( + lambda: self.client.get_artist(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError, requests.exceptions.ConnectionError, JSONDecodeError, - ): - continue + ), + ) + for artist in artists: + albums = with_retry( + lambda a=artist: self.client.get_album(artistId=a["id"]), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ContentDecodingError, + requests.exceptions.ConnectionError, + JSONDecodeError, + ), + ) + for album in albums: + ids.append(album["id"]) self.model_file.delete().where( (self.model_file.EntryId.not_in(ids)) & (self.model_file.ArrInstance == self._name) ).execute() @@ -2367,14 +2406,14 @@ def db_get_files_episodes(self) -> list[list[EpisodeFilesModel, bool, bool]] | N for entry in ( self.model_file.select() .where(condition) + .group_by(self.model_file.SeriesId) .order_by( - reason_priority.asc(), # Primary: order by reason priority + reason_priority.asc(), + self.model_file.EpisodeFileId.asc(), self.model_file.SeriesTitle, self.model_file.SeasonNumber.desc(), self.model_file.AirDateUtc.desc(), ) - .group_by(self.model_file.SeriesId) - .order_by(self.model_file.EpisodeFileId.asc()) .execute() ): entries.append([entry, False, False]) @@ -2539,17 +2578,18 @@ def _db_request_update(self, request_ids: dict[str, set[int | str]]): if self.type == "sonarr" and any(i in request_ids for i in ["ImdbId", "TvdbId"]): TvdbIds = request_ids.get("TvdbId") ImdbIds = request_ids.get("ImdbId") - while True: - try: - series = self.client.get_series() - break - except ( + series = with_retry( + lambda: self.client.get_series(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError, requests.exceptions.ConnectionError, JSONDecodeError, - ): - continue + ), + ) for s in series: episodes = self.client.get_episode(s["id"], True) for e in episodes: @@ -2577,19 +2617,20 @@ def _db_request_update(self, request_ids: dict[str, set[int | str]]): elif self.type == "radarr" and any(i in request_ids for i in ["ImdbId", "TmdbId"]): ImdbIds = request_ids.get("ImdbId") TmdbIds = request_ids.get("TmdbId") - while True: - try: - movies = self.client.get_movie() - break - except ( + movies = with_retry( + lambda: self.client.get_movie(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError, requests.exceptions.ConnectionError, JSONDecodeError, - ): - continue + ), + ) for m in movies: - if m["year"] > datetime.now().year and m["year"] == 0: + if m["year"] > datetime.now().year or m["year"] == 0: continue if TmdbIds and ImdbIds and "tmdbId" in m and "imdbId" in m: if m["tmdbId"] not in TmdbIds or m["imdbId"] not in ImdbIds: @@ -2635,17 +2676,18 @@ def db_update_todays_releases(self): return if self.type == "sonarr": try: - while True: - try: - series = self.client.get_series() - break - except ( + series = with_retry( + lambda: self.client.get_series(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError, requests.exceptions.ConnectionError, JSONDecodeError, - ): - continue + ), + ) for s in series: episodes = self.client.get_episode(s["id"], True) for e in episodes: @@ -2669,7 +2711,7 @@ def db_update_todays_releases(self): continue self.logger.trace("Updating todays releases") self.db_update_single_series(db_entry=e) - except BaseException: + except Exception: self.logger.debug("No episode releases found for today") def db_update(self): @@ -2695,17 +2737,18 @@ def db_update(self): self.logger.info("Started updating database") if self.type == "sonarr": # Always fetch series list for both episode and series-level tracking - while True: - try: - series = self.client.get_series() - break - except ( + series = with_retry( + lambda: self.client.get_series(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError, requests.exceptions.ConnectionError, JSONDecodeError, - ): - continue + ), + ) # Process episodes for episode-level tracking (all episodes) for s in series: @@ -2732,17 +2775,18 @@ def db_update(self): self.db_update_processed = True elif self.type == "radarr": - while True: - try: - movies = self.client.get_movie() - break - except ( + movies = with_retry( + lambda: self.client.get_movie(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError, requests.exceptions.ConnectionError, JSONDecodeError, - ): - continue + ), + ) # Process all movies for m in movies: if isinstance(m, str): @@ -2750,34 +2794,35 @@ def db_update(self): self.db_update_single_series(db_entry=m) self.db_update_processed = True elif self.type == "lidarr": - while True: - try: - artists = self.client.get_artist() - break - except ( + artists = with_retry( + lambda: self.client.get_artist(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError, requests.exceptions.ConnectionError, JSONDecodeError, - ): - continue + ), + ) for artist in artists: if isinstance(artist, str): continue - while True: - try: - # allArtistAlbums=True includes full album data with media/tracks - albums = self.client.get_album( - artistId=artist["id"], allArtistAlbums=True - ) - break - except ( + albums = with_retry( + lambda a=artist: self.client.get_album( + artistId=a["id"], allArtistAlbums=True + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError, requests.exceptions.ConnectionError, JSONDecodeError, - ): - continue + ), + ) for album in albums: if isinstance(album, str): continue @@ -3063,17 +3108,18 @@ def db_update_single_series( (self.model_file.EntryId == db_entry["id"]) & (self.model_file.ArrInstance == self._name) ) - while True: - try: - episode = self.client.get_episode(db_entry["id"]) - break - except ( + episode = with_retry( + lambda: self.client.get_episode(db_entry["id"]), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError, requests.exceptions.ConnectionError, JSONDecodeError, - ): - continue + ), + ) # Validate episode object has required fields if not episode or not isinstance(episode, dict): @@ -3104,67 +3150,75 @@ def db_update_single_series( return if episode.get("monitored", True) or self.search_unmonitored: - while True: - try: - series_info = episode.get("series") or {} - if isinstance(series_info, dict): - quality_profile_id = series_info.get("qualityProfileId") - else: - quality_profile_id = getattr( - series_info, "qualityProfileId", None + series_info = episode.get("series") or {} + if isinstance(series_info, dict): + quality_profile_id = series_info.get("qualityProfileId") + else: + quality_profile_id = getattr(series_info, "qualityProfileId", None) + if not quality_profile_id: + quality_profile_id = db_entry.get("qualityProfileId") + minCustomFormat = ( + getattr(episodeData, "MinCustomFormatScore", 0) if episodeData else 0 + ) + if not minCustomFormat: + if quality_profile_id: + profile = ( + with_retry( + lambda qpid=quality_profile_id: self.client.get_quality_profile( + qpid + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ContentDecodingError, + requests.exceptions.ConnectionError, + JSONDecodeError, + ), ) - if not quality_profile_id: - quality_profile_id = db_entry.get("qualityProfileId") - minCustomFormat = ( - getattr(episodeData, "MinCustomFormatScore", 0) - if episodeData - else 0 + or {} ) - if not minCustomFormat: - if quality_profile_id: - profile = ( - self.client.get_quality_profile(quality_profile_id) - or {} - ) - minCustomFormat = profile.get("minFormatScore") or 0 - else: - self.logger.warning( - "Episode %s missing qualityProfileId; defaulting custom format threshold to 0", - episode.get("id"), - ) - minCustomFormat = 0 - episode_file = episode.get("episodeFile") or {} - if isinstance(episode_file, dict): - episode_file_id = episode_file.get("id") - else: - episode_file_id = getattr(episode_file, "id", None) - has_file = bool(episode.get("hasFile")) - episode_data_file_id = ( - getattr(episodeData, "EpisodeFileId", None) - if episodeData - else None + minCustomFormat = profile.get("minFormatScore") or 0 + else: + self.logger.warning( + "Episode %s missing qualityProfileId; defaulting custom format threshold to 0", + episode.get("id"), ) - if has_file and episode_file_id: - if ( - episode_data_file_id - and episode_file_id == episode_data_file_id - ): - customFormat = getattr(episodeData, "CustomFormatScore", 0) - else: - file_info = ( - self.client.get_episode_file(episode_file_id) or {} - ) - customFormat = file_info.get("customFormatScore") or 0 - else: - customFormat = 0 - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue + minCustomFormat = 0 + episode_file = episode.get("episodeFile") or {} + if isinstance(episode_file, dict): + episode_file_id = episode_file.get("id") + else: + episode_file_id = getattr(episode_file, "id", None) + has_file = bool(episode.get("hasFile")) + episode_data_file_id = ( + getattr(episodeData, "EpisodeFileId", None) if episodeData else None + ) + if has_file and episode_file_id: + if episode_data_file_id and episode_file_id == episode_data_file_id: + customFormat = getattr(episodeData, "CustomFormatScore", 0) + else: + file_info = ( + with_retry( + lambda efid=episode_file_id: self.client.get_episode_file( + efid + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ContentDecodingError, + requests.exceptions.ConnectionError, + JSONDecodeError, + ), + ) + or {} + ) + customFormat = file_info.get("customFormatScore") or 0 + else: + customFormat = 0 QualityUnmet = ( episode["episodeFile"]["qualityCutoffNotMet"] @@ -3402,43 +3456,56 @@ def db_update_single_series( & (self.series_file_model.ArrInstance == self._name) ) if db_entry["monitored"] or self.search_unmonitored: - while True: - try: - seriesMetadata = self.client.get_series(id_=EntryId) or {} - quality_profile_id = None - if isinstance(seriesMetadata, dict): - quality_profile_id = seriesMetadata.get("qualityProfileId") - else: - quality_profile_id = getattr( - seriesMetadata, "qualityProfileId", None - ) - if not seriesData: - if quality_profile_id: - profile = ( - self.client.get_quality_profile(quality_profile_id) - or {} - ) - minCustomFormat = profile.get("minFormatScore") or 0 - else: - self.logger.warning( - "Series %s (%s) missing qualityProfileId; " - "defaulting custom format score to 0", - db_entry.get("title"), - EntryId, - ) - minCustomFormat = 0 - else: - minCustomFormat = getattr( - seriesData, "MinCustomFormatScore", 0 + seriesMetadata = ( + with_retry( + lambda eid=EntryId: self.client.get_series(id_=eid), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ContentDecodingError, + requests.exceptions.ConnectionError, + JSONDecodeError, + ), + ) + or {} + ) + quality_profile_id = None + if isinstance(seriesMetadata, dict): + quality_profile_id = seriesMetadata.get("qualityProfileId") + else: + quality_profile_id = getattr(seriesMetadata, "qualityProfileId", None) + if not seriesData: + if quality_profile_id: + profile = ( + with_retry( + lambda qpid=quality_profile_id: self.client.get_quality_profile( + qpid + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ContentDecodingError, + requests.exceptions.ConnectionError, + JSONDecodeError, + ), ) - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue + or {} + ) + minCustomFormat = profile.get("minFormatScore") or 0 + else: + self.logger.warning( + "Series %s (%s) missing qualityProfileId; " + "defaulting custom format score to 0", + db_entry.get("title"), + EntryId, + ) + minCustomFormat = 0 + else: + minCustomFormat = getattr(seriesData, "MinCustomFormatScore", 0) episodeCount = 0 episodeFileCount = 0 totalEpisodeCount = 0 @@ -3592,48 +3659,68 @@ def db_update_single_series( if self.minimum_availability_check(db_entry) and ( db_entry["monitored"] or self.search_unmonitored ): - while True: - try: - if movieData: - if not movieData.MinCustomFormatScore: - profile = ( - self.client.get_quality_profile( - db_entry["qualityProfileId"] - ) - or {} - ) - minCustomFormat = profile.get("minFormatScore", 0) - else: - minCustomFormat = movieData.MinCustomFormatScore - if db_entry["hasFile"]: - if db_entry["movieFile"]["id"] != movieData.MovieFileId: - customFormat = self.client.get_movie_file( - db_entry["movieFile"]["id"] - )["customFormatScore"] - else: - customFormat = movieData.CustomFormatScore - else: - customFormat = 0 - else: - profile = ( - self.client.get_quality_profile(db_entry["qualityProfileId"]) - or {} + _retry_exc = ( + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ContentDecodingError, + requests.exceptions.ConnectionError, + JSONDecodeError, + ) + if movieData: + if not movieData.MinCustomFormatScore: + profile = ( + with_retry( + lambda: self.client.get_quality_profile( + db_entry["qualityProfileId"] + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_retry_exc, ) - minCustomFormat = profile.get("minFormatScore", 0) - if db_entry["hasFile"]: - customFormat = self.client.get_movie_file( + or {} + ) + minCustomFormat = profile.get("minFormatScore", 0) + else: + minCustomFormat = movieData.MinCustomFormatScore + if db_entry["hasFile"]: + if db_entry["movieFile"]["id"] != movieData.MovieFileId: + customFormat = with_retry( + lambda: self.client.get_movie_file( db_entry["movieFile"]["id"] - ).get("customFormatScore", 0) - else: - customFormat = 0 - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_retry_exc, + )["customFormatScore"] + else: + customFormat = movieData.CustomFormatScore + else: + customFormat = 0 + else: + profile = ( + with_retry( + lambda: self.client.get_quality_profile( + db_entry["qualityProfileId"] + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_retry_exc, + ) + or {} + ) + minCustomFormat = profile.get("minFormatScore", 0) + if db_entry["hasFile"]: + customFormat = with_retry( + lambda: self.client.get_movie_file(db_entry["movieFile"]["id"]), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_retry_exc, + ).get("customFormatScore", 0) + else: + customFormat = 0 QualityUnmet = ( db_entry["movieFile"]["qualityCutoffNotMet"] if "movieFile" in db_entry @@ -3832,107 +3919,91 @@ def db_update_single_series( & (self.model_file.ArrInstance == self._name) ) if db_entry["monitored"] or self.search_unmonitored: - while True: - try: - if albumData: - if not albumData.MinCustomFormatScore: - try: - profile_id = db_entry["profileId"] - # Check if this profile ID is known to be invalid - if profile_id in self._invalid_quality_profiles: - minCustomFormat = 0 - # Check cache first - elif profile_id in self._quality_profile_cache: - minCustomFormat = self._quality_profile_cache[ - profile_id - ].get("minFormatScore", 0) - else: - # Fetch from API and cache - try: - profile = self.client.get_quality_profile( - profile_id - ) - self._quality_profile_cache[profile_id] = ( - profile - ) - minCustomFormat = profile.get( - "minFormatScore", 0 - ) - except PyarrResourceNotFound: - # Mark as invalid to avoid repeated warnings - self._invalid_quality_profiles.add(profile_id) - self.logger.warning( - "Quality profile %s not found for album %s, defaulting to 0", - db_entry.get("profileId"), - db_entry.get("title", "Unknown"), - ) - minCustomFormat = 0 - except Exception: - minCustomFormat = 0 + _retry_exc = ( + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ContentDecodingError, + requests.exceptions.ConnectionError, + JSONDecodeError, + ) + if albumData: + if not albumData.MinCustomFormatScore: + try: + profile_id = db_entry["profileId"] + if profile_id in self._invalid_quality_profiles: + minCustomFormat = 0 + elif profile_id in self._quality_profile_cache: + minCustomFormat = self._quality_profile_cache[ + profile_id + ].get("minFormatScore", 0) else: - minCustomFormat = albumData.MinCustomFormatScore - if ( - db_entry.get("statistics", {}).get("percentOfTracks", 0) - == 100 - ): - # Album has files - albumFileId = db_entry.get("statistics", {}).get( - "sizeOnDisk", 0 - ) - if albumFileId != albumData.AlbumFileId: - # Get custom format score from album files - customFormat = ( - 0 # Lidarr may not have customFormatScore + try: + profile = with_retry( + lambda pid=profile_id: self.client.get_quality_profile( + pid + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_retry_exc, ) - else: - customFormat = albumData.CustomFormatScore - else: - customFormat = 0 + self._quality_profile_cache[profile_id] = profile + minCustomFormat = profile.get("minFormatScore", 0) + except PyarrResourceNotFound: + self._invalid_quality_profiles.add(profile_id) + self.logger.warning( + "Quality profile %s not found for album %s, defaulting to 0", + db_entry.get("profileId"), + db_entry.get("title", "Unknown"), + ) + minCustomFormat = 0 + except Exception: + minCustomFormat = 0 + else: + minCustomFormat = albumData.MinCustomFormatScore + if db_entry.get("statistics", {}).get("percentOfTracks", 0) == 100: + albumFileId = db_entry.get("statistics", {}).get("sizeOnDisk", 0) + if albumFileId != albumData.AlbumFileId: + customFormat = 0 # Lidarr may not have customFormatScore + else: + customFormat = albumData.CustomFormatScore + else: + customFormat = 0 + else: + try: + profile_id = db_entry["profileId"] + if profile_id in self._invalid_quality_profiles: + minCustomFormat = 0 + elif profile_id in self._quality_profile_cache: + minCustomFormat = self._quality_profile_cache[profile_id].get( + "minFormatScore", 0 + ) else: try: - profile_id = db_entry["profileId"] - # Check if this profile ID is known to be invalid - if profile_id in self._invalid_quality_profiles: - minCustomFormat = 0 - # Check cache first - elif profile_id in self._quality_profile_cache: - minCustomFormat = self._quality_profile_cache[ - profile_id - ].get("minFormatScore", 0) - else: - # Fetch from API and cache - try: - profile = self.client.get_quality_profile( - profile_id - ) - self._quality_profile_cache[profile_id] = profile - minCustomFormat = profile.get("minFormatScore", 0) - except PyarrResourceNotFound: - # Mark as invalid to avoid repeated warnings - self._invalid_quality_profiles.add(profile_id) - self.logger.warning( - "Quality profile %s not found for album %s, defaulting to 0", - db_entry.get("profileId"), - db_entry.get("title", "Unknown"), - ) - minCustomFormat = 0 - except Exception: + profile = with_retry( + lambda pid=profile_id: self.client.get_quality_profile( + pid + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_retry_exc, + ) + self._quality_profile_cache[profile_id] = profile + minCustomFormat = profile.get("minFormatScore", 0) + except PyarrResourceNotFound: + self._invalid_quality_profiles.add(profile_id) + self.logger.warning( + "Quality profile %s not found for album %s, defaulting to 0", + db_entry.get("profileId"), + db_entry.get("title", "Unknown"), + ) minCustomFormat = 0 - if ( - db_entry.get("statistics", {}).get("percentOfTracks", 0) - == 100 - ): - customFormat = 0 # Lidarr may not have customFormatScore - else: - customFormat = 0 - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue + except Exception: + minCustomFormat = 0 + if db_entry.get("statistics", {}).get("percentOfTracks", 0) == 100: + customFormat = 0 # Lidarr may not have customFormatScore + else: + customFormat = 0 # Determine if album has all tracks hasAllTracks = ( @@ -4191,43 +4262,52 @@ def db_update_single_series( & (self.artists_file_model.ArrInstance == self._name) ) if db_entry["monitored"] or self.search_unmonitored: - while True: - try: - artistMetadata = self.client.get_artist(id_=EntryId) or {} - quality_profile_id = None - if isinstance(artistMetadata, dict): - quality_profile_id = artistMetadata.get("qualityProfileId") - else: - quality_profile_id = getattr( - artistMetadata, "qualityProfileId", None - ) - if not artistData: - if quality_profile_id: - profile = ( - self.client.get_quality_profile(quality_profile_id) - or {} - ) - minCustomFormat = profile.get("minFormatScore") or 0 - else: - self.logger.warning( - "Artist %s (%s) missing qualityProfileId; " - "defaulting custom format score to 0", - db_entry.get("artistName"), - EntryId, - ) - minCustomFormat = 0 - else: - minCustomFormat = getattr( - artistData, "MinCustomFormatScore", 0 + _retry_exc = ( + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ContentDecodingError, + requests.exceptions.ConnectionError, + JSONDecodeError, + ) + artistMetadata = ( + with_retry( + lambda eid=EntryId: self.client.get_artist(id_=eid), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_retry_exc, + ) + or {} + ) + quality_profile_id = None + if isinstance(artistMetadata, dict): + quality_profile_id = artistMetadata.get("qualityProfileId") + else: + quality_profile_id = getattr(artistMetadata, "qualityProfileId", None) + if not artistData: + if quality_profile_id: + profile = ( + with_retry( + lambda qpid=quality_profile_id: self.client.get_quality_profile( + qpid + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=_retry_exc, ) - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ): - continue + or {} + ) + minCustomFormat = profile.get("minFormatScore") or 0 + else: + self.logger.warning( + "Artist %s (%s) missing qualityProfileId; " + "defaulting custom format score to 0", + db_entry.get("artistName"), + EntryId, + ) + minCustomFormat = 0 + else: + minCustomFormat = getattr(artistData, "MinCustomFormatScore", 0) # Calculate if artist is fully searched based on album statistics statistics = artistMetadata.get("statistics", {}) albumCount = statistics.get("albumCount", 0) @@ -4323,7 +4403,7 @@ def db_update_single_series( db_entry["title"], exc_info=e, ) - raise DelayLoopException(length=300, type=self._name) + raise DelayLoopException(length=300, error_type=self._name) except JSONDecodeError: if self.type == "sonarr": if self.series_search: @@ -4343,21 +4423,18 @@ def db_update_single_series( def delete_from_queue(self, id_, remove_from_client=True, blacklist=True): try: - while True: - try: - res = self.client.del_queue(id_, remove_from_client, blacklist) - # res = self.client._delete( - # f"queue/{id_}?removeFromClient={remove_from_client}&blocklist={blacklist}", - # self.client.ver_uri, - # ) - break - except ( + res = with_retry( + lambda: self.client.del_queue(id_, remove_from_client, blacklist), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError, requests.exceptions.ConnectionError, JSONDecodeError, - ): - continue + ), + ) except PyarrResourceNotFound as e: # Queue item not found - this is expected when Arr has already auto-imported # and removed the item, or if it was manually removed. Clean up internal tracking. @@ -4405,7 +4482,7 @@ def file_is_probeable(self, file: pathlib.Path) -> bool: return False self.files_probed.add(file) return True - except BaseException as e: + except Exception as e: error = e.stderr.decode() self.logger.trace( "Not probeable: Probe returned an error: %s:\n%s", @@ -4550,7 +4627,7 @@ def maybe_do_search( if not features_enabled and not (request or todays): return None elif not self.is_alive: - raise NoConnectionrException(f"Could not connect to {self.uri}", type="arr") + raise NoConnectionrException(f"Could not connect to {self.uri}", error_type="arr") elif self.type == "sonarr": if not series_search: file_model: EpisodeFilesModel @@ -4606,19 +4683,20 @@ def maybe_do_search( Completed=False, EntryId=file_model.EntryId, ArrInstance=self._name ).on_conflict_replace().execute() if file_model.EntryId not in self.queue_file_ids: - while True: - try: - self.client.post_command( - "EpisodeSearch", episodeIds=[file_model.EntryId] - ) - break - except ( + with_retry( + lambda: self.client.post_command( + "EpisodeSearch", episodeIds=[file_model.EntryId] + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError, requests.exceptions.ConnectionError, JSONDecodeError, - ): - continue + ), + ) self.model_file.update(Searched=True, Upgrade=True).where( (self.model_file.EntryId == file_model.EntryId) & (self.model_file.ArrInstance == self._name) @@ -4678,19 +4756,20 @@ def maybe_do_search( self.model_queue.insert( Completed=False, EntryId=file_model.EntryId, ArrInstance=self._name ).on_conflict_replace().execute() - while True: - try: - self.client.post_command( - self.search_api_command, seriesId=file_model.EntryId - ) - break - except ( + with_retry( + lambda: self.client.post_command( + self.search_api_command, seriesId=file_model.EntryId + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError, requests.exceptions.ConnectionError, JSONDecodeError, - ): - continue + ), + ) self.model_file.update(Searched=True, Upgrade=True).where( (self.model_file.EntryId == file_model.EntryId) & (self.model_file.ArrInstance == self._name) @@ -4754,17 +4833,20 @@ def maybe_do_search( Completed=False, EntryId=file_model.EntryId, ArrInstance=self._name ).on_conflict_replace().execute() if file_model.EntryId: - while True: - try: - self.client.post_command("MoviesSearch", movieIds=[file_model.EntryId]) - break - except ( + with_retry( + lambda: self.client.post_command( + "MoviesSearch", movieIds=[file_model.EntryId] + ), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError, requests.exceptions.ConnectionError, JSONDecodeError, - ): - continue + ), + ) self.model_file.update(Searched=True, Upgrade=True).where( (self.model_file.EntryId == file_model.EntryId) & (self.model_file.ArrInstance == self._name) @@ -4842,17 +4924,18 @@ def maybe_do_search( Completed=False, EntryId=file_model.EntryId, ArrInstance=self._name ).on_conflict_replace().execute() if file_model.EntryId: - while True: - try: - self.client.post_command("AlbumSearch", albumIds=[file_model.EntryId]) - break - except ( + with_retry( + lambda: self.client.post_command("AlbumSearch", albumIds=[file_model.EntryId]), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError, requests.exceptions.ConnectionError, JSONDecodeError, - ): - continue + ), + ) self.model_file.update(Searched=True, Upgrade=True).where( (self.model_file.EntryId == file_model.EntryId) & (self.model_file.ArrInstance == self._name) @@ -4961,16 +5044,13 @@ def _get_torrents_from_all_instances( def process_torrents(self): try: try: - while True: - try: - # Multi-instance: Scan all qBit instances for category-matching torrents - torrents_with_instances = self._get_torrents_from_all_instances() - break - except (qbittorrentapi.exceptions.APIError, JSONDecodeError) as e: - if isinstance(e, JSONDecodeError): - continue - else: - raise qbittorrentapi.exceptions.APIError + torrents_with_instances = with_retry( + lambda: self._get_torrents_from_all_instances(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=(JSONDecodeError,), + ) # Filter torrents that have category attribute torrents_with_instances = [ @@ -4981,14 +5061,14 @@ def process_torrents(self): self._warned_no_seeding_limits = False self.category_torrent_count = len(torrents_with_instances) if not len(torrents_with_instances): - raise DelayLoopException(length=LOOP_SLEEP_TIMER, type="no_downloads") + raise DelayLoopException(length=LOOP_SLEEP_TIMER, error_type="no_downloads") # Internet check: use the first available qBit client if not has_internet(self.manager.qbit_manager.client): self.manager.qbit_manager.should_delay_torrent_scan = True - raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="internet") + raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, error_type="internet") if self.manager.qbit_manager.should_delay_torrent_scan: - raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="delay") + raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, error_type="delay") # Initialize database error tracking for exponential backoff if not hasattr(self, "_db_error_count"): @@ -5036,7 +5116,7 @@ def process_torrents(self): except qbittorrentapi.exceptions.APIError: self.logger.error("The qBittorrent API returned an unexpected error") self.logger.debug("Unexpected APIError from qBitTorrent") # , exc_info=e) - raise DelayLoopException(length=300, type="qbit") + raise DelayLoopException(length=300, error_type="qbit") except (OperationalError, DatabaseError) as e: # Database errors after retry exhaustion - implement automatic recovery with backoff error_msg = str(e).lower() @@ -5134,7 +5214,7 @@ def process_torrents(self): ) # Delay processing to avoid hammering failing database - raise DelayLoopException(length=delay_seconds, type="database") + raise DelayLoopException(length=delay_seconds, error_type="database") except DelayLoopException: raise except KeyboardInterrupt: @@ -5828,7 +5908,7 @@ def _get_torrent_important_trackers( except qbittorrentapi.exceptions.APIError as e: self.logger.error("The qBittorrent API returned an unexpected error") self.logger.debug("Unexpected APIError from qBitTorrent", exc_info=e) - raise DelayLoopException(length=300, type="qbit") + raise DelayLoopException(length=300, error_type="qbit") # Host-based matching: resolve qBit announce URLs to their config URIs. # Supports apex/suffix matching so that an announce URL using a subdomain # (e.g. "tracker.torrentleech.org") matches a config URI that is the apex @@ -6404,18 +6484,18 @@ def _process_single_torrent( def custom_format_unmet_check(self, torrent: qbittorrentapi.TorrentDictionary) -> bool: try: - # Retry getting the queue until it succeeds - while True: - try: - queue = self.client.get_queue() - break - except ( + queue = with_retry( + lambda: self.client.get_queue(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError, requests.exceptions.ConnectionError, JSONDecodeError, - ): - continue # Retry on exceptions + ), + ) if not queue.get("records"): return False @@ -6773,37 +6853,24 @@ def parse_quality_profiles(self) -> dict[int, int]: self.quality_profile_mappings, ) - while True: - try: - profiles = self.client.get_quality_profile() - self.logger.debug("Fetched %d quality profiles from API", len(profiles)) - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ) as e: - # transient network/encoding issues; retry - self.logger.warning( - "Transient error fetching quality profiles, retrying: %s", type(e).__name__ - ) - continue - except PyarrServerError as e: - # Server-side error (e.g., Radarr DB disk I/O). Log and wait 5 minutes before retrying. - self.logger.error( - "Failed to get quality profiles (server error): %s -- retrying in 5 minutes", e - ) - try: - time.sleep(300) - except Exception: - pass - continue - except Exception as e: - # Unexpected error; log and continue without profiles. - self.logger.error("Unexpected error getting quality profiles: %s", e) - profiles = [] - break + try: + profiles = with_retry( + lambda: self.client.get_quality_profile(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=( + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ContentDecodingError, + requests.exceptions.ConnectionError, + JSONDecodeError, + PyarrServerError, + ), + ) + self.logger.debug("Fetched %d quality profiles from API", len(profiles)) + except Exception as e: + self.logger.error("Unexpected error getting quality profiles: %s", e) + profiles = [] # Build a lookup dict for profile name -> ID profile_name_to_id = {p["name"]: p["id"] for p in profiles} @@ -7144,7 +7211,7 @@ def run_request_search(self): self.request_search_timer = time.time() except NoConnectionrException as e: self.logger.error(e.message) - raise DelayLoopException(length=300, type=e.type) + raise DelayLoopException(length=300, error_type=e.type) except DelayLoopException: raise except Exception as e: @@ -7218,7 +7285,7 @@ def get_year_search(self) -> tuple[list[int], int]: for s in series: episodes = with_retry( - lambda: self.client.get_episode(s["id"], True), + lambda s=s: self.client.get_episode(s["id"], True), retries=3, backoff=0.5, max_backoff=3, @@ -7297,7 +7364,7 @@ def run_search_loop(self) -> NoReturn: years, years_count = self.get_year_search() try: self.search_current_year = years[years_index] - except BaseException: + except Exception: self.search_current_year = years[: years_index + 1] self.logger.debug("Current year %s", self.search_current_year) try: @@ -7389,7 +7456,7 @@ def run_search_loop(self) -> NoReturn: except NoConnectionrException as e: self.logger.error(e.message) self.manager.qbit_manager.should_delay_torrent_scan = True - raise DelayLoopException(length=300, type=e.type) + raise DelayLoopException(length=300, error_type=e.type) except DelayLoopException: raise except ValueError: @@ -7397,7 +7464,7 @@ def run_search_loop(self) -> NoReturn: self.loop_completed = True except qbittorrentapi.exceptions.APIConnectionError as e: self.logger.warning(e) - raise DelayLoopException(length=300, type="qbit") + raise DelayLoopException(length=300, error_type="qbit") except Exception as e: self.logger.exception(e, exc_info=sys.exc_info()) event.wait(LOOP_SLEEP_TIMER) @@ -7464,13 +7531,13 @@ def run_torrent_loop(self) -> NoReturn: except NoConnectionrException as e: self.logger.error(e.message) self.manager.qbit_manager.should_delay_torrent_scan = True - raise DelayLoopException(length=300, type="arr") + raise DelayLoopException(length=300, error_type="arr") except qbittorrentapi.exceptions.APIConnectionError as e: self.logger.warning(e) - raise DelayLoopException(length=300, type="qbit") + raise DelayLoopException(length=300, error_type="qbit") except qbittorrentapi.exceptions.APIError as e: self.logger.warning(e) - raise DelayLoopException(length=300, type="qbit") + raise DelayLoopException(length=300, error_type="qbit") except DelayLoopException: raise except KeyboardInterrupt: @@ -7618,7 +7685,12 @@ def __init__(self, name: str, manager: ArrManager): self.queue_active_count: int = 0 self.category_torrent_count: int = 0 self.free_space_tagged_count: int = 0 - self.register_search_mode() + if TAGLESS: + self.register_search_mode() + else: + self.torrents = None + self.db = None + self.search_setup_completed = True self.logger.hnotice("Starting %s monitor", self._name) def _get_models( @@ -7941,14 +8013,13 @@ def process(self): def process_torrents(self): try: try: - while True: - try: - torrents_with_instances = self._get_torrents_from_all_instances() - break - except (qbittorrentapi.exceptions.APIError, JSONDecodeError) as e: - if isinstance(e, JSONDecodeError): - continue - raise qbittorrentapi.exceptions.APIError + torrents_with_instances = with_retry( + lambda: self._get_torrents_from_all_instances(), + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=(JSONDecodeError,), + ) torrents_with_instances = [ (instance, t) @@ -7958,13 +8029,13 @@ def process_torrents(self): self._warned_no_seeding_limits = False self.category_torrent_count = len(torrents_with_instances) if not torrents_with_instances: - raise DelayLoopException(length=LOOP_SLEEP_TIMER, type="no_downloads") + raise DelayLoopException(length=LOOP_SLEEP_TIMER, error_type="no_downloads") if not has_internet(self.manager.qbit_manager.client): self.manager.qbit_manager.should_delay_torrent_scan = True - raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="internet") + raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, error_type="internet") if self.manager.qbit_manager.should_delay_torrent_scan: - raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="delay") + raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, error_type="delay") for instance_name, torrent in torrents_with_instances: with contextlib.suppress(qbittorrentapi.NotFound404Error): @@ -7975,10 +8046,10 @@ def process_torrents(self): except qbittorrentapi.exceptions.APIError as e: self.logger.error("The qBittorrent API returned an unexpected error") self.logger.debug("Unexpected APIError from qBitTorrent", exc_info=e) - raise DelayLoopException(length=300, type="qbit") + raise DelayLoopException(length=300, error_type="qbit") except qbittorrentapi.exceptions.APIConnectionError: self.logger.warning("Max retries exceeded") - raise DelayLoopException(length=300, type="qbit") + raise DelayLoopException(length=300, error_type="qbit") except DelayLoopException: raise except KeyboardInterrupt: @@ -8204,24 +8275,29 @@ def process_torrents(self): try: _client = self.manager.qbit_manager.client if _client is None: - raise DelayLoopException(length=LOOP_SLEEP_TIMER, type="no_downloads") - while True: - try: - # Fetch per category to reduce client-side filtering - torrents = [] - for cat in self.categories: - with contextlib.suppress(qbittorrentapi.exceptions.APIError): - torrents.extend( - _client.torrents.info( - status_filter="all", - category=cat, - sort="added_on", - reverse=False, - ) + raise DelayLoopException(length=LOOP_SLEEP_TIMER, error_type="no_downloads") + + def _fetch_free_space_torrents(): + result = [] + for cat in self.categories: + with contextlib.suppress(qbittorrentapi.exceptions.APIError): + result.extend( + _client.torrents.info( + status_filter="all", + category=cat, + sort="added_on", + reverse=False, ) - break - except qbittorrentapi.exceptions.APIError: - continue + ) + return result + + torrents = with_retry( + _fetch_free_space_torrents, + retries=5, + backoff=0.5, + max_backoff=5, + exceptions=(qbittorrentapi.exceptions.APIError,), + ) torrents = [t for t in torrents if hasattr(t, "category")] torrents = [t for t in torrents if t.category in self.categories] torrents = [t for t in torrents if "qBitrr-ignored" not in t.tags] @@ -8233,12 +8309,12 @@ def process_torrents(self): if self.in_tags(t, "qBitrr-free_space_paused", _first_instance) ) if not len(torrents): - raise DelayLoopException(length=LOOP_SLEEP_TIMER, type="no_downloads") + raise DelayLoopException(length=LOOP_SLEEP_TIMER, error_type="no_downloads") if not has_internet(self.manager.qbit_manager.client): self.manager.qbit_manager.should_delay_torrent_scan = True - raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="internet") + raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, error_type="internet") if self.manager.qbit_manager.should_delay_torrent_scan: - raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="delay") + raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, error_type="delay") # Re-resolve path each loop so we use the configured path once it appears # (e.g. Docker volume mounted after process start). Only when path was # auto-chosen (CHANGE_ME); explicit FreeSpaceFolder must exist or we error. @@ -8269,10 +8345,10 @@ def process_torrents(self): except qbittorrentapi.exceptions.APIError as e: self.logger.error("The qBittorrent API returned an unexpected error") self.logger.debug("Unexpected APIError from qBitTorrent", exc_info=e) - raise DelayLoopException(length=300, type="qbit") + raise DelayLoopException(length=300, error_type="qbit") except qbittorrentapi.exceptions.APIConnectionError: self.logger.warning("Max retries exceeded") - raise DelayLoopException(length=300, type="qbit") + raise DelayLoopException(length=300, error_type="qbit") except DelayLoopException: raise except KeyboardInterrupt: diff --git a/qBitrr/database.py b/qBitrr/database.py index 858539378..03dd35fee 100644 --- a/qBitrr/database.py +++ b/qBitrr/database.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import re import sqlite3 from pathlib import Path @@ -30,36 +31,53 @@ _db: SqliteDatabase | None = None -def _offer_fresh_start(db_path: Path) -> None: +def _offer_fresh_start(db_path: Path) -> bool: """ Delete corrupt database so a fresh one is created on connect. Use only after repair has failed; causes loss of existing DB data. + + Returns: + True if all files were successfully removed, False otherwise. """ - try: - for candidate in ( - db_path, - db_path.parent / f"{db_path.name}-wal", - db_path.parent / f"{db_path.name}-shm", - ): - try: - candidate.unlink() - except FileNotFoundError: - continue + all_removed = True + for candidate in ( + db_path, + db_path.parent / f"{db_path.name}-wal", + db_path.parent / f"{db_path.name}-shm", + db_path.with_suffix(".db.temp-journal"), + ): + try: + candidate.unlink() + except FileNotFoundError: + continue + except Exception as e: + logger.error("Failed to delete corrupt database file %s: %s", candidate, e) + all_removed = False + if all_removed: logger.warning( - "Deleted corrupt database and SQLite WAL/SHM files; " + "Deleted corrupt database and auxiliary files; " "a fresh database will be created on connect.", ) - except Exception as e: - logger.debug("Could not delete corrupt database files: %s", e) + else: + logger.critical( + "Could not fully remove corrupt database files. " + "The database may still be corrupt on next connection.", + ) + return all_removed -def _startup_integrity_check_and_repair(db_path: Path) -> None: +def _startup_integrity_check_and_repair(db_path: Path) -> bool: """ Run PRAGMA integrity_check on existing database; if not ok, try WAL checkpoint - then full repair. Runs once at startup before any connection so repair has no contention. + then full repair. As a last resort, delete the database for a fresh start. + + Returns: + True if the database is healthy (repaired or fresh), False if corrupt + and could not be fixed or removed. """ if not db_path.exists(): - return + return True + result = "unknown" try: with sqlite3.connect(str(db_path), timeout=10.0) as conn: @@ -67,7 +85,7 @@ def _startup_integrity_check_and_repair(db_path: Path) -> None: cursor.execute("PRAGMA integrity_check") result = cursor.fetchone()[0] if result == "ok": - return + return True except Exception as e: logger.warning("Startup integrity check failed (will attempt repair): %s", e) @@ -85,39 +103,26 @@ def _startup_integrity_check_and_repair(db_path: Path) -> None: result = cursor.fetchone()[0] if result == "ok": logger.info("WAL checkpoint resolved database issue - integrity ok") - return + return True except Exception as e: logger.debug("Integrity re-check after checkpoint failed: %s", e) logger.warning("Attempting full database repair (dump/restore)...") try: if repair_database(db_path, backup=True, logger_override=logger): - try: - with sqlite3.connect(str(db_path), timeout=10.0) as conn: - cursor = conn.cursor() - cursor.execute("PRAGMA integrity_check") - result = cursor.fetchone()[0] - if result == "ok": - logger.info("Database repair successful - integrity verified") - return - logger.critical( - "Database repair completed but integrity still failing: %s. " - "Continuing anyway; consider manual repair or fresh DB.", - result, - ) - except Exception as e: - logger.critical("Database repair verification failed: %s. Continuing anyway.", e) - return + logger.info("Database repair successful - integrity verified") + return True except Exception as e: - from qBitrr.db_recovery import DatabaseRecoveryError - logger.critical( "Automatic database repair failed: %s. " - "Corrupt database will be removed and a fresh one created on connect (data loss).", + "Corrupt database will be removed for a fresh start (data loss).", e, ) - if isinstance(e, DatabaseRecoveryError) and db_path.exists(): - _offer_fresh_start(db_path) + + logger.warning("All repair attempts failed. Removing corrupt database for fresh start...") + if db_path.exists(): + return _offer_fresh_start(db_path) + return True def get_database() -> SqliteDatabase: @@ -142,10 +147,13 @@ def get_database() -> SqliteDatabase: timeout=15, ) - # Automatic startup repair: integrity check and repair before any connection - _startup_integrity_check_and_repair(db_path) + if not _startup_integrity_check_and_repair(db_path): + logger.critical( + "Database is corrupt and could not be repaired or removed. " + "Please manually delete '%s' and restart.", + db_path, + ) - # Connect with retry logic with_database_retry( lambda: _db.connect(reuse_if_open=True), logger=logger, @@ -230,15 +238,20 @@ def _create_arrinstance_indexes(db: SqliteDatabase, models: list) -> None: table_name = model._meta.table_name index_name = f"idx_arrinstance_{table_name}" - # Check if index already exists + if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", index_name): + logger.warning("Skipping invalid index name: %s", index_name) + continue + if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", table_name): + logger.warning("Skipping invalid table name: %s", table_name) + continue + cursor.execute( "SELECT name FROM sqlite_master WHERE type='index' AND name=?", (index_name,), ) if cursor.fetchone(): - continue # Index already exists + continue - # Create index cursor.execute(f"CREATE INDEX {index_name} ON {table_name}(ArrInstance)") logger.info("Created index: %s on %s.ArrInstance", index_name, table_name) except Exception as e: diff --git a/qBitrr/db_lock.py b/qBitrr/db_lock.py index 12c52f859..75f22a72e 100644 --- a/qBitrr/db_lock.py +++ b/qBitrr/db_lock.py @@ -382,15 +382,13 @@ def check_database_health(db_path: Path, logger=None) -> tuple[bool, str]: import sqlite3 try: - # Use a short timeout to avoid blocking conn = sqlite3.connect(str(db_path), timeout=5.0) - cursor = conn.cursor() - - # Quick integrity check (fast, catches major corruption) - cursor.execute("PRAGMA quick_check") - result = cursor.fetchone()[0] - - conn.close() + try: + cursor = conn.cursor() + cursor.execute("PRAGMA quick_check") + result = cursor.fetchone()[0] + finally: + conn.close() if result != "ok": error_msg = f"PRAGMA quick_check failed: {result}" diff --git a/qBitrr/db_recovery.py b/qBitrr/db_recovery.py index a5f38c8c5..3c4cbe1dc 100644 --- a/qBitrr/db_recovery.py +++ b/qBitrr/db_recovery.py @@ -34,14 +34,12 @@ def checkpoint_wal(db_path: Path, logger_override=None) -> bool: try: log.info("Starting WAL checkpoint for database: %s", db_path) conn = sqlite3.connect(str(db_path), timeout=10.0) - cursor = conn.cursor() - - # Force WAL checkpoint with TRUNCATE mode - # This checkpoints all frames and truncates the WAL file - cursor.execute("PRAGMA wal_checkpoint(TRUNCATE)") - result = cursor.fetchone() - - conn.close() + try: + cursor = conn.cursor() + cursor.execute("PRAGMA wal_checkpoint(TRUNCATE)") + result = cursor.fetchone() + finally: + conn.close() # Result is (busy, log_pages, checkpointed_pages) # If busy=0, checkpoint was fully successful @@ -73,7 +71,7 @@ def repair_database(db_path: Path, backup: bool = True, logger_override=None) -> This operation: 1. Creates a backup of the corrupted database - 2. Dumps all recoverable data to a temporary database + 2. Dumps all recoverable data to a temporary database (handles partial dumps) 3. Replaces the original with the repaired copy 4. Verifies integrity of the repaired database @@ -94,73 +92,93 @@ def repair_database(db_path: Path, backup: bool = True, logger_override=None) -> backup_path = db_path.with_suffix(".db.backup") temp_path = db_path.with_suffix(".db.temp") + source_conn = None + temp_conn = None try: - # Step 1: Backup original if backup: log.info("Creating backup: %s", backup_path) shutil.copy2(db_path, backup_path) - # Step 2: Dump recoverable data log.info("Dumping recoverable data from corrupted database...") source_conn = sqlite3.connect(str(db_path)) - temp_conn = sqlite3.connect(str(temp_path)) - # Dump schema and data + recovered_count = 0 skipped_rows = 0 - for line in source_conn.iterdump(): - try: - temp_conn.execute(line) - except sqlite3.Error as e: - # Log but continue - recover what we can - skipped_rows += 1 - log.debug("Skipping corrupted row during dump: %s", e) + dump_interrupted = False + try: + for line in source_conn.iterdump(): + try: + temp_conn.execute(line) + recovered_count += 1 + except sqlite3.Error as e: + skipped_rows += 1 + log.debug("Skipping corrupted row during dump: %s", e) + except Exception as e: + dump_interrupted = True + log.warning( + "iterdump stopped early due to corruption (%s recovered, %s skipped): %s", + recovered_count, + skipped_rows, + e, + ) + + temp_conn.commit() if skipped_rows > 0: log.warning("Skipped %s corrupted rows during dump", skipped_rows) - temp_conn.commit() - temp_conn.close() source_conn.close() + source_conn = None + temp_conn.close() + temp_conn = None - # Step 3: Replace original with repaired copy - log.info("Replacing database with repaired version...") - db_path.unlink() - shutil.move(str(temp_path), str(db_path)) + if recovered_count == 0: + raise DatabaseRecoveryError("No data could be recovered from corrupt database") - # Step 4: Verify integrity - log.info("Verifying integrity of repaired database...") - verify_conn = sqlite3.connect(str(db_path)) + verify_conn = sqlite3.connect(str(temp_path)) cursor = verify_conn.cursor() cursor.execute("PRAGMA integrity_check") result = cursor.fetchone()[0] verify_conn.close() if result != "ok": - raise DatabaseRecoveryError(f"Repair verification failed: {result}") + raise DatabaseRecoveryError(f"Repaired database failed integrity check: {result}") - log.info("Database repair successful!") + log.info("Replacing database with repaired version...") + for stale in ( + db_path, + db_path.parent / f"{db_path.name}-wal", + db_path.parent / f"{db_path.name}-shm", + ): + try: + stale.unlink() + except FileNotFoundError: + pass + shutil.move(str(temp_path), str(db_path)) + + status = "with partial data (dump interrupted)" if dump_interrupted else "fully" + log.info( + "Database repair successful (%s) - %s statements recovered", status, recovered_count + ) return True except Exception as e: log.error("Database repair failed: %s", e) - # Attempt to restore backup - if backup and backup_path.exists(): - log.warning("Restoring from backup...") - try: - shutil.copy2(backup_path, db_path) - log.info("Backup restored successfully") - except Exception as restore_error: - log.error("Failed to restore backup: %s", restore_error) + for conn in (source_conn, temp_conn): + if conn is not None: + try: + conn.close() + except Exception: + pass - # Cleanup temp files if temp_path.exists(): try: temp_path.unlink() except Exception: - pass # Best effort cleanup + pass raise DatabaseRecoveryError(f"Repair failed: {e}") from e @@ -187,9 +205,10 @@ def vacuum_database(db_path: Path, logger_override=None) -> bool: try: log.info("Running VACUUM on database: %s", db_path) conn = sqlite3.connect(str(db_path), timeout=30.0) - - conn.execute("VACUUM") - conn.close() + try: + conn.execute("VACUUM") + finally: + conn.close() log.info("VACUUM completed successfully") return True diff --git a/qBitrr/errors.py b/qBitrr/errors.py index d105ba997..35168f8e4 100644 --- a/qBitrr/errors.py +++ b/qBitrr/errors.py @@ -23,18 +23,21 @@ class RequireConfigValue(qBitManagerError): def __init__(self, config_class: str, config_key: str): self.message = f"Config key '{config_key}' in '{config_class}' requires a value." + super().__init__(self.message) class NoConnectionrException(qBitManagerError): - def __init__(self, message: str, type: str = "delay"): + def __init__(self, message: str, error_type: str = "delay"): self.message = message - self.type = type + self.type = error_type + super().__init__(message) class DelayLoopException(qBitManagerError): - def __init__(self, length: int, type: str): - self.type = type + def __init__(self, length: int, error_type: str): + self.type = error_type self.length = length + super().__init__(f"Delay loop: {error_type}") class RestartLoopException(ArrManagerException): diff --git a/qBitrr/ffprobe.py b/qBitrr/ffprobe.py index cdd83e58f..07c1f47b0 100644 --- a/qBitrr/ffprobe.py +++ b/qBitrr/ffprobe.py @@ -67,16 +67,17 @@ def update(self): self.logger.debug("Updating local version of FFprobe: %s", upstream_version) self.version_file.write_text(json.dumps({"version": upstream_version})) try: - os.chmod(self.probe_path, 0o777) + os.chmod(self.probe_path, 0o755) self.logger.debug("Successfully changed permissions for ffprobe") except Exception as e: self.logger.debug("Failed to change permissions for ffprobe, %s", e) def download_and_extract(self, ffprobe_url): r = requests.get(ffprobe_url) - z = zipfile.ZipFile(io.BytesIO(r.content)) - self.logger.debug("Extracting downloaded FFprobe to: %s", FF_PROBE.parent) - z.extract(member=self.probe_path.name, path=FF_PROBE.parent) + with zipfile.ZipFile(io.BytesIO(r.content)) as z: + self.logger.debug("Extracting downloaded FFprobe to: %s", FF_PROBE.parent) + z.extract(member=self.probe_path.name, path=FF_PROBE.parent) + r.close() def get_arch(self): part1 = None diff --git a/qBitrr/gen_config.py b/qBitrr/gen_config.py index 314b00f6e..7acbc88db 100755 --- a/qBitrr/gen_config.py +++ b/qBitrr/gen_config.py @@ -15,6 +15,11 @@ T = TypeVar("T") +def _default(value, fallback): + """Return value if not None, otherwise fallback. Unlike ``or``, preserves falsy values like False and 0.""" + return value if value is not None else fallback + + def _add_web_settings_section(config: TOMLDocument): web_settings = table() _gen_default_line( @@ -103,67 +108,70 @@ def _add_settings_section(config: TOMLDocument): settings, "Level of logging; One of CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG, TRACE", "ConsoleLevel", - ENVIRO_CONFIG.settings.console_level or "INFO", + _default(ENVIRO_CONFIG.settings.console_level, "INFO"), ) _gen_default_line( - settings, "Enable logging to files", "Logging", ENVIRO_CONFIG.settings.logging or True + settings, + "Enable logging to files", + "Logging", + _default(ENVIRO_CONFIG.settings.logging, True), ) _gen_default_line( settings, "Folder where your completed downloads are put into. Can be found in qBitTorrent -> Options -> Downloads -> Default Save Path (Please note, replace all '\\' with '/')", "CompletedDownloadFolder", - ENVIRO_CONFIG.settings.completed_download_folder or "CHANGE_ME", + _default(ENVIRO_CONFIG.settings.completed_download_folder, "CHANGE_ME"), ) _gen_default_line( settings, "The desired amount of free space in the downloads directory [K=kilobytes, M=megabytes, G=gigabytes, T=terabytes] (set to -1 to disable, this bypasses AutoPauseResume)", "FreeSpace", - ENVIRO_CONFIG.settings.free_space or "-1", + _default(ENVIRO_CONFIG.settings.free_space, "-1"), ) _gen_default_line( settings, "Folder where the free space handler will check for free space (Please note, replace all '' with '/')", "FreeSpaceFolder", - ENVIRO_CONFIG.settings.free_space_folder or "CHANGE_ME", + _default(ENVIRO_CONFIG.settings.free_space_folder, "CHANGE_ME"), ) _gen_default_line( settings, "Enable automation of pausing and resuming torrents as needed (Required enabled for the FreeSpace logic to function)", "AutoPauseResume", - ENVIRO_CONFIG.settings.auto_pause_resume or True, + _default(ENVIRO_CONFIG.settings.auto_pause_resume, True), ) _gen_default_line( settings, "Time to sleep for if there is no internet (in seconds: 600 = 10 Minutes)", "NoInternetSleepTimer", - ENVIRO_CONFIG.settings.no_internet_sleep_timer or 15, + _default(ENVIRO_CONFIG.settings.no_internet_sleep_timer, 15), ) _gen_default_line( settings, "Time to sleep between reprocessing torrents (in seconds: 600 = 10 Minutes)", "LoopSleepTimer", - ENVIRO_CONFIG.settings.loop_sleep_timer or 5, + _default(ENVIRO_CONFIG.settings.loop_sleep_timer, 5), ) _gen_default_line( settings, "Time to sleep between posting search commands (in seconds: 600 = 10 Minutes)", "SearchLoopDelay", - ENVIRO_CONFIG.settings.search_loop_delay or -1, + _default(ENVIRO_CONFIG.settings.search_loop_delay, -1), ) _gen_default_line( settings, "Add torrents to this category to mark them as failed", "FailedCategory", - ENVIRO_CONFIG.settings.failed_category or "failed", + _default(ENVIRO_CONFIG.settings.failed_category, "failed"), ) _gen_default_line( settings, "Add torrents to this category to trigger them to be rechecked properly", "RecheckCategory", - ENVIRO_CONFIG.settings.recheck_category or "recheck", + _default(ENVIRO_CONFIG.settings.recheck_category, "recheck"), ) _gen_default_line( - settings, "Tagless operation", "Tagless", ENVIRO_CONFIG.settings.tagless or False + settings, "Tagless operation", "Tagless", _default(ENVIRO_CONFIG.settings.tagless, False) ) _gen_default_line( settings, @@ -172,7 +180,7 @@ def _add_settings_section(config: TOMLDocument): "Only applicable to Re-check and failed categories", ], "IgnoreTorrentsYoungerThan", - ENVIRO_CONFIG.settings.ignore_torrents_younger_than or 180, + _default(ENVIRO_CONFIG.settings.ignore_torrents_younger_than, 180), ) _gen_default_line( settings, @@ -181,7 +189,7 @@ def _add_settings_section(config: TOMLDocument): "These will be pinged a **LOT** make sure the service is okay with you sending all the continuous pings.", ], "PingURLS", - ENVIRO_CONFIG.settings.ping_urls or ["one.one.one.one", "dns.google.com"], + _default(ENVIRO_CONFIG.settings.ping_urls, ["one.one.one.one", "dns.google.com"]), ) _gen_default_line( settings, @@ -220,7 +228,7 @@ def _add_settings_section(config: TOMLDocument): "Default is weekly Sunday at 03:00 (0 3 * * 0).", ], "AutoUpdateCron", - ENVIRO_CONFIG.settings.auto_update_cron or "0 3 * * 0", + _default(ENVIRO_CONFIG.settings.auto_update_cron, "0 3 * * 0"), ) _gen_default_line( settings, @@ -274,25 +282,25 @@ def _add_qbit_section(config: TOMLDocument): qbit, 'qbittorrent WebUI URL/IP - Can be found in Options > Web UI (called "IP Address")', "Host", - ENVIRO_CONFIG.qbit.host or "CHANGE_ME", + _default(ENVIRO_CONFIG.qbit.host, "CHANGE_ME"), ) _gen_default_line( qbit, 'qbittorrent WebUI Port - Can be found in Options > Web UI (called "Port" on top right corner of the window)', "Port", - ENVIRO_CONFIG.qbit.port or 8080, + _default(ENVIRO_CONFIG.qbit.port, 8080), ) _gen_default_line( qbit, "qbittorrent WebUI Authentication - Can be found in Options > Web UI > Authentication", "UserName", - ENVIRO_CONFIG.qbit.username or "CHANGE_ME", + _default(ENVIRO_CONFIG.qbit.username, "CHANGE_ME"), ) _gen_default_line( qbit, 'If you set "Bypass authentication on localhost or whitelisted IPs" remove this field.', "Password", - ENVIRO_CONFIG.qbit.password or "CHANGE_ME", + _default(ENVIRO_CONFIG.qbit.password, "CHANGE_ME"), ) _gen_default_line( qbit, diff --git a/qBitrr/main.py b/qBitrr/main.py index 7f6f48bb1..23195f2f3 100644 --- a/qBitrr/main.py +++ b/qBitrr/main.py @@ -5,6 +5,7 @@ import glob import logging import os +import re import signal import sys import time @@ -282,10 +283,10 @@ def _restart(): # Force kill any remaining child processes for proc in list(self.child_processes): - with contextlib.suppress(Exception): - proc.kill() with contextlib.suppress(Exception): proc.terminate() + with contextlib.suppress(Exception): + proc.kill() # Close database connections explicitly try: @@ -1408,9 +1409,7 @@ def _report_config_issues(): issues.append("Settings.FreeSpaceFolder must be set when FreeSpace is enabled") # Check Arr sections for key in CONFIG.sections(): - import re - - m = re.match(r"radarr.*", key, re.IGNORECASE) + m = re.match(r"(rad|son|anim|lid)arr.*", key, re.IGNORECASE) if not m: continue managed = CONFIG.get(f"{key}.Managed", fallback=False) @@ -1479,10 +1478,10 @@ def _cleanup(): with contextlib.suppress(Exception): p.join(timeout=5) for p in manager.child_processes: - with contextlib.suppress(Exception): - p.kill() with contextlib.suppress(Exception): p.terminate() + with contextlib.suppress(Exception): + p.kill() # Register signal handlers for graceful shutdown def _signal_handler(signum, frame): diff --git a/qBitrr/search_activity_store.py b/qBitrr/search_activity_store.py index e33bba819..72dd6dcb9 100644 --- a/qBitrr/search_activity_store.py +++ b/qBitrr/search_activity_store.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from threading import RLock from typing import Any @@ -7,6 +8,8 @@ from qBitrr.database import get_database +logger = logging.getLogger("qBitrr.SearchActivityStore") + _DB_LOCK = RLock() @@ -32,25 +35,27 @@ def record_search_activity(category: str, summary: str | None, timestamp: str | if timestamp is not None and not isinstance(timestamp, str): timestamp = str(timestamp) data: dict[str, Any] = {"summary": summary, "timestamp": timestamp} - with get_database().atomic(): - SearchActivity.insert(category=category, **data).on_conflict( - conflict_target=[SearchActivity.category], - update=data, - ).execute() + with _DB_LOCK: + with get_database().atomic(): + SearchActivity.insert(category=category, **data).on_conflict( + conflict_target=[SearchActivity.category], + update=data, + ).execute() def fetch_search_activities() -> dict[str, dict[str, str | None]]: _ensure_tables() activities: dict[str, dict[str, str | None]] = {} try: - query = SearchActivity.select() - except Exception: - return activities - for row in query: - activities[str(row.category)] = { - "summary": row.summary, - "timestamp": row.timestamp, - } + with _DB_LOCK: + query = SearchActivity.select() + for row in query: + activities[str(row.category)] = { + "summary": row.summary, + "timestamp": row.timestamp, + } + except Exception as e: + logger.error("Failed to fetch search activities: %s", e) return activities @@ -58,5 +63,6 @@ def clear_search_activity(category: str) -> None: if not category: return _ensure_tables() - with get_database().atomic(): - SearchActivity.delete().where(SearchActivity.category == category).execute() + with _DB_LOCK: + with get_database().atomic(): + SearchActivity.delete().where(SearchActivity.category == category).execute() diff --git a/qBitrr/tables.py b/qBitrr/tables.py index 1c139bdcb..393d95b9f 100644 --- a/qBitrr/tables.py +++ b/qBitrr/tables.py @@ -12,7 +12,7 @@ class MoviesFilesModel(Model): TmdbId = IntegerField() Year = IntegerField() ArrInstance = CharField(null=True, default="") - EntryId = IntegerField(unique=True) + EntryId = IntegerField(primary_key=True) Searched = BooleanField(default=False) MovieFileId = IntegerField() IsRequest = BooleanField(default=False) @@ -91,7 +91,7 @@ class AlbumFilesModel(Model): Monitored = BooleanField() ForeignAlbumId = CharField() ReleaseDate = DateTimeField(formats=["%Y-%m-%d %H:%M:%S.%f"], null=True) - EntryId = IntegerField(unique=True) + EntryId = IntegerField(primary_key=True) ArrInstance = CharField(null=True, default="") Searched = BooleanField(default=False) AlbumFileId = IntegerField() diff --git a/qBitrr/utils.py b/qBitrr/utils.py index da44b8439..27b2f7dce 100644 --- a/qBitrr/utils.py +++ b/qBitrr/utils.py @@ -70,40 +70,19 @@ def validate_and_return_torrent_file(file: str) -> pathlib.Path: path = pathlib.Path(file) if path.is_file(): path = path.parent.absolute() - count = 9 - while not path.exists(): + for attempt in range(10): + if path.exists() and str(path) != ".": + return path logger.debug( "Attempt %s/10: File does not yet exist! (Possibly being moved?) | " "%s | Sleeping for 0.1s", - 10 - count, + attempt + 1, path, ) time.sleep(0.1) - if count == 0: - break - count -= 1 - else: - count = 0 - while str(path) == ".": path = pathlib.Path(file) if path.is_file(): path = path.parent.absolute() - while not path.exists(): - logger.debug( - "Attempt %s/10: File does not yet exist! (Possibly being moved?) | " - "%s | Sleeping for 0.1s", - 10 - count, - path, - ) - time.sleep(0.1) - if count == 0: - break - count -= 1 - else: - count = 0 - if count == 0: - break - count -= 1 return path @@ -162,6 +141,7 @@ def is_connected(hostname): return True except ping3.errors.PingError as e: # All ping3 errors are subclasses of `PingError`. logger.debug("Error when connecting to host: %s %s", hostname, e) + return False except ( Exception ): # Ping3 is far more robust but may requite root access, if root access is not available then run the basic mode @@ -264,7 +244,6 @@ def __update__(self): for k, b in self.container.copy().items(): if time.time() - b > self.age: del self.container[k] - return False def __eq__(self, other): if not isinstance(other, ExpiringSet): diff --git a/qBitrr/webui.py b/qBitrr/webui.py index c32a023c0..c5f928076 100644 --- a/qBitrr/webui.py +++ b/qBitrr/webui.py @@ -140,7 +140,7 @@ def add_cache_headers(response): "binary_download_size": None, "binary_download_error": None, } - self._version_cache_expiry = datetime.utcnow() - timedelta(seconds=1) + self._version_cache_expiry = datetime.now(timezone.utc) - timedelta(seconds=1) self._update_state = { "in_progress": False, "last_result": None, @@ -206,7 +206,7 @@ def _fetch_current_version_changelog(self) -> dict[str, Any]: } def _ensure_version_info(self, force: bool = False) -> dict[str, Any]: - now = datetime.utcnow() + now = datetime.now(timezone.utc) with self._version_lock: if not force and now < self._version_cache_expiry: snapshot = dict(self._version_cache) @@ -302,7 +302,7 @@ def _run_manual_update(self) -> None: error_message = str(exc) self.logger.exception("Manual update failed") finally: - completed_at = datetime.utcnow().isoformat() + completed_at = datetime.now(timezone.utc).isoformat() with self._version_lock: self._update_state.update( { @@ -313,7 +313,7 @@ def _run_manual_update(self) -> None: } ) self._update_thread = None - self._version_cache_expiry = datetime.utcnow() - timedelta(seconds=1) + self._version_cache_expiry = datetime.now(timezone.utc) - timedelta(seconds=1) try: self.manager.configure_auto_update() except Exception: @@ -1372,7 +1372,7 @@ def _authorized(): supplied = request.headers.get("Authorization", "").removeprefix( "Bearer " ) or request.args.get("token") - return supplied == self.token + return secrets.compare_digest(supplied, self.token) if supplied else False def require_token(): if not _authorized(): @@ -1553,7 +1553,6 @@ def _collect_metrics(arr_obj): queue_count = len(records) if queue_count: metrics["queue"] = queue_count - records[0] if qbit_client and category: try: torrents = qbit_client.torrents_info( @@ -1810,6 +1809,8 @@ def api_restart_process(category: str, kind: str): @app.post("/web/processes///restart") def web_restart_process(category: str, kind: str): + if (resp := require_token()) is not None: + return resp return _restart_process(category, kind) @app.post("/api/processes/restart_all") @@ -1821,6 +1822,8 @@ def api_restart_all(): @app.post("/web/processes/restart_all") def web_restart_all(): + if (resp := require_token()) is not None: + return resp self._reload_all() return jsonify({"status": "ok"}) @@ -1847,6 +1850,8 @@ def api_loglevel(): @app.post("/web/loglevel") def web_loglevel(): + if (resp := require_token()) is not None: + return resp body = request.get_json(silent=True) or {} level = str(body.get("level", "INFO")).upper() valid = {"CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG", "TRACE"} @@ -1873,6 +1878,8 @@ def api_arr_rebuild(): @app.post("/web/arr/rebuild") def web_arr_rebuild(): + if (resp := require_token()) is not None: + return resp self._reload_all() return jsonify({"status": "ok"}) @@ -2325,6 +2332,8 @@ def api_update(): @app.post("/web/update") def web_update(): + if (resp := require_token()) is not None: + return resp ok, message = self._trigger_manual_update() if not ok: return jsonify({"error": message}), 409 @@ -2547,6 +2556,8 @@ def api_arr_restart(section: str): @app.post("/web/arr/
/restart") def web_arr_restart(section: str): + if (resp := require_token()) is not None: + return resp managed = _managed_objects() if not managed: if not _ensure_arr_manager_ready(): @@ -2746,6 +2757,8 @@ def api_update_config(): @app.post("/web/config") def web_update_config(): + if (resp := require_token()) is not None: + return resp return _handle_config_update() @app.post("/api/arr/test-connection") @@ -2925,8 +2938,9 @@ def web_arr_test_connection(): """ Test connection to Arr instance without saving config. Accepts temporary URI/APIKey and returns connection status + quality profiles. - Public endpoint (mirrors /api/arr/test-connection). """ + if (resp := require_token()) is not None: + return resp try: data = request.get_json() if not data: diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 64780f05f..5742775d6 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type JSX, lazy, Suspense } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState, type JSX, lazy, Suspense } from "react"; const ProcessesView = lazy(() => import("./pages/ProcessesView").then(module => ({ default: module.ProcessesView }))); const LogsView = lazy(() => import("./pages/LogsView").then(module => ({ default: module.LogsView }))); const ArrView = lazy(() => import("./pages/ArrView").then(module => ({ default: module.ArrView }))); @@ -26,6 +26,38 @@ import QbitIcon from "./icons/qbittorrent.svg"; import ConfigIcon from "./icons/gear.svg"; import LogoIcon from "./icons/logo.svg"; +class ErrorBoundary extends React.Component< + { children: React.ReactNode }, + { hasError: boolean; error: Error | null } +> { + constructor(props: { children: React.ReactNode }) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error) { + return { hasError: true, error }; + } + + render() { + if (this.state.hasError) { + return ( +
+

Something went wrong

+

{this.state.error?.message}

+ +
+ ); + } + return this.props.children; + } +} + type Tab = "processes" | "logs" | "radarr" | "sonarr" | "lidarr" | "qbittorrent" | "config"; interface NavTab { @@ -840,13 +872,15 @@ function AppShell(): JSX.Element { export default function App(): JSX.Element { return ( - - - - - - - - + + + + + + + + + + ); } diff --git a/webui/src/api/client.ts b/webui/src/api/client.ts index 16b3f4b60..c5c5c0a5e 100644 --- a/webui/src/api/client.ts +++ b/webui/src/api/client.ts @@ -45,6 +45,9 @@ function resolveToken(): string | null { const fromQuery = params.get("token"); if (fromQuery) { localStorage.setItem("token", fromQuery); + const cleanUrl = new URL(window.location.href); + cleanUrl.searchParams.delete("token"); + window.history.replaceState({}, "", cleanUrl.toString()); return fromQuery; } } catch { diff --git a/webui/src/components/ConfirmDialog.tsx b/webui/src/components/ConfirmDialog.tsx index 1c5070096..1b20f97ac 100644 --- a/webui/src/components/ConfirmDialog.tsx +++ b/webui/src/components/ConfirmDialog.tsx @@ -26,10 +26,13 @@ export function ConfirmDialog({
e.stopPropagation()} >
-

{title}

+

{title}

diff --git a/webui/src/components/CopyButton.tsx b/webui/src/components/CopyButton.tsx index ee6fe4ea0..4bfde1d32 100644 --- a/webui/src/components/CopyButton.tsx +++ b/webui/src/components/CopyButton.tsx @@ -1,4 +1,4 @@ -import { useState, type JSX } from "react"; +import { useEffect, useRef, useState, type JSX } from "react"; import { copyToClipboard } from "../utils/clipboard"; interface CopyButtonProps { @@ -15,13 +15,19 @@ export function CopyButton({ onCopy, }: CopyButtonProps): JSX.Element { const [copied, setCopied] = useState(false); + const timerRef = useRef>(undefined); + + useEffect(() => { + return () => { clearTimeout(timerRef.current); }; + }, []); const handleCopy = async () => { const success = await copyToClipboard(text); if (success) { setCopied(true); onCopy?.(); - setTimeout(() => setCopied(false), 2000); + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setCopied(false), 2000); } }; diff --git a/webui/src/components/TagInput.tsx b/webui/src/components/TagInput.tsx index b37698825..494f7b5ee 100644 --- a/webui/src/components/TagInput.tsx +++ b/webui/src/components/TagInput.tsx @@ -33,7 +33,7 @@ export function TagInput({ value, onChange, placeholder, disabled }: TagInputPro
{value.map((tag, index) => ( - + {tag} {!disabled && (
@@ -2514,7 +2513,7 @@ function FieldGroup({ } placeholder="Select temp profile..." isClearable - styles={getSelectStyles()} + styles={selectStyles} classNamePrefix="react-select" />
@@ -2722,7 +2721,7 @@ function FieldGroup({ localStorage.setItem("theme", theme); } }} - styles={getSelectStyles()} + styles={selectStyles} /> {description &&
{description}
} {isThemeField &&
Theme changes apply immediately
} diff --git a/webui/src/pages/LidarrView.tsx b/webui/src/pages/LidarrView.tsx index 4a24d6ab1..de483abeb 100644 --- a/webui/src/pages/LidarrView.tsx +++ b/webui/src/pages/LidarrView.tsx @@ -32,7 +32,6 @@ import { useDebounce } from "../hooks/useDebounce"; import { useDataSync } from "../hooks/useDataSync"; import { IconImage } from "../components/IconImage"; import RefreshIcon from "../icons/refresh-arrow.svg"; -import RestartIcon from "../icons/refresh-arrow.svg"; interface LidarrAggRow extends LidarrAlbumEntry { __instance: string; @@ -573,26 +572,6 @@ function LidarrInstanceView({ const isFiltered = reasonFilter !== "all" || onlyMissing; const filteredCount = reasonFilteredAlbums.length; - // Count total tracks for instance view - // Track counts (for potential future use) - // const totalTracks = useMemo(() => { - // let count = 0; - // allAlbums.forEach(entry => { - // const tracks = entry.tracks || []; - // count += tracks.length; - // }); - // return count; - // }, [allAlbums]); - - // const filteredTracks = useMemo(() => { - // let count = 0; - // reasonFilteredAlbums.forEach(entry => { - // const tracks = entry.tracks || []; - // count += tracks.length; - // }); - // return count; - // }, [reasonFilteredAlbums]); - // Reset flat page when filters change useEffect(() => { setFlatPage(0); @@ -777,7 +756,7 @@ function LidarrInstanceView({ {lastUpdated ? ` (updated ${lastUpdated})` : ""}
@@ -1365,7 +1344,7 @@ export function LidarrView({ active }: { active: boolean }): JSX.Element { } finally { setAggLoading(false); } - }, [instances, globalSearch, push, aggFilter, groupLidarr]); + }, [instances, globalSearch, push, aggFilter, groupLidarr, aggSummary]); // LiveArr is now loaded via WebUIContext, no need to load config here diff --git a/webui/src/pages/LogsView.tsx b/webui/src/pages/LogsView.tsx index d75259730..292845512 100644 --- a/webui/src/pages/LogsView.tsx +++ b/webui/src/pages/LogsView.tsx @@ -1,7 +1,8 @@ -import { useCallback, useEffect, useRef, useState, type JSX } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from "react"; import { LazyLog } from "@melloware/react-logviewer"; -import { getConfig, getLogDownloadUrl, getLogs } from "../api/client"; +import { getLogDownloadUrl, getLogTail, getLogs } from "../api/client"; import { useToast } from "../context/ToastContext"; +import { useWebUI } from "../context/WebUIContext"; import { useInterval } from "../hooks/useInterval"; import { IconImage } from "../components/IconImage"; import { CopyButton } from "../components/CopyButton"; @@ -12,9 +13,7 @@ interface LogOption { label: string; } -// Helper function for react-select theme-aware styles -const getSelectStyles = (): StylesConfig => { - const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; +const getSelectStyles = (isDark: boolean): StylesConfig => { return { control: (base: CSSObjectWithLabel) => ({ ...base, @@ -70,6 +69,16 @@ interface LogsViewProps { active: boolean; } +function describeError(reason: unknown, context: string): string { + if (reason instanceof Error && reason.message) { + return `${context}: ${reason.message}`; + } + if (typeof reason === "string" && reason.trim().length) { + return `${context}: ${reason}`; + } + return context; +} + export function LogsView({ active }: LogsViewProps): JSX.Element { const [files, setFiles] = useState([]); const [selected, setSelected] = useState("All.log"); @@ -77,20 +86,10 @@ export function LogsView({ active }: LogsViewProps): JSX.Element { const [follow, setFollow] = useState(true); const [loadingList, setLoadingList] = useState(false); const [loadingContent, setLoadingContent] = useState(false); - const [tokenReady, setTokenReady] = useState(false); - const tokenRef = useRef(""); const lastLinesCountRef = useRef(0); const { push } = useToast(); - - const describeError = useCallback((reason: unknown, context: string): string => { - if (reason instanceof Error && reason.message) { - return `${context}: ${reason.message}`; - } - if (typeof reason === "string" && reason.trim().length) { - return `${context}: ${reason}`; - } - return context; - }, []); + const { theme } = useWebUI(); + const selectStyles = useMemo(() => getSelectStyles(theme === 'dark'), [theme]); const loadList = useCallback(async () => { setLoadingList(true); @@ -115,24 +114,7 @@ export function LogsView({ active }: LogsViewProps): JSX.Element { } finally { setLoadingList(false); } - }, [describeError, push]); - - // Fetch and cache the WebUI token from config on mount - useEffect(() => { - const fetchToken = async () => { - try { - const config = await getConfig(); - // Extract token from WebUI.Token field - const webui = config?.WebUI as { Token?: string } | undefined; - tokenRef.current = webui?.Token || ""; - } catch { - // token fetch failed, will proceed without token - } finally { - setTokenReady(true); - } - }; - void fetchToken(); - }, []); + }, [push]); useEffect(() => { void loadList(); @@ -144,23 +126,10 @@ export function LogsView({ active }: LogsViewProps): JSX.Element { if (showLoading) setLoadingContent(true); try { - const params = new URLSearchParams(); - if (tokenRef.current) { - params.set("token", tokenRef.current); - } - - const response = await fetch( - `/web/logs/${encodeURIComponent(selected)}?${params}` - ); - if (!response.ok) { - throw new Error(`${response.status} ${response.statusText}`); - } - - const newContent = await response.text(); + const newContent = await getLogTail(selected); const newLines = newContent.split('\n'); const currentLinesCount = newLines.length; - // Only update if content has changed (new lines added) if (currentLinesCount !== lastLinesCountRef.current) { setLogContent(newContent); lastLinesCountRef.current = currentLinesCount; @@ -171,22 +140,19 @@ export function LogsView({ active }: LogsViewProps): JSX.Element { if (showLoading) setLoadingContent(false); } }, - [selected, push, describeError] + [selected, push] ); useEffect(() => { - if (selected && tokenReady) { + if (selected) { lastLinesCountRef.current = 0; void fetchLogContent(true); } - }, [selected, tokenReady, fetchLogContent]); + }, [selected, fetchLogContent]); - // Refresh content periodically when tab is active useInterval( () => { - if (tokenReady) { - void fetchLogContent(false); - } + void fetchLogContent(false); }, active ? 1000 : null ); @@ -206,7 +172,7 @@ export function LogsView({ active }: LogsViewProps): JSX.Element { value={selected ? { value: selected, label: selected } : null} onChange={(option) => setSelected(option?.value || "")} isDisabled={!files.length} - styles={getSelectStyles()} + styles={selectStyles} />
diff --git a/webui/src/pages/ProcessesView.tsx b/webui/src/pages/ProcessesView.tsx index 29cd70d2c..25d5169a2 100644 --- a/webui/src/pages/ProcessesView.tsx +++ b/webui/src/pages/ProcessesView.tsx @@ -14,10 +14,10 @@ import { IconImage } from "../components/IconImage"; import { ConfirmDialog } from "../components/ConfirmDialog"; import RefreshIcon from "../icons/refresh-arrow.svg"; -import RestartIcon from "../icons/refresh-arrow.svg"; import ToolsIcon from "../icons/build.svg"; -const RELEASE_TOKEN_REGEX = +const + = /\b(480p|576p|720p|1080p|2160p|4k|8k|web[-_. ]?(?:dl|rip)|hdrip|hdtv|bluray|bd(?:rip)?|brrip|webrip|remux|x264|x265|hevc|dts|truehd|atmos|proper|repack|dvdrip|hdr|amzn|nf)\b/i; const EPISODE_TOKEN_REGEX = /\bS\d{1,3}E\d{1,3}\b/i; const SEASON_TOKEN_REGEX = /\bSeason\s+\d+\b/i; @@ -40,7 +40,8 @@ function sanitizeSearchSummary(raw: string): string { const rest = releaseMatch.groups?.rest ?? ""; const looksLikeEpisode = EPISODE_TOKEN_REGEX.test(rest) || SEASON_TOKEN_REGEX.test(rest); - if (rest && !looksLikeEpisode && RELEASE_TOKEN_REGEX.test(rest)) { + if (rest && !looksLikeEpisode && + .test(rest)) { const rawTitle = releaseMatch.groups?.title ?? ""; const cleanedTitle = rawTitle .replace(/[-_.]/g, " ") @@ -191,7 +192,7 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element { [load, push] ); - const handleRestartAll = useCallback(async () => { + const handleRestartAll = useCallback(() => { setConfirmAction({ title: "Restart All Processes", message: "Are you sure you want to restart all processes? This will temporarily interrupt all operations.", @@ -214,7 +215,7 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element { }); }, [load, push]); - const handleRebuildArrs = useCallback(async () => { + const handleRebuildArrs = useCallback(() => { setConfirmAction({ title: "Rebuild Arrs", message: "Are you sure you want to rebuild all Arr instances? This will refresh all connections and may take some time.", @@ -537,7 +538,7 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element { @@ -758,7 +756,7 @@ export function RadarrView({ active }: { active: boolean }): JSX.Element { } finally { setAggLoading(false); } - }, [instances, globalSearch, push, aggFilter]); + }, [instances, globalSearch, push, aggFilter, aggSummary]); // LiveArr is now loaded via WebUIContext, no need to load config here @@ -963,15 +961,6 @@ export function RadarrView({ active }: { active: boolean }): JSX.Element { ); }, []); - const handleInstanceRefresh = useCallback(() => { - if (selection && selection !== "aggregate") { - void fetchInstance(selection, instancePage, instanceQuery, { - preloadAll: false, - showLoading: true, - }); - } - }, [selection, instancePage, instanceQuery, fetchInstance]); - const handleInstanceSelection = useCallback( (event: ChangeEvent) => { const next = (event.target.value || "aggregate") as string | "aggregate"; @@ -1100,7 +1089,6 @@ export function RadarrView({ active }: { active: boolean }): JSX.Element { preloadAll: true, }); }} - onRefresh={handleInstanceRefresh} onRestart={() => void handleRestart()} lastUpdated={lastUpdated} /> diff --git a/webui/src/pages/SonarrView.tsx b/webui/src/pages/SonarrView.tsx index 7853515e4..42d3e324f 100644 --- a/webui/src/pages/SonarrView.tsx +++ b/webui/src/pages/SonarrView.tsx @@ -81,7 +81,11 @@ function filterSeriesEntriesForMissing(seriesEntries: SonarrSeriesEntry[], onlyM } function createFilteredSignature(seriesEntries: SonarrSeriesEntry[], onlyMissing: boolean): string { - return JSON.stringify(filterSeriesEntriesForMissing(seriesEntries, onlyMissing)); + const filtered = filterSeriesEntriesForMissing(seriesEntries, onlyMissing); + if (filtered.length === 0) return "empty"; + const first = filtered[0]; + const last = filtered[filtered.length - 1]; + return `${filtered.length}:${first?.series?.["title"] ?? ''}:${last?.series?.["title"] ?? ''}:${onlyMissing}`; } export function SonarrView({ active }: SonarrViewProps): JSX.Element { @@ -426,7 +430,7 @@ export function SonarrView({ active }: SonarrViewProps): JSX.Element { } finally { setAggLoading(false); } - }, [instances, globalSearch, push, onlyMissing, aggFilter]); + }, [instances, globalSearch, push, onlyMissing, aggFilter, aggSummary]); useEffect(() => { if (!active) return; @@ -458,7 +462,8 @@ export function SonarrView({ active }: SonarrViewProps): JSX.Element { showLoading: true, missingOnly: onlyMissing, }); - }, [active, selection, onlyMissing, fetchInstance, instancePage]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- instancePage excluded to prevent infinite loop + }, [active, selection, onlyMissing, fetchInstance]); useEffect(() => { if (!active) return; @@ -557,21 +562,10 @@ export function SonarrView({ active }: SonarrViewProps): JSX.Element { const isAggFiltered = Boolean(debouncedAggFilter) || onlyMissing || reasonFilter !== "all"; - const sortedAggRows = filteredAggRows; - const aggPages = Math.max( 1, - Math.ceil(sortedAggRows.length / SONARR_AGG_PAGE_SIZE) + Math.ceil(filteredAggRows.length / SONARR_AGG_PAGE_SIZE) ); - // Paginated rows (for potential future use) - // const aggPageRows = useMemo( - // () => sortedAggRows.slice( - // aggPage * SONARR_AGG_PAGE_SIZE, - // aggPage * SONARR_AGG_PAGE_SIZE + SONARR_AGG_PAGE_SIZE - // ), - // [sortedAggRows, aggPage] - // ); - const currentSeries = instancePages[instancePage] ?? []; const allSeries = useMemo(() => { @@ -708,8 +702,8 @@ export function SonarrView({ active }: SonarrViewProps): JSX.Element { {isAggregate ? ( Date: Sat, 28 Feb 2026 23:43:40 +0100 Subject: [PATCH 51/66] chore: codebase review fixes - security, quality, consistency --- .github/workflows/codeql.yml | 1 + .github/workflows/nightly.yml | 2 +- .github/workflows/pull_requests.yml | 2 +- .github/workflows/release.yml | 2 +- .pre-commit-config.yaml | 2 +- Dockerfile | 11 +- docs/advanced/architecture.md | 12 +- pyproject.toml | 33 -- qBitrr/arss.py | 590 ++++++++-------------------- qBitrr/auto_update.py | 4 +- qBitrr/bundled_data.py | 2 +- qBitrr/config.py | 6 +- qBitrr/config_version.py | 22 +- qBitrr/errors.py | 18 +- qBitrr/ffprobe.py | 3 +- qBitrr/gen_config.py | 8 +- qBitrr/home_path.py | 2 +- qBitrr/logger.py | 4 +- qBitrr/main.py | 20 +- qBitrr/tables.py | 32 ++ qBitrr/utils.py | 8 +- qBitrr/webui.py | 541 ++++++++----------------- setup.cfg | 1 - webui/src/api/client.ts | 31 +- webui/src/api/types.ts | 6 +- webui/src/pages/LogsView.tsx | 11 +- 26 files changed, 435 insertions(+), 939 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b320cc14e..107f82821 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -21,6 +21,7 @@ jobs: matrix: language: - python + - javascript-typescript steps: - name: Checkout repository uses: actions/checkout@v6 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index c13ada6ee..037c33fef 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -65,7 +65,7 @@ jobs: uses: docker/build-push-action@v6 with: context: . - platforms: linux/amd64 + platforms: linux/${{ matrix.platform }} push: true tags: feramance/qbitrr:nightly,ghcr.io/${{ steps.string.outputs.lowercase }}:nightly labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/pull_requests.yml b/.github/workflows/pull_requests.yml index 839075494..0ddbb6dd8 100644 --- a/.github/workflows/pull_requests.yml +++ b/.github/workflows/pull_requests.yml @@ -65,7 +65,7 @@ jobs: RELEASE_HASH: ${{ steps.git_hash.outputs.HASH }} run: "python - <<'PY'\nimport os\nfrom pathlib import Path\nimport re\n\nrelease_hash = os.environ[\"RELEASE_HASH\"]\npath = Path(\"qBitrr/bundled_data.py\")\ncontent = path.read_text()\nupdated = re.sub(r'git_hash = \".*\"', f'git_hash = \"{release_hash}\"', content)\npath.write_text(updated)\nPY\n" - name: Retrieve current version - run: "echo \"Current version: $(python setup.py --version)\"\necho \"VERSION=$(python setup.py --version)\" >> $GITHUB_OUTPUT\n" + run: "VERSION=$(python -c \"import configparser; c = configparser.ConfigParser(); c.read('setup.cfg'); print(c['metadata']['version'])\")\necho \"Current version: ${VERSION}\"\necho \"VERSION=${VERSION}\" >> $GITHUB_OUTPUT\n" id: current_version - name: Set up Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eeca74fbf..eefb5aa34 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,7 +61,7 @@ jobs: run: "bump2version \"${RELEASE_TYPE}\"\n" - name: Capture new version id: version - run: "VERSION=$(python setup.py --version)\necho \"value=${VERSION}\" >> \"${GITHUB_OUTPUT}\"\necho \"New version: ${VERSION}\"\n" + run: "VERSION=$(python -c \"import configparser; c = configparser.ConfigParser(); c.read('setup.cfg'); print(c['metadata']['version'])\")\necho \"value=${VERSION}\" >> \"${GITHUB_OUTPUT}\"\necho \"New version: ${VERSION}\"\n" - name: Import GPG key id: import_gpg uses: crazy-max/ghaction-import-gpg@v6.3.0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ba124fa16..5b91854cd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,7 +19,7 @@ repos: rev: v3.21.0 hooks: - id: pyupgrade - args: [--py38-plus] + args: [--py311-plus] - repo: https://github.com/pycqa/isort rev: 7.0.0 hooks: diff --git a/Dockerfile b/Dockerfile index aea06a58d..5569b50fa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,8 +6,7 @@ RUN cd webui && npm ci COPY webui webui RUN mkdir -p qBitrr/static && cd webui && npm run build -# Pin Python to the latest supported version we support in production -FROM python:3.14 +FROM python:3.12-slim LABEL Name="qBitrr" LABEL Maintainer="feramance" @@ -25,11 +24,15 @@ ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 ENV PYTHONOPTIMIZE=1 -RUN pip install --quiet -U pip wheel +RUN pip install --no-cache-dir --quiet -U pip wheel WORKDIR /app COPY . /app COPY --from=webui-build /src/qBitrr/static/ /app/qBitrr/static/ -RUN rm -rf qBitrr2.egg-info *.egg-info && pip install --quiet ".[fast]" +RUN rm -rf qBitrr2.egg-info *.egg-info && pip install --no-cache-dir --quiet ".[fast]" + +RUN groupadd -r qbitrr && useradd -r -g qbitrr -d /config -s /sbin/nologin qbitrr \ + && mkdir -p /config && chown -R qbitrr:qbitrr /config /app +USER qbitrr WORKDIR /config diff --git a/docs/advanced/architecture.md b/docs/advanced/architecture.md index 2958f91c3..1ed109287 100644 --- a/docs/advanced/architecture.md +++ b/docs/advanced/architecture.md @@ -939,7 +939,7 @@ def run_loop(self): time.sleep(LOOP_SLEEP_TIMER) except DelayLoopException as e: - logger.warning(f"Delaying loop for {e.length}s: {e.type}") + logger.warning(f"Delaying loop for {e.length}s: {e.error_type}") time.sleep(e.length) except RestartLoopException: @@ -970,9 +970,9 @@ if torrent['category'] not in self.categories: ```python class DelayLoopException(qBitManagerError): - def __init__(self, length: int, type: str): - self.type = type # Reason for delay - self.length = length # Seconds to delay + def __init__(self, length: int, error_type: str): + self.error_type = error_type # Reason for delay + self.length = length # Seconds to delay # Usage: raised when qBittorrent or an Arr API is unreachable raise DelayLoopException(length=60, error_type="qbittorrent_offline") @@ -989,9 +989,9 @@ class RestartLoopException(ArrManagerException): ```python class NoConnectionrException(qBitManagerError): - def __init__(self, message: str, type: str = "delay"): + def __init__(self, message: str, error_type: str = "delay"): self.message = message - self.type = type # "delay" or "fatal" + self.error_type = error_type # "delay" or "fatal" ``` ### Torrent State Machine diff --git a/pyproject.toml b/pyproject.toml index 4754efd86..f621e64bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,39 +26,6 @@ known_local_folder = ["qBitrr"] line-length = 99 target-version = ['py311'] -[tool.poetry] -name = "pypi-public" -version = "5.9.1" -description = "Intelligent automation for qBittorrent and *Arr apps (Radarr/Sonarr/Lidarr) - health monitoring, instant imports, quality upgrades, request integration" -authors = ["Drapersniper", "Feramance"] -readme = "README.md" -repository = "https://github.com/Feramance/qBitrr" -documentation = "https://github.com/Feramance/qBitrr/blob/master/API_DOCUMENTATION.md" -homepage = "https://github.com/Feramance/qBitrr" -url = "https://pypi.org/simple/" -keywords = ["qbittorrent", "radarr", "sonarr", "lidarr", "arr", "automation", "torrent", "media", "plex", "jellyfin"] -classifiers = [ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "Intended Audience :: End Users/Desktop", - "Intended Audience :: System Administrators", - "License :: OSI Approved :: MIT License", - "Natural Language :: English", - "Operating System :: MacOS :: MacOS X", - "Operating System :: Microsoft :: Windows", - "Operating System :: POSIX :: Linux", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: Implementation :: CPython", - "Topic :: Communications", - "Topic :: Internet", - "Topic :: Multimedia :: Video", - "Topic :: System :: Monitoring", - "Topic :: Utilities", - "Typing :: Typed", -] - [tool.autopep8] max_line_length = 99 ignore = "E712" diff --git a/qBitrr/arss.py b/qBitrr/arss.py index 2718ee49f..de2c4cba9 100755 --- a/qBitrr/arss.py +++ b/qBitrr/arss.py @@ -10,9 +10,10 @@ import sys import time from collections import defaultdict +from collections.abc import Callable, Iterable, Iterator from copy import copy from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Callable, Iterable, Iterator, NoReturn +from typing import TYPE_CHECKING, NoReturn from urllib.parse import urlparse import ffmpeg @@ -78,11 +79,27 @@ absolute_file_paths, format_bytes, has_internet, + mask_secret, parse_size, validate_and_return_torrent_file, with_retry, ) +_ARR_RETRY_EXCEPTIONS = ( + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ContentDecodingError, + requests.exceptions.ConnectionError, + JSONDecodeError, +) + +_ARR_RETRY_EXCEPTIONS_EXTENDED = ( + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ContentDecodingError, + requests.exceptions.ConnectionError, + JSONDecodeError, + requests.exceptions.RequestException, +) + def _extract_tracker_host(uri: str) -> str: """Extract the hostname from a tracker URI for matching purposes. @@ -115,12 +132,6 @@ def _tracker_host_matches(config_uri: str, tracker_url: str) -> bool: ) -def _mask_secret(secret: str | None) -> str: - if not secret: - return "" - return "[redacted]" - - def _normalize_media_status(value: int | str | None) -> str: """Normalise Overseerr media status values across API versions.""" int_mapping = { @@ -216,7 +227,7 @@ def __init__( if not self.completed_folder.exists() and not SEARCH_ONLY: try: self.completed_folder.mkdir(parents=True, exist_ok=True) - self.completed_folder.chmod(mode=0o777) + self.completed_folder.chmod(mode=0o755) except Exception: self.logger.warning( "%s completed folder is a soft requirement. The specified folder does not exist %s and cannot be created. This will disable all file monitoring.", @@ -624,7 +635,7 @@ def __init__( self.re_search, self.category, self.uri, - _mask_secret(self.apikey), + mask_secret(self.apikey), self.refresh_downloads_timer, self.rss_sync_timer, ) @@ -670,7 +681,7 @@ def __init__( self.logger.debug("Script Config: SearchOmbiRequests=%s", self.ombi_search_requests) if self.ombi_search_requests: self.logger.debug("Script Config: OmbiURI=%s", self.ombi_uri) - self.logger.debug("Script Config: OmbiAPIKey=%s", _mask_secret(self.ombi_api_key)) + self.logger.debug("Script Config: OmbiAPIKey=%s", mask_secret(self.ombi_api_key)) self.logger.debug("Script Config: ApprovedOnly=%s", self.ombi_approved_only) self.logger.debug( "Script Config: SearchOverseerrRequests=%s", self.overseerr_requests @@ -678,7 +689,7 @@ def __init__( if self.overseerr_requests: self.logger.debug("Script Config: OverseerrURI=%s", self.overseerr_uri) self.logger.debug( - "Script Config: OverseerrAPIKey=%s", _mask_secret(self.overseerr_api_key) + "Script Config: OverseerrAPIKey=%s", mask_secret(self.overseerr_api_key) ) if self.ombi_search_requests or self.overseerr_requests: self.logger.debug( @@ -690,7 +701,7 @@ def __init__( self.quality_unmet_search or self.do_upgrade_search or self.custom_format_unmet_search - or self.series_search == True + or self.series_search is True ): self.search_api_command = "SeriesSearch" elif self.series_search == "smart": @@ -884,7 +895,9 @@ def is_alive(self) -> bool: self.expiring_bool.add(1) return True req = self.session.get( - f"{self.uri}/api/v3/system/status", timeout=10, params={"apikey": self.apikey} + f"{self.uri}/api/v3/system/status", + timeout=10, + headers={"X-Api-Key": self.apikey}, ) req.raise_for_status() self.logger.trace("Successfully connected to %s", self.uri) @@ -949,6 +962,43 @@ def is_downloading_state(torrent: TorrentDictionary) -> bool: """Returns True if the State is categorized as Downloading.""" return torrent.state_enum in (TorrentStates.DOWNLOADING, TorrentStates.PAUSED_DOWNLOAD) + _TAGLESS_FIELD_MAP = { + "qBitrr-allowed_seeding": "AllowedSeeding", + "qBitrr-imported": "Imported", + "qBitrr-allowed_stalled": "AllowedStalled", + "qBitrr-free_space_paused": "FreeSpacePaused", + } + + def _ensure_torrent_row( + self, torrent: TorrentDictionary, instance_name: str = "default" + ) -> None: + """Ensure a TorrentLibrary row exists for the given torrent.""" + query = ( + self.torrents.select() + .where( + (self.torrents.Hash == torrent.hash) + & (self.torrents.Category == torrent.category) + & (self.torrents.QbitInstance == instance_name) + ) + .execute() + ) + if not query: + self.torrents.insert( + Hash=torrent.hash, + Category=torrent.category, + QbitInstance=instance_name, + ).on_conflict_ignore().execute() + + def _torrent_condition( + self, torrent: TorrentDictionary, instance_name: str = "default" + ): + """Return the base WHERE condition for a torrent row.""" + return ( + (self.torrents.Hash == torrent.hash) + & (self.torrents.Category == torrent.category) + & (self.torrents.QbitInstance == instance_name) + ) + def in_tags( self, torrent: TorrentDictionary, tag: str, instance_name: str = "default" ) -> bool: @@ -957,51 +1007,20 @@ def in_tags( if tag == "qBitrr-ignored": return_value = "qBitrr-ignored" in torrent.tags else: - query = ( - self.torrents.select() - .where( - (self.torrents.Hash == torrent.hash) - & (self.torrents.Category == torrent.category) - & (self.torrents.QbitInstance == instance_name) - ) - .execute() - ) - if not query: - self.torrents.insert( - Hash=torrent.hash, - Category=torrent.category, - QbitInstance=instance_name, - ).on_conflict_ignore().execute() - condition = ( - (self.torrents.Hash == torrent.hash) - & (self.torrents.Category == torrent.category) - & (self.torrents.QbitInstance == instance_name) - ) - if tag == "qBitrr-allowed_seeding": - condition &= self.torrents.AllowedSeeding == True - elif tag == "qBitrr-imported": - condition &= self.torrents.Imported == True - elif tag == "qBitrr-allowed_stalled": - condition &= self.torrents.AllowedStalled == True - elif tag == "qBitrr-free_space_paused": - condition &= self.torrents.FreeSpacePaused == True - query = self.torrents.select().where(condition).execute() - if query: - return_value = True - else: - return_value = False + self._ensure_torrent_row(torrent, instance_name) + condition = self._torrent_condition(torrent, instance_name) + field_name = self._TAGLESS_FIELD_MAP.get(tag) + if field_name: + condition &= getattr(self.torrents, field_name) == True + return_value = bool(self.torrents.select().where(condition).execute()) else: - if tag in torrent.tags: - return_value = True - else: - return_value = False + return_value = tag in torrent.tags if return_value: self.logger.trace("Tag %s in %s", tag, torrent.name) - return True else: self.logger.trace("Tag %s not in %s", tag, torrent.name) - return False + return return_value def remove_tags( self, torrent: TorrentDictionary, tags: list, instance_name: str = "default" @@ -1009,45 +1028,13 @@ def remove_tags( for tag in tags: self.logger.trace("Removing tag %s from %s", tag, torrent.name) if TAGLESS: + self._ensure_torrent_row(torrent, instance_name) + condition = self._torrent_condition(torrent, instance_name) for tag in tags: - query = ( - self.torrents.select() - .where( - (self.torrents.Hash == torrent.hash) - & (self.torrents.Category == torrent.category) - & (self.torrents.QbitInstance == instance_name) - ) - .execute() - ) - if not query: - self.torrents.insert( - Hash=torrent.hash, - Category=torrent.category, - QbitInstance=instance_name, - ).on_conflict_ignore().execute() - if tag == "qBitrr-allowed_seeding": - self.torrents.update(AllowedSeeding=False).where( - (self.torrents.Hash == torrent.hash) - & (self.torrents.Category == torrent.category) - & (self.torrents.QbitInstance == instance_name) - ).execute() - elif tag == "qBitrr-imported": - self.torrents.update(Imported=False).where( - (self.torrents.Hash == torrent.hash) - & (self.torrents.Category == torrent.category) - & (self.torrents.QbitInstance == instance_name) - ).execute() - elif tag == "qBitrr-allowed_stalled": - self.torrents.update(AllowedStalled=False).where( - (self.torrents.Hash == torrent.hash) - & (self.torrents.Category == torrent.category) - & (self.torrents.QbitInstance == instance_name) - ).execute() - elif tag == "qBitrr-free_space_paused": - self.torrents.update(FreeSpacePaused=False).where( - (self.torrents.Hash == torrent.hash) - & (self.torrents.Category == torrent.category) - & (self.torrents.QbitInstance == instance_name) + field_name = self._TAGLESS_FIELD_MAP.get(tag) + if field_name: + self.torrents.update({getattr(self.torrents, field_name): False}).where( + condition ).execute() else: with contextlib.suppress(Exception): @@ -1069,45 +1056,13 @@ def add_tags( for tag in tags: self.logger.trace("Adding tag %s from %s", tag, torrent.name) if TAGLESS: + self._ensure_torrent_row(torrent, instance_name) + condition = self._torrent_condition(torrent, instance_name) for tag in tags: - query = ( - self.torrents.select() - .where( - (self.torrents.Hash == torrent.hash) - & (self.torrents.Category == torrent.category) - & (self.torrents.QbitInstance == instance_name) - ) - .execute() - ) - if not query: - self.torrents.insert( - Hash=torrent.hash, - Category=torrent.category, - QbitInstance=instance_name, - ).on_conflict_ignore().execute() - if tag == "qBitrr-allowed_seeding": - self.torrents.update(AllowedSeeding=True).where( - (self.torrents.Hash == torrent.hash) - & (self.torrents.Category == torrent.category) - & (self.torrents.QbitInstance == instance_name) - ).execute() - elif tag == "qBitrr-imported": - self.torrents.update(Imported=True).where( - (self.torrents.Hash == torrent.hash) - & (self.torrents.Category == torrent.category) - & (self.torrents.QbitInstance == instance_name) - ).execute() - elif tag == "qBitrr-allowed_stalled": - self.torrents.update(AllowedStalled=True).where( - (self.torrents.Hash == torrent.hash) - & (self.torrents.Category == torrent.category) - & (self.torrents.QbitInstance == instance_name) - ).execute() - elif tag == "qBitrr-free_space_paused": - self.torrents.update(FreeSpacePaused=True).where( - (self.torrents.Hash == torrent.hash) - & (self.torrents.Category == torrent.category) - & (self.torrents.QbitInstance == instance_name) + field_name = self._TAGLESS_FIELD_MAP.get(tag) + if field_name: + self.torrents.update({getattr(self.torrents, field_name): True}).where( + condition ).execute() else: with contextlib.suppress(Exception): @@ -1377,66 +1332,29 @@ def _process_imports(self) -> None: continue self.sent_to_scan_hashes.add(torrent.hash) try: - if self.type == "sonarr": - with_retry( - lambda: self.client.post_command( - "DownloadedEpisodesScan", - path=str(path), - downloadClientId=torrent.hash.upper(), - importMode=self.import_mode, - ), - retries=3, - backoff=0.5, - max_backoff=3, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - requests.exceptions.RequestException, - ), - ) - self.logger.success("DownloadedEpisodesScan: %s", path) - elif self.type == "radarr": - with_retry( - lambda: self.client.post_command( - "DownloadedMoviesScan", - path=str(path), - downloadClientId=torrent.hash.upper(), - importMode=self.import_mode, - ), - retries=3, - backoff=0.5, - max_backoff=3, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - requests.exceptions.RequestException, - ), - ) - self.logger.success("DownloadedMoviesScan: %s", path) - elif self.type == "lidarr": + scan_commands = { + "sonarr": "DownloadedEpisodesScan", + "radarr": "DownloadedMoviesScan", + "lidarr": "DownloadedAlbumsScan", + } + scan_cmd = scan_commands.get(self.type) + if scan_cmd: + _path = str(path) + _hash = torrent.hash.upper() + _mode = self.import_mode with_retry( lambda: self.client.post_command( - "DownloadedAlbumsScan", - path=str(path), - downloadClientId=torrent.hash.upper(), - importMode=self.import_mode, + scan_cmd, + path=_path, + downloadClientId=_hash, + importMode=_mode, ), retries=3, backoff=0.5, max_backoff=3, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - requests.exceptions.RequestException, - ), + exceptions=_ARR_RETRY_EXCEPTIONS_EXTENDED, ) - self.logger.success("DownloadedAlbumsScan: %s", path) + self.logger.success("%s: %s", scan_cmd, path) except Exception as ex: self.logger.error( "Downloaded scan error: [%s][%s][%s][%s]", @@ -1480,12 +1398,7 @@ def _process_failed_individual( retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) name = data["title"] series_id = data["id"] @@ -1519,12 +1432,7 @@ def _process_failed_individual( retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) if self.persistent_queue: self.persistent_queue.insert( @@ -1591,12 +1499,7 @@ def _process_failed_individual( retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) if self.persistent_queue: self.persistent_queue.insert( @@ -1645,12 +1548,7 @@ def _process_failed_individual( retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) if self.persistent_queue: self.persistent_queue.insert( @@ -1699,12 +1597,7 @@ def _process_failed_individual( retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) if self.persistent_queue: self.persistent_queue.insert( @@ -1942,13 +1835,7 @@ def api_calls(self) -> None: retries=3, backoff=0.5, max_backoff=3, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - requests.exceptions.RequestException, - ), + exceptions=_ARR_RETRY_EXCEPTIONS_EXTENDED, ) self.rss_sync_timer_last_checked = now @@ -1962,13 +1849,7 @@ def api_calls(self) -> None: retries=3, backoff=0.5, max_backoff=3, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - requests.exceptions.RequestException, - ), + exceptions=_ARR_RETRY_EXCEPTIONS_EXTENDED, ) self.refresh_downloads_timer_last_checked = now @@ -1981,12 +1862,7 @@ def arr_db_query_commands_count(self) -> int: retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) for command in commands: if command["name"].endswith("Search") and command["status"] != "completed": @@ -2030,7 +1906,7 @@ def db_get_files( ) -> Iterable[ tuple[MoviesFilesModel | EpisodeFilesModel | SeriesFilesModel, bool, bool, bool, int] ]: - if self.type == "sonarr" and self.series_search == True: + if self.type == "sonarr" and self.series_search is True: serieslist = self.db_get_files_series() for series in serieslist: yield series[0], series[1], series[2], series[2] is not True, len(serieslist) @@ -2120,12 +1996,7 @@ def db_reset__series_searched_state(self): retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) for s in series: ids.append(s["id"]) @@ -2149,12 +2020,7 @@ def db_reset__episode_searched_state(self): retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) for s in series: episodes = with_retry( @@ -2162,12 +2028,7 @@ def db_reset__episode_searched_state(self): retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) for e in episodes: ids.append(e["id"]) @@ -2190,12 +2051,7 @@ def db_reset__movie_searched_state(self): retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) for m in movies: ids.append(m["id"]) @@ -2218,12 +2074,7 @@ def db_reset__album_searched_state(self): retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) for artist in artists: albums = with_retry( @@ -2231,12 +2082,7 @@ def db_reset__album_searched_state(self): retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) for album in albums: ids.append(album["id"]) @@ -2583,12 +2429,7 @@ def _db_request_update(self, request_ids: dict[str, set[int | str]]): retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) for s in series: episodes = self.client.get_episode(s["id"], True) @@ -2622,12 +2463,7 @@ def _db_request_update(self, request_ids: dict[str, set[int | str]]): retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) for m in movies: if m["year"] > datetime.now().year or m["year"] == 0: @@ -2681,12 +2517,7 @@ def db_update_todays_releases(self): retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) for s in series: episodes = self.client.get_episode(s["id"], True) @@ -2742,12 +2573,7 @@ def db_update(self): retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) # Process episodes for episode-level tracking (all episodes) @@ -2780,12 +2606,7 @@ def db_update(self): retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) # Process all movies for m in movies: @@ -2799,12 +2620,7 @@ def db_update(self): retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) for artist in artists: if isinstance(artist, str): @@ -2816,12 +2632,7 @@ def db_update(self): retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) for album in albums: if isinstance(album, str): @@ -3113,12 +2924,7 @@ def db_update_single_series( retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) # Validate episode object has required fields @@ -3170,12 +2976,7 @@ def db_update_single_series( retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) or {} ) @@ -3207,12 +3008,7 @@ def db_update_single_series( retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) or {} ) @@ -3278,8 +3074,6 @@ def db_update_single_series( new_profile_id, ) # Reverting to main - clear tracking fields - from datetime import datetime - profile_switch_timestamp = datetime.now() original_profile_for_db = None current_profile_for_db = None @@ -3297,8 +3091,6 @@ def db_update_single_series( new_profile_id, ) # Downgrading to temp - track original and switch time - from datetime import datetime - profile_switch_timestamp = datetime.now() original_profile_for_db = quality_profile_id current_profile_for_db = new_profile_id @@ -3462,12 +3254,7 @@ def db_update_single_series( retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) or {} ) @@ -3486,12 +3273,7 @@ def db_update_single_series( retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) or {} ) @@ -3761,8 +3543,6 @@ def db_update_single_series( new_main_id, ) # Reverting to main - clear tracking fields - from datetime import datetime - profile_switch_timestamp = datetime.now() original_profile_for_db = None current_profile_for_db = None @@ -3779,8 +3559,6 @@ def db_update_single_series( new_temp_id, ) # Downgrading to temp - track original and switch time - from datetime import datetime - profile_switch_timestamp = datetime.now() original_profile_for_db = quality_profile_id current_profile_for_db = new_temp_id @@ -4428,12 +4206,7 @@ def delete_from_queue(self, id_, remove_from_client=True, blacklist=True): retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) except PyarrResourceNotFound as e: # Queue item not found - this is expected when Arr has already auto-imported @@ -4690,12 +4463,7 @@ def maybe_do_search( retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) self.model_file.update(Searched=True, Upgrade=True).where( (self.model_file.EntryId == file_model.EntryId) @@ -4763,12 +4531,7 @@ def maybe_do_search( retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) self.model_file.update(Searched=True, Upgrade=True).where( (self.model_file.EntryId == file_model.EntryId) @@ -4840,12 +4603,7 @@ def maybe_do_search( retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) self.model_file.update(Searched=True, Upgrade=True).where( (self.model_file.EntryId == file_model.EntryId) @@ -4929,12 +4687,7 @@ def maybe_do_search( retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) self.model_file.update(Searched=True, Upgrade=True).where( (self.model_file.EntryId == file_model.EntryId) @@ -6489,12 +6242,7 @@ def custom_format_unmet_check(self, torrent: qbittorrentapi.TorrentDictionary) - retries=5, backoff=0.5, max_backoff=5, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ), + exceptions=_ARR_RETRY_EXCEPTIONS, ) if not queue.get("records"): @@ -6793,13 +6541,7 @@ def get_queue(self, page=1, page_size=1000, sort_direction="ascending", sort_key retries=3, backoff=0.5, max_backoff=3, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - requests.exceptions.RequestException, - ), + exceptions=_ARR_RETRY_EXCEPTIONS_EXTENDED, ) try: res = res.get("records", []) @@ -6935,10 +6677,10 @@ def _reset_all_temp_profiles(self): items = self.client.get_artist() item_type = "artist" else: - self.logger.warning(f"Unknown Arr type for temp profile reset: {self._name}") + self.logger.warning("Unknown Arr type for temp profile reset: %s", self._name) return - self.logger.info(f"Checking {len(items)} {item_type}s for temp profile resets...") + self.logger.info("Checking %d %ss for temp profile resets...", len(items), item_type) for item in items: profile_id = item.get("qualityProfileId") @@ -6989,15 +6731,13 @@ def _reset_all_temp_profiles(self): ) except Exception as e: - self.logger.error(f"Error during temp profile reset: {e}", exc_info=True) + self.logger.error("Error during temp profile reset: %s", e, exc_info=True) def _check_temp_profile_timeouts(self): """Check for items with temp profiles that have exceeded the timeout and reset them.""" if self.temp_profile_timeout_minutes == 0: return # Feature disabled - from datetime import timedelta - timeout_threshold = datetime.now() - timedelta(minutes=self.temp_profile_timeout_minutes) reset_count = 0 @@ -7085,7 +6825,7 @@ def _check_temp_profile_timeouts(self): ) except Exception as e: - self.logger.error(f"Error checking temp profile timeouts: {e}", exc_info=True) + self.logger.error("Error checking temp profile timeouts: %s", e, exc_info=True) def register_search_mode(self): """Initialize database models using the single shared database.""" @@ -7211,33 +6951,33 @@ def run_request_search(self): self.request_search_timer = time.time() except NoConnectionrException as e: self.logger.error(e.message) - raise DelayLoopException(length=300, error_type=e.type) + raise DelayLoopException(length=300, error_type=e.error_type) except DelayLoopException: raise except Exception as e: self.logger.exception(e, exc_info=sys.exc_info()) except DelayLoopException as e: - if e.type == "qbit": + if e.error_type == "qbit": self.logger.critical( "Failed to connected to qBit client, sleeping for %s", timedelta(seconds=e.length), ) - elif e.type == "internet": + elif e.error_type == "internet": self.logger.critical( "Failed to connected to the internet, sleeping for %s", timedelta(seconds=e.length), ) - elif e.type == "arr": + elif e.error_type == "arr": self.logger.critical( "Failed to connected to the Arr instance, sleeping for %s", timedelta(seconds=e.length), ) - elif e.type == "delay": + elif e.error_type == "delay": self.logger.critical( "Forced delay due to temporary issue with environment, sleeping for %s", timedelta(seconds=e.length), ) - elif e.type == "no_downloads": + elif e.error_type == "no_downloads": self.logger.debug( "No downloads in category, sleeping for %s", timedelta(seconds=e.length) ) @@ -7253,13 +6993,7 @@ def get_year_search(self) -> tuple[list[int], int]: retries=3, backoff=0.5, max_backoff=3, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - requests.exceptions.RequestException, - ), + exceptions=_ARR_RETRY_EXCEPTIONS_EXTENDED, ) for m in movies: @@ -7274,13 +7008,7 @@ def get_year_search(self) -> tuple[list[int], int]: retries=3, backoff=0.5, max_backoff=3, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - requests.exceptions.RequestException, - ), + exceptions=_ARR_RETRY_EXCEPTIONS_EXTENDED, ) for s in series: @@ -7289,13 +7017,7 @@ def get_year_search(self) -> tuple[list[int], int]: retries=3, backoff=0.5, max_backoff=3, - exceptions=( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - requests.exceptions.RequestException, - ), + exceptions=_ARR_RETRY_EXCEPTIONS_EXTENDED, ) for e in episodes: if "airDateUtc" in e: @@ -7456,7 +7178,7 @@ def run_search_loop(self) -> NoReturn: except NoConnectionrException as e: self.logger.error(e.message) self.manager.qbit_manager.should_delay_torrent_scan = True - raise DelayLoopException(length=300, error_type=e.type) + raise DelayLoopException(length=300, error_type=e.error_type) except DelayLoopException: raise except ValueError: @@ -7469,22 +7191,22 @@ def run_search_loop(self) -> NoReturn: self.logger.exception(e, exc_info=sys.exc_info()) event.wait(LOOP_SLEEP_TIMER) except DelayLoopException as e: - if e.type == "qbit": + if e.error_type == "qbit": self.logger.critical( "Failed to connected to qBit client, sleeping for %s", timedelta(seconds=e.length), ) - elif e.type == "internet": + elif e.error_type == "internet": self.logger.critical( "Failed to connected to the internet, sleeping for %s", timedelta(seconds=e.length), ) - elif e.type == "arr": + elif e.error_type == "arr": self.logger.critical( "Failed to connected to the Arr instance, sleeping for %s", timedelta(seconds=e.length), ) - elif e.type == "delay": + elif e.error_type == "delay": self.logger.critical( "Forced delay due to temporary issue with environment, " "sleeping for %s", @@ -7547,28 +7269,28 @@ def run_torrent_loop(self) -> NoReturn: self.logger.error(e, exc_info=sys.exc_info()) event.wait(LOOP_SLEEP_TIMER) except DelayLoopException as e: - if e.type == "qbit": + if e.error_type == "qbit": self.logger.critical( "Failed to connected to qBit client, sleeping for %s", timedelta(seconds=e.length), ) - elif e.type == "internet": + elif e.error_type == "internet": self.logger.critical( "Failed to connected to the internet, sleeping for %s", timedelta(seconds=e.length), ) - elif e.type == "arr": + elif e.error_type == "arr": self.logger.critical( "Failed to connected to the Arr instance, sleeping for %s", timedelta(seconds=e.length), ) - elif e.type == "delay": + elif e.error_type == "delay": self.logger.critical( "Forced delay due to temporary issue with environment, " "sleeping for %s.", timedelta(seconds=e.length), ) - elif e.type == "no_downloads": + elif e.error_type == "no_downloads": self.logger.debug( "No downloads in category, sleeping for %s", timedelta(seconds=e.length), diff --git a/qBitrr/auto_update.py b/qBitrr/auto_update.py index 6fc1e380d..bfeffe241 100644 --- a/qBitrr/auto_update.py +++ b/qBitrr/auto_update.py @@ -5,9 +5,10 @@ import subprocess import sys import threading +from collections.abc import Callable from datetime import datetime from pathlib import Path -from typing import Any, Callable +from typing import Any import requests from croniter import croniter @@ -288,7 +289,6 @@ def perform_self_update(logger: logging.Logger, target_version: str | None = Non # GIT INSTALLATION elif install_type == "git": repo_root = Path(__file__).resolve().parent.parent - repo_root / ".git" logger.debug("Git repository detected at %s", repo_root) if target_version: diff --git a/qBitrr/bundled_data.py b/qBitrr/bundled_data.py index 6af898500..89761e46a 100644 --- a/qBitrr/bundled_data.py +++ b/qBitrr/bundled_data.py @@ -4,4 +4,4 @@ "Licence can be found on:\n\nhttps://github.com/Feramance/qBitrr/blob/master/LICENSE" ) patched_version = f"{version}-{git_hash}" -tagged_version = f"{version}" +tagged_version = version diff --git a/qBitrr/config.py b/qBitrr/config.py index 2d9ec423d..1fb6f9ea3 100644 --- a/qBitrr/config.py +++ b/qBitrr/config.py @@ -11,6 +11,8 @@ from qBitrr.gen_config import MyConfig, _write_config_file, apply_config_migrations, generate_doc from qBitrr.home_path import APPDATA_FOLDER, HOME_PATH +CHANGE_ME_SENTINEL = "CHANGE_ME" + def process_flags() -> argparse.Namespace | bool: parser = argparse.ArgumentParser(description="An interface to interact with qBit and *arrs.") @@ -45,8 +47,6 @@ def process_flags() -> argparse.Namespace | bool: args = parser.parse_args() if args.gen_config: - from qBitrr.gen_config import _write_config_file - _write_config_file() return True elif args.license: @@ -177,7 +177,7 @@ def _has_any_qbit_section() -> bool: print("Exiting...") sys.exit(1) -if SEARCH_ONLY and QBIT_DISABLED is False: +if SEARCH_ONLY and not QBIT_DISABLED: QBIT_DISABLED = True print("QBITRR_OVERRIDES_SEARCH_ONLY is enabled, forcing qBitTorrent setting off") diff --git a/qBitrr/config_version.py b/qBitrr/config_version.py index 9083eb7bd..e22c18795 100644 --- a/qBitrr/config_version.py +++ b/qBitrr/config_version.py @@ -36,7 +36,7 @@ def _parse_version(v: str | int) -> Version: try: return Version(str(v)) except InvalidVersion: - logger.warning(f"Invalid version string: {v}, falling back to 0.0.1") + logger.warning("Invalid version string: %s, falling back to 0.0.1", v) return Version("0.0.1") @@ -57,7 +57,7 @@ def get_config_version(config: MyConfig) -> str: Version(str(version)) return str(version) except InvalidVersion: - logger.warning(f"Invalid ConfigVersion value: {version}, defaulting to 0.0.1") + logger.warning("Invalid ConfigVersion value: %s, defaulting to 0.0.1", version) return "0.0.1" @@ -75,7 +75,7 @@ def set_config_version(config: MyConfig, version: str) -> None: config.config["Settings"] = table() config.config["Settings"]["ConfigVersion"] = version - logger.info(f"Set ConfigVersion to {version}") + logger.info("Set ConfigVersion to %s", version) def validate_config_version(config: MyConfig) -> tuple[bool, str | None]: @@ -95,12 +95,12 @@ def validate_config_version(config: MyConfig) -> tuple[bool, str | None]: expected = _parse_version(EXPECTED_CONFIG_VERSION) if current == expected: - logger.debug(f"Config version matches expected: {EXPECTED_CONFIG_VERSION}") + logger.debug("Config version matches expected: %s", EXPECTED_CONFIG_VERSION) return True, None if current < expected: logger.info( - f"Config version {current} is older than expected {expected}, " "migration needed" + "Config version %s is older than expected %s, migration needed", current, expected ) return True, "migration_needed" @@ -125,7 +125,7 @@ def backup_config(config_path: Path) -> Path | None: Path to backup file, or None if backup failed """ if not config_path.exists(): - logger.warning(f"Config file not found for backup: {config_path}") + logger.warning("Config file not found for backup: %s", config_path) return None timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") @@ -135,10 +135,10 @@ def backup_config(config_path: Path) -> Path | None: import shutil shutil.copy2(config_path, backup_path) - logger.info(f"Created config backup: {backup_path}") + logger.info("Created config backup: %s", backup_path) return backup_path except Exception as e: - logger.error(f"Failed to create config backup: {e}") + logger.error("Failed to create config backup: %s", e) return None @@ -154,15 +154,15 @@ def restore_config_backup(backup_path: Path, config_path: Path) -> bool: True if restore succeeded, False otherwise """ if not backup_path.exists(): - logger.error(f"Backup file not found: {backup_path}") + logger.error("Backup file not found: %s", backup_path) return False try: import shutil shutil.copy2(backup_path, config_path) - logger.info(f"Restored config from backup: {backup_path}") + logger.info("Restored config from backup: %s", backup_path) return True except Exception as e: - logger.error(f"Failed to restore config from backup: {e}") + logger.error("Failed to restore config from backup: %s", e) return False diff --git a/qBitrr/errors.py b/qBitrr/errors.py index 35168f8e4..62a212342 100644 --- a/qBitrr/errors.py +++ b/qBitrr/errors.py @@ -27,15 +27,29 @@ def __init__(self, config_class: str, config_key: str): class NoConnectionrException(qBitManagerError): + """Raised when a connection to a service cannot be established. + + The ``error_type`` attribute (``"delay"`` or ``"no_internet"``) controls + whether the caller should retry after a short delay or treat the outage + as a prolonged connectivity failure. + """ + def __init__(self, message: str, error_type: str = "delay"): self.message = message - self.type = error_type + self.error_type = error_type super().__init__(message) class DelayLoopException(qBitManagerError): + """Raised to signal that the current event-loop iteration should pause. + + This is a control-flow exception, not an error. ``length`` is the + requested sleep duration in seconds and ``error_type`` indicates the + reason (e.g. ``"delay"`` or ``"no_internet"``). + """ + def __init__(self, length: int, error_type: str): - self.type = error_type + self.error_type = error_type self.length = length super().__init__(f"Delay loop: {error_type}") diff --git a/qBitrr/ffprobe.py b/qBitrr/ffprobe.py index 07c1f47b0..07557afa8 100644 --- a/qBitrr/ffprobe.py +++ b/qBitrr/ffprobe.py @@ -73,7 +73,8 @@ def update(self): self.logger.debug("Failed to change permissions for ffprobe, %s", e) def download_and_extract(self, ffprobe_url): - r = requests.get(ffprobe_url) + r = requests.get(ffprobe_url, timeout=(10, 300)) + r.raise_for_status() with zipfile.ZipFile(io.BytesIO(r.content)) as z: self.logger.debug("Extracting downloaded FFprobe to: %s", FF_PROBE.parent) z.extract(member=self.probe_path.name, path=FF_PROBE.parent) diff --git a/qBitrr/gen_config.py b/qBitrr/gen_config.py index 7acbc88db..d2972295c 100755 --- a/qBitrr/gen_config.py +++ b/qBitrr/gen_config.py @@ -1216,12 +1216,12 @@ def _migrate_quality_profile_mappings(config: MyConfig) -> bool: inline_mappings.update(mappings) config.config[str(key)]["EntrySearch"]["QualityProfileMappings"] = inline_mappings changes_made = True - logger.info(f"Migrated {key} to QualityProfileMappings: {mappings}") + logger.info("Migrated %s to QualityProfileMappings: %s", key, mappings) # Remove old format del config.config[str(key)]["EntrySearch"]["MainQualityProfile"] del config.config[str(key)]["EntrySearch"]["TempQualityProfile"] - logger.debug(f"Removed legacy profile lists from {key}") + logger.debug("Removed legacy profile lists from %s", key) return changes_made @@ -1286,7 +1286,7 @@ def _migrate_qbit_category_settings(config: MyConfig) -> bool: if "ManagedCategories" not in qbit_section: qbit_section["ManagedCategories"] = [] changes_made = True - logger.info(f"Added ManagedCategories = [] to [{section}]") + logger.info("Added ManagedCategories = [] to [%s]", section) if "CategorySeeding" not in qbit_section: seeding = table() @@ -1303,7 +1303,7 @@ def _migrate_qbit_category_settings(config: MyConfig) -> bool: seeding["TrackerUpdateBuffer"] = 0 qbit_section["CategorySeeding"] = seeding changes_made = True - logger.info(f"Added CategorySeeding configuration to [{section}]") + logger.info("Added CategorySeeding configuration to [%s]", section) if changes_made: print("Migration v3→v4: Added qBit category management settings") diff --git a/qBitrr/home_path.py b/qBitrr/home_path.py index 3cff3147e..742791368 100644 --- a/qBitrr/home_path.py +++ b/qBitrr/home_path.py @@ -20,4 +20,4 @@ APPDATA_FOLDER = HOME_PATH.joinpath("qBitManager") APPDATA_FOLDER.mkdir(parents=True, exist_ok=True) -APPDATA_FOLDER.chmod(mode=0o777) +APPDATA_FOLDER.chmod(mode=0o755) diff --git a/qBitrr/logger.py b/qBitrr/logger.py index eb28b7b20..5b0517615 100644 --- a/qBitrr/logger.py +++ b/qBitrr/logger.py @@ -126,7 +126,7 @@ def run_logs(logger: Logger, _name: str = None) -> None: if ALL_LOGS_HANDLER is None: logs_folder = HOME_PATH.joinpath("logs") logs_folder.mkdir(parents=True, exist_ok=True) - logs_folder.chmod(mode=0o777) + logs_folder.chmod(mode=0o755) all_logfile = logs_folder.joinpath("All.log") # Rotate old All.log if it exists if all_logfile.exists(): @@ -168,7 +168,7 @@ def run_logs(logger: Logger, _name: str = None) -> None: if _name: logs_folder = HOME_PATH.joinpath("logs") logs_folder.mkdir(parents=True, exist_ok=True) - logs_folder.chmod(mode=0o777) + logs_folder.chmod(mode=0o755) logfile = logs_folder.joinpath(_name + ".log") if pathlib.Path(logfile).is_file(): logold = logs_folder.joinpath(_name + ".log.old") diff --git a/qBitrr/main.py b/qBitrr/main.py index 23195f2f3..73d9dfc97 100644 --- a/qBitrr/main.py +++ b/qBitrr/main.py @@ -37,23 +37,20 @@ from qBitrr.home_path import APPDATA_FOLDER from qBitrr.logger import run_logs from qBitrr.qbit_category_manager import qBitCategoryManager -from qBitrr.utils import ExpiringSet +from qBitrr.utils import ExpiringSet, mask_secret from qBitrr.versioning import fetch_latest_release from qBitrr.webui import WebUI if CONFIG_EXISTS: from qBitrr.arss import ArrManager else: - sys.exit(0) + print("Configuration not found. Please create a config file and restart.") + sys.exit(1) logger = logging.getLogger("qBitrr") run_logs(logger, "Main") -def _mask_secret(value: str | None) -> str: - return "[redacted]" if value else "" - - def _delete_all_databases() -> None: """ Delete old per-instance database files from the APPDATA_FOLDER on startup. @@ -107,7 +104,7 @@ def __init__(self): self.qBit_Host, self.qBit_Port, self.qBit_UserName, - _mask_secret(self.qBit_Password), + mask_secret(self.qBit_Password), ) self._validated_version = False self.current_qbit_version = None @@ -1319,7 +1316,7 @@ def _restart_process( """ category = meta.get("category", "") role = meta.get("role", "worker") - meta.get("name", "") + name = meta.get("name", "") try: # Wait before restarting @@ -1438,10 +1435,9 @@ def run(): # Delete all databases on startup _delete_all_databases() - try: - manager = qBitManager() - except NameError: - sys.exit(0) + if not CONFIG_EXISTS: + sys.exit(1) + manager = qBitManager() run_logs(logger) # Early consolidated config validation feedback _report_config_issues() diff --git a/qBitrr/tables.py b/qBitrr/tables.py index 393d95b9f..5840ee744 100644 --- a/qBitrr/tables.py +++ b/qBitrr/tables.py @@ -30,6 +30,12 @@ class MoviesFilesModel(Model): CurrentProfileId = IntegerField(null=True) OriginalProfileId = IntegerField(null=True) + class Meta: + indexes = ( + (("ArrInstance",), False), + (("Searched",), False), + ) + class EpisodeFilesModel(Model): EntryId = IntegerField(primary_key=True) @@ -60,6 +66,13 @@ class EpisodeFilesModel(Model): CurrentProfileId = IntegerField(null=True) OriginalProfileId = IntegerField(null=True) + class Meta: + indexes = ( + (("SeriesId", "SeasonNumber"), False), + (("ArrInstance",), False), + (("Searched",), False), + ) + class SeriesFilesModel(Model): EntryId = IntegerField(primary_key=True) @@ -73,6 +86,9 @@ class SeriesFilesModel(Model): QualityProfileId = IntegerField(null=True) QualityProfileName = TextField(null=True) + class Meta: + indexes = ((("ArrInstance",), False),) + class MovieQueueModel(Model): EntryId = IntegerField(unique=True) @@ -112,6 +128,13 @@ class AlbumFilesModel(Model): CurrentProfileId = IntegerField(null=True) OriginalProfileId = IntegerField(null=True) + class Meta: + indexes = ( + (("ArrInstance",), False), + (("ArtistId",), False), + (("Searched",), False), + ) + class TrackFilesModel(Model): EntryId = IntegerField(primary_key=True) @@ -124,6 +147,12 @@ class TrackFilesModel(Model): TrackFileId = IntegerField(null=True) Monitored = BooleanField(default=False) + class Meta: + indexes = ( + (("AlbumId",), False), + (("ArrInstance",), False), + ) + class ArtistFilesModel(Model): EntryId = IntegerField(primary_key=True) @@ -137,6 +166,9 @@ class ArtistFilesModel(Model): QualityProfileId = IntegerField(null=True) QualityProfileName = TextField(null=True) + class Meta: + indexes = ((("ArrInstance",), False),) + class AlbumQueueModel(Model): EntryId = IntegerField(unique=True) diff --git a/qBitrr/utils.py b/qBitrr/utils.py index 27b2f7dce..b637b5b82 100644 --- a/qBitrr/utils.py +++ b/qBitrr/utils.py @@ -6,7 +6,7 @@ import re import socket import time -from typing import Iterator +from collections.abc import Iterator import ping3 import qbittorrentapi @@ -64,6 +64,7 @@ def absolute_file_paths(directory: pathlib.Path | str) -> Iterator[pathlib.Path] file_counter += 1 if file_counter == 1: logger.warning("%s - %s", e.strerror, e.filename) + time.sleep(0.1) def validate_and_return_torrent_file(file: str) -> pathlib.Path: @@ -251,3 +252,8 @@ def __eq__(self, other): self.__update__() other.__update__() return set(self.container.keys()) == set(other.container.keys()) + + +def mask_secret(value: str | None) -> str: + """Return '[redacted]' if value is truthy, else empty string.""" + return "[redacted]" if value else "" diff --git a/qBitrr/webui.py b/qBitrr/webui.py index c5f928076..437ac677c 100644 --- a/qBitrr/webui.py +++ b/qBitrr/webui.py @@ -65,6 +65,27 @@ def _toml_delete(doc, dotted_key: str) -> None: break +_SENSITIVE_KEY_PATTERNS = re.compile( + r"(apikey|api_key|token|password|secret|passkey|credential)", re.IGNORECASE +) + + +def _strip_sensitive_keys(obj: Any, _parent_key: str = "") -> Any: + """Recursively redact values whose keys look like secrets.""" + if isinstance(obj, dict): + return { + k: ( + "[redacted]" + if isinstance(v, str) and _SENSITIVE_KEY_PATTERNS.search(k) + else _strip_sensitive_keys(v, k) + ) + for k, v in obj.items() + } + if isinstance(obj, list): + return [_strip_sensitive_keys(v, _parent_key) for v in obj] + return obj + + def _toml_to_jsonable(obj: Any) -> Any: try: if hasattr(obj, "unwrap"): @@ -636,7 +657,6 @@ def _lidarr_albums_from_db( # Convert to list to avoid multiple iterations all_artists = [row.ArtistTitle for row in artists_subquery] - len(all_artists) # Paginate the artist list in Python start_idx = page * page_size @@ -674,7 +694,10 @@ def _lidarr_albums_from_db( ) track_count = track_query.count() self.logger.debug( - f"Album {album.EntryId} ({album.Title}): Found {track_count} tracks in database" + "Album %s (%s): Found %d tracks in database", + album.EntryId, + album.Title, + track_count, ) for track in track_query: @@ -699,7 +722,10 @@ def _lidarr_albums_from_db( ) except Exception as e: self.logger.warning( - f"Failed to fetch tracks for album {album.EntryId} ({album.Title}): {e}" + "Failed to fetch tracks for album %s (%s): %s", + album.EntryId, + album.Title, + e, ) track_missing_count = max(track_monitored_count - track_available_count, 0) @@ -890,7 +916,7 @@ def _lidarr_tracks_from_db( "tracks": tracks, } except Exception as e: - self.logger.error(f"Error fetching Lidarr tracks: {e}") + self.logger.error("Error fetching Lidarr tracks: %s", e) return { "counts": {"available": 0, "monitored": 0, "missing": 0}, "total": 0, @@ -1024,7 +1050,11 @@ def _sonarr_series_from_db( episodes = episodes_query.iterator() episodes_list = list(episodes) self.logger.debug( - f"[Sonarr Series] Series {getattr(series, 'Title', 'unknown')} (ID {getattr(series, 'EntryId', '?')}) has {len(episodes_list)} episodes (missing_only={missing_only})" + "[Sonarr Series] Series %s (ID %s) has %d episodes (missing_only=%s)", + getattr(series, "Title", "unknown"), + getattr(series, "EntryId", "?"), + len(episodes_list), + missing_only, ) seasons: dict[str, dict[str, Any]] = {} series_monitored = 0 @@ -1328,10 +1358,12 @@ def _sonarr_series_from_db( len(season.get("episodes", [])) for season in first_seasons.values() ) self.logger.info( - f"[Sonarr API] Returning {len(payload)} series, " - f"first series '{first_series.get('series', {}).get('title', '?')}' has " - f"{len(first_seasons)} seasons, {total_episodes_in_response} episodes " - f"(missing_only={missing_only})" + "[Sonarr API] Returning %d series, first series '%s' has %d seasons, %d episodes (missing_only=%s)", + len(payload), + first_series.get("series", {}).get("title", "?"), + len(first_seasons), + total_episodes_in_response, + missing_only, ) return result @@ -1827,10 +1859,7 @@ def web_restart_all(): self._reload_all() return jsonify({"status": "ok"}) - @app.post("/api/loglevel") - def api_loglevel(): - if (resp := require_token()) is not None: - return resp + def _handle_loglevel(): body = request.get_json(silent=True) or {} level = str(body.get("level", "INFO")).upper() valid = {"CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG", "TRACE"} @@ -1848,26 +1877,17 @@ def api_loglevel(): pass return jsonify({"status": "ok", "level": level}) + @app.post("/api/loglevel") + def api_loglevel(): + if (resp := require_token()) is not None: + return resp + return _handle_loglevel() + @app.post("/web/loglevel") def web_loglevel(): if (resp := require_token()) is not None: return resp - body = request.get_json(silent=True) or {} - level = str(body.get("level", "INFO")).upper() - valid = {"CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG", "TRACE"} - if level not in valid: - return jsonify({"error": f"invalid level {level}"}), 400 - target_level = getattr(logging, level, logging.INFO) - logging.getLogger().setLevel(target_level) - for name, lg in logging.root.manager.loggerDict.items(): - if isinstance(lg, logging.Logger) and str(name).startswith("qBitrr"): - lg.setLevel(target_level) - try: - _toml_set(CONFIG.config, "Settings.ConsoleLevel", level) - CONFIG.save() - except Exception: - pass - return jsonify({"status": "ok", "level": level}) + return _handle_loglevel() @app.post("/api/arr/rebuild") def api_arr_rebuild(): @@ -1899,15 +1919,10 @@ def api_logs(): def web_logs(): return jsonify({"files": _list_logs()}) - @app.get("/api/logs/") - def api_log(name: str): - if (resp := require_token()) is not None: - return resp + def _serve_log_content(name: str): file = _resolve_log_file(name) if file is None or not file.exists(): return jsonify({"error": "not found"}), 404 - - # Stream full log file to support dynamic loading in LazyLog try: content = file.read_text(encoding="utf-8", errors="ignore") except Exception: @@ -1921,26 +1936,15 @@ def api_log(name: str): response.headers["Cache-Control"] = "no-cache" return response + @app.get("/api/logs/") + def api_log(name: str): + if (resp := require_token()) is not None: + return resp + return _serve_log_content(name) + @app.get("/web/logs/") def web_log(name: str): - # Public endpoint for Authentik bypass - no token required - file = _resolve_log_file(name) - if file is None or not file.exists(): - return jsonify({"error": "not found"}), 404 - - # Stream full log file to support dynamic loading in LazyLog - try: - content = file.read_text(encoding="utf-8", errors="ignore") - except Exception: - content = "" - response = send_file( - io.BytesIO(content.encode("utf-8")), - mimetype="text/plain", - as_attachment=False, - ) - response.headers["Content-Type"] = "text/plain; charset=utf-8" - response.headers["Cache-Control"] = "no-cache" - return response + return _serve_log_content(name) @app.get("/api/logs//download") def api_log_download(name: str): @@ -1958,10 +1962,7 @@ def web_log_download(name: str): return jsonify({"error": "not found"}), 404 return send_file(file, as_attachment=True) - @app.get("/api/radarr//movies") - def api_radarr_movies(category: str): - if (resp := require_token()) is not None: - return resp + def _handle_radarr_movies(category: str): managed = _managed_objects() if not managed: if not _ensure_arr_manager_ready(): @@ -1971,7 +1972,7 @@ def api_radarr_movies(category: str): return jsonify({"error": f"Unknown radarr category {category}"}), 404 q = request.args.get("q", default=None, type=str) page = request.args.get("page", default=0, type=int) - page_size = request.args.get("page_size", default=50, type=int) + page_size = min(request.args.get("page_size", default=50, type=int), 1000) year_min = request.args.get("year_min", default=None, type=int) year_max = request.args.get("year_max", default=None, type=int) monitored = ( @@ -2009,59 +2010,17 @@ def api_radarr_movies(category: str): payload["category"] = category return jsonify(payload) + @app.get("/api/radarr//movies") + def api_radarr_movies(category: str): + if (resp := require_token()) is not None: + return resp + return _handle_radarr_movies(category) + @app.get("/web/radarr//movies") def web_radarr_movies(category: str): - managed = _managed_objects() - if not managed: - if not _ensure_arr_manager_ready(): - return jsonify({"error": "Arr manager is still initialising"}), 503 - arr = managed.get(category) - if arr is None or getattr(arr, "type", None) != "radarr": - return jsonify({"error": f"Unknown radarr category {category}"}), 404 - q = request.args.get("q", default=None, type=str) - page = request.args.get("page", default=0, type=int) - page_size = request.args.get("page_size", default=50, type=int) - year_min = request.args.get("year_min", default=None, type=int) - year_max = request.args.get("year_max", default=None, type=int) - monitored = ( - self._safe_bool(request.args.get("monitored")) - if "monitored" in request.args - else None - ) - has_file = ( - self._safe_bool(request.args.get("has_file")) - if "has_file" in request.args - else None - ) - quality_met = ( - self._safe_bool(request.args.get("quality_met")) - if "quality_met" in request.args - else None - ) - is_request = ( - self._safe_bool(request.args.get("is_request")) - if "is_request" in request.args - else None - ) - payload = self._radarr_movies_from_db( - arr, - q, - page, - page_size, - year_min=year_min, - year_max=year_max, - monitored=monitored, - has_file=has_file, - quality_met=quality_met, - is_request=is_request, - ) - payload["category"] = category - return jsonify(payload) + return _handle_radarr_movies(category) - @app.get("/api/sonarr//series") - def api_sonarr_series(category: str): - if (resp := require_token()) is not None: - return resp + def _handle_sonarr_series(category: str): managed = _managed_objects() if not managed: if not _ensure_arr_manager_ready(): @@ -2071,7 +2030,7 @@ def api_sonarr_series(category: str): return jsonify({"error": f"Unknown sonarr category {category}"}), 404 q = request.args.get("q", default=None, type=str) page = request.args.get("page", default=0, type=int) - page_size = request.args.get("page_size", default=25, type=int) + page_size = min(request.args.get("page_size", default=25, type=int), 1000) missing_only = self._safe_bool( request.args.get("missing") or request.args.get("only_missing") ) @@ -2081,26 +2040,15 @@ def api_sonarr_series(category: str): payload["category"] = category return jsonify(payload) + @app.get("/api/sonarr//series") + def api_sonarr_series(category: str): + if (resp := require_token()) is not None: + return resp + return _handle_sonarr_series(category) + @app.get("/web/sonarr//series") def web_sonarr_series(category: str): - managed = _managed_objects() - if not managed: - if not _ensure_arr_manager_ready(): - return jsonify({"error": "Arr manager is still initialising"}), 503 - arr = managed.get(category) - if arr is None or getattr(arr, "type", None) != "sonarr": - return jsonify({"error": f"Unknown sonarr category {category}"}), 404 - q = request.args.get("q", default=None, type=str) - page = request.args.get("page", default=0, type=int) - page_size = request.args.get("page_size", default=25, type=int) - missing_only = self._safe_bool( - request.args.get("missing") or request.args.get("only_missing") - ) - payload = self._sonarr_series_from_db( - arr, q, page, page_size, missing_only=missing_only - ) - payload["category"] = category - return jsonify(payload) + return _handle_sonarr_series(category) @app.get("/web/lidarr//albums") def web_lidarr_albums(category: str): @@ -2114,6 +2062,7 @@ def web_lidarr_albums(category: str): q = request.args.get("q", default=None, type=str) page = request.args.get("page", default=0, type=int) page_size = request.args.get("page_size", default=50, type=int) + page_size = min(page_size, 1000) monitored = ( self._safe_bool(request.args.get("monitored")) if "monitored" in request.args @@ -2321,30 +2270,25 @@ def web_meta(): force = self._safe_bool(request.args.get("force")) return jsonify(self._ensure_version_info(force=force)) - @app.post("/api/update") - def api_update(): - if (resp := require_token()) is not None: - return resp + def _handle_update(): ok, message = self._trigger_manual_update() if not ok: return jsonify({"error": message}), 409 return jsonify({"status": "started"}) - @app.post("/web/update") - def web_update(): + @app.post("/api/update") + def api_update(): if (resp := require_token()) is not None: return resp - ok, message = self._trigger_manual_update() - if not ok: - return jsonify({"error": message}), 409 - return jsonify({"status": "started"}) + return _handle_update() - @app.get("/api/download-update") - def api_download_update(): - """Redirect to binary download URL for current platform.""" + @app.post("/web/update") + def web_update(): if (resp := require_token()) is not None: return resp + return _handle_update() + def _handle_download_update(): from qBitrr.auto_update import get_installation_type install_type = get_installation_type() @@ -2352,7 +2296,6 @@ def api_download_update(): if install_type != "binary": return jsonify({"error": "Download only available for binary installations"}), 400 - # Get latest version info version_info = self._ensure_version_info() if not version_info.get("update_available"): @@ -2365,38 +2308,19 @@ def api_download_update(): ) return jsonify({"error": error}), 404 - # Redirect to GitHub download URL from flask import redirect return redirect(download_url) + @app.get("/api/download-update") + def api_download_update(): + if (resp := require_token()) is not None: + return resp + return _handle_download_update() + @app.get("/web/download-update") def web_download_update(): - """Redirect to binary download URL for current platform.""" - from qBitrr.auto_update import get_installation_type - - install_type = get_installation_type() - - if install_type != "binary": - return jsonify({"error": "Download only available for binary installations"}), 400 - - # Get latest version info - version_info = self._ensure_version_info() - - if not version_info.get("update_available"): - return jsonify({"error": "No update available"}), 404 - - download_url = version_info.get("binary_download_url") - if not download_url: - error = version_info.get( - "binary_download_error", "No binary available for your platform" - ) - return jsonify({"error": error}), 404 - - # Redirect to GitHub download URL - from flask import redirect - - return redirect(download_url) + return _handle_download_update() def _status_payload() -> dict[str, Any]: # Legacy single-instance qBit info (for backward compatibility) @@ -2592,6 +2516,9 @@ def web_get_config(): pass data = _toml_to_jsonable(CONFIG.config) + if not _authorized(): + data = _strip_sensitive_keys(data) + # Check config version and add warning if mismatch from qBitrr.config_version import get_config_version, validate_config_version @@ -2706,7 +2633,9 @@ def _handle_config_update(): affected_instances_list = sorted(affected_arr_instances) self.logger.notice( - f"Reloading {len(affected_instances_list)} Arr instance(s): {', '.join(affected_instances_list)}" + "Reloading %d Arr instance(s): %s", + len(affected_instances_list), + ", ".join(affected_instances_list), ) # Reload each affected instance in sequence @@ -2761,15 +2690,7 @@ def web_update_config(): return resp return _handle_config_update() - @app.post("/api/arr/test-connection") - def api_arr_test_connection(): - """ - Test connection to Arr instance without saving config. - Accepts temporary URI/APIKey and returns connection status + quality profiles. - """ - if (resp := require_token()) is not None: - return resp - + def _handle_test_connection(): try: data = request.get_json() if not data: @@ -2791,173 +2712,20 @@ def api_arr_test_connection(): 400, ) - # Try to find existing Arr instance with matching URI - existing_arr = None - managed = _managed_objects() - for group_name, arr_instance in managed.items(): - if hasattr(arr_instance, "uri") and hasattr(arr_instance, "apikey"): - if arr_instance.uri == uri and arr_instance.apikey == api_key: - existing_arr = arr_instance - self.logger.info(f"Using existing Arr instance: {group_name}") - break - - # Use existing client if available, otherwise create temporary one - if existing_arr and hasattr(existing_arr, "client"): - client = existing_arr.client - self.logger.info(f"Reusing existing client for {existing_arr._name}") - else: - # Create temporary Arr API client - self.logger.info(f"Creating temporary {arr_type} client for {uri}") - if arr_type == "radarr": - from pyarr import RadarrAPI - - client = RadarrAPI(uri, api_key) - elif arr_type == "sonarr": - from pyarr import SonarrAPI - - client = SonarrAPI(uri, api_key) - elif arr_type == "lidarr": - from pyarr import LidarrAPI - - client = LidarrAPI(uri, api_key) - else: - return ( - jsonify({"success": False, "message": f"Invalid arrType: {arr_type}"}), - 400, - ) - - # Test connection (no timeout - Flask/Waitress handles this) - try: - self.logger.info(f"Testing connection to {arr_type} at {uri}") - - # Get system info to verify connection - system_info = client.get_system_status() - self.logger.info( - f"System status retrieved: {system_info.get('version', 'unknown')}" - ) - - # Fetch quality profiles with retry logic (same as backend) - from json import JSONDecodeError - - import requests - from pyarr.exceptions import PyarrServerError - - max_retries = 3 - retry_count = 0 - quality_profiles = [] - - while retry_count < max_retries: - try: - quality_profiles = client.get_quality_profile() - self.logger.info( - f"Quality profiles retrieved: {len(quality_profiles)} profiles" - ) - break - except ( - requests.exceptions.ChunkedEncodingError, - requests.exceptions.ContentDecodingError, - requests.exceptions.ConnectionError, - JSONDecodeError, - ) as e: - retry_count += 1 - self.logger.warning( - f"Transient error fetching quality profiles (attempt {retry_count}/{max_retries}): {e}" - ) - if retry_count >= max_retries: - self.logger.error("Failed to fetch quality profiles after retries") - quality_profiles = [] - break - time.sleep(1) - except PyarrServerError as e: - self.logger.error(f"Server error fetching quality profiles: {e}") - quality_profiles = [] - break - except Exception as e: - self.logger.error(f"Unexpected error fetching quality profiles: {e}") - quality_profiles = [] - break + from urllib.parse import urlparse as _urlparse - # Format response - return jsonify( - { - "success": True, - "message": "Connected successfully", - "systemInfo": { - "version": system_info.get("version", "unknown"), - "branch": system_info.get("branch"), - }, - "qualityProfiles": [ - {"id": p["id"], "name": p["name"]} for p in quality_profiles - ], - } + parsed = _urlparse(uri) + if parsed.scheme not in ("http", "https"): + return ( + jsonify( + {"success": False, "message": "URI must use http or https scheme"} + ), + 400, ) - - except Exception as e: - # Handle specific error types - error_msg = str(e) - # Log full error for debugging but sanitize user-facing message - self.logger.error(f"Connection test failed: {error_msg}") - - if "401" in error_msg or "Unauthorized" in error_msg: - return ( - jsonify( - {"success": False, "message": "Unauthorized: Invalid API key"} - ), - 401, - ) - elif "404" in error_msg: - return ( - jsonify( - {"success": False, "message": f"Not found: Check URI ({uri})"} - ), - 404, - ) - elif "Connection refused" in error_msg or "ConnectionError" in error_msg: - return ( - jsonify( - { - "success": False, - "message": f"Connection refused: Cannot reach {uri}", - } - ), - 503, - ) - else: - # Generic error message - details logged above - return ( - jsonify({"success": False, "message": "Connection test failed"}), - 500, - ) - - except Exception as e: - self.logger.error("Test connection error: %s", e) - return jsonify({"success": False, "message": "Connection test failed"}), 500 - - @app.post("/web/arr/test-connection") - def web_arr_test_connection(): - """ - Test connection to Arr instance without saving config. - Accepts temporary URI/APIKey and returns connection status + quality profiles. - """ - if (resp := require_token()) is not None: - return resp - try: - data = request.get_json() - if not data: - return jsonify({"success": False, "message": "Missing request body"}), 400 - - arr_type = data.get("arrType") # "radarr" | "sonarr" | "lidarr" - uri = data.get("uri") - api_key = data.get("apiKey") - - # Validate inputs - if not all([arr_type, uri, api_key]): + if not parsed.hostname: return ( jsonify( - { - "success": False, - "message": "Missing required fields: arrType, uri, or apiKey", - } + {"success": False, "message": "URI must contain a valid hostname"} ), 400, ) @@ -2969,16 +2737,16 @@ def web_arr_test_connection(): if hasattr(arr_instance, "uri") and hasattr(arr_instance, "apikey"): if arr_instance.uri == uri and arr_instance.apikey == api_key: existing_arr = arr_instance - self.logger.info(f"Using existing Arr instance: {group_name}") + self.logger.info("Using existing Arr instance: %s", group_name) break # Use existing client if available, otherwise create temporary one if existing_arr and hasattr(existing_arr, "client"): client = existing_arr.client - self.logger.info(f"Reusing existing client for {existing_arr._name}") + self.logger.info("Reusing existing client for %s", existing_arr._name) else: # Create temporary Arr API client - self.logger.info(f"Creating temporary {arr_type} client for {uri}") + self.logger.info("Creating temporary %s client for %s", arr_type, uri) if arr_type == "radarr": from pyarr import RadarrAPI @@ -2999,12 +2767,12 @@ def web_arr_test_connection(): # Test connection (no timeout - Flask/Waitress handles this) try: - self.logger.info(f"Testing connection to {arr_type} at {uri}") + self.logger.info("Testing connection to %s at %s", arr_type, uri) # Get system info to verify connection system_info = client.get_system_status() self.logger.info( - f"System status retrieved: {system_info.get('version', 'unknown')}" + "System status retrieved: %s", system_info.get("version", "unknown") ) # Fetch quality profiles with retry logic (same as backend) @@ -3021,7 +2789,7 @@ def web_arr_test_connection(): try: quality_profiles = client.get_quality_profile() self.logger.info( - f"Quality profiles retrieved: {len(quality_profiles)} profiles" + "Quality profiles retrieved: %d profiles", len(quality_profiles) ) break except ( @@ -3032,7 +2800,10 @@ def web_arr_test_connection(): ) as e: retry_count += 1 self.logger.warning( - f"Transient error fetching quality profiles (attempt {retry_count}/{max_retries}): {e}" + "Transient error fetching quality profiles (attempt %d/%d): %s", + retry_count, + max_retries, + e, ) if retry_count >= max_retries: self.logger.error("Failed to fetch quality profiles after retries") @@ -3040,11 +2811,11 @@ def web_arr_test_connection(): break time.sleep(1) except PyarrServerError as e: - self.logger.error(f"Server error fetching quality profiles: {e}") + self.logger.error("Server error fetching quality profiles: %s", e) quality_profiles = [] break except Exception as e: - self.logger.error(f"Unexpected error fetching quality profiles: {e}") + self.logger.error("Unexpected error fetching quality profiles: %s", e) quality_profiles = [] break @@ -3067,7 +2838,7 @@ def web_arr_test_connection(): # Handle specific error types error_msg = str(e) # Log full error for debugging but sanitize user-facing message - self.logger.error(f"Connection test failed: {error_msg}") + self.logger.error("Connection test failed: %s", error_msg) if "401" in error_msg or "Unauthorized" in error_msg: return ( @@ -3104,6 +2875,18 @@ def web_arr_test_connection(): self.logger.error("Test connection error: %s", e) return jsonify({"success": False, "message": "Connection test failed"}), 500 + @app.post("/api/arr/test-connection") + def api_arr_test_connection(): + if (resp := require_token()) is not None: + return resp + return _handle_test_connection() + + @app.post("/web/arr/test-connection") + def web_arr_test_connection(): + if (resp := require_token()) is not None: + return resp + return _handle_test_connection() + def _reload_all(self): # Set rebuilding flag self._rebuilding_arrs = True @@ -3128,22 +2911,22 @@ def _reload_all(self): if hasattr(arr, "search_db_file") and arr.search_db_file: # Delete main database file if arr.search_db_file.exists(): - self.logger.info(f"Deleting database file: {arr.search_db_file}") + self.logger.info("Deleting database file: %s", arr.search_db_file) arr.search_db_file.unlink() - self.logger.success(f"Deleted database file for {arr._name}") + self.logger.success("Deleted database file for %s", arr._name) # Delete WAL file (Write-Ahead Log) wal_file = arr.search_db_file.with_suffix(".db-wal") if wal_file.exists(): - self.logger.info(f"Deleting WAL file: {wal_file}") + self.logger.info("Deleting WAL file: %s", wal_file) wal_file.unlink() # Delete SHM file (Shared Memory) shm_file = arr.search_db_file.with_suffix(".db-shm") if shm_file.exists(): - self.logger.info(f"Deleting SHM file: {shm_file}") + self.logger.info("Deleting SHM file: %s", shm_file) shm_file.unlink() except Exception as e: self.logger.warning( - f"Failed to delete database files for {arr._name}: {e}" + "Failed to delete database files for %s: %s", arr._name, e ) # Rebuild arr manager from config and spawn fresh @@ -3181,7 +2964,7 @@ def _restart_webui(self): try: CONFIG.load() except Exception as e: - self.logger.warning(f"Failed to reload config: {e}") + self.logger.warning("Failed to reload config: %s", e) # Update in-memory values new_host = CONFIG.get("WebUI.Host", fallback="0.0.0.0") @@ -3208,11 +2991,11 @@ def _restart_webui(self): self._restart_requested = True self._shutdown_event.set() - self.logger.info(f"WebUI will restart on {self.host}:{self.port}") + self.logger.info("WebUI will restart on %s:%s", self.host, self.port) def _stop_arr_instance(self, arr, category: str): """Stop and cleanup a single Arr instance.""" - self.logger.info(f"Stopping Arr instance: {category}") + self.logger.info("Stopping Arr instance: %s", category) # Stop processes for loop_kind in ("search", "torrent"): @@ -3231,26 +3014,26 @@ def _stop_arr_instance(self, arr, category: str): self.manager.child_processes.remove(process) except Exception: pass - self.logger.debug(f"Stopped {loop_kind} process for {category}") + self.logger.debug("Stopped %s process for %s", loop_kind, category) # Delete database files try: if hasattr(arr, "search_db_file") and arr.search_db_file: if arr.search_db_file.exists(): - self.logger.info(f"Deleting database file: {arr.search_db_file}") + self.logger.info("Deleting database file: %s", arr.search_db_file) arr.search_db_file.unlink() self.logger.success( - f"Deleted database file for {getattr(arr, '_name', category)}" + "Deleted database file for %s", getattr(arr, "_name", category) ) # Delete WAL and SHM files for suffix in (".db-wal", ".db-shm"): aux_file = arr.search_db_file.with_suffix(suffix) if aux_file.exists(): - self.logger.debug(f"Deleting auxiliary file: {aux_file}") + self.logger.debug("Deleting auxiliary file: %s", aux_file) aux_file.unlink() except Exception as e: self.logger.warning( - f"Failed to delete database files for {getattr(arr, '_name', category)}: {e}" + "Failed to delete database files for %s: %s", getattr(arr, "_name", category), e ) # Remove from managed_objects @@ -3259,15 +3042,15 @@ def _stop_arr_instance(self, arr, category: str): self.manager.arr_manager.uris.discard(getattr(arr, "uri", "")) self.manager.arr_manager.arr_categories.discard(category) - self.logger.success(f"Stopped and cleaned up Arr instance: {category}") + self.logger.success("Stopped and cleaned up Arr instance: %s", category) def _start_arr_instance(self, instance_name: str): """Create and start a single Arr instance.""" - self.logger.info(f"Starting Arr instance: {instance_name}") + self.logger.info("Starting Arr instance: %s", instance_name) # Check if instance is managed if not CONFIG.get(f"{instance_name}.Managed", fallback=False): - self.logger.info(f"Instance {instance_name} is not managed, skipping") + self.logger.info("Instance %s is not managed, skipping", instance_name) return # Determine client class based on name @@ -3285,7 +3068,7 @@ def _start_arr_instance(self, instance_name: str): client_cls = LidarrAPI else: - self.logger.error(f"Unknown Arr type for instance: {instance_name}") + self.logger.error("Unknown Arr type for instance: %s", instance_name) return try: @@ -3306,22 +3089,24 @@ def _start_arr_instance(self, instance_name: str): for p in procs: try: p.start() - self.logger.debug(f"Started process (PID: {p.pid}) for {instance_name}") + self.logger.debug("Started process (PID: %s) for %s", p.pid, instance_name) except Exception as e: - self.logger.error(f"Failed to start process for {instance_name}: {e}") + self.logger.error("Failed to start process for %s: %s", instance_name, e) self.logger.success( - f"Started Arr instance: {instance_name} (category: {new_arr.category})" + "Started Arr instance: %s (category: %s)", instance_name, new_arr.category ) except SkipException: - self.logger.info(f"Instance {instance_name} skipped (not managed or disabled)") + self.logger.info("Instance %s skipped (not managed or disabled)", instance_name) except Exception as e: - self.logger.error(f"Failed to start Arr instance {instance_name}: {e}", exc_info=True) + self.logger.error( + "Failed to start Arr instance %s: %s", instance_name, e, exc_info=True + ) def _reload_arr_instance(self, instance_name: str): """Reload a single Arr instance without affecting others.""" - self.logger.notice(f"Reloading Arr instance: {instance_name}") + self.logger.notice("Reloading Arr instance: %s", instance_name) if not hasattr(self.manager, "arr_manager") or not self.manager.arr_manager: self.logger.warning("Cannot reload Arr instance: ArrManager not initialized") @@ -3344,19 +3129,19 @@ def _reload_arr_instance(self, instance_name: str): # Handle deletion case if not instance_exists_in_config: if old_arr: - self.logger.info(f"Instance {instance_name} removed from config, stopping...") + self.logger.info("Instance %s removed from config, stopping...", instance_name) self._stop_arr_instance(old_arr, old_category) else: - self.logger.debug(f"Instance {instance_name} not found in config or memory") + self.logger.debug("Instance %s not found in config or memory", instance_name) return # Handle update/addition if old_arr: # Update existing - stop old processes first - self.logger.info(f"Updating existing Arr instance: {instance_name}") + self.logger.info("Updating existing Arr instance: %s", instance_name) self._stop_arr_instance(old_arr, old_category) else: - self.logger.info(f"Adding new Arr instance: {instance_name}") + self.logger.info("Adding new Arr instance: %s", instance_name) # Small delay to ensure cleanup completes time.sleep(0.5) @@ -3364,7 +3149,7 @@ def _reload_arr_instance(self, instance_name: str): # Create new instance self._start_arr_instance(instance_name) - self.logger.success(f"Successfully reloaded Arr instance: {instance_name}") + self.logger.success("Successfully reloaded Arr instance: %s", instance_name) def start(self): if self._thread and self._thread.is_alive(): diff --git a/setup.cfg b/setup.cfg index f35406380..264cf352e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -83,7 +83,6 @@ include_package_data = True [options.packages.find] include = qBitrr - config.example.toml [options.package_data] qBitrr = diff --git a/webui/src/api/client.ts b/webui/src/api/client.ts index c5c5c0a5e..83cbe37e8 100644 --- a/webui/src/api/client.ts +++ b/webui/src/api/client.ts @@ -316,37 +316,10 @@ export async function getConfig(): Promise { export async function updateConfig( payload: ConfigUpdatePayload ): Promise { - const token = resolveToken(); - const response = await fetch("/web/config", buildInit({ + return fetchJson("/web/config", { method: "POST", body: JSON.stringify(payload), - }, token)); - - if (!response.ok) { - let detail: unknown = null; - try { - detail = await response.json(); - } catch { - // ignore - } - let message = `${response.status} ${response.statusText}`; - if ( - detail && - typeof detail === "object" && - "error" in detail && - typeof (detail as Record).error === "string" - ) { - const errorText = (detail as Record).error as string; - if (errorText.trim()) { - message = errorText; - } - } - throw new Error(message); - } - - // Parse response body with full type information - const data = await response.json() as ConfigUpdateResponse; - return data; + }); } export async function triggerUpdate(): Promise { diff --git a/webui/src/api/types.ts b/webui/src/api/types.ts index d71949196..9d9888b75 100644 --- a/webui/src/api/types.ts +++ b/webui/src/api/types.ts @@ -1,4 +1,4 @@ -export type ArrType = "radarr" | "sonarr" | "lidarr" | string; +export type ArrType = "radarr" | "sonarr" | "lidarr"; export interface ProcessInfo { category: string; @@ -116,7 +116,6 @@ export interface RadarrMovie { reason?: string | null; qualityProfileId?: number | null; qualityProfileName?: string | null; - [key: string]: unknown; } export interface RadarrMoviesResponse { @@ -137,7 +136,6 @@ export interface SonarrEpisode { hasFile?: boolean; airDateUtc?: string; reason?: string | null; - [key: string]: unknown; } export interface SonarrSeason { @@ -207,7 +205,6 @@ export interface LidarrAlbum { percentOfTracks?: number; qualityProfileId?: number | null; qualityProfileName?: string | null; - [key: string]: unknown; } export interface LidarrAlbumEntry { @@ -221,7 +218,6 @@ export interface LidarrAlbumEntry { missing?: number; }; tracks: LidarrTrack[]; - [key: string]: unknown; } export interface LidarrAlbumsResponse { diff --git a/webui/src/pages/LogsView.tsx b/webui/src/pages/LogsView.tsx index 292845512..38e50f91b 100644 --- a/webui/src/pages/LogsView.tsx +++ b/webui/src/pages/LogsView.tsx @@ -89,7 +89,8 @@ export function LogsView({ active }: LogsViewProps): JSX.Element { const lastLinesCountRef = useRef(0); const { push } = useToast(); const { theme } = useWebUI(); - const selectStyles = useMemo(() => getSelectStyles(theme === 'dark'), [theme]); + const isDark = theme === 'dark'; + const selectStyles = useMemo(() => getSelectStyles(isDark), [isDark]); const loadList = useCallback(async () => { setLoadingList(true); @@ -217,7 +218,7 @@ export function LogsView({ active }: LogsViewProps): JSX.Element { borderRadius: '4px' }}> {loadingContent ? ( -
+
Loading logs...
@@ -231,15 +232,15 @@ export function LogsView({ active }: LogsViewProps): JSX.Element { extraLines={1} style={{ height: '100%', - backgroundColor: '#0a0e14', - color: '#e5e5e5', + backgroundColor: isDark ? '#0a0e14' : '#fafafa', + color: isDark ? '#e5e5e5' : '#1d1d1f', fontFamily: '"Cascadia Code", "Fira Code", "Consolas", "Monaco", monospace', fontSize: '13px', lineHeight: '1.5' }} /> ) : ( -
Select a log file to view...
+
Select a log file to view...
)}
From 2c29b9221089e91d9d14f4e2b134717038f508e3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 28 Feb 2026 22:44:20 +0000 Subject: [PATCH 52/66] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .github/autofix/auto_fix.py | 2 +- .github/scripts/update_releases.py | 5 ++--- qBitrr/arss.py | 4 +--- qBitrr/db_lock.py | 2 +- 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/.github/autofix/auto_fix.py b/.github/autofix/auto_fix.py index a4fb85713..f0bae658c 100644 --- a/.github/autofix/auto_fix.py +++ b/.github/autofix/auto_fix.py @@ -9,10 +9,10 @@ import sys import textwrap import uuid +from collections.abc import Iterable from dataclasses import dataclass from io import BytesIO from pathlib import Path -from typing import Iterable from zipfile import ZipFile import requests diff --git a/.github/scripts/update_releases.py b/.github/scripts/update_releases.py index ff2bff8c7..fe25d634b 100755 --- a/.github/scripts/update_releases.py +++ b/.github/scripts/update_releases.py @@ -7,10 +7,9 @@ import re import subprocess import sys -from typing import Dict, List -def extract_changelog_entries() -> Dict[str, str]: +def extract_changelog_entries() -> dict[str, str]: """Extract all changelog entries from CHANGELOG.md""" with open("CHANGELOG.md") as f: content = f.read() @@ -31,7 +30,7 @@ def extract_changelog_entries() -> Dict[str, str]: return entries -def get_existing_releases() -> List[str]: +def get_existing_releases() -> list[str]: """Get list of existing GitHub releases""" result = subprocess.run( ["gh", "release", "list", "--limit", "100"], capture_output=True, text=True, check=True diff --git a/qBitrr/arss.py b/qBitrr/arss.py index de2c4cba9..bc2cc628b 100755 --- a/qBitrr/arss.py +++ b/qBitrr/arss.py @@ -989,9 +989,7 @@ def _ensure_torrent_row( QbitInstance=instance_name, ).on_conflict_ignore().execute() - def _torrent_condition( - self, torrent: TorrentDictionary, instance_name: str = "default" - ): + def _torrent_condition(self, torrent: TorrentDictionary, instance_name: str = "default"): """Return the base WHERE condition for a torrent row.""" return ( (self.torrents.Hash == torrent.hash) diff --git a/qBitrr/db_lock.py b/qBitrr/db_lock.py index 75f22a72e..2c0deb926 100644 --- a/qBitrr/db_lock.py +++ b/qBitrr/db_lock.py @@ -2,9 +2,9 @@ import os import threading +from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path -from typing import Iterator from qBitrr.db_recovery import checkpoint_wal, repair_database from qBitrr.home_path import APPDATA_FOLDER From f669711b0804bf9f66d32f8b8553c376c78fca5b Mon Sep 17 00:00:00 2001 From: Feramance Date: Sun, 1 Mar 2026 00:39:31 +0100 Subject: [PATCH 53/66] fix: Docker config volume permissions and WebUI/build errors - Docker: add entrypoint that chowns /config to PUID/PGID so app can write (fixes 'attempt to write a readonly database' on mounted volumes) - Dockerfile: install gosu, use entrypoint, remove USER so root can chown at start - home_path/logger: make chmod non-fatal when volume does not allow it - database: ensure db dir writable, retry on readonly with clear error message - WebUI: fix ProcessesView.tsx QUALITY_TOKEN_REGEX name and usage - WebUI: add Hashable index signature to RadarrMovie and LidarrAlbumEntry --- Dockerfile | 10 +++-- docker-entrypoint.sh | 14 +++++++ qBitrr/database.py | 61 ++++++++++++++++++++++++++++--- qBitrr/home_path.py | 5 ++- qBitrr/logger.py | 10 ++++- webui/src/api/types.ts | 2 + webui/src/pages/ProcessesView.tsx | 5 +-- 7 files changed, 91 insertions(+), 16 deletions(-) create mode 100644 docker-entrypoint.sh diff --git a/Dockerfile b/Dockerfile index 5569b50fa..80f40502e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,8 +13,8 @@ LABEL Maintainer="feramance" LABEL Version="5.9.1" LABEL org.opencontainers.image.source=https://github.com/feramance/qbitrr -# Install tini so PID 1 forwards SIGTERM to Python (enables graceful shutdown and DB checkpoint) -RUN apt-get update && apt-get install -y --no-install-recommends tini \ +# Install tini (PID 1, SIGTERM forwarding) and gosu (drop privileges after chown) +RUN apt-get update && apt-get install -y --no-install-recommends tini gosu \ && rm -rf /var/lib/apt/lists/* # Env used by the script to determine if it's inside a docker - @@ -32,11 +32,13 @@ RUN rm -rf qBitrr2.egg-info *.egg-info && pip install --no-cache-dir --quiet ".[ RUN groupadd -r qbitrr && useradd -r -g qbitrr -d /config -s /sbin/nologin qbitrr \ && mkdir -p /config && chown -R qbitrr:qbitrr /config /app -USER qbitrr + +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh WORKDIR /config EXPOSE 6969 -ENTRYPOINT ["/usr/bin/tini", "--"] +ENTRYPOINT ["/docker-entrypoint.sh"] CMD ["python", "-m", "qBitrr.main"] diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 000000000..6afe7eb1c --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -e +# Default to 1000:1000 when PUID/PGID are not set (e.g. docker run without -e) +export PUID="${PUID:-1000}" +export PGID="${PGID:-1000}" + +# When running as root, ensure /config is owned by PUID:PGID so the app can write +# (mounted volumes often appear as root-owned; this restores writability) +if [ "$(id -u)" = "0" ] && [ "${PUID}" != "0" ]; then + chown -R "${PUID}:${PGID}" /config 2>/dev/null || true +fi + +# Run the container CMD as the specified user (tini + python) +exec gosu "${PUID}:${PGID}" /usr/bin/tini -- "$@" diff --git a/qBitrr/database.py b/qBitrr/database.py index 03dd35fee..255521782 100644 --- a/qBitrr/database.py +++ b/qBitrr/database.py @@ -3,11 +3,12 @@ from __future__ import annotations import logging +import os import re import sqlite3 from pathlib import Path -from peewee import SqliteDatabase +from peewee import OperationalError, SqliteDatabase from qBitrr.config import APPDATA_FOLDER from qBitrr.db_lock import with_database_retry @@ -31,6 +32,29 @@ _db: SqliteDatabase | None = None +def _ensure_db_directory_writable(db_path: Path) -> None: + """ + Try to make the database directory and file writable (e.g. when running in + Docker with a mounted volume that had wrong permissions). Ignore errors. + """ + try: + os.chmod(db_path.parent, 0o755) + except (PermissionError, OSError): + pass + if db_path.exists(): + try: + os.chmod(db_path, 0o644) + except (PermissionError, OSError): + pass + for extra in (f"{db_path.name}-wal", f"{db_path.name}-shm"): + p = db_path.parent / extra + if p.exists(): + try: + os.chmod(p, 0o644) + except (PermissionError, OSError): + pass + + def _offer_fresh_start(db_path: Path) -> bool: """ Delete corrupt database so a fresh one is created on connect. @@ -131,6 +155,7 @@ def get_database() -> SqliteDatabase: if _db is None: db_path = Path(APPDATA_FOLDER) / "qbitrr.db" db_path.parent.mkdir(parents=True, exist_ok=True) + _ensure_db_directory_writable(db_path) _db = SqliteDatabase( str(db_path), @@ -175,12 +200,36 @@ def get_database() -> SqliteDatabase: ] _db.bind(models) - # Create all tables - _db.create_tables(models, safe=True) + # Create all tables (and indexes); retry once if readonly (e.g. Docker volume perms) + def _create_tables_and_indexes() -> None: + _db.create_tables(models, safe=True) + _migrate_arrinstance_field(models) + _create_arrinstance_indexes(_db, models) - # Run migrations - _migrate_arrinstance_field(models) - _create_arrinstance_indexes(_db, models) + try: + _create_tables_and_indexes() + except OperationalError as e: + if "readonly" in str(e).lower(): + logger.warning( + "Database is read-only (often due to volume permissions). " + "Attempting to fix permissions and retry..." + ) + _ensure_db_directory_writable(db_path) + try: + _create_tables_and_indexes() + except OperationalError as e2: + if "readonly" in str(e2).lower(): + logger.critical( + "Database directory or file is read-only. " + "Ensure the config volume is writable by the container user (e.g. set PUID/PGID in docker-compose). " + "On the host: chown -R PUID:PGID /path/to/config. " + "Alternatively remove the database directory and restart to recreate it (data loss)." + ) + _db.close() + _db = None + raise + else: + raise logger.info("Initialized single database: %s", db_path) diff --git a/qBitrr/home_path.py b/qBitrr/home_path.py index 742791368..e6e98a816 100644 --- a/qBitrr/home_path.py +++ b/qBitrr/home_path.py @@ -20,4 +20,7 @@ APPDATA_FOLDER = HOME_PATH.joinpath("qBitManager") APPDATA_FOLDER.mkdir(parents=True, exist_ok=True) -APPDATA_FOLDER.chmod(mode=0o755) +try: + APPDATA_FOLDER.chmod(mode=0o755) +except (PermissionError, OSError): + pass # Mounted volumes (e.g. Docker /config) may not allow chmod diff --git a/qBitrr/logger.py b/qBitrr/logger.py index 5b0517615..b2d6b027d 100644 --- a/qBitrr/logger.py +++ b/qBitrr/logger.py @@ -126,7 +126,10 @@ def run_logs(logger: Logger, _name: str = None) -> None: if ALL_LOGS_HANDLER is None: logs_folder = HOME_PATH.joinpath("logs") logs_folder.mkdir(parents=True, exist_ok=True) - logs_folder.chmod(mode=0o755) + try: + logs_folder.chmod(mode=0o755) + except (PermissionError, OSError): + pass # Mounted volumes (e.g. Docker /config) may not allow chmod all_logfile = logs_folder.joinpath("All.log") # Rotate old All.log if it exists if all_logfile.exists(): @@ -168,7 +171,10 @@ def run_logs(logger: Logger, _name: str = None) -> None: if _name: logs_folder = HOME_PATH.joinpath("logs") logs_folder.mkdir(parents=True, exist_ok=True) - logs_folder.chmod(mode=0o755) + try: + logs_folder.chmod(mode=0o755) + except (PermissionError, OSError): + pass # Mounted volumes (e.g. Docker /config) may not allow chmod logfile = logs_folder.joinpath(_name + ".log") if pathlib.Path(logfile).is_file(): logold = logs_folder.joinpath(_name + ".log.old") diff --git a/webui/src/api/types.ts b/webui/src/api/types.ts index 9d9888b75..7c84d6571 100644 --- a/webui/src/api/types.ts +++ b/webui/src/api/types.ts @@ -108,6 +108,7 @@ export interface RadarrCounts { } export interface RadarrMovie { + [key: string]: unknown; id?: number; title?: string; year?: number; @@ -208,6 +209,7 @@ export interface LidarrAlbum { } export interface LidarrAlbumEntry { + [key: string]: unknown; album: Record & { qualityProfileId?: number | null; qualityProfileName?: string | null; diff --git a/webui/src/pages/ProcessesView.tsx b/webui/src/pages/ProcessesView.tsx index 25d5169a2..c35a000e3 100644 --- a/webui/src/pages/ProcessesView.tsx +++ b/webui/src/pages/ProcessesView.tsx @@ -16,8 +16,7 @@ import { ConfirmDialog } from "../components/ConfirmDialog"; import RefreshIcon from "../icons/refresh-arrow.svg"; import ToolsIcon from "../icons/build.svg"; -const - = +const QUALITY_TOKEN_REGEX = /\b(480p|576p|720p|1080p|2160p|4k|8k|web[-_. ]?(?:dl|rip)|hdrip|hdtv|bluray|bd(?:rip)?|brrip|webrip|remux|x264|x265|hevc|dts|truehd|atmos|proper|repack|dvdrip|hdr|amzn|nf)\b/i; const EPISODE_TOKEN_REGEX = /\bS\d{1,3}E\d{1,3}\b/i; const SEASON_TOKEN_REGEX = /\bSeason\s+\d+\b/i; @@ -41,7 +40,7 @@ function sanitizeSearchSummary(raw: string): string { const looksLikeEpisode = EPISODE_TOKEN_REGEX.test(rest) || SEASON_TOKEN_REGEX.test(rest); if (rest && !looksLikeEpisode && - .test(rest)) { + QUALITY_TOKEN_REGEX.test(rest)) { const rawTitle = releaseMatch.groups?.title ?? ""; const cleanedTitle = rawTitle .replace(/[-_.]/g, " ") From 6fec59128fc1aaa163e9c9480c54e88cbc933188 Mon Sep 17 00:00:00 2001 From: Feramance Date: Sun, 1 Mar 2026 09:06:14 +0100 Subject: [PATCH 54/66] feat(logs): tail-only API, Live checkbox, Refresh, load older logs - Backend: add lines/offset params to log API for efficient tail and pagination - Logs view: Live checkbox disables polling; Refresh fetches current tail - Load older logs button to prepend older lines (scroll up indefinitely) - Auto-scroll remains separate from Live --- qBitrr/webui.py | 50 +++++++++++++++- webui/src/api/client.ts | 16 ++++- webui/src/pages/LogsView.tsx | 111 +++++++++++++++++++++++++++++++---- 3 files changed, 163 insertions(+), 14 deletions(-) diff --git a/qBitrr/webui.py b/qBitrr/webui.py index 437ac677c..fefdcd9cb 100644 --- a/qBitrr/webui.py +++ b/qBitrr/webui.py @@ -1919,12 +1919,60 @@ def api_logs(): def web_logs(): return jsonify({"files": _list_logs()}) + def _read_tail(path: Path, n: int, offset: int = 0) -> str: + """Read n lines from the end of the file, optionally skipping the last `offset` lines. + So offset=0 returns the last n lines; offset=2000 returns the n lines before that. + """ + if n <= 0: + return "" + to_read = n + offset + if to_read <= 0: + return "" + try: + size = path.stat().st_size + except OSError: + return "" + if size == 0: + return "" + chunk_size = 65536 + with path.open("rb") as f: + buf = b"" + pos = size + while pos > 0: + read_size = min(chunk_size, pos) + pos -= read_size + f.seek(pos) + buf = f.read(read_size) + buf + text = buf.decode("utf-8", errors="ignore") + if text.count("\n") + (1 if text.rstrip("\n") else 0) >= to_read: + break + text = buf.decode("utf-8", errors="ignore") + lines = text.splitlines() + total = len(lines) + if total <= offset: + return "" + # Return the n lines ending at (end - offset): lines[-(offset+n):-offset] or lines[-n:] when offset==0 + start = -(offset + n) if (offset + n) <= total else 0 + end = -offset if offset > 0 else total + if start >= end: + return "" + return "\n".join(lines[start:end]) + def _serve_log_content(name: str): file = _resolve_log_file(name) if file is None or not file.exists(): return jsonify({"error": "not found"}), 404 + lines_param = request.args.get("lines", type=int) + offset_param = request.args.get("offset", default=0, type=int) try: - content = file.read_text(encoding="utf-8", errors="ignore") + if lines_param is not None and lines_param > 0: + content = _read_tail( + file, + min(lines_param, 50000), + offset=max(0, offset_param), + ) + else: + content = file.read_text(encoding="utf-8", errors="ignore") except Exception: content = "" response = send_file( diff --git a/webui/src/api/client.ts b/webui/src/api/client.ts index 83cbe37e8..9cfa3df61 100644 --- a/webui/src/api/client.ts +++ b/webui/src/api/client.ts @@ -205,8 +205,20 @@ export async function getLogs(): Promise { return fetchJson("/web/logs"); } -export async function getLogTail(name: string): Promise { - return fetchTextResponse(`/web/logs/${encodeURIComponent(name)}`); +export async function getLogTail( + name: string, + lines?: number, + offset?: number +): Promise { + const base = `/web/logs/${encodeURIComponent(name)}`; + if (lines == null || lines <= 0) { + return fetchTextResponse(base); + } + const params = new URLSearchParams({ lines: String(lines) }); + if (offset != null && offset > 0) { + params.set("offset", String(offset)); + } + return fetchTextResponse(`${base}?${params.toString()}`); } export function getLogDownloadUrl(name: string): string { diff --git a/webui/src/pages/LogsView.tsx b/webui/src/pages/LogsView.tsx index 38e50f91b..418dfd3ea 100644 --- a/webui/src/pages/LogsView.tsx +++ b/webui/src/pages/LogsView.tsx @@ -79,13 +79,22 @@ function describeError(reason: unknown, context: string): string { return context; } +/** Number of lines to fetch per chunk for the log viewer; keeps load fast. */ +const DEFAULT_LOG_TAIL_LINES = 2000; + export function LogsView({ active }: LogsViewProps): JSX.Element { const [files, setFiles] = useState([]); const [selected, setSelected] = useState("All.log"); const [logContent, setLogContent] = useState(""); const [follow, setFollow] = useState(true); + /** When true, poll for new log content every second; when false, only refresh on manual Refresh. */ + const [liveUpdates, setLiveUpdates] = useState(true); + /** Number of lines we've loaded "above" the tail (for load-more). When 0, we only have the tail. */ + const [offsetFromEnd, setOffsetFromEnd] = useState(0); const [loadingList, setLoadingList] = useState(false); const [loadingContent, setLoadingContent] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMoreAbove, setHasMoreAbove] = useState(true); const lastLinesCountRef = useRef(0); const { push } = useToast(); const { theme } = useWebUI(); @@ -122,18 +131,23 @@ export function LogsView({ active }: LogsViewProps): JSX.Element { }, [loadList]); const fetchLogContent = useCallback( - async (showLoading: boolean = false) => { + async (showLoading: boolean = false, onlyIfTail: boolean = false) => { if (!selected) return; + if (onlyIfTail && offsetFromEnd > 0) return; if (showLoading) setLoadingContent(true); try { - const newContent = await getLogTail(selected); - const newLines = newContent.split('\n'); + const newContent = await getLogTail(selected, DEFAULT_LOG_TAIL_LINES, 0); + const newLines = newContent.split("\n"); const currentLinesCount = newLines.length; - if (currentLinesCount !== lastLinesCountRef.current) { + if (currentLinesCount !== lastLinesCountRef.current || !onlyIfTail) { setLogContent(newContent); lastLinesCountRef.current = currentLinesCount; + if (!onlyIfTail) { + setOffsetFromEnd(0); + setHasMoreAbove(true); + } } } catch (error) { push(describeError(error, `Failed to read ${selected}`), "error"); @@ -141,21 +155,55 @@ export function LogsView({ active }: LogsViewProps): JSX.Element { if (showLoading) setLoadingContent(false); } }, - [selected, push] + [selected, push, offsetFromEnd] ); + const loadMoreAbove = useCallback(async () => { + if (!selected || loadingMore || !hasMoreAbove) return; + const nextOffset = offsetFromEnd + DEFAULT_LOG_TAIL_LINES; + setLoadingMore(true); + try { + const olderContent = await getLogTail( + selected, + DEFAULT_LOG_TAIL_LINES, + nextOffset + ); + if (olderContent.length === 0) { + setHasMoreAbove(false); + } else { + setLogContent((prev) => + prev ? `${olderContent}\n${prev}` : olderContent + ); + setOffsetFromEnd(nextOffset); + } + } catch (error) { + push( + describeError(error, `Failed to load older logs for ${selected}`), + "error" + ); + } finally { + setLoadingMore(false); + } + }, [selected, offsetFromEnd, loadingMore, hasMoreAbove, push]); + + const handleRefreshLogs = useCallback(() => { + void fetchLogContent(true, false); + }, [fetchLogContent]); + useEffect(() => { if (selected) { lastLinesCountRef.current = 0; + setOffsetFromEnd(0); + setHasMoreAbove(true); void fetchLogContent(true); } }, [selected, fetchLogContent]); useInterval( () => { - void fetchLogContent(false); + void fetchLogContent(false, true); }, - active ? 1000 : null + active && liveUpdates ? 1000 : null ); @@ -184,6 +232,14 @@ export function LogsView({ active }: LogsViewProps): JSX.Element { Reload List + + + )} +
+ + /> +
+ ) : (
Select a log file to view...
)} From 280a754fe91fdecf9aa28dac65059ce885e613da Mon Sep 17 00:00:00 2001 From: Feramance Date: Sun, 1 Mar 2026 09:16:39 +0100 Subject: [PATCH 55/66] fix(webui): render changelog as markdown and improve Check Updates flow - WelcomeModal: use ReactMarkdown for release notes instead of pre - Check Updates: show popup after check - either 'already on latest' with current version changelog (markdown) or update-available modal with latest changelog (markdown) - refreshMeta now returns fetched MetaResponse for callers - Add AlreadyUpToDateModal for Check Updates when no update available --- webui/src/App.tsx | 110 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 103 insertions(+), 7 deletions(-) diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 5742775d6..e747e6f0c 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -115,9 +115,11 @@ function WelcomeModal({

Release Notes

-
-              {changelog?.trim() ? changelog.trim() : "No changelog available for this version."}
-            
+
+ + {changelog?.trim() ? changelog.trim() : "No changelog available for this version."} + +
@@ -145,6 +147,77 @@ function WelcomeModal({ ); } +interface AlreadyUpToDateModalProps { + currentVersion: string; + changelog: string | null; + changelogUrl: string | null; + repositoryUrl: string; + onClose: () => void; +} + +function AlreadyUpToDateModal({ + currentVersion, + changelog, + changelogUrl, + repositoryUrl, + onClose, +}: AlreadyUpToDateModalProps): JSX.Element { + return ( +
+
event.stopPropagation()} + > +
+

✓ You're on the latest version

+ +
+
+
+

+ Current version: {formatVersionLabel(currentVersion)} +

+
+ {changelog?.trim() ? ( +
+

Release Notes

+
+ {changelog.trim()} +
+
+ ) : null} +
+
+
+ {(changelogUrl || repositoryUrl) && ( + + + View on GitHub + + )} +
+
+ +
+
+
+
+ ); +} + interface ChangelogModalProps { currentVersion: string; latestVersion: string | null; @@ -359,6 +432,7 @@ function AppShell(): JSX.Element { const [meta, setMeta] = useState(null); const [metaLoading, setMetaLoading] = useState(false); const [showChangelog, setShowChangelog] = useState(false); + const [showAlreadyUpToDateModal, setShowAlreadyUpToDateModal] = useState(false); const [updateBusy, setUpdateBusy] = useState(false); const [backendRestarting, setBackendRestarting] = useState(false); const restartPollCount = useRef(0); @@ -389,7 +463,7 @@ function AppShell(): JSX.Element { }, []); const refreshMeta = useCallback( - async (options?: { force?: boolean; silent?: boolean }) => { + async (options?: { force?: boolean; silent?: boolean }): Promise => { const force = options?.force ?? false; const silent = options?.silent ?? !force; if (!silent) { @@ -398,12 +472,14 @@ function AppShell(): JSX.Element { try { const data = await getMeta({ force }); setMeta(data); + return data; } catch (error) { if (!silent) { const message = error instanceof Error ? error.message : "Failed to fetch version information"; push(message, "error"); } + return null; } finally { if (!silent) { setMetaLoading(false); @@ -682,8 +758,15 @@ function AppShell(): JSX.Element { } }, [tabs, activeTab]); - const handleCheckUpdates = useCallback(() => { - void refreshMeta({ force: true }); + const handleCheckUpdates = useCallback(async () => { + const data = await refreshMeta({ force: true }); + if (data) { + if (data.update_available) { + setShowChangelog(true); + } else { + setShowAlreadyUpToDateModal(true); + } + } }, [refreshMeta]); const handleOpenChangelog = useCallback(() => { @@ -697,6 +780,10 @@ function AppShell(): JSX.Element { setShowChangelog(false); }, []); + const handleCloseAlreadyUpToDateModal = useCallback(() => { + setShowAlreadyUpToDateModal(false); + }, []); + const handleCloseWelcomeChangelog = useCallback(() => { setShowWelcomeChangelog(false); // Mark this version as seen @@ -775,7 +862,7 @@ function AppShell(): JSX.Element {
); } From 66bfeb7ef7bf1d6879bfb2de6113828f6103a5a7 Mon Sep 17 00:00:00 2001 From: Feramance Date: Sun, 1 Mar 2026 10:04:46 +0100 Subject: [PATCH 56/66] fix: DB locks, config path, logging, log file hardening - AGENTS.md: correct locked_database() to database_lock() - config: load from CONFIG_FILE or resolved CONFIG_PATH after copy - database: wrap init (connect, create_tables) in database_lock() - search_activity_store: wrap all DB ops in database_lock() - arss: wrap TorrentLibrary/tagless, db_reset_*, queue cleanup in database_lock() - webui: wrap Radarr/Lidarr/Sonarr DB access in database_lock() - webui: add debug logging to remaining silent except blocks - webui: harden _resolve_log_file (sanitize name, log on exception) --- AGENTS.md | 2 +- qBitrr/arss.py | 204 +++++----- qBitrr/config.py | 3 +- qBitrr/database.py | 99 ++--- qBitrr/search_activity_store.py | 47 ++- qBitrr/webui.py | 697 ++++++++++++++++++-------------- 6 files changed, 586 insertions(+), 466 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 227cb123c..5adcf5020 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -307,7 +307,7 @@ Each release produces: - **Tagging**: Radarr/Sonarr downloads MUST have tags matching the category configured for qBitrr to track them - **Paths**: qBit's "Save Path" must be accessible to Radarr/Sonarr (common issue in Docker with mismatched volumes) - **WebUI Token**: If `Settings.WebUIToken` is set, all `/api/*` calls require `X-API-Token` header -- **Database Locks**: Use `db_lock.py:locked_database()` context manager for all Peewee queries to avoid conflicts +- **Database Locks**: Use `db_lock.py:database_lock()` context manager for all Peewee queries to avoid conflicts ## Documentation Maintenance diff --git a/qBitrr/arss.py b/qBitrr/arss.py index bc2cc628b..455ab119a 100755 --- a/qBitrr/arss.py +++ b/qBitrr/arss.py @@ -47,7 +47,7 @@ SEARCH_ONLY, TAGLESS, ) -from qBitrr.db_lock import with_database_retry +from qBitrr.db_lock import database_lock, with_database_retry from qBitrr.errors import ( DelayLoopException, NoConnectionrException, @@ -973,21 +973,22 @@ def _ensure_torrent_row( self, torrent: TorrentDictionary, instance_name: str = "default" ) -> None: """Ensure a TorrentLibrary row exists for the given torrent.""" - query = ( - self.torrents.select() - .where( - (self.torrents.Hash == torrent.hash) - & (self.torrents.Category == torrent.category) - & (self.torrents.QbitInstance == instance_name) + with database_lock(): + query = ( + self.torrents.select() + .where( + (self.torrents.Hash == torrent.hash) + & (self.torrents.Category == torrent.category) + & (self.torrents.QbitInstance == instance_name) + ) + .execute() ) - .execute() - ) - if not query: - self.torrents.insert( - Hash=torrent.hash, - Category=torrent.category, - QbitInstance=instance_name, - ).on_conflict_ignore().execute() + if not query: + self.torrents.insert( + Hash=torrent.hash, + Category=torrent.category, + QbitInstance=instance_name, + ).on_conflict_ignore().execute() def _torrent_condition(self, torrent: TorrentDictionary, instance_name: str = "default"): """Return the base WHERE condition for a torrent row.""" @@ -1005,12 +1006,13 @@ def in_tags( if tag == "qBitrr-ignored": return_value = "qBitrr-ignored" in torrent.tags else: - self._ensure_torrent_row(torrent, instance_name) - condition = self._torrent_condition(torrent, instance_name) - field_name = self._TAGLESS_FIELD_MAP.get(tag) - if field_name: - condition &= getattr(self.torrents, field_name) == True - return_value = bool(self.torrents.select().where(condition).execute()) + with database_lock(): + self._ensure_torrent_row(torrent, instance_name) + condition = self._torrent_condition(torrent, instance_name) + field_name = self._TAGLESS_FIELD_MAP.get(tag) + if field_name: + condition &= getattr(self.torrents, field_name) == True + return_value = bool(self.torrents.select().where(condition).execute()) else: return_value = tag in torrent.tags @@ -1026,14 +1028,15 @@ def remove_tags( for tag in tags: self.logger.trace("Removing tag %s from %s", tag, torrent.name) if TAGLESS: - self._ensure_torrent_row(torrent, instance_name) - condition = self._torrent_condition(torrent, instance_name) - for tag in tags: - field_name = self._TAGLESS_FIELD_MAP.get(tag) - if field_name: - self.torrents.update({getattr(self.torrents, field_name): False}).where( - condition - ).execute() + with database_lock(): + self._ensure_torrent_row(torrent, instance_name) + condition = self._torrent_condition(torrent, instance_name) + for tag in tags: + field_name = self._TAGLESS_FIELD_MAP.get(tag) + if field_name: + self.torrents.update({getattr(self.torrents, field_name): False}).where( + condition + ).execute() else: with contextlib.suppress(Exception): with_retry( @@ -1054,14 +1057,15 @@ def add_tags( for tag in tags: self.logger.trace("Adding tag %s from %s", tag, torrent.name) if TAGLESS: - self._ensure_torrent_row(torrent, instance_name) - condition = self._torrent_condition(torrent, instance_name) - for tag in tags: - field_name = self._TAGLESS_FIELD_MAP.get(tag) - if field_name: - self.torrents.update({getattr(self.torrents, field_name): True}).where( - condition - ).execute() + with database_lock(): + self._ensure_torrent_row(torrent, instance_name) + condition = self._torrent_condition(torrent, instance_name) + for tag in tags: + field_name = self._TAGLESS_FIELD_MAP.get(tag) + if field_name: + self.torrents.update({getattr(self.torrents, field_name): True}).where( + condition + ).execute() else: with contextlib.suppress(Exception): with_retry( @@ -1985,10 +1989,11 @@ def db_reset__series_searched_state(self): if ( self.loop_completed and self.reset_on_completion and self.series_search ): # Only wipe if a loop completed was tagged - self.series_file_model.update(Searched=False, Upgrade=False).where( - (self.series_file_model.Searched == True) - & (self.series_file_model.ArrInstance == self._name) - ).execute() + with database_lock(): + self.series_file_model.update(Searched=False, Upgrade=False).where( + (self.series_file_model.Searched == True) + & (self.series_file_model.ArrInstance == self._name) + ).execute() series = with_retry( lambda: self.client.get_series(), retries=5, @@ -1998,10 +2003,11 @@ def db_reset__series_searched_state(self): ) for s in series: ids.append(s["id"]) - self.series_file_model.delete().where( - (self.series_file_model.EntryId.not_in(ids)) - & (self.series_file_model.ArrInstance == self._name) - ).execute() + with database_lock(): + self.series_file_model.delete().where( + (self.series_file_model.EntryId.not_in(ids)) + & (self.series_file_model.ArrInstance == self._name) + ).execute() self.loop_completed = False def db_reset__episode_searched_state(self): @@ -2010,9 +2016,11 @@ def db_reset__episode_searched_state(self): if ( self.loop_completed is True and self.reset_on_completion ): # Only wipe if a loop completed was tagged - self.model_file.update(Searched=False, Upgrade=False).where( - (self.model_file.Searched == True) & (self.model_file.ArrInstance == self._name) - ).execute() + with database_lock(): + self.model_file.update(Searched=False, Upgrade=False).where( + (self.model_file.Searched == True) + & (self.model_file.ArrInstance == self._name) + ).execute() series = with_retry( lambda: self.client.get_series(), retries=5, @@ -2030,9 +2038,11 @@ def db_reset__episode_searched_state(self): ) for e in episodes: ids.append(e["id"]) - self.model_file.delete().where( - (self.model_file.EntryId.not_in(ids)) & (self.model_file.ArrInstance == self._name) - ).execute() + with database_lock(): + self.model_file.delete().where( + (self.model_file.EntryId.not_in(ids)) + & (self.model_file.ArrInstance == self._name) + ).execute() self.loop_completed = False def db_reset__movie_searched_state(self): @@ -2041,9 +2051,11 @@ def db_reset__movie_searched_state(self): if ( self.loop_completed is True and self.reset_on_completion ): # Only wipe if a loop completed was tagged - self.model_file.update(Searched=False, Upgrade=False).where( - (self.model_file.Searched == True) & (self.model_file.ArrInstance == self._name) - ).execute() + with database_lock(): + self.model_file.update(Searched=False, Upgrade=False).where( + (self.model_file.Searched == True) + & (self.model_file.ArrInstance == self._name) + ).execute() movies = with_retry( lambda: self.client.get_movie(), retries=5, @@ -2053,9 +2065,11 @@ def db_reset__movie_searched_state(self): ) for m in movies: ids.append(m["id"]) - self.model_file.delete().where( - (self.model_file.EntryId.not_in(ids)) & (self.model_file.ArrInstance == self._name) - ).execute() + with database_lock(): + self.model_file.delete().where( + (self.model_file.EntryId.not_in(ids)) + & (self.model_file.ArrInstance == self._name) + ).execute() self.loop_completed = False def db_reset__album_searched_state(self): @@ -2064,9 +2078,11 @@ def db_reset__album_searched_state(self): if ( self.loop_completed is True and self.reset_on_completion ): # Only wipe if a loop completed was tagged - self.model_file.update(Searched=False, Upgrade=False).where( - (self.model_file.Searched == True) & (self.model_file.ArrInstance == self._name) - ).execute() + with database_lock(): + self.model_file.update(Searched=False, Upgrade=False).where( + (self.model_file.Searched == True) + & (self.model_file.ArrInstance == self._name) + ).execute() artists = with_retry( lambda: self.client.get_artist(), retries=5, @@ -2084,9 +2100,11 @@ def db_reset__album_searched_state(self): ) for album in albums: ids.append(album["id"]) - self.model_file.delete().where( - (self.model_file.EntryId.not_in(ids)) & (self.model_file.ArrInstance == self._name) - ).execute() + with database_lock(): + self.model_file.delete().where( + (self.model_file.EntryId.not_in(ids)) + & (self.model_file.ArrInstance == self._name) + ).execute() self.loop_completed = False def db_get_files_series(self) -> list[list[SeriesFilesModel, bool, bool]] | None: @@ -6474,15 +6492,16 @@ def refresh_download_queue(self): entry["episodeId"] for entry in self.queue if entry.get("episodeId") } if self.model_queue: - with_database_retry( - lambda: self.model_queue.delete() - .where( - (self.model_queue.EntryId.not_in(list(self.queue_file_ids))) - & (self.model_queue.ArrInstance == self._name) + with database_lock(): + with_database_retry( + lambda: self.model_queue.delete() + .where( + (self.model_queue.EntryId.not_in(list(self.queue_file_ids))) + & (self.model_queue.ArrInstance == self._name) + ) + .execute(), + logger=self.logger, ) - .execute(), - logger=self.logger, - ) else: for entry in self.queue: if r := entry.get("seriesId"): @@ -6491,15 +6510,16 @@ def refresh_download_queue(self): entry["seriesId"] for entry in self.queue if entry.get("seriesId") } if self.model_queue: - with_database_retry( - lambda: self.model_queue.delete() - .where( - (self.model_queue.EntryId.not_in(list(self.queue_file_ids))) - & (self.model_queue.ArrInstance == self._name) + with database_lock(): + with_database_retry( + lambda: self.model_queue.delete() + .where( + (self.model_queue.EntryId.not_in(list(self.queue_file_ids))) + & (self.model_queue.ArrInstance == self._name) + ) + .execute(), + logger=self.logger, ) - .execute(), - logger=self.logger, - ) elif self.type == "radarr": self.requeue_cache = { entry["id"]: entry["movieId"] for entry in self.queue if entry.get("movieId") @@ -6508,12 +6528,13 @@ def refresh_download_queue(self): entry["movieId"] for entry in self.queue if entry.get("movieId") } if self.model_queue: - with_database_retry( - lambda: self.model_queue.delete() - .where(self.model_queue.EntryId.not_in(list(self.queue_file_ids))) - .execute(), - logger=self.logger, - ) + with database_lock(): + with_database_retry( + lambda: self.model_queue.delete() + .where(self.model_queue.EntryId.not_in(list(self.queue_file_ids))) + .execute(), + logger=self.logger, + ) elif self.type == "lidarr": self.requeue_cache = { entry["id"]: entry["albumId"] for entry in self.queue if entry.get("albumId") @@ -6522,12 +6543,13 @@ def refresh_download_queue(self): entry["albumId"] for entry in self.queue if entry.get("albumId") } if self.model_queue: - with_database_retry( - lambda: self.model_queue.delete() - .where(self.model_queue.EntryId.not_in(list(self.queue_file_ids))) - .execute(), - logger=self.logger, - ) + with database_lock(): + with_database_retry( + lambda: self.model_queue.delete() + .where(self.model_queue.EntryId.not_in(list(self.queue_file_ids))) + .execute(), + logger=self.logger, + ) self._update_bad_queue_items() diff --git a/qBitrr/config.py b/qBitrr/config.py index 1fb6f9ea3..f5f519279 100644 --- a/qBitrr/config.py +++ b/qBitrr/config.py @@ -97,7 +97,8 @@ def process_flags() -> argparse.Namespace | bool: ): # If file already exist or can't copy to APPDATA_FOLDER ignore the exception shutil.copy(CONFIG_PATH, CONFIG_FILE) COPIED_TO_NEW_DIR = True - CONFIG = MyConfig("./config.toml") + # Load from CONFIG_FILE after copy so we use the same path regardless of cwd + CONFIG = MyConfig(CONFIG_FILE if CONFIG_FILE.exists() else CONFIG_PATH.resolve()) if COPIED_TO_NEW_DIR is not None: # print(f"STARTING QBITRR | {CONFIG.path} |\n{CONFIG}") diff --git a/qBitrr/database.py b/qBitrr/database.py index 255521782..62f91dab0 100644 --- a/qBitrr/database.py +++ b/qBitrr/database.py @@ -11,7 +11,7 @@ from peewee import OperationalError, SqliteDatabase from qBitrr.config import APPDATA_FOLDER -from qBitrr.db_lock import with_database_retry +from qBitrr.db_lock import database_lock, with_database_retry from qBitrr.tables import ( AlbumFilesModel, AlbumQueueModel, @@ -179,57 +179,58 @@ def get_database() -> SqliteDatabase: db_path, ) - with_database_retry( - lambda: _db.connect(reuse_if_open=True), - logger=logger, - ) + with database_lock(): + with_database_retry( + lambda: _db.connect(reuse_if_open=True), + logger=logger, + ) - # Bind models to database - models = [ - MoviesFilesModel, - EpisodeFilesModel, - AlbumFilesModel, - SeriesFilesModel, - ArtistFilesModel, - TrackFilesModel, - MovieQueueModel, - EpisodeQueueModel, - AlbumQueueModel, - FilesQueued, - TorrentLibrary, - ] - _db.bind(models) - - # Create all tables (and indexes); retry once if readonly (e.g. Docker volume perms) - def _create_tables_and_indexes() -> None: - _db.create_tables(models, safe=True) - _migrate_arrinstance_field(models) - _create_arrinstance_indexes(_db, models) + # Bind models to database + models = [ + MoviesFilesModel, + EpisodeFilesModel, + AlbumFilesModel, + SeriesFilesModel, + ArtistFilesModel, + TrackFilesModel, + MovieQueueModel, + EpisodeQueueModel, + AlbumQueueModel, + FilesQueued, + TorrentLibrary, + ] + _db.bind(models) + + # Create all tables (and indexes); retry once if readonly (e.g. Docker volume perms) + def _create_tables_and_indexes() -> None: + _db.create_tables(models, safe=True) + _migrate_arrinstance_field(models) + _create_arrinstance_indexes(_db, models) - try: - _create_tables_and_indexes() - except OperationalError as e: - if "readonly" in str(e).lower(): - logger.warning( - "Database is read-only (often due to volume permissions). " - "Attempting to fix permissions and retry..." - ) - _ensure_db_directory_writable(db_path) - try: - _create_tables_and_indexes() - except OperationalError as e2: - if "readonly" in str(e2).lower(): - logger.critical( - "Database directory or file is read-only. " - "Ensure the config volume is writable by the container user (e.g. set PUID/PGID in docker-compose). " - "On the host: chown -R PUID:PGID /path/to/config. " - "Alternatively remove the database directory and restart to recreate it (data loss)." - ) - _db.close() - _db = None + try: + _create_tables_and_indexes() + except OperationalError as e: + if "readonly" in str(e).lower(): + logger.warning( + "Database is read-only (often due to volume permissions). " + "Attempting to fix permissions and retry..." + ) + _ensure_db_directory_writable(db_path) + try: + _create_tables_and_indexes() + except OperationalError as e2: + if "readonly" in str(e2).lower(): + logger.critical( + "Database directory or file is read-only. " + "Ensure the config volume is writable by the container user (e.g. set PUID/PGID in docker-compose). " + "On the host: chown -R PUID:PGID /path/to/config. " + "Alternatively remove the database directory and restart to recreate it (data loss)." + ) + _db.close() + _db = None + raise + else: raise - else: - raise logger.info("Initialized single database: %s", db_path) diff --git a/qBitrr/search_activity_store.py b/qBitrr/search_activity_store.py index 72dd6dcb9..a7871769f 100644 --- a/qBitrr/search_activity_store.py +++ b/qBitrr/search_activity_store.py @@ -7,6 +7,7 @@ from peewee import Model, TextField from qBitrr.database import get_database +from qBitrr.db_lock import database_lock logger = logging.getLogger("qBitrr.SearchActivityStore") @@ -21,11 +22,12 @@ class SearchActivity(Model): def _ensure_tables() -> None: db = get_database() - with _DB_LOCK: - # Bind model to database if not already bound - if not SearchActivity._meta.database: - db.bind([SearchActivity]) - db.create_tables([SearchActivity], safe=True) + with database_lock(): + with _DB_LOCK: + # Bind model to database if not already bound + if not SearchActivity._meta.database: + db.bind([SearchActivity]) + db.create_tables([SearchActivity], safe=True) def record_search_activity(category: str, summary: str | None, timestamp: str | None) -> None: @@ -35,25 +37,27 @@ def record_search_activity(category: str, summary: str | None, timestamp: str | if timestamp is not None and not isinstance(timestamp, str): timestamp = str(timestamp) data: dict[str, Any] = {"summary": summary, "timestamp": timestamp} - with _DB_LOCK: - with get_database().atomic(): - SearchActivity.insert(category=category, **data).on_conflict( - conflict_target=[SearchActivity.category], - update=data, - ).execute() + with database_lock(): + with _DB_LOCK: + with get_database().atomic(): + SearchActivity.insert(category=category, **data).on_conflict( + conflict_target=[SearchActivity.category], + update=data, + ).execute() def fetch_search_activities() -> dict[str, dict[str, str | None]]: _ensure_tables() activities: dict[str, dict[str, str | None]] = {} try: - with _DB_LOCK: - query = SearchActivity.select() - for row in query: - activities[str(row.category)] = { - "summary": row.summary, - "timestamp": row.timestamp, - } + with database_lock(): + with _DB_LOCK: + query = SearchActivity.select() + for row in query: + activities[str(row.category)] = { + "summary": row.summary, + "timestamp": row.timestamp, + } except Exception as e: logger.error("Failed to fetch search activities: %s", e) return activities @@ -63,6 +67,7 @@ def clear_search_activity(category: str) -> None: if not category: return _ensure_tables() - with _DB_LOCK: - with get_database().atomic(): - SearchActivity.delete().where(SearchActivity.category == category).execute() + with database_lock(): + with _DB_LOCK: + with get_database().atomic(): + SearchActivity.delete().where(SearchActivity.category == category).execute() diff --git a/qBitrr/webui.py b/qBitrr/webui.py index fefdcd9cb..67e4e4441 100644 --- a/qBitrr/webui.py +++ b/qBitrr/webui.py @@ -18,6 +18,7 @@ from qBitrr.arss import FreeSpaceManager, PlaceHolderArr from qBitrr.bundled_data import patched_version, tagged_version from qBitrr.config import CONFIG, HOME_PATH +from qBitrr.db_lock import database_lock from qBitrr.logger import run_logs from qBitrr.search_activity_store import ( clear_search_activity, @@ -96,6 +97,7 @@ def _toml_to_jsonable(obj: Any) -> Any: return [_toml_to_jsonable(v) for v in obj] return obj except Exception: + logging.getLogger("qBitrr.WebUI").debug("_toml_to_jsonable failed", exc_info=True) return obj @@ -139,7 +141,9 @@ def add_cache_headers(response): _toml_set(CONFIG.config, "WebUI.Token", self.token) CONFIG.save() except Exception: - pass + self.logger.warning( + "Failed to persist generated WebUI token to config", exc_info=True + ) else: self.logger.notice("Generated new WebUI token") self._github_repo = "Feramance/qBitrr" @@ -355,6 +359,9 @@ def _ensure_arr_db(self, arr) -> bool: try: arr.register_search_mode() except Exception: + self.logger.debug( + "register_search_mode failed for %s", getattr(arr, "_name", arr), exc_info=True + ) return False if not getattr(arr, "search_setup_completed", False): return False @@ -363,6 +370,9 @@ def _ensure_arr_db(self, arr) -> bool: arr.db_update() arr._webui_db_loaded = True except Exception: + self.logger.debug( + "db_update failed for %s", getattr(arr, "_name", arr), exc_info=True + ) arr._webui_db_loaded = False return True @@ -416,107 +426,110 @@ def _radarr_movies_from_db( page = max(page, 0) page_size = max(page_size, 1) arr_instance = getattr(arr, "_name", "") - with db.connection_context(): - # Filter by ArrInstance - base_query = model.select().where(model.ArrInstance == arr_instance) - - # Calculate counts - monitored_count = ( - model.select(fn.COUNT(model.EntryId)) - .where( - (model.ArrInstance == arr_instance) & (model.Monitored == True) - ) # noqa: E712 - .scalar() - or 0 - ) - available_count = ( - model.select(fn.COUNT(model.EntryId)) - .where( - (model.ArrInstance == arr_instance) - & (model.Monitored == True) # noqa: E712 - & (model.MovieFileId.is_null(False)) - & (model.MovieFileId != 0) + with database_lock(): + with db.connection_context(): + # Filter by ArrInstance + base_query = model.select().where(model.ArrInstance == arr_instance) + + # Calculate counts + monitored_count = ( + model.select(fn.COUNT(model.EntryId)) + .where( + (model.ArrInstance == arr_instance) & (model.Monitored == True) + ) # noqa: E712 + .scalar() + or 0 ) - .scalar() - or 0 - ) - missing_count = max(monitored_count - available_count, 0) - quality_met_count = ( - model.select(fn.COUNT(model.EntryId)) - .where( - (model.ArrInstance == arr_instance) & (model.QualityMet == True) - ) # noqa: E712 - .scalar() - or 0 - ) - request_count = ( - model.select(fn.COUNT(model.EntryId)) - .where( - (model.ArrInstance == arr_instance) & (model.IsRequest == True) - ) # noqa: E712 - .scalar() - or 0 - ) - - # Build filtered query - query = base_query - if search: - query = query.where(model.Title.contains(search)) - if year_min is not None: - query = query.where(model.Year >= year_min) - if year_max is not None: - query = query.where(model.Year <= year_max) - if monitored is not None: - query = query.where(model.Monitored == monitored) - if has_file is not None: - if has_file: - query = query.where( - (model.MovieFileId.is_null(False)) & (model.MovieFileId != 0) - ) - else: - query = query.where( - (model.MovieFileId.is_null(True)) | (model.MovieFileId == 0) + available_count = ( + model.select(fn.COUNT(model.EntryId)) + .where( + (model.ArrInstance == arr_instance) + & (model.Monitored == True) # noqa: E712 + & (model.MovieFileId.is_null(False)) + & (model.MovieFileId != 0) ) - if quality_met is not None: - query = query.where(model.QualityMet == quality_met) - if is_request is not None: - query = query.where(model.IsRequest == is_request) - - # Total should be ALL items for this instance, not filtered results - total = base_query.count() - page_items = query.order_by(model.Title.asc()).paginate(page + 1, page_size).iterator() - movies = [] - for movie in page_items: - # Read quality profile from database - quality_profile_id = ( - getattr(movie, "QualityProfileId", None) - if hasattr(model, "QualityProfileId") - else None + .scalar() + or 0 ) - quality_profile_name = ( - getattr(movie, "QualityProfileName", None) - if hasattr(model, "QualityProfileName") - else None + missing_count = max(monitored_count - available_count, 0) + quality_met_count = ( + model.select(fn.COUNT(model.EntryId)) + .where( + (model.ArrInstance == arr_instance) & (model.QualityMet == True) + ) # noqa: E712 + .scalar() + or 0 + ) + request_count = ( + model.select(fn.COUNT(model.EntryId)) + .where( + (model.ArrInstance == arr_instance) & (model.IsRequest == True) + ) # noqa: E712 + .scalar() + or 0 ) - movies.append( - { - "id": movie.EntryId, - "title": movie.Title or "", - "year": movie.Year, - "monitored": self._safe_bool(movie.Monitored), - "hasFile": self._safe_bool(movie.MovieFileId), - "qualityMet": self._safe_bool(movie.QualityMet), - "isRequest": self._safe_bool(movie.IsRequest), - "upgrade": self._safe_bool(movie.Upgrade), - "customFormatScore": movie.CustomFormatScore, - "minCustomFormatScore": movie.MinCustomFormatScore, - "customFormatMet": self._safe_bool(movie.CustomFormatMet), - "reason": movie.Reason, - "qualityProfileId": quality_profile_id, - "qualityProfileName": quality_profile_name, - } + # Build filtered query + query = base_query + if search: + query = query.where(model.Title.contains(search)) + if year_min is not None: + query = query.where(model.Year >= year_min) + if year_max is not None: + query = query.where(model.Year <= year_max) + if monitored is not None: + query = query.where(model.Monitored == monitored) + if has_file is not None: + if has_file: + query = query.where( + (model.MovieFileId.is_null(False)) & (model.MovieFileId != 0) + ) + else: + query = query.where( + (model.MovieFileId.is_null(True)) | (model.MovieFileId == 0) + ) + if quality_met is not None: + query = query.where(model.QualityMet == quality_met) + if is_request is not None: + query = query.where(model.IsRequest == is_request) + + # Total should be ALL items for this instance, not filtered results + total = base_query.count() + page_items = ( + query.order_by(model.Title.asc()).paginate(page + 1, page_size).iterator() ) + movies = [] + for movie in page_items: + # Read quality profile from database + quality_profile_id = ( + getattr(movie, "QualityProfileId", None) + if hasattr(model, "QualityProfileId") + else None + ) + quality_profile_name = ( + getattr(movie, "QualityProfileName", None) + if hasattr(model, "QualityProfileName") + else None + ) + + movies.append( + { + "id": movie.EntryId, + "title": movie.Title or "", + "year": movie.Year, + "monitored": self._safe_bool(movie.Monitored), + "hasFile": self._safe_bool(movie.MovieFileId), + "qualityMet": self._safe_bool(movie.QualityMet), + "isRequest": self._safe_bool(movie.IsRequest), + "upgrade": self._safe_bool(movie.Upgrade), + "customFormatScore": movie.CustomFormatScore, + "minCustomFormatScore": movie.MinCustomFormatScore, + "customFormatMet": self._safe_bool(movie.CustomFormatMet), + "reason": movie.Reason, + "qualityProfileId": quality_profile_id, + "qualityProfileName": quality_profile_name, + } + ) return { "counts": { "available": available_count, @@ -580,194 +593,199 @@ def _lidarr_albums_from_db( # Quality profiles are now stored in the database # No need to fetch from API - with db.connection_context(): - # Filter by ArrInstance - base_query = model.select().where(model.ArrInstance == arr_instance) + with database_lock(): + with db.connection_context(): + # Filter by ArrInstance + base_query = model.select().where(model.ArrInstance == arr_instance) - # Calculate counts - monitored_count = ( - model.select(fn.COUNT(model.EntryId)) - .where( - (model.ArrInstance == arr_instance) & (model.Monitored == True) - ) # noqa: E712 - .scalar() - or 0 - ) - available_count = ( - model.select(fn.COUNT(model.EntryId)) - .where( - (model.ArrInstance == arr_instance) - & (model.Monitored == True) # noqa: E712 - & (model.AlbumFileId.is_null(False)) - & (model.AlbumFileId != 0) + # Calculate counts + monitored_count = ( + model.select(fn.COUNT(model.EntryId)) + .where( + (model.ArrInstance == arr_instance) & (model.Monitored == True) + ) # noqa: E712 + .scalar() + or 0 ) - .scalar() - or 0 - ) - missing_count = max(monitored_count - available_count, 0) - quality_met_count = ( - model.select(fn.COUNT(model.EntryId)) - .where( - (model.ArrInstance == arr_instance) & (model.QualityMet == True) - ) # noqa: E712 - .scalar() - or 0 - ) - request_count = ( - model.select(fn.COUNT(model.EntryId)) - .where( - (model.ArrInstance == arr_instance) & (model.IsRequest == True) - ) # noqa: E712 - .scalar() - or 0 - ) - - # Build filtered query - query = base_query - if search: - query = query.where(model.Title.contains(search)) - if monitored is not None: - query = query.where(model.Monitored == monitored) - if has_file is not None: - if has_file: - query = query.where( - (model.AlbumFileId.is_null(False)) & (model.AlbumFileId != 0) - ) - else: - query = query.where( - (model.AlbumFileId.is_null(True)) | (model.AlbumFileId == 0) + available_count = ( + model.select(fn.COUNT(model.EntryId)) + .where( + (model.ArrInstance == arr_instance) + & (model.Monitored == True) # noqa: E712 + & (model.AlbumFileId.is_null(False)) + & (model.AlbumFileId != 0) ) - if quality_met is not None: - query = query.where(model.QualityMet == quality_met) - if is_request is not None: - query = query.where(model.IsRequest == is_request) - - albums = [] - - # Total should be ALL albums for this instance, not filtered results - total = base_query.count() - - if group_by_artist: - # Paginate by artists: Two-pass approach with Peewee - # First, get all distinct artist names from the filtered query - # Use a subquery to get distinct artists efficiently - artists_subquery = ( - query.select(model.ArtistTitle).distinct().order_by(model.ArtistTitle) + .scalar() + or 0 ) + missing_count = max(monitored_count - available_count, 0) + quality_met_count = ( + model.select(fn.COUNT(model.EntryId)) + .where( + (model.ArrInstance == arr_instance) & (model.QualityMet == True) + ) # noqa: E712 + .scalar() + or 0 + ) + request_count = ( + model.select(fn.COUNT(model.EntryId)) + .where( + (model.ArrInstance == arr_instance) & (model.IsRequest == True) + ) # noqa: E712 + .scalar() + or 0 + ) + + # Build filtered query + query = base_query + if search: + query = query.where(model.Title.contains(search)) + if monitored is not None: + query = query.where(model.Monitored == monitored) + if has_file is not None: + if has_file: + query = query.where( + (model.AlbumFileId.is_null(False)) & (model.AlbumFileId != 0) + ) + else: + query = query.where( + (model.AlbumFileId.is_null(True)) | (model.AlbumFileId == 0) + ) + if quality_met is not None: + query = query.where(model.QualityMet == quality_met) + if is_request is not None: + query = query.where(model.IsRequest == is_request) + + albums = [] + + # Total should be ALL albums for this instance, not filtered results + total = base_query.count() + + if group_by_artist: + # Paginate by artists: Two-pass approach with Peewee + # First, get all distinct artist names from the filtered query + # Use a subquery to get distinct artists efficiently + artists_subquery = ( + query.select(model.ArtistTitle).distinct().order_by(model.ArtistTitle) + ) - # Convert to list to avoid multiple iterations - all_artists = [row.ArtistTitle for row in artists_subquery] + # Convert to list to avoid multiple iterations + all_artists = [row.ArtistTitle for row in artists_subquery] - # Paginate the artist list in Python - start_idx = page * page_size - end_idx = start_idx + page_size - paginated_artists = all_artists[start_idx:end_idx] + # Paginate the artist list in Python + start_idx = page * page_size + end_idx = start_idx + page_size + paginated_artists = all_artists[start_idx:end_idx] - # Fetch all albums for these paginated artists - if paginated_artists: - album_results = list( - query.where(model.ArtistTitle.in_(paginated_artists)).order_by( - model.ArtistTitle, model.ReleaseDate + # Fetch all albums for these paginated artists + if paginated_artists: + album_results = list( + query.where(model.ArtistTitle.in_(paginated_artists)).order_by( + model.ArtistTitle, model.ReleaseDate + ) ) - ) + else: + album_results = [] else: - album_results = [] - else: - # Flat mode: paginate by albums as before - # Note: total is already set to base_query.count() above - album_results = list(query.order_by(model.Title).paginate(page + 1, page_size)) - - for album in album_results: - # Always fetch tracks from database (Lidarr only) - track_model = getattr(arr, "track_file_model", None) - tracks_list = [] - track_monitored_count = 0 - track_available_count = 0 - - if track_model: - try: - # Query tracks from database for this album - track_query = ( - track_model.select() - .where(track_model.AlbumId == album.EntryId) - .order_by(track_model.TrackNumber) - ) - track_count = track_query.count() - self.logger.debug( - "Album %s (%s): Found %d tracks in database", - album.EntryId, - album.Title, - track_count, - ) + # Flat mode: paginate by albums as before + # Note: total is already set to base_query.count() above + album_results = list(query.order_by(model.Title).paginate(page + 1, page_size)) + + for album in album_results: + # Always fetch tracks from database (Lidarr only) + track_model = getattr(arr, "track_file_model", None) + tracks_list = [] + track_monitored_count = 0 + track_available_count = 0 + + if track_model: + try: + # Query tracks from database for this album + track_query = ( + track_model.select() + .where(track_model.AlbumId == album.EntryId) + .order_by(track_model.TrackNumber) + ) + track_count = track_query.count() + self.logger.debug( + "Album %s (%s): Found %d tracks in database", + album.EntryId, + album.Title, + track_count, + ) - for track in track_query: - is_monitored = self._safe_bool(track.Monitored) - has_file = self._safe_bool(track.HasFile) + for track in track_query: + is_monitored = self._safe_bool(track.Monitored) + has_file = self._safe_bool(track.HasFile) - if is_monitored: - track_monitored_count += 1 - if has_file: - track_available_count += 1 + if is_monitored: + track_monitored_count += 1 + if has_file: + track_available_count += 1 - tracks_list.append( - { - "id": track.EntryId, - "trackNumber": track.TrackNumber, - "title": track.Title, - "duration": track.Duration, - "hasFile": has_file, - "trackFileId": track.TrackFileId, - "monitored": is_monitored, - } + tracks_list.append( + { + "id": track.EntryId, + "trackNumber": track.TrackNumber, + "title": track.Title, + "duration": track.Duration, + "hasFile": has_file, + "trackFileId": track.TrackFileId, + "monitored": is_monitored, + } + ) + except Exception as e: + self.logger.warning( + "Failed to fetch tracks for album %s (%s): %s", + album.EntryId, + album.Title, + e, ) - except Exception as e: - self.logger.warning( - "Failed to fetch tracks for album %s (%s): %s", - album.EntryId, - album.Title, - e, - ) - track_missing_count = max(track_monitored_count - track_available_count, 0) - - # Get quality profile from database model - quality_profile_id = getattr(album, "QualityProfileId", None) - quality_profile_name = getattr(album, "QualityProfileName", None) - - # Build album data in Sonarr-like structure - album_item = { - "album": { - "id": album.EntryId, - "title": album.Title, - "artistId": album.ArtistId, - "artistName": album.ArtistTitle, - "monitored": self._safe_bool(album.Monitored), - "hasFile": bool(album.AlbumFileId and album.AlbumFileId != 0), - "foreignAlbumId": album.ForeignAlbumId, - "releaseDate": ( - album.ReleaseDate.isoformat() - if album.ReleaseDate and hasattr(album.ReleaseDate, "isoformat") - else album.ReleaseDate if isinstance(album.ReleaseDate, str) else None - ), - "qualityMet": self._safe_bool(album.QualityMet), - "isRequest": self._safe_bool(album.IsRequest), - "upgrade": self._safe_bool(album.Upgrade), - "customFormatScore": album.CustomFormatScore, - "minCustomFormatScore": album.MinCustomFormatScore, - "customFormatMet": self._safe_bool(album.CustomFormatMet), - "reason": album.Reason, - "qualityProfileId": quality_profile_id, - "qualityProfileName": quality_profile_name, - }, - "totals": { - "available": track_available_count, - "monitored": track_monitored_count, - "missing": track_missing_count, - }, - "tracks": tracks_list, - } + track_missing_count = max(track_monitored_count - track_available_count, 0) + + # Get quality profile from database model + quality_profile_id = getattr(album, "QualityProfileId", None) + quality_profile_name = getattr(album, "QualityProfileName", None) + + # Build album data in Sonarr-like structure + album_item = { + "album": { + "id": album.EntryId, + "title": album.Title, + "artistId": album.ArtistId, + "artistName": album.ArtistTitle, + "monitored": self._safe_bool(album.Monitored), + "hasFile": bool(album.AlbumFileId and album.AlbumFileId != 0), + "foreignAlbumId": album.ForeignAlbumId, + "releaseDate": ( + album.ReleaseDate.isoformat() + if album.ReleaseDate and hasattr(album.ReleaseDate, "isoformat") + else ( + album.ReleaseDate + if isinstance(album.ReleaseDate, str) + else None + ) + ), + "qualityMet": self._safe_bool(album.QualityMet), + "isRequest": self._safe_bool(album.IsRequest), + "upgrade": self._safe_bool(album.Upgrade), + "customFormatScore": album.CustomFormatScore, + "minCustomFormatScore": album.MinCustomFormatScore, + "customFormatMet": self._safe_bool(album.CustomFormatMet), + "reason": album.Reason, + "qualityProfileId": quality_profile_id, + "qualityProfileName": quality_profile_name, + }, + "totals": { + "available": track_available_count, + "monitored": track_monitored_count, + "missing": track_missing_count, + }, + "tracks": tracks_list, + } - albums.append(album_item) + albums.append(album_item) return { "counts": { "available": available_count, @@ -961,16 +979,17 @@ def _sonarr_series_from_db( episodes_model.EpisodeFileId == 0 ) - with db.connection_context(): - monitored_count = ( - episodes_model.select(fn.COUNT(episodes_model.EntryId)) - .where( - (episodes_model.ArrInstance == arr_instance) - & (episodes_model.Monitored == True) # noqa: E712 + with database_lock(): + with db.connection_context(): + monitored_count = ( + episodes_model.select(fn.COUNT(episodes_model.EntryId)) + .where( + (episodes_model.ArrInstance == arr_instance) + & (episodes_model.Monitored == True) # noqa: E712 + ) + .scalar() + or 0 ) - .scalar() - or 0 - ) available_count = ( episodes_model.select(fn.COUNT(episodes_model.EntryId)) .where( @@ -1312,9 +1331,12 @@ def _sonarr_series_from_db( profile.get("name") if profile else None ) except Exception: - pass + self.logger.debug( + "Sonarr quality profile lookup failed", + exc_info=True, + ) except Exception: - pass + self.logger.debug("Sonarr series payload build failed", exc_info=True) payload.append( { @@ -1373,9 +1395,17 @@ def _register_routes(self): logs_root = (HOME_PATH / "logs").resolve() def _resolve_log_file(name: str) -> Path | None: + # Restrict to safe log file names (alphanumeric, dash, underscore, dot) + if not name or not name.strip(): + return None + safe = "".join(c for c in name if c.isalnum() or c in "._-").strip() or None + if safe is None or safe != name: + self.logger.debug("Rejected log file name (invalid characters): %r", name) + return None try: - candidate = (logs_root / name).resolve(strict=False) + candidate = (logs_root / safe).resolve(strict=False) except Exception: + self.logger.debug("Failed to resolve log path for %r", safe, exc_info=True) return None try: candidate.relative_to(logs_root) @@ -1547,7 +1577,9 @@ def _collect_metrics(arr_obj): metrics["category"] = count metrics["queue"] = count except Exception: - pass + self.logger.debug( + "Process metrics (free_space) fetch failed", exc_info=True + ) return metrics if isinstance(arr_obj, PlaceHolderArr): @@ -1565,7 +1597,9 @@ def _collect_metrics(arr_obj): metrics["queue"] = count metrics["category"] = count except Exception: - pass + self.logger.debug( + "Process metrics (PlaceHolderArr) fetch failed", exc_info=True + ) return metrics # Standard Arr (Radarr/Sonarr) @@ -1596,7 +1630,7 @@ def _collect_metrics(arr_obj): if getattr(torrent, "category", None) == category ) except Exception: - pass + self.logger.debug("Process metrics (category count) failed", exc_info=True) category_key = getattr(arr_obj, "category", None) if category_key: entry = search_activity_map.get(str(category_key)) @@ -1757,15 +1791,23 @@ def _restart_process(category: str, kind: str): try: target_proc.kill() except Exception: - pass + self.logger.debug( + "Category manager process kill failed for %s", category, exc_info=True + ) try: target_proc.terminate() except Exception: - pass + self.logger.debug( + "Category manager process terminate failed for %s", category, exc_info=True + ) try: self.manager.child_processes.remove(target_proc) except Exception: - pass + self.logger.debug( + "child_processes.remove failed for category manager %s", + category, + exc_info=True, + ) self.manager._process_registry.pop(target_proc, None) manager = self.manager.qbit_category_managers.get(instance_name) if manager is None: @@ -1806,15 +1848,27 @@ def _restart_process(category: str, kind: str): try: process.kill() except Exception: - pass + self.logger.debug( + "Process kill failed for %s %s", category, loop_kind, exc_info=True + ) try: process.terminate() except Exception: - pass + self.logger.debug( + "Process terminate failed for %s %s", + category, + loop_kind, + exc_info=True, + ) try: self.manager.child_processes.remove(process) except Exception: - pass + self.logger.debug( + "child_processes.remove failed for %s %s", + category, + loop_kind, + exc_info=True, + ) self.manager._process_registry.pop(process, None) target = getattr(arr, f"run_{loop_kind}_loop", None) if target is None: @@ -1874,7 +1928,7 @@ def _handle_loglevel(): _toml_set(CONFIG.config, "Settings.ConsoleLevel", level) CONFIG.save() except Exception: - pass + self.logger.debug("Failed to persist log level to config", exc_info=True) return jsonify({"status": "ok", "level": level}) @app.post("/api/loglevel") @@ -1974,6 +2028,7 @@ def _serve_log_content(name: str): else: content = file.read_text(encoding="utf-8", errors="ignore") except Exception: + self.logger.debug("Failed to read log file %s", file, exc_info=True) content = "" response = send_file( io.BytesIO(content.encode("utf-8")), @@ -2408,7 +2463,9 @@ def _status_payload() -> dict[str, Any]: alive = True break except Exception: - pass + self.logger.debug( + "Process is_alive check failed for %s", k, exc_info=True + ) name = getattr(arr, "_name", k) category = getattr(arr, "category", k) arrs.append({"category": category, "name": name, "type": t, "alive": alive}) @@ -2485,15 +2542,30 @@ def _restart_arr_instance(arr): try: p.kill() except Exception: - pass + self.logger.debug( + "Process kill failed for %s %s", + getattr(arr, "_name", ""), + k, + exc_info=True, + ) try: p.terminate() except Exception: - pass + self.logger.debug( + "Process terminate failed for %s %s", + getattr(arr, "_name", ""), + k, + exc_info=True, + ) try: self.manager.child_processes.remove(p) except Exception: - pass + self.logger.debug( + "child_processes.remove failed for %s %s", + getattr(arr, "_name", ""), + k, + exc_info=True, + ) self.manager._process_registry.pop(p, None) import pathos @@ -2548,7 +2620,7 @@ def api_get_config(): try: CONFIG.load() except Exception: - pass + self.logger.debug("CONFIG.load failed in api_get_config", exc_info=True) # Render current config as a JSON-able dict via tomlkit data = _toml_to_jsonable(CONFIG.config) return jsonify(data) @@ -2561,7 +2633,7 @@ def web_get_config(): try: CONFIG.load() except Exception: - pass + self.logger.debug("CONFIG.load failed in web_get_config", exc_info=True) data = _toml_to_jsonable(CONFIG.config) if not _authorized(): @@ -2944,11 +3016,11 @@ def _reload_all(self): try: p.kill() except Exception: - pass + self.logger.debug("Reload: process kill failed", exc_info=True) try: p.terminate() except Exception: - pass + self.logger.debug("Reload: process terminate failed", exc_info=True) self.manager.child_processes.clear() self.manager._process_registry.clear() @@ -2989,7 +3061,11 @@ def _reload_all(self): try: p.start() except Exception: - pass + self.logger.debug( + "Reload: failed to start process for %s", + getattr(arr, "_name", ""), + exc_info=True, + ) # Rebuild qBit category managers from fresh config self.manager.qbit_category_configs.clear() @@ -3053,15 +3129,30 @@ def _stop_arr_instance(self, arr, category: str): try: process.kill() except Exception: - pass + self.logger.debug( + "Stop instance: process kill failed for %s %s", + category, + loop_kind, + exc_info=True, + ) try: process.terminate() except Exception: - pass + self.logger.debug( + "Stop instance: process terminate failed for %s %s", + category, + loop_kind, + exc_info=True, + ) try: self.manager.child_processes.remove(process) except Exception: - pass + self.logger.debug( + "Stop instance: child_processes.remove failed for %s %s", + category, + loop_kind, + exc_info=True, + ) self.logger.debug("Stopped %s process for %s", loop_kind, category) # Delete database files From 758981fee0f6b37ecfdfb3af500ef4bc03c633ae Mon Sep 17 00:00:00 2001 From: Feramance Date: Sun, 1 Mar 2026 10:09:20 +0100 Subject: [PATCH 57/66] fix(webui): prevent qBit category processes from appearing as cards in Other section --- webui/src/pages/ProcessesView.tsx | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/webui/src/pages/ProcessesView.tsx b/webui/src/pages/ProcessesView.tsx index c35a000e3..3bb9c3b9f 100644 --- a/webui/src/pages/ProcessesView.tsx +++ b/webui/src/pages/ProcessesView.tsx @@ -271,6 +271,10 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element { const hasSonarr = arrs.some((arr) => arr.type === "sonarr"); const hasLidarr = arrs.some((arr) => arr.type === "lidarr"); + const qbitInstanceNames = statusData?.qbitInstances + ? Object.keys(statusData.qbitInstances) + : []; + processes.forEach((proc) => { const app = classifyApp(proc); @@ -279,6 +283,23 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element { if (app === "Sonarr" && !hasSonarr) return; if (app === "Lidarr" && !hasLidarr) return; + // Skip qBit category processes that would otherwise show as separate cards in Other. + // They are already represented by the category chips in the qBittorrent card. + const kindLower = (proc.kind ?? "").toLowerCase(); + if (app === "Other" && kindLower === "category" && qbitInstanceNames.length > 0) { + const procCategory = (proc.category ?? "").toLowerCase(); + const matchesQbitInstance = qbitInstanceNames.some((inst) => { + const instLower = inst.toLowerCase(); + return ( + procCategory === instLower || + procCategory === `qbit-${instLower}` || + procCategory.endsWith(`-${instLower}`) || + procCategory.endsWith(`_${instLower}`) + ); + }); + if (matchesQbitInstance) return; + } + if (!appBuckets.has(app)) appBuckets.set(app, new Map()); const instances = appBuckets.get(app)!; const instanceKey = @@ -288,9 +309,6 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element { }); // Ensure a qBittorrent card exists for every defined qBit instance (even with no processes) - const qbitInstanceNames = statusData?.qbitInstances - ? Object.keys(statusData.qbitInstances) - : []; if (qbitInstanceNames.length > 0) { if (!appBuckets.has("qBittorrent")) appBuckets.set("qBittorrent", new Map()); const qbitInstances = appBuckets.get("qBittorrent")!; From 977857d5eb250609a73a42da3bfe9b4ea6be4a78 Mon Sep 17 00:00:00 2001 From: Feramance Date: Sun, 1 Mar 2026 11:06:26 +0100 Subject: [PATCH 58/66] fix(webui): hide configured qBit categories from Other section in Process view --- webui/src/pages/ProcessesView.tsx | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/webui/src/pages/ProcessesView.tsx b/webui/src/pages/ProcessesView.tsx index 3bb9c3b9f..7b08c3590 100644 --- a/webui/src/pages/ProcessesView.tsx +++ b/webui/src/pages/ProcessesView.tsx @@ -274,6 +274,9 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element { const qbitInstanceNames = statusData?.qbitInstances ? Object.keys(statusData.qbitInstances) : []; + const qbitCategoryNames = new Set( + qbitCategories.map((c) => c.category.toLowerCase()) + ); processes.forEach((proc) => { const app = classifyApp(proc); @@ -286,18 +289,20 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element { // Skip qBit category processes that would otherwise show as separate cards in Other. // They are already represented by the category chips in the qBittorrent card. const kindLower = (proc.kind ?? "").toLowerCase(); - if (app === "Other" && kindLower === "category" && qbitInstanceNames.length > 0) { - const procCategory = (proc.category ?? "").toLowerCase(); - const matchesQbitInstance = qbitInstanceNames.some((inst) => { - const instLower = inst.toLowerCase(); - return ( - procCategory === instLower || - procCategory === `qbit-${instLower}` || - procCategory.endsWith(`-${instLower}`) || - procCategory.endsWith(`_${instLower}`) - ); - }); - if (matchesQbitInstance) return; + const procCategoryLower = (proc.category ?? "").toLowerCase(); + if (app === "Other" && qbitInstanceNames.length > 0) { + const isQbitCategoryKind = kindLower === "category"; + const matchesQbitInstance = isQbitCategoryKind && qbitInstanceNames.some((inst) => { + const instLower = inst.toLowerCase(); + return ( + procCategoryLower === instLower || + procCategoryLower === `qbit-${instLower}` || + procCategoryLower.endsWith(`-${instLower}`) || + procCategoryLower.endsWith(`_${instLower}`) + ); + }); + const isConfiguredQbitCategory = qbitCategoryNames.has(procCategoryLower); + if (matchesQbitInstance || isConfiguredQbitCategory) return; } if (!appBuckets.has(app)) appBuckets.set(app, new Map()); @@ -346,7 +351,7 @@ export function ProcessesView({ active }: ProcessesViewProps): JSX.Element { }); return result; - }, [processes, statusData]); + }, [processes, statusData, qbitCategories]); const handleRestartGroup = useCallback( async (items: ProcessInfo[]) => { From cea8b330c94b02e4a563aaa8e819d5e276e972dc Mon Sep 17 00:00:00 2001 From: Feramance Date: Sun, 1 Mar 2026 11:16:09 +0100 Subject: [PATCH 59/66] fix(PlaceHolderArr): only cleanup cache for successfully deleted torrents PlaceHolderArr._process_failed was unioning original to_delete_all into the cleanup set even when the qBit delete API failed, causing torrents to be removed from name_cache/cache while still in qBittorrent. Now only cleaned_hashes = deleted_hashes | temp_to_delete are used for cache cleanup, matching Arr._process_failed behavior. --- qBitrr/arss.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/qBitrr/arss.py b/qBitrr/arss.py index 455ab119a..b8792a1e7 100755 --- a/qBitrr/arss.py +++ b/qBitrr/arss.py @@ -79,7 +79,6 @@ absolute_file_paths, format_bytes, has_internet, - mask_secret, parse_size, validate_and_return_torrent_file, with_retry, @@ -619,6 +618,7 @@ def __init__( self.manager.completed_folders.add(self.completed_folder) self.manager.category_allowlist.add(self.category) + _masked_apikey = "[redacted]" if self.apikey else "" self.logger.debug( "%s Config: " "Managed: %s, " @@ -635,7 +635,7 @@ def __init__( self.re_search, self.category, self.uri, - mask_secret(self.apikey), + _masked_apikey, self.refresh_downloads_timer, self.rss_sync_timer, ) @@ -681,16 +681,16 @@ def __init__( self.logger.debug("Script Config: SearchOmbiRequests=%s", self.ombi_search_requests) if self.ombi_search_requests: self.logger.debug("Script Config: OmbiURI=%s", self.ombi_uri) - self.logger.debug("Script Config: OmbiAPIKey=%s", mask_secret(self.ombi_api_key)) + _masked_ombi = "[redacted]" if self.ombi_api_key else "" + self.logger.debug("Script Config: OmbiAPIKey=%s", _masked_ombi) self.logger.debug("Script Config: ApprovedOnly=%s", self.ombi_approved_only) self.logger.debug( "Script Config: SearchOverseerrRequests=%s", self.overseerr_requests ) if self.overseerr_requests: self.logger.debug("Script Config: OverseerrURI=%s", self.overseerr_uri) - self.logger.debug( - "Script Config: OverseerrAPIKey=%s", mask_secret(self.overseerr_api_key) - ) + _masked_overseerr = "[redacted]" if self.overseerr_api_key else "" + self.logger.debug("Script Config: OverseerrAPIKey=%s", _masked_overseerr) if self.ombi_search_requests or self.overseerr_requests: self.logger.debug( "Script Config: SearchRequestsEvery=%s", self.search_requests_every_x_seconds @@ -7686,8 +7686,8 @@ def _process_failed(self) -> None: "Cannot delete %d torrent(s): no qBit client (manager.qbit is None)", len(rest), ) - to_delete_all = to_delete_all.union(temp_to_delete).union(deleted_hashes) - for h in to_delete_all: + cleaned_hashes = deleted_hashes.union(temp_to_delete) + for h in cleaned_hashes: self.cleaned_torrents.discard(h) self.sent_to_scan_hashes.discard(h) if h in self.manager.qbit_manager.name_cache: From 4805ed9fd472df62e277e160564cb206317254d8 Mon Sep 17 00:00:00 2001 From: Feramance Date: Sun, 1 Mar 2026 11:55:07 +0100 Subject: [PATCH 60/66] fix: only clean cache for successfully deleted torrents in PlaceHolderArr._process_failed --- qBitrr/main.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/qBitrr/main.py b/qBitrr/main.py index 73d9dfc97..ab79431cc 100644 --- a/qBitrr/main.py +++ b/qBitrr/main.py @@ -37,7 +37,7 @@ from qBitrr.home_path import APPDATA_FOLDER from qBitrr.logger import run_logs from qBitrr.qbit_category_manager import qBitCategoryManager -from qBitrr.utils import ExpiringSet, mask_secret +from qBitrr.utils import ExpiringSet from qBitrr.versioning import fetch_latest_release from qBitrr.webui import WebUI @@ -99,12 +99,13 @@ def __init__(self): self.qBit_Password = CONFIG.get("qBit.Password", fallback=None) self.logger = logging.getLogger(f"qBitrr.{self._name}") run_logs(self.logger, self._name) + _masked_pwd = "[redacted]" if self.qBit_Password else "" self.logger.debug( "qBitTorrent Config: Host: %s Port: %s, Username: %s, Password: %s", self.qBit_Host, self.qBit_Port, self.qBit_UserName, - mask_secret(self.qBit_Password), + _masked_pwd, ) self._validated_version = False self.current_qbit_version = None From 37b6dcf852762c7bcb361923bb5bfeb06b15a519 Mon Sep 17 00:00:00 2001 From: Feramance Date: Sun, 1 Mar 2026 19:28:00 +0100 Subject: [PATCH 61/66] fix: resolve CodeQL security findings - Add explicit permissions to pre-commit and dockerhub-description workflows - Mask qBit username in debug log (main.py) to avoid clear-text logging - Stop exposing exception/stack trace in WebUI API error responses (webui.py) --- .github/workflows/dockerhub-description.yml | 2 ++ .github/workflows/pre-commit.yaml | 2 ++ qBitrr/main.py | 3 ++- qBitrr/webui.py | 16 +++++++++------- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/workflows/dockerhub-description.yml b/.github/workflows/dockerhub-description.yml index c83f26019..841f27bac 100644 --- a/.github/workflows/dockerhub-description.yml +++ b/.github/workflows/dockerhub-description.yml @@ -13,6 +13,8 @@ jobs: publish-descriptions: name: Update Docker Hub and PyPI metadata runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Check out repository uses: actions/checkout@v6 diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index 78c127dc9..5b90c3ae6 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -8,6 +8,8 @@ on: jobs: pre-commit: runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v6 - name: Set up Python diff --git a/qBitrr/main.py b/qBitrr/main.py index ab79431cc..cca8ae789 100644 --- a/qBitrr/main.py +++ b/qBitrr/main.py @@ -100,11 +100,12 @@ def __init__(self): self.logger = logging.getLogger(f"qBitrr.{self._name}") run_logs(self.logger, self._name) _masked_pwd = "[redacted]" if self.qBit_Password else "" + _masked_user = "[redacted]" if self.qBit_UserName else "" self.logger.debug( "qBitTorrent Config: Host: %s Port: %s, Username: %s, Password: %s", self.qBit_Host, self.qBit_Port, - self.qBit_UserName, + _masked_user, _masked_pwd, ) self._validated_version = False diff --git a/qBitrr/webui.py b/qBitrr/webui.py index 67e4e4441..1e8a61e43 100644 --- a/qBitrr/webui.py +++ b/qBitrr/webui.py @@ -2624,10 +2624,10 @@ def api_get_config(): # Render current config as a JSON-able dict via tomlkit data = _toml_to_jsonable(CONFIG.config) return jsonify(data) - except Exception as e: - return jsonify({"error": str(e)}), 500 + except Exception: + self.logger.debug("api_get_config failed", exc_info=True) + return jsonify({"error": "Failed to load config"}), 500 - @app.get("/web/config") def web_get_config(): try: try: @@ -2656,8 +2656,9 @@ def web_get_config(): return jsonify(response_data) return jsonify(data) - except Exception as e: - return jsonify({"error": str(e)}), 500 + except Exception: + self.logger.debug("web_get_config failed", exc_info=True) + return jsonify({"error": "Failed to load config"}), 500 def _handle_config_update(): """Common handler for config updates with intelligent reload detection.""" @@ -2728,8 +2729,9 @@ def _handle_config_update(): # Persist config try: CONFIG.save() - except Exception as e: - return jsonify({"error": f"Failed to save config: {e}"}), 500 + except Exception: + self.logger.debug("Failed to save config", exc_info=True) + return jsonify({"error": "Failed to save config"}), 500 # Determine reload strategy reload_type = "none" From d089b8b80959b6f538cd2e6337253d2acbeedc6a Mon Sep 17 00:00:00 2001 From: Feramance Date: Sun, 1 Mar 2026 19:46:13 +0100 Subject: [PATCH 62/66] fix(webui): restore GET /web/config route for Config view The CodeQL security fix (37b6dcf8) accidentally removed the @app.get('/web/config') decorator when editing exception handling, so the Config tab received 405 Method Not Allowed and users could not load or adjust config. Re-add the decorator so GET /web/config is registered again. --- qBitrr/webui.py | 1 + 1 file changed, 1 insertion(+) diff --git a/qBitrr/webui.py b/qBitrr/webui.py index 1e8a61e43..2b8e2d319 100644 --- a/qBitrr/webui.py +++ b/qBitrr/webui.py @@ -2628,6 +2628,7 @@ def api_get_config(): self.logger.debug("api_get_config failed", exc_info=True) return jsonify({"error": "Failed to load config"}), 500 + @app.get("/web/config") def web_get_config(): try: try: From c5ba5f192a44de8498a4f8fd46489a4796d4dda8 Mon Sep 17 00:00:00 2001 From: Feramance Date: Sun, 1 Mar 2026 20:17:33 +0100 Subject: [PATCH 63/66] security: redact qBit passwords and Arr API keys in API/Web UI - Always strip sensitive keys in GET /api/config and GET /web/config - Skip overwriting secrets when update sends redaction placeholder - Add REDACTED_PLACEHOLDER and _is_sensitive_dotted_key helper --- qBitrr/webui.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/qBitrr/webui.py b/qBitrr/webui.py index 2b8e2d319..29447c98c 100644 --- a/qBitrr/webui.py +++ b/qBitrr/webui.py @@ -70,13 +70,24 @@ def _toml_delete(doc, dotted_key: str) -> None: r"(apikey|api_key|token|password|secret|passkey|credential)", re.IGNORECASE ) +# Placeholder returned by API/Web UI for sensitive values; never send real secrets. +# When config update sends this value for a sensitive key, the existing secret is left unchanged. +REDACTED_PLACEHOLDER = "[redacted]" + + +def _is_sensitive_dotted_key(dotted_key: str) -> bool: + """Return True if the config key is considered sensitive (e.g. qBit.Password, Radarr-x.APIKey).""" + if not dotted_key or "." not in dotted_key: + return bool(_SENSITIVE_KEY_PATTERNS.search(dotted_key)) + return bool(_SENSITIVE_KEY_PATTERNS.search(dotted_key.split(".")[-1])) + def _strip_sensitive_keys(obj: Any, _parent_key: str = "") -> Any: """Recursively redact values whose keys look like secrets.""" if isinstance(obj, dict): return { k: ( - "[redacted]" + REDACTED_PLACEHOLDER if isinstance(v, str) and _SENSITIVE_KEY_PATTERNS.search(k) else _strip_sensitive_keys(v, k) ) @@ -2621,8 +2632,9 @@ def api_get_config(): CONFIG.load() except Exception: self.logger.debug("CONFIG.load failed in api_get_config", exc_info=True) - # Render current config as a JSON-able dict via tomlkit + # Render current config as a JSON-able dict via tomlkit; never expose secrets data = _toml_to_jsonable(CONFIG.config) + data = _strip_sensitive_keys(data) return jsonify(data) except Exception: self.logger.debug("api_get_config failed", exc_info=True) @@ -2636,9 +2648,8 @@ def web_get_config(): except Exception: self.logger.debug("CONFIG.load failed in web_get_config", exc_info=True) data = _toml_to_jsonable(CONFIG.config) - - if not _authorized(): - data = _strip_sensitive_keys(data) + # Always redact secrets so API/Web UI never expose qBit passwords or Arr API keys + data = _strip_sensitive_keys(data) # Check config version and add warning if mismatch from qBitrr.config_version import get_config_version, validate_config_version @@ -2722,6 +2733,9 @@ def _handle_config_update(): if key == "WebUI.Token": self.token = "" continue + # Never overwrite a real secret with the redaction placeholder from the client + if _is_sensitive_dotted_key(key) and str(val).strip() == REDACTED_PLACEHOLDER: + continue _toml_set(CONFIG.config, key, val) if key == "WebUI.Token": # Update in-memory token immediately From 50117cc5b7a01059f74c6f5dc54e14921482de0a Mon Sep 17 00:00:00 2001 From: Feramance Date: Sun, 1 Mar 2026 23:28:47 +0100 Subject: [PATCH 64/66] Added curl and fixed nightly workflow docker images --- .github/workflows/nightly.yml | 5 +---- Dockerfile | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 037c33fef..6e1541051 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -23,9 +23,6 @@ jobs: packages: write contents: read if: ${{ ! startsWith(github.event.head_commit.message, '[patch]') && ! startsWith(github.event.head_commit.message, '[minor]') && ! startsWith(github.event.head_commit.message, '[major]') }} - strategy: - matrix: - platform: [amd64, arm64] steps: - id: string uses: ASzc/change-string-case-action@v6 @@ -65,7 +62,7 @@ jobs: uses: docker/build-push-action@v6 with: context: . - platforms: linux/${{ matrix.platform }} + platforms: linux/amd64,linux/arm64 push: true tags: feramance/qbitrr:nightly,ghcr.io/${{ steps.string.outputs.lowercase }}:nightly labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile index 80f40502e..9f27c263c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,8 +13,8 @@ LABEL Maintainer="feramance" LABEL Version="5.9.1" LABEL org.opencontainers.image.source=https://github.com/feramance/qbitrr -# Install tini (PID 1, SIGTERM forwarding) and gosu (drop privileges after chown) -RUN apt-get update && apt-get install -y --no-install-recommends tini gosu \ +# Install tini (PID 1, SIGTERM forwarding), gosu (drop privileges after chown), and curl (health checks) +RUN apt-get update && apt-get install -y --no-install-recommends tini gosu curl \ && rm -rf /var/lib/apt/lists/* # Env used by the script to determine if it's inside a docker - From e6b4e75c762add0e4abc61cbe7d78c40b3bd2d77 Mon Sep 17 00:00:00 2001 From: Feramance Date: Sun, 1 Mar 2026 23:31:52 +0100 Subject: [PATCH 65/66] Added favicon and some docker comments --- docs/getting-started/installation/docker.md | 4 +++- mkdocs.yml | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/getting-started/installation/docker.md b/docs/getting-started/installation/docker.md index 97c22b50e..f35d63c5d 100644 --- a/docs/getting-started/installation/docker.md +++ b/docs/getting-started/installation/docker.md @@ -319,7 +319,7 @@ services: ### Health Checks -Add a health check: +Add a health check (the official image includes `curl` for this): ```yaml services: @@ -579,6 +579,8 @@ services: ### 3. Use Health Checks +The official image includes `curl` for the health check. Example: + ```yaml services: qbitrr: diff --git a/mkdocs.yml b/mkdocs.yml index 17d3d4886..515791534 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -19,6 +19,7 @@ theme: name: material language: en logo: assets/logov2-clean.svg + favicon: assets/favicon.ico icon: repo: fontawesome/brands/github palette: From 29de695f074efaa4d2789b13101f565afe4544f2 Mon Sep 17 00:00:00 2001 From: Feramance Date: Sun, 1 Mar 2026 23:50:54 +0100 Subject: [PATCH 66/66] docs: add qBitrr vs Huntarr comparison and From Huntarr migration - Add comparison-huntarr.md focusing on missing-item search and torrent handling - Add nav entry under Getting Started and From Huntarr subsection in migration.md --- docs/getting-started/comparison-huntarr.md | 109 +++++++++++++++++++++ docs/getting-started/migration.md | 13 +++ mkdocs.yml | 1 + 3 files changed, 123 insertions(+) create mode 100644 docs/getting-started/comparison-huntarr.md diff --git a/docs/getting-started/comparison-huntarr.md b/docs/getting-started/comparison-huntarr.md new file mode 100644 index 000000000..983485bc4 --- /dev/null +++ b/docs/getting-started/comparison-huntarr.md @@ -0,0 +1,109 @@ +# Comparison: qBitrr vs Huntarr + +Huntarr automates hunting for missing and upgradeable content across *Arr instances and offers optional Swaparr (stalled-download swapping) and Movie Hunt (standalone movie grabber). qBitrr focuses on **qBittorrent–\*Arr glue**: torrent health, instant import, and automated search. This page highlights where qBitrr is **as good or better** for missing-item search and torrent handling. + +--- + +## Missing-items search: qBitrr's strengths + +### Continuous, configurable search loop + +qBitrr queries each Arr instance for missing items, sorts them by priority (year, release date, etc.), and runs searches with a configurable delay (`SearchLoopDelay`) between individual search commands. Optional `SearchAgainOnSearchCompletion` restarts the loop so newly available releases are found without manual intervention. + +See [Automated Search](../features/automated-search.md) for full details. + +### Per-Arr, per-instance control + +Each Radarr, Sonarr, and Lidarr instance has its own `EntrySearch` settings: `SearchMissing`, `DoUpgradeSearch`, custom format options, and temporary quality profiles. You can tune search behavior separately for 4K vs 1080p, TV vs anime, or music vs movies. + +### Request prioritization + +[Overseerr](../configuration/search/overseerr.md) and [Ombi](../configuration/search/ombi.md) integration lets qBitrr search for **requested** items before general missing/upgrade queues, so user requests are fulfilled sooner. + +### Quality and custom formats + +qBitrr supports quality upgrade search, custom format scoring (including TRaSH-style guides), and temporary quality profiles for hard-to-find content. Optional force-reset on startup returns items to their main profile after testing. + +### No separate scheduler + +Search runs in the **same process** as torrent monitoring. One config file (TOML), one deployment—no separate hunt service to install or keep in sync. + +### Comparison note (Huntarr) + +Huntarr uses scheduled hunt cycles (e.g. every 30 minutes, N missing + M upgrade items per cycle). qBitrr uses a continuous loop with configurable delay between individual searches and optional restart-on-completion. Both support multi-instance and quality upgrades; qBitrr adds request integration and lives in the same stack as torrent handling. + +--- + +## Torrent handling: qBitrr's strengths + +### Instant import + +When a torrent completes in qBittorrent, qBitrr triggers the Arr's download scan **immediately** (within seconds) instead of waiting for Arr's periodic 1–5 minute poll. Media appears in your library faster. + +See [Instant Imports](../features/instant-imports.md) for details. + +### Stalled detection and action + +qBitrr uses time-based stalled detection (`StalledDelay`, MaxETA). When a torrent is stalled beyond the threshold, you can optionally re-search **before** removal (`ReSearchStalled`). Then qBitrr removes it from qBittorrent and the Arr queue and triggers a new search if `ReSearch` is enabled, so the Arr finds an alternative release. + +See [Health Monitoring](../features/health-monitoring.md) for the full workflow. + +### Failed download handling + +Failed imports are blacklisted in the Arr so the same bad release is not grabbed again. With `ReSearch` enabled, qBitrr triggers a new search so the Arr can grab a different release. + +### FFprobe verification + +qBitrr can validate media files with FFprobe before import. Invalid or corrupted files are rejected and re-search is triggered when enabled, reducing bad imports. + +### Single process, single config + +Health checks and search run together. There is no separate Swaparr-like service to install or tune—stalled and failed handling are built in. + +### Stalled handling comparison (Swaparr) + +Swaparr (often used with Huntarr) offers strike-based removal and an "ignore above size" option; qBitrr uses a single time/ETA threshold and does not have size exemption or dry run. For many users, qBitrr's time-based stalled handling plus instant import and failed/blacklist handling is sufficient and simpler to operate. + +--- + +## Feature snapshot + +| Feature | qBitrr | Huntarr / related | +| ------- | ------ | ----------------- | +| Missing media search | Yes; continuous loop, configurable delay | Yes; scheduled hunt cycles | +| Quality upgrade search | Yes; per-instance, custom formats | Yes | +| Request prioritization (Overseerr/Ombi) | Yes; built-in | No (separate request flow) | +| Instant import on completion | Yes; triggers Arr scan within seconds | No; relies on Arr polling | +| Stalled torrent handling | Yes; time-based, optional re-search before removal | Swaparr: strike-based, optional | +| Failed/blacklist + re-search | Yes; blacklist in Arr, trigger new search | Varies | +| FFprobe verification | Yes; validate before import | No | +| Multi-instance *Arr | Yes | Yes | +| Single config / single process | Yes; one TOML, one process | Huntarr + optional Swaparr/Movie Hunt | + +--- + +## What Huntarr has that qBitrr doesn't + +- **Swaparr:** Strike-based removal (multiple checks before removal), "ignore above size" for large files, and dry-run mode. If you need those, you can run Swaparr alongside qBitrr. +- **Movie Hunt:** A standalone movie grabber with its own indexers and download client—you can search and grab movies without adding them to Radarr. qBitrr does not add or grab content on its own; it only manages torrents that were added by an Arr. + +If you rely on standalone grabbing or Swaparr's strike/size options, you can run Huntarr (or Swaparr) alongside qBitrr. qBitrr does not replace standalone grabbing. + +--- + +## When to choose qBitrr + +- You want **one tool** for missing-item search and torrent health in a qBittorrent + *Arr setup. +- You care about **instant import**, **stalled/failed handling**, and **request prioritization** without adding another scheduler or client. +- You prefer a **single config and process** and a [Web UI](../webui/index.md) for monitoring. + +--- + +## Migrating from Huntarr + +1. **Install qBitrr** — [Installation](installation/index.md) (Docker, pip, or binary). +2. **Configure** — Point qBitrr at the same qBittorrent and Arr instances; set categories to match your Arr download clients. +3. **Enable search** — In each Arr's `EntrySearch` section, set `SearchMissing = true` and `DoUpgradeSearch = true` as needed. Optionally add [Overseerr](../configuration/search/overseerr.md) or [Ombi](../configuration/search/ombi.md). +4. **Disable or repurpose Huntarr** — Turn off Huntarr hunt cycles (or stop Huntarr) once qBitrr is handling search and torrents to your satisfaction. + +For backup, rollback, and detailed migration steps, see the [Migration Guide](migration.md). diff --git a/docs/getting-started/migration.md b/docs/getting-started/migration.md index f8d07b686..6c52e5852 100644 --- a/docs/getting-started/migration.md +++ b/docs/getting-started/migration.md @@ -305,6 +305,19 @@ qBittorrent-Manager users will find qBitrr familiar but with many enhancements. --- +### From Huntarr + +Huntarr focuses on scheduled hunt cycles for missing/upgrade content and optional Swaparr (stalled-download swapping) and Movie Hunt (standalone grabber). qBitrr focuses on missing-item search and torrent handling—instant import, stalled/failed handling—in one place with a single config. For a focused comparison of what qBitrr does as well or better for search and torrents, see [Comparison: qBitrr vs Huntarr](comparison-huntarr.md). + +#### Migration steps + +1. **Install qBitrr** — Follow the [Installation Guide](installation/index.md). +2. **Configure** — Point qBitrr at the same qBittorrent and Arr instances; set categories to match your Arr download clients. +3. **Enable search** — Set `SearchMissing = true` and `DoUpgradeSearch = true` in each Arr's `EntrySearch` section; optionally add [Overseerr](../configuration/search/overseerr.md) or [Ombi](../configuration/search/ombi.md). +4. **Disable Huntarr hunt cycles** (or stop Huntarr) once qBitrr is handling search and torrents to your satisfaction. + +--- + ### From Arr-Scripts Users running custom Arr scripts can consolidate functionality into qBitrr. diff --git a/mkdocs.yml b/mkdocs.yml index 515791534..c0aa55691 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -207,6 +207,7 @@ nav: - getting-started/installation/binary.md - Quick Start: getting-started/quickstart.md - Migration Guide: getting-started/migration.md + - 'Comparison: qBitrr vs Huntarr': getting-started/comparison-huntarr.md - Configuration: - Overview: configuration/index.md