qBitrr2 5.1.0__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 +175 -34
- qBitrr/bundled_data.py +2 -2
- qBitrr/main.py +0 -12
- qBitrr/search_activity_store.py +44 -17
- qBitrr/tables.py +8 -325
- {qbitrr2-5.1.0.dist-info → qbitrr2-5.1.1.dist-info}/METADATA +1 -1
- {qbitrr2-5.1.0.dist-info → qbitrr2-5.1.1.dist-info}/RECORD +11 -11
- {qbitrr2-5.1.0.dist-info → qbitrr2-5.1.1.dist-info}/WHEEL +0 -0
- {qbitrr2-5.1.0.dist-info → qbitrr2-5.1.1.dist-info}/entry_points.txt +0 -0
- {qbitrr2-5.1.0.dist-info → qbitrr2-5.1.1.dist-info}/licenses/LICENSE +0 -0
- {qbitrr2-5.1.0.dist-info → qbitrr2-5.1.1.dist-info}/top_level.txt +0 -0
qBitrr/arss.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import atexit
|
|
3
4
|
import contextlib
|
|
4
5
|
import itertools
|
|
5
6
|
import logging
|
|
@@ -19,7 +20,7 @@ import qbittorrentapi
|
|
|
19
20
|
import qbittorrentapi.exceptions
|
|
20
21
|
import requests
|
|
21
22
|
from packaging import version as version_parser
|
|
22
|
-
from peewee import Model
|
|
23
|
+
from peewee import Model, SqliteDatabase
|
|
23
24
|
from pyarr import RadarrAPI, SonarrAPI
|
|
24
25
|
from pyarr.exceptions import PyarrResourceNotFound, PyarrServerError
|
|
25
26
|
from pyarr.types import JsonObject
|
|
@@ -27,6 +28,7 @@ from qbittorrentapi import TorrentDictionary, TorrentStates
|
|
|
27
28
|
from ujson import JSONDecodeError
|
|
28
29
|
|
|
29
30
|
from qBitrr.config import (
|
|
31
|
+
APPDATA_FOLDER,
|
|
30
32
|
AUTO_PAUSE_RESUME,
|
|
31
33
|
COMPLETED_DOWNLOAD_FOLDER,
|
|
32
34
|
CONFIG,
|
|
@@ -55,7 +57,15 @@ from qBitrr.search_activity_store import (
|
|
|
55
57
|
fetch_search_activities,
|
|
56
58
|
record_search_activity,
|
|
57
59
|
)
|
|
58
|
-
from qBitrr.tables import
|
|
60
|
+
from qBitrr.tables import (
|
|
61
|
+
EpisodeFilesModel,
|
|
62
|
+
EpisodeQueueModel,
|
|
63
|
+
FilesQueued,
|
|
64
|
+
MovieQueueModel,
|
|
65
|
+
MoviesFilesModel,
|
|
66
|
+
SeriesFilesModel,
|
|
67
|
+
TorrentLibrary,
|
|
68
|
+
)
|
|
59
69
|
from qBitrr.utils import (
|
|
60
70
|
ExpiringSet,
|
|
61
71
|
absolute_file_paths,
|
|
@@ -293,6 +303,9 @@ class Arr:
|
|
|
293
303
|
else:
|
|
294
304
|
self._delta = -1
|
|
295
305
|
|
|
306
|
+
self._app_data_folder = APPDATA_FOLDER
|
|
307
|
+
self.search_db_file = self._app_data_folder.joinpath(f"{self._name}.db")
|
|
308
|
+
|
|
296
309
|
self.ombi_search_requests = CONFIG.get(
|
|
297
310
|
f"{name}.EntrySearch.Ombi.SearchOmbiRequests", fallback=False
|
|
298
311
|
)
|
|
@@ -559,9 +572,24 @@ class Arr:
|
|
|
559
572
|
self.model_queue: Model | None = None
|
|
560
573
|
self.persistent_queue: Model | None = None
|
|
561
574
|
self.torrents: TorrentLibrary | None = None
|
|
575
|
+
self.torrent_db: SqliteDatabase | None = None
|
|
576
|
+
self.db: SqliteDatabase | None = None
|
|
562
577
|
# Initialize search mode (and torrent tag-emulation DB in TAGLESS)
|
|
563
578
|
# early and fail fast if it cannot be set up.
|
|
564
579
|
self.register_search_mode()
|
|
580
|
+
atexit.register(
|
|
581
|
+
lambda: (
|
|
582
|
+
hasattr(self, "db") and self.db and not self.db.is_closed() and self.db.close()
|
|
583
|
+
)
|
|
584
|
+
)
|
|
585
|
+
atexit.register(
|
|
586
|
+
lambda: (
|
|
587
|
+
hasattr(self, "torrent_db")
|
|
588
|
+
and self.torrent_db
|
|
589
|
+
and not self.torrent_db.is_closed()
|
|
590
|
+
and self.torrent_db.close()
|
|
591
|
+
)
|
|
592
|
+
)
|
|
565
593
|
self.logger.hnotice("Starting %s monitor", self._name)
|
|
566
594
|
|
|
567
595
|
@staticmethod
|
|
@@ -4795,46 +4823,139 @@ class Arr:
|
|
|
4795
4823
|
if self.search_setup_completed:
|
|
4796
4824
|
return
|
|
4797
4825
|
|
|
4798
|
-
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4826
|
+
db1, db2, db3, db4 = self._get_models()
|
|
4827
|
+
|
|
4828
|
+
if not (
|
|
4829
|
+
self.search_missing
|
|
4830
|
+
or self.do_upgrade_search
|
|
4831
|
+
or self.quality_unmet_search
|
|
4832
|
+
or self.custom_format_unmet_search
|
|
4833
|
+
or self.ombi_search_requests
|
|
4834
|
+
or self.overseerr_requests
|
|
4835
|
+
):
|
|
4836
|
+
if db4 and getattr(self, "torrents", None) is None:
|
|
4837
|
+
self.torrent_db = SqliteDatabase(None)
|
|
4838
|
+
self.torrent_db.init(
|
|
4839
|
+
str(self._app_data_folder.joinpath("Torrents.db")),
|
|
4840
|
+
pragmas={
|
|
4841
|
+
"journal_mode": "wal",
|
|
4842
|
+
"cache_size": -64_000,
|
|
4843
|
+
"foreign_keys": 1,
|
|
4844
|
+
"ignore_check_constraints": 0,
|
|
4845
|
+
"synchronous": 0,
|
|
4846
|
+
},
|
|
4847
|
+
timeout=15,
|
|
4848
|
+
)
|
|
4849
|
+
|
|
4850
|
+
class Torrents(db4):
|
|
4851
|
+
class Meta:
|
|
4852
|
+
database = self.torrent_db
|
|
4853
|
+
|
|
4854
|
+
self.torrent_db.connect()
|
|
4855
|
+
self.torrent_db.create_tables([Torrents])
|
|
4856
|
+
self.torrents = Torrents
|
|
4857
|
+
self.search_setup_completed = True
|
|
4858
|
+
return
|
|
4859
|
+
|
|
4860
|
+
self.search_db_file.parent.mkdir(parents=True, exist_ok=True)
|
|
4861
|
+
self.db = SqliteDatabase(None)
|
|
4862
|
+
self.db.init(
|
|
4863
|
+
str(self.search_db_file),
|
|
4864
|
+
pragmas={
|
|
4865
|
+
"journal_mode": "wal",
|
|
4866
|
+
"cache_size": -64_000,
|
|
4867
|
+
"foreign_keys": 1,
|
|
4868
|
+
"ignore_check_constraints": 0,
|
|
4869
|
+
"synchronous": 0,
|
|
4870
|
+
},
|
|
4871
|
+
timeout=15,
|
|
4807
4872
|
)
|
|
4808
|
-
include_series = self.type == "sonarr" and self.series_search
|
|
4809
|
-
include_torrents = TAGLESS
|
|
4810
4873
|
|
|
4811
|
-
|
|
4874
|
+
class Files(db1):
|
|
4875
|
+
class Meta:
|
|
4876
|
+
database = self.db
|
|
4812
4877
|
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
self.
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
|
|
4878
|
+
class Queue(db2):
|
|
4879
|
+
class Meta:
|
|
4880
|
+
database = self.db
|
|
4881
|
+
|
|
4882
|
+
class PersistingQueue(FilesQueued):
|
|
4883
|
+
class Meta:
|
|
4884
|
+
database = self.db
|
|
4885
|
+
|
|
4886
|
+
self.db.connect()
|
|
4887
|
+
if db3:
|
|
4888
|
+
|
|
4889
|
+
class Series(db3):
|
|
4890
|
+
class Meta:
|
|
4891
|
+
database = self.db
|
|
4892
|
+
|
|
4893
|
+
self.db.create_tables([Files, Queue, PersistingQueue, Series])
|
|
4894
|
+
self.series_file_model = Series
|
|
4825
4895
|
else:
|
|
4826
|
-
self.
|
|
4827
|
-
self.model_queue = None
|
|
4828
|
-
self.persistent_queue = None
|
|
4896
|
+
self.db.create_tables([Files, Queue, PersistingQueue])
|
|
4829
4897
|
self.series_file_model = None
|
|
4830
|
-
if include_torrents:
|
|
4831
|
-
ensure_table_schema(TorrentLibrary)
|
|
4832
|
-
self.torrents = TorrentLibrary
|
|
4833
|
-
else:
|
|
4834
|
-
self.torrents = None
|
|
4835
4898
|
|
|
4899
|
+
if db4:
|
|
4900
|
+
self.torrent_db = SqliteDatabase(None)
|
|
4901
|
+
self.torrent_db.init(
|
|
4902
|
+
str(self._app_data_folder.joinpath("Torrents.db")),
|
|
4903
|
+
pragmas={
|
|
4904
|
+
"journal_mode": "wal",
|
|
4905
|
+
"cache_size": -64_000,
|
|
4906
|
+
"foreign_keys": 1,
|
|
4907
|
+
"ignore_check_constraints": 0,
|
|
4908
|
+
"synchronous": 0,
|
|
4909
|
+
},
|
|
4910
|
+
timeout=15,
|
|
4911
|
+
)
|
|
4912
|
+
|
|
4913
|
+
class Torrents(db4):
|
|
4914
|
+
class Meta:
|
|
4915
|
+
database = self.torrent_db
|
|
4916
|
+
|
|
4917
|
+
self.torrent_db.connect()
|
|
4918
|
+
self.torrent_db.create_tables([Torrents])
|
|
4919
|
+
self.torrents = Torrents
|
|
4920
|
+
else:
|
|
4921
|
+
self.torrents = None
|
|
4922
|
+
|
|
4923
|
+
self.model_file = Files
|
|
4924
|
+
self.model_queue = Queue
|
|
4925
|
+
self.persistent_queue = PersistingQueue
|
|
4836
4926
|
self.search_setup_completed = True
|
|
4837
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
|
+
|
|
4838
4959
|
def run_request_search(self):
|
|
4839
4960
|
if (
|
|
4840
4961
|
(
|
|
@@ -5471,6 +5592,7 @@ class FreeSpaceManager(Arr):
|
|
|
5471
5592
|
)
|
|
5472
5593
|
self.timed_ignore_cache = ExpiringSet(max_age_seconds=self.ignore_torrents_younger_than)
|
|
5473
5594
|
self.needs_cleanup = False
|
|
5595
|
+
self._app_data_folder = APPDATA_FOLDER
|
|
5474
5596
|
# Track search setup state to cooperate with Arr.register_search_mode
|
|
5475
5597
|
self.search_setup_completed = False
|
|
5476
5598
|
if FREE_SPACE_FOLDER == "CHANGE_ME":
|
|
@@ -5498,6 +5620,7 @@ class FreeSpaceManager(Arr):
|
|
|
5498
5620
|
self.session = None
|
|
5499
5621
|
# Ensure torrent tag-emulation tables exist when needed.
|
|
5500
5622
|
self.torrents = None
|
|
5623
|
+
self.torrent_db: SqliteDatabase | None = None
|
|
5501
5624
|
self.last_search_description: str | None = None
|
|
5502
5625
|
self.last_search_timestamp: str | None = None
|
|
5503
5626
|
self.queue_active_count: int = 0
|
|
@@ -5505,6 +5628,24 @@ class FreeSpaceManager(Arr):
|
|
|
5505
5628
|
self.free_space_tagged_count: int = 0
|
|
5506
5629
|
self.register_search_mode()
|
|
5507
5630
|
self.logger.hnotice("Starting %s monitor", self._name)
|
|
5631
|
+
atexit.register(
|
|
5632
|
+
lambda: (
|
|
5633
|
+
hasattr(self, "torrent_db")
|
|
5634
|
+
and self.torrent_db
|
|
5635
|
+
and not self.torrent_db.is_closed()
|
|
5636
|
+
and self.torrent_db.close()
|
|
5637
|
+
)
|
|
5638
|
+
)
|
|
5639
|
+
|
|
5640
|
+
def _get_models(
|
|
5641
|
+
self,
|
|
5642
|
+
) -> tuple[
|
|
5643
|
+
None,
|
|
5644
|
+
None,
|
|
5645
|
+
None,
|
|
5646
|
+
type[TorrentLibrary] | None,
|
|
5647
|
+
]:
|
|
5648
|
+
return None, None, None, (TorrentLibrary if TAGLESS else None)
|
|
5508
5649
|
|
|
5509
5650
|
def _process_single_torrent_pause_disk_space(self, torrent: qbittorrentapi.TorrentDictionary):
|
|
5510
5651
|
self.logger.info(
|
qBitrr/bundled_data.py
CHANGED
qBitrr/main.py
CHANGED
|
@@ -32,7 +32,6 @@ 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.tables import ensure_core_tables, get_database, purge_database_files
|
|
36
35
|
from qBitrr.utils import ExpiringSet
|
|
37
36
|
from qBitrr.versioning import fetch_latest_release
|
|
38
37
|
from qBitrr.webui import WebUI
|
|
@@ -490,17 +489,6 @@ def run():
|
|
|
490
489
|
child.kill()
|
|
491
490
|
|
|
492
491
|
|
|
493
|
-
def initialize_database() -> None:
|
|
494
|
-
try:
|
|
495
|
-
purge_database_files()
|
|
496
|
-
get_database()
|
|
497
|
-
ensure_core_tables()
|
|
498
|
-
except Exception:
|
|
499
|
-
logger.exception("Failed to initialize database schema")
|
|
500
|
-
raise
|
|
501
|
-
|
|
502
|
-
|
|
503
492
|
if __name__ == "__main__":
|
|
504
493
|
freeze_support()
|
|
505
|
-
initialize_database()
|
|
506
494
|
run()
|
qBitrr/search_activity_store.py
CHANGED
|
@@ -3,34 +3,60 @@ from __future__ import annotations
|
|
|
3
3
|
from threading import RLock
|
|
4
4
|
from typing import Any
|
|
5
5
|
|
|
6
|
-
from peewee import SqliteDatabase
|
|
6
|
+
from peewee import Model, SqliteDatabase, TextField
|
|
7
7
|
|
|
8
|
-
from qBitrr.
|
|
8
|
+
from qBitrr.home_path import APPDATA_FOLDER
|
|
9
9
|
|
|
10
10
|
_DB_LOCK = RLock()
|
|
11
|
-
|
|
11
|
+
_DB_INSTANCE: SqliteDatabase | None = None
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
def
|
|
15
|
-
global
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
def _get_database() -> SqliteDatabase:
|
|
15
|
+
global _DB_INSTANCE
|
|
16
|
+
if _DB_INSTANCE is None:
|
|
17
|
+
path = APPDATA_FOLDER.joinpath("webui_activity.db")
|
|
18
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
_DB_INSTANCE = SqliteDatabase(
|
|
20
|
+
str(path),
|
|
21
|
+
pragmas={
|
|
22
|
+
"journal_mode": "wal",
|
|
23
|
+
"cache_size": -64_000,
|
|
24
|
+
"foreign_keys": 1,
|
|
25
|
+
"ignore_check_constraints": 0,
|
|
26
|
+
"synchronous": 0,
|
|
27
|
+
},
|
|
28
|
+
timeout=15,
|
|
29
|
+
check_same_thread=False,
|
|
30
|
+
)
|
|
31
|
+
return _DB_INSTANCE
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BaseModel(Model):
|
|
35
|
+
class Meta:
|
|
36
|
+
database = _get_database()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SearchActivity(BaseModel):
|
|
40
|
+
category = TextField(primary_key=True)
|
|
41
|
+
summary = TextField(null=True)
|
|
42
|
+
timestamp = TextField(null=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ensure_tables() -> None:
|
|
46
|
+
db = _get_database()
|
|
19
47
|
with _DB_LOCK:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
_TABLE_READY = True
|
|
23
|
-
return db
|
|
48
|
+
db.connect(reuse_if_open=True)
|
|
49
|
+
db.create_tables([SearchActivity], safe=True)
|
|
24
50
|
|
|
25
51
|
|
|
26
52
|
def record_search_activity(category: str, summary: str | None, timestamp: str | None) -> None:
|
|
27
53
|
if not category:
|
|
28
54
|
return
|
|
29
|
-
|
|
55
|
+
_ensure_tables()
|
|
30
56
|
if timestamp is not None and not isinstance(timestamp, str):
|
|
31
57
|
timestamp = str(timestamp)
|
|
32
58
|
data: dict[str, Any] = {"summary": summary, "timestamp": timestamp}
|
|
33
|
-
with
|
|
59
|
+
with _get_database().atomic():
|
|
34
60
|
SearchActivity.insert(category=category, **data).on_conflict(
|
|
35
61
|
conflict_target=[SearchActivity.category],
|
|
36
62
|
update=data,
|
|
@@ -38,8 +64,9 @@ def record_search_activity(category: str, summary: str | None, timestamp: str |
|
|
|
38
64
|
|
|
39
65
|
|
|
40
66
|
def fetch_search_activities() -> dict[str, dict[str, str | None]]:
|
|
41
|
-
|
|
67
|
+
_ensure_tables()
|
|
42
68
|
activities: dict[str, dict[str, str | None]] = {}
|
|
69
|
+
db = _get_database()
|
|
43
70
|
db.connect(reuse_if_open=True)
|
|
44
71
|
try:
|
|
45
72
|
query = SearchActivity.select()
|
|
@@ -56,6 +83,6 @@ def fetch_search_activities() -> dict[str, dict[str, str | None]]:
|
|
|
56
83
|
def clear_search_activity(category: str) -> None:
|
|
57
84
|
if not category:
|
|
58
85
|
return
|
|
59
|
-
|
|
60
|
-
with
|
|
86
|
+
_ensure_tables()
|
|
87
|
+
with _get_database().atomic():
|
|
61
88
|
SearchActivity.delete().where(SearchActivity.category == category).execute()
|
qBitrr/tables.py
CHANGED
|
@@ -1,229 +1,11 @@
|
|
|
1
|
-
from
|
|
1
|
+
from peewee import BooleanField, CharField, DateTimeField, IntegerField, Model, TextField
|
|
2
2
|
|
|
3
|
-
import logging
|
|
4
|
-
import re
|
|
5
|
-
from functools import lru_cache
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from typing import NamedTuple
|
|
8
3
|
|
|
9
|
-
|
|
10
|
-
BooleanField,
|
|
11
|
-
CharField,
|
|
12
|
-
DatabaseError,
|
|
13
|
-
DatabaseProxy,
|
|
14
|
-
DateTimeField,
|
|
15
|
-
IntegerField,
|
|
16
|
-
Model,
|
|
17
|
-
OperationalError,
|
|
18
|
-
SqliteDatabase,
|
|
19
|
-
TextField,
|
|
20
|
-
)
|
|
21
|
-
|
|
22
|
-
from qBitrr.db_lock import database_lock
|
|
23
|
-
from qBitrr.home_path import APPDATA_FOLDER
|
|
24
|
-
|
|
25
|
-
logger = logging.getLogger("qBitrr.Database")
|
|
26
|
-
|
|
27
|
-
DATABASE_FILE = APPDATA_FOLDER.joinpath("qbitrr.db")
|
|
28
|
-
_database_proxy: DatabaseProxy = DatabaseProxy()
|
|
29
|
-
_DATABASE: SqliteDatabase | None = None
|
|
30
|
-
_DB_ARTIFACT_SUFFIXES: tuple[str, ...] = ("", "-wal", "-shm")
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
class LockedSqliteDatabase(SqliteDatabase):
|
|
34
|
-
def connect(self, **kwargs):
|
|
35
|
-
with database_lock():
|
|
36
|
-
return super().connect(**kwargs)
|
|
37
|
-
|
|
38
|
-
def close(self):
|
|
39
|
-
with database_lock():
|
|
40
|
-
return super().close()
|
|
41
|
-
|
|
42
|
-
def execute_sql(self, *args, **kwargs):
|
|
43
|
-
with database_lock():
|
|
44
|
-
return super().execute_sql(*args, **kwargs)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def _database_artifact_paths() -> tuple[Path, ...]:
|
|
48
|
-
return tuple(
|
|
49
|
-
DATABASE_FILE if suffix == "" else DATABASE_FILE.with_name(f"{DATABASE_FILE.name}{suffix}")
|
|
50
|
-
for suffix in _DB_ARTIFACT_SUFFIXES
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def purge_database_files() -> list[Path]:
|
|
55
|
-
removed: list[Path] = []
|
|
56
|
-
with database_lock():
|
|
57
|
-
for candidate in _database_artifact_paths():
|
|
58
|
-
try:
|
|
59
|
-
candidate.unlink()
|
|
60
|
-
removed.append(candidate)
|
|
61
|
-
except FileNotFoundError:
|
|
62
|
-
continue
|
|
63
|
-
except OSError as exc:
|
|
64
|
-
logger.warning("Unable to remove database artifact '%s': %s", candidate, exc)
|
|
65
|
-
if removed:
|
|
66
|
-
logger.info(
|
|
67
|
-
"Removed database artifacts: %s",
|
|
68
|
-
", ".join(str(path) for path in removed),
|
|
69
|
-
)
|
|
70
|
-
return removed
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def _reset_database(exc: BaseException) -> None:
|
|
74
|
-
global _DATABASE
|
|
75
|
-
logger.warning("Database reset triggered after failure: %s", exc)
|
|
76
|
-
with database_lock():
|
|
77
|
-
try:
|
|
78
|
-
if _DATABASE is not None and not _DATABASE.is_closed():
|
|
79
|
-
_DATABASE.close()
|
|
80
|
-
except Exception as close_error: # pragma: no cover - best effort cleanup
|
|
81
|
-
logger.debug("Error closing database while resetting: %s", close_error)
|
|
82
|
-
_DATABASE = None
|
|
83
|
-
purge_database_files()
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
class BaseModel(Model):
|
|
87
|
-
class Meta:
|
|
88
|
-
database = _database_proxy
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def get_database(*, _retry: bool = True) -> SqliteDatabase:
|
|
92
|
-
global _DATABASE
|
|
93
|
-
if _DATABASE is None:
|
|
94
|
-
DATABASE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
-
_DATABASE = LockedSqliteDatabase(
|
|
96
|
-
str(DATABASE_FILE),
|
|
97
|
-
pragmas={
|
|
98
|
-
"journal_mode": "wal",
|
|
99
|
-
"cache_size": -64_000,
|
|
100
|
-
"foreign_keys": 1,
|
|
101
|
-
"ignore_check_constraints": 0,
|
|
102
|
-
"synchronous": "NORMAL",
|
|
103
|
-
"busy_timeout": 60_000,
|
|
104
|
-
},
|
|
105
|
-
timeout=15,
|
|
106
|
-
check_same_thread=False,
|
|
107
|
-
autocommit=True,
|
|
108
|
-
)
|
|
109
|
-
_database_proxy.initialize(_DATABASE)
|
|
110
|
-
try:
|
|
111
|
-
_DATABASE.connect(reuse_if_open=True)
|
|
112
|
-
except DatabaseError as exc:
|
|
113
|
-
if not _retry:
|
|
114
|
-
raise
|
|
115
|
-
_reset_database(exc)
|
|
116
|
-
return get_database(_retry=False)
|
|
117
|
-
return _DATABASE
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
def ensure_table_schema(model: type[BaseModel]) -> None:
|
|
121
|
-
database = get_database()
|
|
122
|
-
table_name = model._meta.table_name
|
|
123
|
-
with database:
|
|
124
|
-
database.create_tables([model], safe=True)
|
|
125
|
-
existing_columns = {column.name for column in database.get_columns(table_name)}
|
|
126
|
-
try:
|
|
127
|
-
primary_keys = {column.lower() for column in database.get_primary_keys(table_name)}
|
|
128
|
-
except OperationalError:
|
|
129
|
-
primary_keys = set()
|
|
130
|
-
try:
|
|
131
|
-
index_metadata = database.get_indexes(table_name)
|
|
132
|
-
except OperationalError:
|
|
133
|
-
index_metadata = []
|
|
134
|
-
|
|
135
|
-
def _refresh_indexes() -> None:
|
|
136
|
-
nonlocal index_metadata
|
|
137
|
-
try:
|
|
138
|
-
index_metadata = database.get_indexes(table_name)
|
|
139
|
-
except OperationalError:
|
|
140
|
-
index_metadata = []
|
|
141
|
-
|
|
142
|
-
def _has_unique(column: str) -> bool:
|
|
143
|
-
lower_column = column.lower()
|
|
144
|
-
for index in index_metadata:
|
|
145
|
-
if not index.unique:
|
|
146
|
-
continue
|
|
147
|
-
normalized = tuple(col.lower() for col in index.columns or ())
|
|
148
|
-
if normalized == (lower_column,):
|
|
149
|
-
return True
|
|
150
|
-
return False
|
|
151
|
-
|
|
152
|
-
def _deduplicate(column: str) -> None:
|
|
153
|
-
try:
|
|
154
|
-
duplicates = database.execute_sql(
|
|
155
|
-
f"""
|
|
156
|
-
SELECT {column}, MIN(rowid) AS keep_rowid
|
|
157
|
-
FROM {table_name}
|
|
158
|
-
WHERE {column} IS NOT NULL
|
|
159
|
-
GROUP BY {column}
|
|
160
|
-
HAVING COUNT(*) > 1
|
|
161
|
-
"""
|
|
162
|
-
).fetchall()
|
|
163
|
-
except OperationalError:
|
|
164
|
-
return
|
|
165
|
-
if not duplicates:
|
|
166
|
-
return
|
|
167
|
-
for value, keep_rowid in duplicates:
|
|
168
|
-
try:
|
|
169
|
-
database.execute_sql(
|
|
170
|
-
f"""
|
|
171
|
-
DELETE FROM {table_name}
|
|
172
|
-
WHERE {column} = ?
|
|
173
|
-
AND rowid != ?
|
|
174
|
-
""",
|
|
175
|
-
(value, keep_rowid),
|
|
176
|
-
)
|
|
177
|
-
except OperationalError:
|
|
178
|
-
logger.warning(
|
|
179
|
-
"Failed to deduplicate rows on %s.%s for value %s",
|
|
180
|
-
table_name,
|
|
181
|
-
column,
|
|
182
|
-
value,
|
|
183
|
-
)
|
|
184
|
-
if duplicates:
|
|
185
|
-
logger.info(
|
|
186
|
-
"Deduplicated %s entries on %s.%s to restore unique constraint",
|
|
187
|
-
len(duplicates),
|
|
188
|
-
table_name,
|
|
189
|
-
column,
|
|
190
|
-
)
|
|
191
|
-
|
|
192
|
-
def _ensure_unique(column: str) -> None:
|
|
193
|
-
if _has_unique(column):
|
|
194
|
-
return
|
|
195
|
-
_deduplicate(column)
|
|
196
|
-
try:
|
|
197
|
-
index_name = f"{table_name}_{column}_uniq".replace(".", "_")
|
|
198
|
-
database.execute_sql(
|
|
199
|
-
f'CREATE UNIQUE INDEX IF NOT EXISTS "{index_name}" '
|
|
200
|
-
f'ON "{table_name}" ("{column}")'
|
|
201
|
-
)
|
|
202
|
-
_refresh_indexes()
|
|
203
|
-
except OperationalError:
|
|
204
|
-
logger.warning(
|
|
205
|
-
"Unable to create unique index on %s.%s; uniqueness guarantees may be missing",
|
|
206
|
-
table_name,
|
|
207
|
-
column,
|
|
208
|
-
)
|
|
209
|
-
return
|
|
210
|
-
_refresh_indexes()
|
|
211
|
-
|
|
212
|
-
for field in model._meta.sorted_fields:
|
|
213
|
-
column_name = field.column_name
|
|
214
|
-
if column_name not in existing_columns:
|
|
215
|
-
database.add_column(table_name, column_name, field)
|
|
216
|
-
if field.primary_key and column_name.lower() not in primary_keys:
|
|
217
|
-
_ensure_unique(column_name)
|
|
218
|
-
elif field.unique:
|
|
219
|
-
_ensure_unique(column_name)
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
class FilesQueued(BaseModel):
|
|
4
|
+
class FilesQueued(Model):
|
|
223
5
|
EntryId = IntegerField(primary_key=True, null=False, unique=True)
|
|
224
6
|
|
|
225
7
|
|
|
226
|
-
class MoviesFilesModel(
|
|
8
|
+
class MoviesFilesModel(Model):
|
|
227
9
|
Title = CharField()
|
|
228
10
|
Monitored = BooleanField()
|
|
229
11
|
TmdbId = IntegerField()
|
|
@@ -240,7 +22,7 @@ class MoviesFilesModel(BaseModel):
|
|
|
240
22
|
Reason = TextField(null=True)
|
|
241
23
|
|
|
242
24
|
|
|
243
|
-
class EpisodeFilesModel(
|
|
25
|
+
class EpisodeFilesModel(Model):
|
|
244
26
|
EntryId = IntegerField(primary_key=True)
|
|
245
27
|
SeriesTitle = TextField(null=True)
|
|
246
28
|
Title = TextField(null=True)
|
|
@@ -262,7 +44,7 @@ class EpisodeFilesModel(BaseModel):
|
|
|
262
44
|
Reason = TextField(null=True)
|
|
263
45
|
|
|
264
46
|
|
|
265
|
-
class SeriesFilesModel(
|
|
47
|
+
class SeriesFilesModel(Model):
|
|
266
48
|
EntryId = IntegerField(primary_key=True)
|
|
267
49
|
Title = TextField(null=True)
|
|
268
50
|
Monitored = BooleanField(null=True)
|
|
@@ -271,119 +53,20 @@ class SeriesFilesModel(BaseModel):
|
|
|
271
53
|
MinCustomFormatScore = IntegerField(null=True)
|
|
272
54
|
|
|
273
55
|
|
|
274
|
-
class MovieQueueModel(
|
|
56
|
+
class MovieQueueModel(Model):
|
|
275
57
|
EntryId = IntegerField(unique=True)
|
|
276
58
|
Completed = BooleanField(default=False)
|
|
277
59
|
|
|
278
60
|
|
|
279
|
-
class EpisodeQueueModel(
|
|
61
|
+
class EpisodeQueueModel(Model):
|
|
280
62
|
EntryId = IntegerField(unique=True)
|
|
281
63
|
Completed = BooleanField(default=False)
|
|
282
64
|
|
|
283
65
|
|
|
284
|
-
class TorrentLibrary(
|
|
66
|
+
class TorrentLibrary(Model):
|
|
285
67
|
Hash = TextField(null=False)
|
|
286
68
|
Category = TextField(null=False)
|
|
287
69
|
AllowedSeeding = BooleanField(default=False)
|
|
288
70
|
Imported = BooleanField(default=False)
|
|
289
71
|
AllowedStalled = BooleanField(default=False)
|
|
290
72
|
FreeSpacePaused = BooleanField(default=False)
|
|
291
|
-
|
|
292
|
-
class Meta:
|
|
293
|
-
table_name = "torrent_library"
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
class SearchActivity(BaseModel):
|
|
297
|
-
category = TextField(primary_key=True)
|
|
298
|
-
summary = TextField(null=True)
|
|
299
|
-
timestamp = TextField(null=True)
|
|
300
|
-
|
|
301
|
-
class Meta:
|
|
302
|
-
table_name = "search_activity"
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
class ArrTables(NamedTuple):
|
|
306
|
-
files: type[BaseModel]
|
|
307
|
-
queue: type[BaseModel]
|
|
308
|
-
series: type[BaseModel] | None
|
|
309
|
-
persisting_queue: type[BaseModel]
|
|
310
|
-
torrents: type[BaseModel] | None
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
_SAFE_IDENTIFIER = re.compile(r"[^0-9A-Za-z_]+")
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
def _sanitize_identifier(name: str) -> str:
|
|
317
|
-
token = name.strip().replace(" ", "_")
|
|
318
|
-
token = _SAFE_IDENTIFIER.sub("_", token)
|
|
319
|
-
token = token.strip("_")
|
|
320
|
-
if not token:
|
|
321
|
-
token = "Arr"
|
|
322
|
-
if token[0].isdigit():
|
|
323
|
-
token = f"Arr_{token}"
|
|
324
|
-
return token
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
@lru_cache(maxsize=None)
|
|
328
|
-
def create_arr_tables(
|
|
329
|
-
arr_name: str,
|
|
330
|
-
arr_type: str,
|
|
331
|
-
*,
|
|
332
|
-
include_series: bool,
|
|
333
|
-
include_torrents: bool,
|
|
334
|
-
) -> ArrTables:
|
|
335
|
-
table_prefix = _sanitize_identifier(arr_name)
|
|
336
|
-
files_base: type[BaseModel]
|
|
337
|
-
queue_base: type[BaseModel]
|
|
338
|
-
if arr_type.lower() == "sonarr":
|
|
339
|
-
files_base = EpisodeFilesModel
|
|
340
|
-
queue_base = EpisodeQueueModel
|
|
341
|
-
elif arr_type.lower() == "radarr":
|
|
342
|
-
files_base = MoviesFilesModel
|
|
343
|
-
queue_base = MovieQueueModel
|
|
344
|
-
else:
|
|
345
|
-
raise ValueError(f"Unknown arr_type '{arr_type}'")
|
|
346
|
-
|
|
347
|
-
class Files(files_base):
|
|
348
|
-
class Meta:
|
|
349
|
-
table_name = f"{table_prefix}_files"
|
|
350
|
-
|
|
351
|
-
class Queue(queue_base):
|
|
352
|
-
class Meta:
|
|
353
|
-
table_name = f"{table_prefix}_queue"
|
|
354
|
-
|
|
355
|
-
class PersistingQueue(FilesQueued):
|
|
356
|
-
class Meta:
|
|
357
|
-
table_name = f"{table_prefix}_persisting_queue"
|
|
358
|
-
|
|
359
|
-
series_model: type[BaseModel] | None = None
|
|
360
|
-
if include_series:
|
|
361
|
-
|
|
362
|
-
class Series(SeriesFilesModel):
|
|
363
|
-
class Meta:
|
|
364
|
-
table_name = f"{table_prefix}_series"
|
|
365
|
-
|
|
366
|
-
series_model = Series
|
|
367
|
-
|
|
368
|
-
torrents_model: type[BaseModel] | None = TorrentLibrary if include_torrents else None
|
|
369
|
-
|
|
370
|
-
ensure_table_schema(Files)
|
|
371
|
-
ensure_table_schema(Queue)
|
|
372
|
-
ensure_table_schema(PersistingQueue)
|
|
373
|
-
if series_model is not None:
|
|
374
|
-
ensure_table_schema(series_model)
|
|
375
|
-
if torrents_model is not None:
|
|
376
|
-
ensure_table_schema(torrents_model)
|
|
377
|
-
|
|
378
|
-
return ArrTables(
|
|
379
|
-
files=Files,
|
|
380
|
-
queue=Queue,
|
|
381
|
-
series=series_model,
|
|
382
|
-
persisting_queue=PersistingQueue,
|
|
383
|
-
torrents=torrents_model,
|
|
384
|
-
)
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
def ensure_core_tables() -> None:
|
|
388
|
-
ensure_table_schema(TorrentLibrary)
|
|
389
|
-
ensure_table_schema(SearchActivity)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
qBitrr/__init__.py,sha256=smiPIV7d2lMJ_KTtFdAVlxLEBobFTheILdgry1iqpjQ,405
|
|
2
|
-
qBitrr/arss.py,sha256=
|
|
2
|
+
qBitrr/arss.py,sha256=WwDobdQcGcHSvQdh9tiDaza0S8wnPgOMtiRLpmd3Ans,263064
|
|
3
3
|
qBitrr/auto_update.py,sha256=hVAvAlKEdOHm6AJLlKvtkklbQhjotVcFOCH-MTigHQM,4419
|
|
4
|
-
qBitrr/bundled_data.py,sha256=
|
|
4
|
+
qBitrr/bundled_data.py,sha256=qkprw9cE-4KVdWUx1-7xn2heRUSPOIMW9LZd04dOzuo,190
|
|
5
5
|
qBitrr/config.py,sha256=brGy1PQJY6D0HG1V6gpuTi1gPbMH3zIvfozASkvPZR8,6177
|
|
6
6
|
qBitrr/db_lock.py,sha256=SRCDIoqg-AFLU-VDChAmGdfx8nhgLGETn6XKF3RdJT4,2449
|
|
7
7
|
qBitrr/env_config.py,sha256=299u_uEoyxlM_ceTD0Z_i41JdYjSHmqO6FKe7qGFgTM,2866
|
|
@@ -10,15 +10,15 @@ qBitrr/ffprobe.py,sha256=2IM0iuPPTEb0xHmN1OetQoBd80-Nmv5Oq7P6o-mjBd0,4019
|
|
|
10
10
|
qBitrr/gen_config.py,sha256=lDRbCzjWoJuUyOZNnOmNjChuZoR5K6fuwKCJ2qxzu78,29862
|
|
11
11
|
qBitrr/home_path.py,sha256=zvBheAR2xvr8LBZRk1FyqfnALE-eFzsY9CyqyZDjxiE,626
|
|
12
12
|
qBitrr/logger.py,sha256=os7cHbJ3sbkxDh6Nno9o_41aCwsLp-Y963nZe-rglKA,5505
|
|
13
|
-
qBitrr/main.py,sha256=
|
|
14
|
-
qBitrr/search_activity_store.py,sha256=
|
|
15
|
-
qBitrr/tables.py,sha256=
|
|
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
16
|
qBitrr/utils.py,sha256=DEnkQrbXFPWunhzId0OE6_oWuUTd5V4aDCZ2yHdrvo0,7306
|
|
17
17
|
qBitrr/versioning.py,sha256=k3n8cOh1E5mevN8OkYWOA3110PuOajMOpGyCKy3rFEc,2279
|
|
18
18
|
qBitrr/webui.py,sha256=HaM3w-rzuvVyGtphRCROY2GDXZtRmny3blkC5WoTOSk,68298
|
|
19
|
-
qbitrr2-5.1.
|
|
20
|
-
qbitrr2-5.1.
|
|
21
|
-
qbitrr2-5.1.
|
|
22
|
-
qbitrr2-5.1.
|
|
23
|
-
qbitrr2-5.1.
|
|
24
|
-
qbitrr2-5.1.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|