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 +48 -42
- qBitrr/auto_update.py +9 -3
- qBitrr/bundled_data.py +2 -2
- qBitrr/db_lock.py +79 -0
- qBitrr/gen_config.py +2 -2
- qBitrr/logger.py +1 -0
- qBitrr/main.py +60 -17
- qBitrr/versioning.py +70 -0
- qBitrr/webui.py +18 -53
- {qbitrr2-5.0.2.dist-info → qbitrr2-5.1.1.dist-info}/METADATA +5 -2
- qbitrr2-5.1.1.dist-info/RECORD +24 -0
- qbitrr2-5.0.2.dist-info/RECORD +0 -22
- {qbitrr2-5.0.2.dist-info → qbitrr2-5.1.1.dist-info}/WHEEL +0 -0
- {qbitrr2-5.0.2.dist-info → qbitrr2-5.1.1.dist-info}/entry_points.txt +0 -0
- {qbitrr2-5.0.2.dist-info → qbitrr2-5.1.1.dist-info}/licenses/LICENSE +0 -0
- {qbitrr2-5.0.2.dist-info → qbitrr2-5.1.1.dist-info}/top_level.txt +0 -0
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:
|
|
570
|
-
self.series_file_model:
|
|
571
|
-
self.model_queue:
|
|
572
|
-
self.persistent_queue:
|
|
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": -
|
|
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": -
|
|
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": -
|
|
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
|
|
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
|
-
#
|
|
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) ->
|
|
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
|
-
|
|
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
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
|
|
162
|
+
"WebUI listen host (default 0.0.0.0)",
|
|
163
163
|
"WebUIHost",
|
|
164
|
-
"
|
|
164
|
+
"0.0.0.0",
|
|
165
165
|
)
|
|
166
166
|
_gen_default_line(
|
|
167
167
|
settings,
|
qBitrr/logger.py
CHANGED
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
|
|
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("
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
"Auto update
|
|
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 = "
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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.
|
|
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
|
-
|
|
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,,
|
qbitrr2-5.0.2.dist-info/RECORD
DELETED
|
@@ -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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|