qBitrr2 3.7.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/__init__.py +15 -0
- qBitrr/arr_tables.py +138 -0
- qBitrr/arss.py +3918 -0
- qBitrr/bundled_data.py +6 -0
- qBitrr/config.py +172 -0
- qBitrr/env_config.py +60 -0
- qBitrr/errors.py +41 -0
- qBitrr/ffprobe.py +105 -0
- qBitrr/gen_config.py +773 -0
- qBitrr/home_path.py +23 -0
- qBitrr/logger.py +146 -0
- qBitrr/main.py +204 -0
- qBitrr/tables.py +52 -0
- qBitrr/utils.py +192 -0
- qBitrr2-3.7.1.dist-info/LICENSE +21 -0
- qBitrr2-3.7.1.dist-info/METADATA +224 -0
- qBitrr2-3.7.1.dist-info/RECORD +20 -0
- qBitrr2-3.7.1.dist-info/WHEEL +5 -0
- qBitrr2-3.7.1.dist-info/entry_points.txt +2 -0
- qBitrr2-3.7.1.dist-info/top_level.txt +1 -0
qBitrr/home_path.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
|
|
3
|
+
from jaraco.docker import is_docker
|
|
4
|
+
|
|
5
|
+
from qBitrr.env_config import ENVIRO_CONFIG
|
|
6
|
+
|
|
7
|
+
if (
|
|
8
|
+
ENVIRO_CONFIG.overrides.data_path is None
|
|
9
|
+
or not (p := pathlib.Path(ENVIRO_CONFIG.overrides.data_path)).exists()
|
|
10
|
+
):
|
|
11
|
+
if is_docker():
|
|
12
|
+
ON_DOCKER = True
|
|
13
|
+
HOME_PATH = pathlib.Path("/config")
|
|
14
|
+
HOME_PATH.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
else:
|
|
16
|
+
ON_DOCKER = False
|
|
17
|
+
HOME_PATH = pathlib.Path().absolute().joinpath(".config")
|
|
18
|
+
HOME_PATH.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
else:
|
|
20
|
+
HOME_PATH = p
|
|
21
|
+
|
|
22
|
+
APPDATA_FOLDER = HOME_PATH.joinpath("qBitManager")
|
|
23
|
+
APPDATA_FOLDER.mkdir(parents=True, exist_ok=True)
|
qBitrr/logger.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from logging import Logger
|
|
6
|
+
|
|
7
|
+
import coloredlogs
|
|
8
|
+
|
|
9
|
+
from qBitrr.config import (
|
|
10
|
+
COMPLETED_DOWNLOAD_FOLDER,
|
|
11
|
+
CONFIG,
|
|
12
|
+
CONSOLE_LOGGING_LEVEL_STRING,
|
|
13
|
+
COPIED_TO_NEW_DIR,
|
|
14
|
+
FAILED_CATEGORY,
|
|
15
|
+
HOME_PATH,
|
|
16
|
+
IGNORE_TORRENTS_YOUNGER_THAN,
|
|
17
|
+
LOOP_SLEEP_TIMER,
|
|
18
|
+
NO_INTERNET_SLEEP_TIMER,
|
|
19
|
+
PING_URLS,
|
|
20
|
+
RECHECK_CATEGORY,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = ("run_logs",)
|
|
24
|
+
|
|
25
|
+
TRACE = 5
|
|
26
|
+
VERBOSE = 7
|
|
27
|
+
NOTICE = 23
|
|
28
|
+
HNOTICE = 24
|
|
29
|
+
SUCCESS = 25
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class VerboseLogger(Logger):
|
|
33
|
+
def _init__(self, *args, **kwargs):
|
|
34
|
+
super().__init__(*args, **kwargs)
|
|
35
|
+
if self.name.startswith("qBitrr"):
|
|
36
|
+
self.set_config_level()
|
|
37
|
+
|
|
38
|
+
def success(self, message, *args, **kwargs):
|
|
39
|
+
if self.isEnabledFor(SUCCESS):
|
|
40
|
+
self._log(SUCCESS, message, args, **kwargs)
|
|
41
|
+
|
|
42
|
+
def hnotice(self, message, *args, **kwargs):
|
|
43
|
+
if self.isEnabledFor(HNOTICE):
|
|
44
|
+
self._log(HNOTICE, message, args, **kwargs)
|
|
45
|
+
|
|
46
|
+
def notice(self, message, *args, **kwargs):
|
|
47
|
+
if self.isEnabledFor(NOTICE):
|
|
48
|
+
self._log(NOTICE, message, args, **kwargs)
|
|
49
|
+
|
|
50
|
+
def verbose(self, message, *args, **kwargs):
|
|
51
|
+
if self.isEnabledFor(VERBOSE):
|
|
52
|
+
self._log(VERBOSE, message, args, **kwargs)
|
|
53
|
+
|
|
54
|
+
def trace(self, message, *args, **kwargs):
|
|
55
|
+
if self.isEnabledFor(TRACE):
|
|
56
|
+
self._log(TRACE, message, args, **kwargs)
|
|
57
|
+
|
|
58
|
+
def set_config_level(self):
|
|
59
|
+
self.setLevel(CONSOLE_LOGGING_LEVEL_STRING)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
logging.addLevelName(SUCCESS, "SUCCESS")
|
|
63
|
+
logging.addLevelName(HNOTICE, "HNOTICE")
|
|
64
|
+
logging.addLevelName(NOTICE, "NOTICE")
|
|
65
|
+
logging.addLevelName(VERBOSE, "VERBOSE")
|
|
66
|
+
logging.addLevelName(TRACE, "TRACE")
|
|
67
|
+
logging.setLoggerClass(VerboseLogger)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def getLogger(name: str | None = None):
|
|
71
|
+
if name:
|
|
72
|
+
return VerboseLogger.manager.getLogger(name)
|
|
73
|
+
else:
|
|
74
|
+
return logging.root
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
logging.getLogger = getLogger
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
logger = logging.getLogger("qBitrr.Misc")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
HAS_RUN = False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def run_logs(logger: Logger) -> None:
|
|
87
|
+
global HAS_RUN
|
|
88
|
+
try:
|
|
89
|
+
configkeys = {f"qBitrr.{i}" for i in CONFIG.sections()}
|
|
90
|
+
key_length = max(len(max(configkeys, key=len)), 10)
|
|
91
|
+
except BaseException:
|
|
92
|
+
key_length = 10
|
|
93
|
+
coloredlogs.install(
|
|
94
|
+
logger=logger,
|
|
95
|
+
level=logging._nameToLevel.get(CONSOLE_LOGGING_LEVEL_STRING),
|
|
96
|
+
fmt="[%(asctime)-15s] [pid:%(process)8d][tid:%(thread)8d] "
|
|
97
|
+
f"%(levelname)-8s: %(name)-{key_length}s: %(message)s",
|
|
98
|
+
level_styles=dict(
|
|
99
|
+
trace=dict(color="black", bold=True),
|
|
100
|
+
debug=dict(color="magenta", bold=True),
|
|
101
|
+
verbose=dict(color="blue", bold=True),
|
|
102
|
+
info=dict(color="white"),
|
|
103
|
+
notice=dict(color="cyan"),
|
|
104
|
+
hnotice=dict(color="cyan", bold=True),
|
|
105
|
+
warning=dict(color="yellow", bold=True),
|
|
106
|
+
success=dict(color="green", bold=True),
|
|
107
|
+
error=dict(color="red"),
|
|
108
|
+
critical=dict(color="red", bold=True),
|
|
109
|
+
),
|
|
110
|
+
field_styles=dict(
|
|
111
|
+
asctime=dict(color="green"),
|
|
112
|
+
process=dict(color="magenta"),
|
|
113
|
+
levelname=dict(color="red", bold=True),
|
|
114
|
+
name=dict(color="blue", bold=True),
|
|
115
|
+
thread=dict(color="cyan"),
|
|
116
|
+
),
|
|
117
|
+
reconfigure=True,
|
|
118
|
+
)
|
|
119
|
+
if HAS_RUN is False:
|
|
120
|
+
logger.debug("Log Level: %s", CONSOLE_LOGGING_LEVEL_STRING)
|
|
121
|
+
logger.debug("Ping URLs: %s", PING_URLS)
|
|
122
|
+
logger.debug("Script Config: FailedCategory=%s", FAILED_CATEGORY)
|
|
123
|
+
logger.debug("Script Config: RecheckCategory=%s", RECHECK_CATEGORY)
|
|
124
|
+
logger.debug("Script Config: CompletedDownloadFolder=%s", COMPLETED_DOWNLOAD_FOLDER)
|
|
125
|
+
logger.debug("Script Config: LoopSleepTimer=%s", LOOP_SLEEP_TIMER)
|
|
126
|
+
logger.debug(
|
|
127
|
+
"Script Config: NoInternetSleepTimer=%s",
|
|
128
|
+
NO_INTERNET_SLEEP_TIMER,
|
|
129
|
+
)
|
|
130
|
+
logger.debug(
|
|
131
|
+
"Script Config: IgnoreTorrentsYoungerThan=%s",
|
|
132
|
+
IGNORE_TORRENTS_YOUNGER_THAN,
|
|
133
|
+
)
|
|
134
|
+
HAS_RUN = True
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if COPIED_TO_NEW_DIR is False and not HOME_PATH.joinpath("config.toml").exists():
|
|
138
|
+
logger.warning(
|
|
139
|
+
"Config.toml should exist in '%s', in a future update this will be a requirement.",
|
|
140
|
+
HOME_PATH,
|
|
141
|
+
)
|
|
142
|
+
time.sleep(5)
|
|
143
|
+
if COPIED_TO_NEW_DIR:
|
|
144
|
+
logger.warning("Config.toml new location is %s", HOME_PATH)
|
|
145
|
+
time.sleep(5)
|
|
146
|
+
run_logs(logger)
|
qBitrr/main.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from multiprocessing import freeze_support
|
|
9
|
+
|
|
10
|
+
import pathos
|
|
11
|
+
import qbittorrentapi
|
|
12
|
+
import requests
|
|
13
|
+
from packaging import version as version_parser
|
|
14
|
+
from packaging.version import Version as VersionClass
|
|
15
|
+
from qbittorrentapi import APINames
|
|
16
|
+
from qbittorrentapi.decorators import login_required # , response_text
|
|
17
|
+
|
|
18
|
+
from qBitrr.arss import ArrManager
|
|
19
|
+
from qBitrr.bundled_data import patched_version
|
|
20
|
+
from qBitrr.config import APPDATA_FOLDER, CONFIG, QBIT_DISABLED, SEARCH_ONLY, process_flags
|
|
21
|
+
from qBitrr.env_config import ENVIRO_CONFIG
|
|
22
|
+
from qBitrr.ffprobe import FFprobeDownloader
|
|
23
|
+
from qBitrr.logger import run_logs
|
|
24
|
+
from qBitrr.utils import ExpiringSet
|
|
25
|
+
|
|
26
|
+
CHILD_PROCESSES = []
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("qBitrr")
|
|
29
|
+
run_logs(logger)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class qBitManager:
|
|
33
|
+
min_supported_version = VersionClass("4.3.9")
|
|
34
|
+
soft_not_supported_supported_version = VersionClass("4.5")
|
|
35
|
+
max_supported_version = VersionClass("4.5.5")
|
|
36
|
+
_head_less_mode = False
|
|
37
|
+
|
|
38
|
+
def __init__(self):
|
|
39
|
+
self.qBit_Host = CONFIG.get("qBit.Host", fallback="localhost")
|
|
40
|
+
self.qBit_Port = CONFIG.get("qBit.Port", fallback=8105)
|
|
41
|
+
self.qBit_UserName = CONFIG.get("qBit.UserName", fallback=None)
|
|
42
|
+
self.qBit_Password = CONFIG.get("qBit.Password", fallback=None)
|
|
43
|
+
self.logger = logging.getLogger(
|
|
44
|
+
"qBitrr.Manager",
|
|
45
|
+
)
|
|
46
|
+
run_logs(self.logger)
|
|
47
|
+
self.logger.debug(
|
|
48
|
+
"qBitTorrent Config: Host: %s Port: %s, Username: %s, Password: %s",
|
|
49
|
+
self.qBit_Host,
|
|
50
|
+
self.qBit_Port,
|
|
51
|
+
self.qBit_UserName,
|
|
52
|
+
self.qBit_Password,
|
|
53
|
+
)
|
|
54
|
+
self._validated_version = False
|
|
55
|
+
self.client = None
|
|
56
|
+
self.current_qbit_version = None
|
|
57
|
+
if not any([QBIT_DISABLED, SEARCH_ONLY]):
|
|
58
|
+
self.client = qbittorrentapi.Client(
|
|
59
|
+
host=self.qBit_Host,
|
|
60
|
+
port=self.qBit_Port,
|
|
61
|
+
username=self.qBit_UserName,
|
|
62
|
+
password=self.qBit_Password,
|
|
63
|
+
SIMPLE_RESPONSES=False,
|
|
64
|
+
)
|
|
65
|
+
try:
|
|
66
|
+
self.current_qbit_version = version_parser.parse(self.client.app_version())
|
|
67
|
+
self._validated_version = True
|
|
68
|
+
except BaseException:
|
|
69
|
+
self.current_qbit_version = self.min_supported_version
|
|
70
|
+
self.logger.error(
|
|
71
|
+
"Could not establish qBitTorrent version, "
|
|
72
|
+
"you may experience errors, please report this error."
|
|
73
|
+
)
|
|
74
|
+
self._version_validator()
|
|
75
|
+
self.expiring_bool = ExpiringSet(max_age_seconds=10)
|
|
76
|
+
self.cache = {}
|
|
77
|
+
self.name_cache = {}
|
|
78
|
+
self.should_delay_torrent_scan = False # If true torrent scan is delayed by 5 minutes.
|
|
79
|
+
self.child_processes = []
|
|
80
|
+
self.ffprobe_downloader = FFprobeDownloader()
|
|
81
|
+
try:
|
|
82
|
+
if not any([QBIT_DISABLED, SEARCH_ONLY]):
|
|
83
|
+
self.ffprobe_downloader.update()
|
|
84
|
+
except Exception as e:
|
|
85
|
+
self.logger.error(
|
|
86
|
+
"FFprobe manager error: %s while attempting to download/update FFprobe", e
|
|
87
|
+
)
|
|
88
|
+
self.arr_manager = ArrManager(self).build_arr_instances()
|
|
89
|
+
run_logs(self.logger)
|
|
90
|
+
|
|
91
|
+
def _version_validator(self):
|
|
92
|
+
if self.min_supported_version <= self.current_qbit_version <= self.max_supported_version:
|
|
93
|
+
if self._validated_version:
|
|
94
|
+
self.logger.info(
|
|
95
|
+
"Current qBitTorrent version is supported: %s",
|
|
96
|
+
self.current_qbit_version,
|
|
97
|
+
)
|
|
98
|
+
else:
|
|
99
|
+
self.logger.warning(
|
|
100
|
+
"Could not validate current qBitTorrent version, assuming: %s",
|
|
101
|
+
self.current_qbit_version,
|
|
102
|
+
)
|
|
103
|
+
time.sleep(10)
|
|
104
|
+
else:
|
|
105
|
+
self.logger.critical(
|
|
106
|
+
"You are currently running qBitTorrent version %s, "
|
|
107
|
+
"Supported version range is %s to < %s",
|
|
108
|
+
self.current_qbit_version,
|
|
109
|
+
self.min_supported_version,
|
|
110
|
+
self.max_supported_version,
|
|
111
|
+
)
|
|
112
|
+
sys.exit(1)
|
|
113
|
+
|
|
114
|
+
# @response_text(str)
|
|
115
|
+
@login_required
|
|
116
|
+
def app_version(self, **kwargs):
|
|
117
|
+
return self.client._get(
|
|
118
|
+
_name=APINames.Application,
|
|
119
|
+
_method="version",
|
|
120
|
+
_retries=0,
|
|
121
|
+
_retry_backoff_factor=0,
|
|
122
|
+
**kwargs,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def is_alive(self) -> bool:
|
|
127
|
+
try:
|
|
128
|
+
if 1 in self.expiring_bool or self.client is None:
|
|
129
|
+
return True
|
|
130
|
+
self.client.app_version()
|
|
131
|
+
self.logger.trace("Successfully connected to %s:%s", self.qBit_Host, self.qBit_Port)
|
|
132
|
+
self.expiring_bool.add(1)
|
|
133
|
+
return True
|
|
134
|
+
except requests.RequestException:
|
|
135
|
+
self.logger.warning("Could not connect to %s:%s", self.qBit_Host, self.qBit_Port)
|
|
136
|
+
self.should_delay_torrent_scan = True
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
def get_child_processes(self) -> list[pathos.helpers.mp.Process]:
|
|
140
|
+
run_logs(self.logger)
|
|
141
|
+
self.logger.debug("Managing %s categories", len(self.arr_manager.managed_objects))
|
|
142
|
+
count = 0
|
|
143
|
+
procs = []
|
|
144
|
+
for arr in self.arr_manager.managed_objects.values():
|
|
145
|
+
numb, processes = arr.spawn_child_processes()
|
|
146
|
+
count += numb
|
|
147
|
+
procs.extend(processes)
|
|
148
|
+
return procs
|
|
149
|
+
|
|
150
|
+
def run(self):
|
|
151
|
+
try:
|
|
152
|
+
self.logger.debug("Starting %s child processes", len(self.child_processes))
|
|
153
|
+
[p.start() for p in self.child_processes]
|
|
154
|
+
[p.join() for p in self.child_processes]
|
|
155
|
+
except KeyboardInterrupt:
|
|
156
|
+
self.logger.info("Detected Ctrl+C - Terminating process")
|
|
157
|
+
sys.exit(0)
|
|
158
|
+
except BaseException as e:
|
|
159
|
+
self.logger.info("Detected Ctrl+C - Terminating process: %r", e)
|
|
160
|
+
sys.exit(1)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def run():
|
|
164
|
+
global CHILD_PROCESSES
|
|
165
|
+
early_exit = process_flags()
|
|
166
|
+
if early_exit is True:
|
|
167
|
+
sys.exit(0)
|
|
168
|
+
logger.info("Starting qBitrr: Version: %s.", patched_version)
|
|
169
|
+
manager = qBitManager()
|
|
170
|
+
run_logs(logger)
|
|
171
|
+
logger.debug("Environment variables: %r", ENVIRO_CONFIG)
|
|
172
|
+
try:
|
|
173
|
+
if CHILD_PROCESSES := manager.get_child_processes():
|
|
174
|
+
manager.run()
|
|
175
|
+
else:
|
|
176
|
+
logger.warning(
|
|
177
|
+
"No tasks to perform, if this is unintended double check your config file."
|
|
178
|
+
)
|
|
179
|
+
except KeyboardInterrupt:
|
|
180
|
+
logger.info("Detected Ctrl+C - Terminating process")
|
|
181
|
+
sys.exit(0)
|
|
182
|
+
except Exception:
|
|
183
|
+
logger.info("Attempting to terminate child processes, please wait a moment.")
|
|
184
|
+
for child in manager.child_processes:
|
|
185
|
+
child.kill()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def cleanup():
|
|
189
|
+
for p in CHILD_PROCESSES:
|
|
190
|
+
p.kill()
|
|
191
|
+
p.terminate()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
atexit.register(cleanup)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
if __name__ == "__main__":
|
|
198
|
+
extensions = [".db", ".db-shm", ".db-wal"]
|
|
199
|
+
for file in os.listdir(APPDATA_FOLDER):
|
|
200
|
+
for ext in extensions:
|
|
201
|
+
if file.endswith(ext):
|
|
202
|
+
os.remove(os.path.join(APPDATA_FOLDER, file))
|
|
203
|
+
freeze_support()
|
|
204
|
+
run()
|
qBitrr/tables.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from peewee import BooleanField, CharField, DateTimeField, IntegerField, Model, TextField
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class FilesQueued(Model):
|
|
5
|
+
EntryId = IntegerField(primary_key=True, null=False, unique=True)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MoviesFilesModel(Model):
|
|
9
|
+
Title = CharField()
|
|
10
|
+
Monitored = BooleanField()
|
|
11
|
+
TmdbId = IntegerField()
|
|
12
|
+
Year = IntegerField()
|
|
13
|
+
EntryId = IntegerField(unique=True)
|
|
14
|
+
Searched = BooleanField(default=False)
|
|
15
|
+
MovieFileId = IntegerField()
|
|
16
|
+
IsRequest = BooleanField(default=False)
|
|
17
|
+
QualityMet = BooleanField(default=False)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EpisodeFilesModel(Model):
|
|
21
|
+
EntryId = IntegerField(primary_key=True)
|
|
22
|
+
SeriesTitle = TextField(null=True)
|
|
23
|
+
Title = TextField(null=True)
|
|
24
|
+
SeriesId = IntegerField(null=False)
|
|
25
|
+
EpisodeFileId = IntegerField(null=True)
|
|
26
|
+
EpisodeNumber = IntegerField(null=False)
|
|
27
|
+
SeasonNumber = IntegerField(null=False)
|
|
28
|
+
AbsoluteEpisodeNumber = IntegerField(null=True)
|
|
29
|
+
SceneAbsoluteEpisodeNumber = IntegerField(null=True)
|
|
30
|
+
LastSearchTime = DateTimeField(formats=["%Y-%m-%d %H:%M:%S.%f"], null=True)
|
|
31
|
+
AirDateUtc = DateTimeField(formats=["%Y-%m-%d %H:%M:%S.%f"], null=True)
|
|
32
|
+
Monitored = BooleanField(null=True)
|
|
33
|
+
Searched = BooleanField(default=False)
|
|
34
|
+
IsRequest = BooleanField(default=False)
|
|
35
|
+
QualityMet = BooleanField(default=False)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SeriesFilesModel(Model):
|
|
39
|
+
EntryId = IntegerField(primary_key=True)
|
|
40
|
+
Title = TextField(null=True)
|
|
41
|
+
Monitored = BooleanField(null=True)
|
|
42
|
+
Searched = BooleanField(default=False)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MovieQueueModel(Model):
|
|
46
|
+
EntryId = IntegerField(unique=True)
|
|
47
|
+
Completed = BooleanField(default=False)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class EpisodeQueueModel(Model):
|
|
51
|
+
EntryId = IntegerField(unique=True)
|
|
52
|
+
Completed = BooleanField(default=False)
|
qBitrr/utils.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import pathlib
|
|
5
|
+
import random
|
|
6
|
+
import socket
|
|
7
|
+
import time
|
|
8
|
+
from typing import Iterator
|
|
9
|
+
|
|
10
|
+
import ping3
|
|
11
|
+
from cachetools import TTLCache
|
|
12
|
+
|
|
13
|
+
ping3.EXCEPTIONS = True
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("qBitrr.Utils")
|
|
16
|
+
|
|
17
|
+
CACHE = TTLCache(maxsize=50, ttl=60)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def absolute_file_paths(directory: pathlib.Path | str) -> Iterator[pathlib.Path]:
|
|
21
|
+
error = True
|
|
22
|
+
while error is True:
|
|
23
|
+
try:
|
|
24
|
+
yield from pathlib.Path(directory).glob("**/*")
|
|
25
|
+
error = False
|
|
26
|
+
except FileNotFoundError as e:
|
|
27
|
+
logger.warning("%s - %s", e.strerror, e.filename)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate_and_return_torrent_file(file: str) -> pathlib.Path:
|
|
31
|
+
path = pathlib.Path(file)
|
|
32
|
+
if path.is_file():
|
|
33
|
+
path = path.parent.absolute()
|
|
34
|
+
count = 9
|
|
35
|
+
while not path.exists():
|
|
36
|
+
logger.debug(
|
|
37
|
+
"Attempt %s/10: File does not yet exists! (Possibly being moved?) | "
|
|
38
|
+
"%s | Sleeping for 0.1s",
|
|
39
|
+
path,
|
|
40
|
+
10 - count,
|
|
41
|
+
)
|
|
42
|
+
time.sleep(0.1)
|
|
43
|
+
if count == 0:
|
|
44
|
+
break
|
|
45
|
+
count -= 1
|
|
46
|
+
else:
|
|
47
|
+
count = 0
|
|
48
|
+
while str(path) == ".":
|
|
49
|
+
path = pathlib.Path(file)
|
|
50
|
+
if path.is_file():
|
|
51
|
+
path = path.parent.absolute()
|
|
52
|
+
while not path.exists():
|
|
53
|
+
logger.debug(
|
|
54
|
+
"Attempt %s/10:File does not yet exists! (Possibly being moved?) | "
|
|
55
|
+
"%s | Sleeping for 0.1s",
|
|
56
|
+
path,
|
|
57
|
+
10 - count,
|
|
58
|
+
)
|
|
59
|
+
time.sleep(0.1)
|
|
60
|
+
if count == 0:
|
|
61
|
+
break
|
|
62
|
+
count -= 1
|
|
63
|
+
else:
|
|
64
|
+
count = 0
|
|
65
|
+
if count == 0:
|
|
66
|
+
break
|
|
67
|
+
count -= 1
|
|
68
|
+
return path
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def has_internet():
|
|
72
|
+
from qBitrr.config import PING_URLS
|
|
73
|
+
|
|
74
|
+
url = random.choice(PING_URLS)
|
|
75
|
+
if not is_connected(url):
|
|
76
|
+
return False
|
|
77
|
+
logger.debug("Successfully connected to %s", url)
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _basic_ping(hostname):
|
|
82
|
+
host = "N/A"
|
|
83
|
+
try:
|
|
84
|
+
# if this hostname was called within the last 10 seconds skip it
|
|
85
|
+
# if it was previous successful
|
|
86
|
+
# Reducing the number of call to it and the likelihood of rate-limits.
|
|
87
|
+
if hostname in CACHE:
|
|
88
|
+
return CACHE[hostname]
|
|
89
|
+
# see if we can resolve the host name -- tells us if there is
|
|
90
|
+
# a DNS listening
|
|
91
|
+
host = socket.gethostbyname(hostname)
|
|
92
|
+
# connect to the host -- tells us if the host is actually
|
|
93
|
+
# reachable
|
|
94
|
+
s = socket.create_connection((host, 80), 5)
|
|
95
|
+
s.close()
|
|
96
|
+
CACHE[hostname] = True
|
|
97
|
+
return True
|
|
98
|
+
except Exception as e:
|
|
99
|
+
logger.debug(
|
|
100
|
+
"Error when connecting to host: %s %s %s",
|
|
101
|
+
hostname,
|
|
102
|
+
host,
|
|
103
|
+
e,
|
|
104
|
+
)
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def is_connected(hostname):
|
|
109
|
+
try:
|
|
110
|
+
# if this hostname was called within the last 10 seconds skip it
|
|
111
|
+
# if it was previous successful
|
|
112
|
+
# Reducing the number of call to it and the likelihood of rate-limits.
|
|
113
|
+
if hostname in CACHE:
|
|
114
|
+
return CACHE[hostname]
|
|
115
|
+
ping3.ping(hostname, timeout=5)
|
|
116
|
+
CACHE[hostname] = True
|
|
117
|
+
return True
|
|
118
|
+
except ping3.errors.PingError as e: # All ping3 errors are subclasses of `PingError`.
|
|
119
|
+
logger.debug(
|
|
120
|
+
"Error when connecting to host: %s %s",
|
|
121
|
+
hostname,
|
|
122
|
+
e,
|
|
123
|
+
)
|
|
124
|
+
except (
|
|
125
|
+
Exception
|
|
126
|
+
): # Ping3 is far more robust but may requite root access, if root access is not available then run the basic mode
|
|
127
|
+
return _basic_ping(hostname)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class ExpiringSet:
|
|
131
|
+
def __init__(self, *args: list, **kwargs):
|
|
132
|
+
max_age_seconds = kwargs.get("max_age_seconds", 0)
|
|
133
|
+
assert max_age_seconds > 0
|
|
134
|
+
self.age = max_age_seconds
|
|
135
|
+
self.container = {}
|
|
136
|
+
for arg in args:
|
|
137
|
+
self.add(arg)
|
|
138
|
+
|
|
139
|
+
def __repr__(self):
|
|
140
|
+
self.__update__()
|
|
141
|
+
return f"{self.__class__.__name__}({', '.join(self.container.keys())})"
|
|
142
|
+
|
|
143
|
+
def extend(self, args):
|
|
144
|
+
"""Add several items at once."""
|
|
145
|
+
for arg in args:
|
|
146
|
+
self.add(arg)
|
|
147
|
+
|
|
148
|
+
def add(self, value):
|
|
149
|
+
self.container[value] = time.time()
|
|
150
|
+
|
|
151
|
+
def remove(self, item):
|
|
152
|
+
del self.container[item]
|
|
153
|
+
|
|
154
|
+
def contains(self, value):
|
|
155
|
+
if value not in self.container:
|
|
156
|
+
return False
|
|
157
|
+
if time.time() - self.container[value] > self.age:
|
|
158
|
+
del self.container[value]
|
|
159
|
+
return False
|
|
160
|
+
return True
|
|
161
|
+
|
|
162
|
+
__contains__ = contains
|
|
163
|
+
|
|
164
|
+
def __getitem__(self, index):
|
|
165
|
+
self.__update__()
|
|
166
|
+
return list(self.container.keys())[index]
|
|
167
|
+
|
|
168
|
+
def __iter__(self):
|
|
169
|
+
self.__update__()
|
|
170
|
+
return iter(self.container.copy())
|
|
171
|
+
|
|
172
|
+
def __len__(self):
|
|
173
|
+
self.__update__()
|
|
174
|
+
return len(self.container)
|
|
175
|
+
|
|
176
|
+
def __copy__(self):
|
|
177
|
+
self.__update__()
|
|
178
|
+
temp = ExpiringSet(max_age_seconds=self.age)
|
|
179
|
+
temp.container = self.container.copy()
|
|
180
|
+
return temp
|
|
181
|
+
|
|
182
|
+
def __update__(self):
|
|
183
|
+
for k, b in self.container.copy().items():
|
|
184
|
+
if time.time() - b > self.age:
|
|
185
|
+
del self.container[k]
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
def __hash__(self):
|
|
189
|
+
return hash(*(self.container.keys()))
|
|
190
|
+
|
|
191
|
+
def __eq__(self, other):
|
|
192
|
+
return self.__hash__() == other.__hash__()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021 Feramance
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|