qBitrr2 5.0.2__py3-none-any.whl → 5.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
qBitrr/arss.py CHANGED
@@ -20,7 +20,7 @@ import qbittorrentapi
20
20
  import qbittorrentapi.exceptions
21
21
  import requests
22
22
  from packaging import version as version_parser
23
- from peewee import SqliteDatabase
23
+ from peewee import Model, SqliteDatabase
24
24
  from pyarr import RadarrAPI, SonarrAPI
25
25
  from pyarr.exceptions import PyarrResourceNotFound, PyarrServerError
26
26
  from pyarr.types import JsonObject
@@ -302,6 +302,7 @@ class Arr:
302
302
  self._delta = 1
303
303
  else:
304
304
  self._delta = -1
305
+
305
306
  self._app_data_folder = APPDATA_FOLDER
306
307
  self.search_db_file = self._app_data_folder.joinpath(f"{self._name}.db")
307
308
 
@@ -566,15 +567,16 @@ class Arr:
566
567
  elif not QBIT_DISABLED and TAGLESS:
567
568
  self.manager.qbit_manager.client.torrents_create_tags(["qBitrr-ignored"])
568
569
  self.search_setup_completed = False
569
- self.model_file: EpisodeFilesModel | MoviesFilesModel = None
570
- self.series_file_model: SeriesFilesModel = None
571
- self.model_queue: EpisodeQueueModel | MovieQueueModel = None
572
- self.persistent_queue: FilesQueued = None
570
+ self.model_file: Model | None = None
571
+ self.series_file_model: Model | None = None
572
+ self.model_queue: Model | None = None
573
+ self.persistent_queue: Model | None = None
573
574
  self.torrents: TorrentLibrary | None = None
575
+ self.torrent_db: SqliteDatabase | None = None
576
+ self.db: SqliteDatabase | None = None
574
577
  # Initialize search mode (and torrent tag-emulation DB in TAGLESS)
575
578
  # early and fail fast if it cannot be set up.
576
579
  self.register_search_mode()
577
- # Ensure DBs are closed on process exit
578
580
  atexit.register(
579
581
  lambda: (
580
582
  hasattr(self, "db") and self.db and not self.db.is_closed() and self.db.close()
@@ -848,28 +850,6 @@ class Arr:
848
850
  ),
849
851
  )
850
852
 
851
- def _get_models(
852
- self,
853
- ) -> tuple[
854
- type[EpisodeFilesModel] | type[MoviesFilesModel],
855
- type[EpisodeQueueModel] | type[MovieQueueModel],
856
- type[SeriesFilesModel] | None,
857
- type[TorrentLibrary] | None,
858
- ]:
859
- if self.type == "sonarr":
860
- if self.series_search:
861
- return (
862
- EpisodeFilesModel,
863
- EpisodeQueueModel,
864
- SeriesFilesModel,
865
- TorrentLibrary if TAGLESS else None,
866
- )
867
- return EpisodeFilesModel, EpisodeQueueModel, None, TorrentLibrary if TAGLESS else None
868
- elif self.type == "radarr":
869
- return MoviesFilesModel, MovieQueueModel, None, TorrentLibrary if TAGLESS else None
870
- else:
871
- raise UnhandledError(f"Well you shouldn't have reached here, Arr.type={self.type}")
872
-
873
853
  def _get_oversee_requests_all(self) -> dict[str, set]:
874
854
  try:
875
855
  data = defaultdict(set)
@@ -4843,11 +4823,8 @@ class Arr:
4843
4823
  if self.search_setup_completed:
4844
4824
  return
4845
4825
 
4846
- # Determine which models we need in this mode (including TorrentLibrary when TAGLESS)
4847
4826
  db1, db2, db3, db4 = self._get_models()
4848
4827
 
4849
- # If searches are disabled, we still want the torrent tag-emulation DB in TAGLESS mode,
4850
- # but can skip the per-entry search database setup.
4851
4828
  if not (
4852
4829
  self.search_missing
4853
4830
  or self.do_upgrade_search
@@ -4862,7 +4839,7 @@ class Arr:
4862
4839
  str(self._app_data_folder.joinpath("Torrents.db")),
4863
4840
  pragmas={
4864
4841
  "journal_mode": "wal",
4865
- "cache_size": -1 * 64000, # 64MB
4842
+ "cache_size": -64_000,
4866
4843
  "foreign_keys": 1,
4867
4844
  "ignore_check_constraints": 0,
4868
4845
  "synchronous": 0,
@@ -4880,12 +4857,13 @@ class Arr:
4880
4857
  self.search_setup_completed = True
4881
4858
  return
4882
4859
 
4860
+ self.search_db_file.parent.mkdir(parents=True, exist_ok=True)
4883
4861
  self.db = SqliteDatabase(None)
4884
4862
  self.db.init(
4885
4863
  str(self.search_db_file),
4886
4864
  pragmas={
4887
4865
  "journal_mode": "wal",
4888
- "cache_size": -1 * 64000, # 64MB
4866
+ "cache_size": -64_000,
4889
4867
  "foreign_keys": 1,
4890
4868
  "ignore_check_constraints": 0,
4891
4869
  "synchronous": 0,
@@ -4916,6 +4894,7 @@ class Arr:
4916
4894
  self.series_file_model = Series
4917
4895
  else:
4918
4896
  self.db.create_tables([Files, Queue, PersistingQueue])
4897
+ self.series_file_model = None
4919
4898
 
4920
4899
  if db4:
4921
4900
  self.torrent_db = SqliteDatabase(None)
@@ -4923,7 +4902,7 @@ class Arr:
4923
4902
  str(self._app_data_folder.joinpath("Torrents.db")),
4924
4903
  pragmas={
4925
4904
  "journal_mode": "wal",
4926
- "cache_size": -1 * 64000, # 64MB
4905
+ "cache_size": -64_000,
4927
4906
  "foreign_keys": 1,
4928
4907
  "ignore_check_constraints": 0,
4929
4908
  "synchronous": 0,
@@ -4939,13 +4918,44 @@ class Arr:
4939
4918
  self.torrent_db.create_tables([Torrents])
4940
4919
  self.torrents = Torrents
4941
4920
  else:
4942
- self.torrents: TorrentLibrary = None
4921
+ self.torrents = None
4943
4922
 
4944
4923
  self.model_file = Files
4945
4924
  self.model_queue = Queue
4946
4925
  self.persistent_queue = PersistingQueue
4947
4926
  self.search_setup_completed = True
4948
4927
 
4928
+ def _get_models(
4929
+ self,
4930
+ ) -> tuple[
4931
+ type[EpisodeFilesModel] | type[MoviesFilesModel],
4932
+ type[EpisodeQueueModel] | type[MovieQueueModel],
4933
+ type[SeriesFilesModel] | None,
4934
+ type[TorrentLibrary] | None,
4935
+ ]:
4936
+ if self.type == "sonarr":
4937
+ if self.series_search:
4938
+ return (
4939
+ EpisodeFilesModel,
4940
+ EpisodeQueueModel,
4941
+ SeriesFilesModel,
4942
+ TorrentLibrary if TAGLESS else None,
4943
+ )
4944
+ return (
4945
+ EpisodeFilesModel,
4946
+ EpisodeQueueModel,
4947
+ None,
4948
+ TorrentLibrary if TAGLESS else None,
4949
+ )
4950
+ if self.type == "radarr":
4951
+ return (
4952
+ MoviesFilesModel,
4953
+ MovieQueueModel,
4954
+ None,
4955
+ TorrentLibrary if TAGLESS else None,
4956
+ )
4957
+ raise UnhandledError(f"Well you shouldn't have reached here, Arr.type={self.type}")
4958
+
4949
4959
  def run_request_search(self):
4950
4960
  if (
4951
4961
  (
@@ -5567,6 +5577,7 @@ class PlaceHolderArr(Arr):
5567
5577
  class FreeSpaceManager(Arr):
5568
5578
  def __init__(self, categories: set[str], manager: ArrManager):
5569
5579
  self._name = "FreeSpaceManager"
5580
+ self.type = "FreeSpaceManager"
5570
5581
  self.manager = manager
5571
5582
  self.logger = logging.getLogger(f"qBitrr.{self._name}")
5572
5583
  self._LOG_LEVEL = self.manager.qbit_manager.logger.level
@@ -5581,7 +5592,6 @@ class FreeSpaceManager(Arr):
5581
5592
  )
5582
5593
  self.timed_ignore_cache = ExpiringSet(max_age_seconds=self.ignore_torrents_younger_than)
5583
5594
  self.needs_cleanup = False
5584
- # Needed by register_search_mode for torrent DB pathing
5585
5595
  self._app_data_folder = APPDATA_FOLDER
5586
5596
  # Track search setup state to cooperate with Arr.register_search_mode
5587
5597
  self.search_setup_completed = False
@@ -5608,9 +5618,9 @@ class FreeSpaceManager(Arr):
5608
5618
  self.ombi_search_requests = False
5609
5619
  self.overseerr_requests = False
5610
5620
  self.session = None
5611
- # Reuse Arr's search-mode initializer to set up the torrent tag-emulation DB
5612
- # without needing Arr type, by overriding _get_models below.
5621
+ # Ensure torrent tag-emulation tables exist when needed.
5613
5622
  self.torrents = None
5623
+ self.torrent_db: SqliteDatabase | None = None
5614
5624
  self.last_search_description: str | None = None
5615
5625
  self.last_search_timestamp: str | None = None
5616
5626
  self.queue_active_count: int = 0
@@ -5618,7 +5628,6 @@ class FreeSpaceManager(Arr):
5618
5628
  self.free_space_tagged_count: int = 0
5619
5629
  self.register_search_mode()
5620
5630
  self.logger.hnotice("Starting %s monitor", self._name)
5621
- # Ensure DB is closed when process exits (guard attribute existence)
5622
5631
  atexit.register(
5623
5632
  lambda: (
5624
5633
  hasattr(self, "torrent_db")
@@ -5636,9 +5645,6 @@ class FreeSpaceManager(Arr):
5636
5645
  None,
5637
5646
  type[TorrentLibrary] | None,
5638
5647
  ]:
5639
- # FreeSpaceManager should never create the per-entry search database.
5640
- # Return None for file and queue models so only the torrent DB (TAGLESS)
5641
- # can be initialized by register_search_mode.
5642
5648
  return None, None, None, (TorrentLibrary if TAGLESS else None)
5643
5649
 
5644
5650
  def _process_single_torrent_pause_disk_space(self, torrent: qbittorrentapi.TorrentDictionary):
qBitrr/auto_update.py CHANGED
@@ -82,8 +82,11 @@ class AutoUpdater:
82
82
  self._logger.info("Auto update completed")
83
83
 
84
84
 
85
- def perform_self_update(logger: logging.Logger) -> None:
86
- """Attempt to update qBitrr in-place using git or pip."""
85
+ def perform_self_update(logger: logging.Logger) -> bool:
86
+ """Attempt to update qBitrr in-place using git or pip.
87
+
88
+ Returns True when the update command completed successfully, False otherwise.
89
+ """
87
90
 
88
91
  repo_root = Path(__file__).resolve().parent.parent
89
92
  git_dir = repo_root / ".git"
@@ -100,10 +103,11 @@ def perform_self_update(logger: logging.Logger) -> None:
100
103
  stdout = (result.stdout or "").strip()
101
104
  if stdout:
102
105
  logger.info("git pull output:\n%s", stdout)
106
+ return True
103
107
  except subprocess.CalledProcessError as exc:
104
108
  stderr = (exc.stderr or "").strip()
105
109
  logger.error("Failed to update repository via git: %s", stderr or exc)
106
- return
110
+ return False
107
111
 
108
112
  package = "qBitrr2"
109
113
  logger.debug("Fallback to pip upgrade for package %s", package)
@@ -117,6 +121,8 @@ def perform_self_update(logger: logging.Logger) -> None:
117
121
  stdout = (result.stdout or "").strip()
118
122
  if stdout:
119
123
  logger.info("pip upgrade output:\n%s", stdout)
124
+ return True
120
125
  except subprocess.CalledProcessError as exc:
121
126
  stderr = (exc.stderr or "").strip()
122
127
  logger.error("Failed to upgrade package via pip: %s", stderr or exc)
128
+ return False
qBitrr/bundled_data.py CHANGED
@@ -1,5 +1,5 @@
1
- version = "5.0.2"
2
- git_hash = "34ae835"
1
+ version = "5.1.1"
2
+ git_hash = "203e3ef"
3
3
  license_text = (
4
4
  "Licence can be found on:\n\nhttps://github.com/Feramance/qBitrr/blob/master/LICENSE"
5
5
  )
qBitrr/db_lock.py ADDED
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import threading
5
+ from contextlib import contextmanager
6
+ from pathlib import Path
7
+ from typing import Iterator
8
+
9
+ from qBitrr.home_path import APPDATA_FOLDER
10
+
11
+ if os.name == "nt": # pragma: no cover - platform specific
12
+ import msvcrt
13
+ else: # pragma: no cover
14
+ import fcntl
15
+
16
+ _LOCK_FILE = APPDATA_FOLDER.joinpath("qbitrr.db.lock")
17
+
18
+
19
+ class _InterProcessFileLock:
20
+ """Cross-process, re-entrant file lock to guard SQLite access."""
21
+
22
+ def __init__(self, path: Path):
23
+ self._path = path
24
+ self._thread_gate = threading.RLock()
25
+ self._local = threading.local()
26
+
27
+ def acquire(self) -> None:
28
+ depth = getattr(self._local, "depth", 0)
29
+ if depth == 0:
30
+ self._thread_gate.acquire()
31
+ self._path.parent.mkdir(parents=True, exist_ok=True)
32
+ handle = open(self._path, "a+b")
33
+ try:
34
+ if os.name == "nt": # pragma: no cover - Windows specific branch
35
+ msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
36
+ else: # pragma: no cover - POSIX branch
37
+ fcntl.flock(handle, fcntl.LOCK_EX)
38
+ except Exception:
39
+ handle.close()
40
+ self._thread_gate.release()
41
+ raise
42
+ self._local.handle = handle
43
+ self._local.depth = depth + 1
44
+
45
+ def release(self) -> None:
46
+ depth = getattr(self._local, "depth", 0)
47
+ if depth <= 0:
48
+ raise RuntimeError("Attempted to release an unacquired database lock")
49
+ depth -= 1
50
+ if depth == 0:
51
+ handle = getattr(self._local, "handle")
52
+ try:
53
+ if os.name == "nt": # pragma: no cover
54
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
55
+ else: # pragma: no cover
56
+ fcntl.flock(handle, fcntl.LOCK_UN)
57
+ finally:
58
+ handle.close()
59
+ del self._local.handle
60
+ self._thread_gate.release()
61
+ self._local.depth = depth
62
+
63
+ @contextmanager
64
+ def context(self) -> Iterator[None]:
65
+ self.acquire()
66
+ try:
67
+ yield
68
+ finally:
69
+ self.release()
70
+
71
+
72
+ _DB_LOCK = _InterProcessFileLock(_LOCK_FILE)
73
+
74
+
75
+ @contextmanager
76
+ def database_lock() -> Iterator[None]:
77
+ """Provide a shared lock used to serialize SQLite access across processes."""
78
+ with _DB_LOCK.context():
79
+ yield
qBitrr/gen_config.py CHANGED
@@ -159,9 +159,9 @@ def _add_settings_section(config: TOMLDocument):
159
159
  )
160
160
  _gen_default_line(
161
161
  settings,
162
- "WebUI listen host (default 127.0.0.1)",
162
+ "WebUI listen host (default 0.0.0.0)",
163
163
  "WebUIHost",
164
- "127.0.0.1",
164
+ "0.0.0.0",
165
165
  )
166
166
  _gen_default_line(
167
167
  settings,
qBitrr/logger.py CHANGED
@@ -119,6 +119,7 @@ def run_logs(logger: Logger, _name: str = None) -> None:
119
119
  },
120
120
  reconfigure=True,
121
121
  )
122
+ logger.propagate = False
122
123
  if ENABLE_LOGS and _name:
123
124
  logs_folder = HOME_PATH.joinpath("logs")
124
125
  logs_folder.mkdir(parents=True, exist_ok=True)
qBitrr/main.py CHANGED
@@ -2,9 +2,10 @@ from __future__ import annotations
2
2
 
3
3
  import atexit
4
4
  import contextlib
5
- import itertools
6
5
  import logging
6
+ import os
7
7
  import sys
8
+ import time
8
9
  from multiprocessing import Event, freeze_support
9
10
  from queue import SimpleQueue
10
11
  from threading import Event as ThreadEvent
@@ -21,7 +22,6 @@ from qbittorrentapi import APINames
21
22
  from qBitrr.auto_update import AutoUpdater, perform_self_update
22
23
  from qBitrr.bundled_data import patched_version
23
24
  from qBitrr.config import (
24
- APPDATA_FOLDER,
25
25
  CONFIG,
26
26
  CONFIG_EXISTS,
27
27
  QBIT_DISABLED,
@@ -32,7 +32,8 @@ from qBitrr.config import (
32
32
  from qBitrr.env_config import ENVIRO_CONFIG
33
33
  from qBitrr.ffprobe import FFprobeDownloader
34
34
  from qBitrr.logger import run_logs
35
- from qBitrr.utils import ExpiringSet, absolute_file_paths
35
+ from qBitrr.utils import ExpiringSet
36
+ from qBitrr.versioning import fetch_latest_release
36
37
  from qBitrr.webui import WebUI
37
38
 
38
39
  if CONFIG_EXISTS:
@@ -101,6 +102,8 @@ class qBitManager:
101
102
  self.arr_manager = None
102
103
  self._bootstrap_ready = ThreadEvent()
103
104
  self._startup_thread: Thread | None = None
105
+ self._restart_requested = False
106
+ self._restart_thread: Thread | None = None
104
107
  self.ffprobe_downloader = FFprobeDownloader()
105
108
  try:
106
109
  if not (QBIT_DISABLED or SEARCH_ONLY):
@@ -115,6 +118,11 @@ class qBitManager:
115
118
  except Exception:
116
119
  web_port = 6969
117
120
  web_host = CONFIG.get("Settings.WebUIHost", fallback="127.0.0.1") or "127.0.0.1"
121
+ if os.environ.get("QBITRR_DOCKER_RUNNING") == "69420" and web_host in {
122
+ "127.0.0.1",
123
+ "localhost",
124
+ }:
125
+ web_host = "0.0.0.0"
118
126
  if web_host in {"0.0.0.0", "::"}:
119
127
  self.logger.warning(
120
128
  "WebUI host configured for %s; ensure exposure is intentional and protected.",
@@ -144,11 +152,55 @@ class qBitManager:
144
152
  self.logger.error("Auto update could not be scheduled; leaving it disabled")
145
153
 
146
154
  def _perform_auto_update(self) -> None:
147
- self.logger.notice("Performing auto update...")
148
- perform_self_update(self.logger)
149
- self.logger.notice(
150
- "Auto update cycle complete. A restart may be required if files were updated."
151
- )
155
+ self.logger.notice("Checking for updates...")
156
+ release_info = fetch_latest_release()
157
+ if release_info.get("error"):
158
+ self.logger.error("Auto update skipped: %s", release_info["error"])
159
+ return
160
+ target_version = release_info.get("raw_tag") or release_info.get("normalized")
161
+ if not release_info.get("update_available"):
162
+ if target_version:
163
+ self.logger.info(
164
+ "Auto update skipped: already running the latest release (%s).",
165
+ target_version,
166
+ )
167
+ else:
168
+ self.logger.info("Auto update skipped: no new release detected.")
169
+ return
170
+
171
+ self.logger.notice("Updating from %s to %s", patched_version, target_version or "latest")
172
+ updated = perform_self_update(self.logger)
173
+ if not updated:
174
+ self.logger.error("Auto update failed; manual intervention may be required.")
175
+ return
176
+ self.logger.notice("Update applied successfully; restarting to load the new version.")
177
+ self.request_restart()
178
+
179
+ def request_restart(self, delay: float = 3.0) -> None:
180
+ if self._restart_requested:
181
+ return
182
+ self._restart_requested = True
183
+
184
+ def _restart():
185
+ if delay > 0:
186
+ time.sleep(delay)
187
+ self.logger.notice("Exiting to complete restart.")
188
+ try:
189
+ self.shutdown_event.set()
190
+ except Exception:
191
+ pass
192
+ for proc in list(self.child_processes):
193
+ with contextlib.suppress(Exception):
194
+ proc.join(timeout=5)
195
+ for proc in list(self.child_processes):
196
+ with contextlib.suppress(Exception):
197
+ proc.kill()
198
+ with contextlib.suppress(Exception):
199
+ proc.terminate()
200
+ os._exit(0)
201
+
202
+ self._restart_thread = Thread(target=_restart, name="qBitrr-Restart", daemon=True)
203
+ self._restart_thread.start()
152
204
 
153
205
  def _prepare_arr_processes(self, arr, timeout_seconds: int = 30) -> None:
154
206
  timeout = max(
@@ -437,15 +489,6 @@ def run():
437
489
  child.kill()
438
490
 
439
491
 
440
- def file_cleanup():
441
- extensions = [".db", ".db-shm", ".db-wal"]
442
- all_files_in_folder = list(absolute_file_paths(APPDATA_FOLDER))
443
- for file, ext in itertools.product(all_files_in_folder, extensions):
444
- if file.name.endswith(ext):
445
- file.unlink(missing_ok=True)
446
-
447
-
448
492
  if __name__ == "__main__":
449
493
  freeze_support()
450
- file_cleanup()
451
494
  run()
qBitrr/versioning.py ADDED
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import requests
6
+ from packaging import version as version_parser
7
+
8
+ from qBitrr.bundled_data import patched_version
9
+
10
+ DEFAULT_REPOSITORY = "Feramance/qBitrr"
11
+
12
+
13
+ def normalize_version(raw: str | None) -> str | None:
14
+ if not raw:
15
+ return None
16
+ cleaned = raw.strip()
17
+ if cleaned.startswith(("v", "V")):
18
+ cleaned = cleaned[1:]
19
+ if "-" in cleaned:
20
+ cleaned = cleaned.split("-", 1)[0]
21
+ return cleaned or None
22
+
23
+
24
+ def is_newer_version(candidate: str | None, current: str | None = None) -> bool:
25
+ if not candidate:
26
+ return False
27
+ normalized_current = normalize_version(current or patched_version)
28
+ if not normalized_current:
29
+ return True
30
+ try:
31
+ latest_version = version_parser.parse(candidate)
32
+ current_version = version_parser.parse(normalized_current)
33
+ return latest_version > current_version
34
+ except Exception:
35
+ return candidate != normalized_current
36
+
37
+
38
+ def fetch_latest_release(repo: str = DEFAULT_REPOSITORY, *, timeout: int = 10) -> dict[str, Any]:
39
+ url = f"https://api.github.com/repos/{repo}/releases/latest"
40
+ headers = {"Accept": "application/vnd.github+json"}
41
+ try:
42
+ response = requests.get(url, headers=headers, timeout=timeout)
43
+ response.raise_for_status()
44
+ payload = response.json()
45
+ except Exception as exc:
46
+ message = str(exc)
47
+ if len(message) > 200:
48
+ message = f"{message[:197]}..."
49
+ return {
50
+ "raw_tag": None,
51
+ "normalized": None,
52
+ "changelog": "",
53
+ "changelog_url": f"https://github.com/{repo}/releases",
54
+ "update_available": False,
55
+ "error": message,
56
+ }
57
+
58
+ raw_tag = (payload.get("tag_name") or payload.get("name") or "").strip()
59
+ normalized = normalize_version(raw_tag)
60
+ changelog = payload.get("body") or ""
61
+ changelog_url = payload.get("html_url") or f"https://github.com/{repo}/releases"
62
+ update_available = is_newer_version(normalized)
63
+ return {
64
+ "raw_tag": raw_tag or None,
65
+ "normalized": normalized,
66
+ "changelog": changelog,
67
+ "changelog_url": changelog_url,
68
+ "update_available": update_available,
69
+ "error": None,
70
+ }
qBitrr/webui.py CHANGED
@@ -11,9 +11,7 @@ from datetime import datetime, timedelta, timezone
11
11
  from pathlib import Path
12
12
  from typing import Any
13
13
 
14
- import requests
15
14
  from flask import Flask, jsonify, redirect, request, send_file
16
- from packaging import version as version_parser
17
15
  from peewee import fn
18
16
 
19
17
  from qBitrr.arss import FreeSpaceManager, PlaceHolderArr
@@ -24,6 +22,7 @@ from qBitrr.search_activity_store import (
24
22
  clear_search_activity,
25
23
  fetch_search_activities,
26
24
  )
25
+ from qBitrr.versioning import fetch_latest_release
27
26
 
28
27
 
29
28
  def _toml_set(doc, dotted_key: str, value: Any):
@@ -69,7 +68,7 @@ def _toml_to_jsonable(obj: Any) -> Any:
69
68
 
70
69
 
71
70
  class WebUI:
72
- def __init__(self, manager, host: str = "127.0.0.1", port: int = 6969):
71
+ def __init__(self, manager, host: str = "0.0.0.0", port: int = 6969):
73
72
  self.manager = manager
74
73
  self.host = host
75
74
  self.port = port
@@ -131,58 +130,17 @@ class WebUI:
131
130
  self._thread: threading.Thread | None = None
132
131
  self._use_dev_server: bool | None = None
133
132
 
134
- @staticmethod
135
- def _normalize_version(value: str | None) -> str | None:
136
- if not value:
137
- return None
138
- cleaned = value.strip()
139
- if not cleaned:
140
- return None
141
- if cleaned[0] in {"v", "V"}:
142
- cleaned = cleaned[1:]
143
- if "-" in cleaned:
144
- cleaned = cleaned.split("-", 1)[0]
145
- return cleaned or None
146
-
147
- def _is_newer_version(self, candidate: str | None) -> bool:
148
- if not candidate:
149
- return False
150
- current_norm = self._normalize_version(patched_version)
151
- if not current_norm:
152
- return True
153
- try:
154
- latest_version = version_parser.parse(candidate)
155
- current_version = version_parser.parse(current_norm)
156
- return latest_version > current_version
157
- except Exception:
158
- return candidate != current_norm
159
-
160
133
  def _fetch_version_info(self) -> dict[str, Any]:
161
- repo = self._github_repo
162
- url = f"https://api.github.com/repos/{repo}/releases/latest"
163
- headers = {"Accept": "application/vnd.github+json"}
164
- try:
165
- response = requests.get(url, headers=headers, timeout=10)
166
- response.raise_for_status()
167
- payload = response.json()
168
- except Exception as exc:
169
- message = str(exc)
170
- if len(message) > 200:
171
- message = f"{message[:197]}..."
172
- self.logger.debug("Failed to fetch latest release information: %s", exc)
173
- return {"error": message}
174
-
175
- raw_tag = (payload.get("tag_name") or payload.get("name") or "").strip()
176
- normalized_latest = self._normalize_version(raw_tag)
177
- latest_display = raw_tag or normalized_latest
178
- changelog = payload.get("body") or ""
179
- changelog_url = payload.get("html_url") or f"https://github.com/{repo}/releases"
180
- update_available = self._is_newer_version(normalized_latest)
134
+ info = fetch_latest_release(self._github_repo)
135
+ if info.get("error"):
136
+ self.logger.debug("Failed to fetch latest release information: %s", info["error"])
137
+ return {"error": info["error"]}
138
+ latest_display = info.get("raw_tag") or info.get("normalized")
181
139
  return {
182
140
  "latest_version": latest_display,
183
- "update_available": update_available,
184
- "changelog": changelog,
185
- "changelog_url": changelog_url,
141
+ "update_available": bool(info.get("update_available")),
142
+ "changelog": info.get("changelog") or "",
143
+ "changelog_url": info.get("changelog_url"),
186
144
  "error": None,
187
145
  }
188
146
 
@@ -251,7 +209,14 @@ class WebUI:
251
209
  except AttributeError:
252
210
  from qBitrr.auto_update import perform_self_update
253
211
 
254
- perform_self_update(self.manager.logger)
212
+ if not perform_self_update(self.manager.logger):
213
+ raise RuntimeError("pip upgrade did not complete successfully")
214
+ try:
215
+ self.manager.request_restart()
216
+ except Exception:
217
+ self.logger.warning(
218
+ "Update applied but restart request failed; exiting manually."
219
+ )
255
220
  except Exception as exc:
256
221
  result = "error"
257
222
  error_message = str(exc)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qBitrr2
3
- Version: 5.0.2
3
+ Version: 5.1.1
4
4
  Summary: "A simple Python script to talk to qBittorrent and Arr's"
5
5
  Home-page: https://github.com/Feramance/qBitrr
6
6
  Author: Feramance
@@ -138,13 +138,16 @@ Minimal setup:
138
138
  docker run -d \
139
139
  --name qbitrr \
140
140
  -e TZ=Europe/London \
141
+ -p 6969:6969 \
141
142
  -v /etc/localtime:/etc/localtime:ro \
142
143
  -v /path/to/appdata/qbitrr:/config \
143
144
  -v /path/to/completed/downloads:/completed_downloads:rw \
144
145
  --restart unless-stopped \
145
- feramance/qbitrr:latest
146
+ feramance/qbitrr:latest
146
147
  ```
147
148
 
149
+ The container automatically binds its WebUI to `0.0.0.0`; exposing `6969` makes the dashboard reachable at `http://<host>:6969/ui`.
150
+
148
151
  Compose example with a little more structure:
149
152
  ```yaml
150
153
  services:
@@ -0,0 +1,24 @@
1
+ qBitrr/__init__.py,sha256=smiPIV7d2lMJ_KTtFdAVlxLEBobFTheILdgry1iqpjQ,405
2
+ qBitrr/arss.py,sha256=WwDobdQcGcHSvQdh9tiDaza0S8wnPgOMtiRLpmd3Ans,263064
3
+ qBitrr/auto_update.py,sha256=hVAvAlKEdOHm6AJLlKvtkklbQhjotVcFOCH-MTigHQM,4419
4
+ qBitrr/bundled_data.py,sha256=qkprw9cE-4KVdWUx1-7xn2heRUSPOIMW9LZd04dOzuo,190
5
+ qBitrr/config.py,sha256=brGy1PQJY6D0HG1V6gpuTi1gPbMH3zIvfozASkvPZR8,6177
6
+ qBitrr/db_lock.py,sha256=SRCDIoqg-AFLU-VDChAmGdfx8nhgLGETn6XKF3RdJT4,2449
7
+ qBitrr/env_config.py,sha256=299u_uEoyxlM_ceTD0Z_i41JdYjSHmqO6FKe7qGFgTM,2866
8
+ qBitrr/errors.py,sha256=5_n1x0XX4UvMlieC_J1Hc5pq5JD17orfjJy9KfxDXA4,1107
9
+ qBitrr/ffprobe.py,sha256=2IM0iuPPTEb0xHmN1OetQoBd80-Nmv5Oq7P6o-mjBd0,4019
10
+ qBitrr/gen_config.py,sha256=lDRbCzjWoJuUyOZNnOmNjChuZoR5K6fuwKCJ2qxzu78,29862
11
+ qBitrr/home_path.py,sha256=zvBheAR2xvr8LBZRk1FyqfnALE-eFzsY9CyqyZDjxiE,626
12
+ qBitrr/logger.py,sha256=os7cHbJ3sbkxDh6Nno9o_41aCwsLp-Y963nZe-rglKA,5505
13
+ qBitrr/main.py,sha256=x1jzrOBX3PziARnRY5UaSgrRmbCGwG6s2AnoUI6M-Zk,19003
14
+ qBitrr/search_activity_store.py,sha256=_7MD7fFna4uTSo_pRT7DqoytSVz7tPoU9D2AV2mn-oc,2474
15
+ qBitrr/tables.py,sha256=si_EpQXj6OOF78rgJGDMeTEnT2zpvfnR3NGPaVZHUXc,2479
16
+ qBitrr/utils.py,sha256=DEnkQrbXFPWunhzId0OE6_oWuUTd5V4aDCZ2yHdrvo0,7306
17
+ qBitrr/versioning.py,sha256=k3n8cOh1E5mevN8OkYWOA3110PuOajMOpGyCKy3rFEc,2279
18
+ qBitrr/webui.py,sha256=HaM3w-rzuvVyGtphRCROY2GDXZtRmny3blkC5WoTOSk,68298
19
+ qbitrr2-5.1.1.dist-info/licenses/LICENSE,sha256=P978aVGi7dPbKz8lfvdiryOS5IjTAU7AA47XhBhVBlI,1066
20
+ qbitrr2-5.1.1.dist-info/METADATA,sha256=DFS1E6dKhTG132BhkflK2scrC_kGR5XeonsNOsX0Nk4,10122
21
+ qbitrr2-5.1.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
22
+ qbitrr2-5.1.1.dist-info/entry_points.txt,sha256=MIR-l5s31VBs9qlv3HiAaMdpOOyy0MNGfM7Ib1-fKeQ,43
23
+ qbitrr2-5.1.1.dist-info/top_level.txt,sha256=jIINodarzsPcQeTf-vvK8-_g7cQ8CvxEg41ms14K97g,7
24
+ qbitrr2-5.1.1.dist-info/RECORD,,
@@ -1,22 +0,0 @@
1
- qBitrr/__init__.py,sha256=smiPIV7d2lMJ_KTtFdAVlxLEBobFTheILdgry1iqpjQ,405
2
- qBitrr/arss.py,sha256=jJgCKobZT_y5HnakpR5uj3ul-0CUhiL4uLhB8HkaGSo,263449
3
- qBitrr/auto_update.py,sha256=faHYeJ9GsSmz5XfOt6Uq3fZOeEmqZ20MMUotKtq9M7s,4256
4
- qBitrr/bundled_data.py,sha256=Y0Qq5-HofqPBmU3sL9_Ihp7t0k-Yl-RiwYYUz4tQJdc,190
5
- qBitrr/config.py,sha256=brGy1PQJY6D0HG1V6gpuTi1gPbMH3zIvfozASkvPZR8,6177
6
- qBitrr/env_config.py,sha256=299u_uEoyxlM_ceTD0Z_i41JdYjSHmqO6FKe7qGFgTM,2866
7
- qBitrr/errors.py,sha256=5_n1x0XX4UvMlieC_J1Hc5pq5JD17orfjJy9KfxDXA4,1107
8
- qBitrr/ffprobe.py,sha256=2IM0iuPPTEb0xHmN1OetQoBd80-Nmv5Oq7P6o-mjBd0,4019
9
- qBitrr/gen_config.py,sha256=ub5sZAPj7uh4yxR-RIV-LhqrWS66uH9kitybXLAk1e4,29866
10
- qBitrr/home_path.py,sha256=zvBheAR2xvr8LBZRk1FyqfnALE-eFzsY9CyqyZDjxiE,626
11
- qBitrr/logger.py,sha256=lp9aPXtdcSVrSv7SQX_Nokq1nzQdHHkmZJ23RofWnos,5476
12
- qBitrr/main.py,sha256=mz5c-m7ZlnTkSrIS_eTJdWUh2ANioH4LJ4AkAHoB_FA,17208
13
- qBitrr/search_activity_store.py,sha256=_7MD7fFna4uTSo_pRT7DqoytSVz7tPoU9D2AV2mn-oc,2474
14
- qBitrr/tables.py,sha256=si_EpQXj6OOF78rgJGDMeTEnT2zpvfnR3NGPaVZHUXc,2479
15
- qBitrr/utils.py,sha256=DEnkQrbXFPWunhzId0OE6_oWuUTd5V4aDCZ2yHdrvo0,7306
16
- qBitrr/webui.py,sha256=YhEhEm0os1UE-kVHxtEZN4ZV06hSeE5D0HPMIV4pPjs,69493
17
- qbitrr2-5.0.2.dist-info/licenses/LICENSE,sha256=P978aVGi7dPbKz8lfvdiryOS5IjTAU7AA47XhBhVBlI,1066
18
- qbitrr2-5.0.2.dist-info/METADATA,sha256=lQTz_WgcB9k4ifTv9Vi5rIwL57fESc0HpzyR3isNjV4,9974
19
- qbitrr2-5.0.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
20
- qbitrr2-5.0.2.dist-info/entry_points.txt,sha256=MIR-l5s31VBs9qlv3HiAaMdpOOyy0MNGfM7Ib1-fKeQ,43
21
- qbitrr2-5.0.2.dist-info/top_level.txt,sha256=jIINodarzsPcQeTf-vvK8-_g7cQ8CvxEg41ms14K97g,7
22
- qbitrr2-5.0.2.dist-info/RECORD,,