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/bundled_data.py
ADDED
qBitrr/config.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import contextlib
|
|
5
|
+
import os
|
|
6
|
+
import pathlib
|
|
7
|
+
import shutil
|
|
8
|
+
import signal
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from qBitrr.bundled_data import license_text, patched_version
|
|
12
|
+
from qBitrr.env_config import ENVIRO_CONFIG
|
|
13
|
+
from qBitrr.gen_config import MyConfig, _write_config_file, generate_doc
|
|
14
|
+
from qBitrr.home_path import APPDATA_FOLDER, HOME_PATH, ON_DOCKER
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def process_flags() -> argparse.Namespace | bool:
|
|
18
|
+
parser = argparse.ArgumentParser(description="An interface to interact with qBit and *arrs.")
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--gen-config",
|
|
21
|
+
"-gc",
|
|
22
|
+
dest="gen_config",
|
|
23
|
+
help="Generate a config file in the current working directory",
|
|
24
|
+
action="store_true",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"-v", "--version", action="version", version=f"qBitrr version: {patched_version}"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"-l",
|
|
32
|
+
"--license",
|
|
33
|
+
dest="license",
|
|
34
|
+
action="store_const",
|
|
35
|
+
const=license_text,
|
|
36
|
+
help="Show the qBitrr's licence",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"-s",
|
|
40
|
+
"--source",
|
|
41
|
+
action="store_const",
|
|
42
|
+
dest="source",
|
|
43
|
+
const="Source code can be found on: https://github.com/Feramance/qBitrr",
|
|
44
|
+
help="Shows a link to qBitrr's source",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
args = parser.parse_args()
|
|
48
|
+
|
|
49
|
+
if args.gen_config:
|
|
50
|
+
from qBitrr.gen_config import _write_config_file
|
|
51
|
+
|
|
52
|
+
_write_config_file()
|
|
53
|
+
return True
|
|
54
|
+
elif args.license:
|
|
55
|
+
print(args.license)
|
|
56
|
+
return True
|
|
57
|
+
elif args.source:
|
|
58
|
+
print(args.source)
|
|
59
|
+
return True
|
|
60
|
+
return args
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
COPIED_TO_NEW_DIR = False
|
|
64
|
+
file = "config.toml"
|
|
65
|
+
CONFIG_FILE = HOME_PATH.joinpath(file)
|
|
66
|
+
CONFIG_PATH = pathlib.Path(f"./{file}")
|
|
67
|
+
if any(
|
|
68
|
+
a in sys.argv
|
|
69
|
+
for a in [
|
|
70
|
+
"--gen-config",
|
|
71
|
+
"-gc",
|
|
72
|
+
"--version",
|
|
73
|
+
"-v",
|
|
74
|
+
"--license",
|
|
75
|
+
"-l",
|
|
76
|
+
"--source",
|
|
77
|
+
"-s",
|
|
78
|
+
"-h",
|
|
79
|
+
"--help",
|
|
80
|
+
]
|
|
81
|
+
):
|
|
82
|
+
CONFIG = MyConfig(CONFIG_FILE, config=generate_doc())
|
|
83
|
+
COPIED_TO_NEW_DIR = None
|
|
84
|
+
elif (not CONFIG_FILE.exists()) and (not CONFIG_PATH.exists()):
|
|
85
|
+
if ON_DOCKER:
|
|
86
|
+
print(f"{file} has not been found")
|
|
87
|
+
|
|
88
|
+
CONFIG_FILE = _write_config_file(docker=True)
|
|
89
|
+
print(f"'{CONFIG_FILE.name}' has been generated")
|
|
90
|
+
print('Rename it to "config.toml" then edit it and restart the container')
|
|
91
|
+
|
|
92
|
+
os.kill(os.getppid(), signal.SIGTERM)
|
|
93
|
+
|
|
94
|
+
else:
|
|
95
|
+
print(f"{file} has not been found")
|
|
96
|
+
|
|
97
|
+
CONFIG_FILE = _write_config_file(docker=True)
|
|
98
|
+
print(f"'{CONFIG_FILE.name}' has been generated")
|
|
99
|
+
print('Rename it to "config.toml" then edit it and restart the container')
|
|
100
|
+
|
|
101
|
+
os.kill(os.getppid(), signal.SIGTERM)
|
|
102
|
+
|
|
103
|
+
elif CONFIG_FILE.exists():
|
|
104
|
+
CONFIG = MyConfig(CONFIG_FILE)
|
|
105
|
+
else:
|
|
106
|
+
with contextlib.suppress(
|
|
107
|
+
Exception
|
|
108
|
+
): # If file already exist or can't copy to APPDATA_FOLDER ignore the exception
|
|
109
|
+
shutil.copy(CONFIG_PATH, CONFIG_FILE)
|
|
110
|
+
COPIED_TO_NEW_DIR = True
|
|
111
|
+
CONFIG = MyConfig("./config.toml")
|
|
112
|
+
|
|
113
|
+
if COPIED_TO_NEW_DIR is not None:
|
|
114
|
+
# print(f"STARTUP | {CONFIG.path} |\n{CONFIG}")
|
|
115
|
+
print(f"STARTUP")
|
|
116
|
+
else:
|
|
117
|
+
print(f"STARTUP | CONFIG_FILE={CONFIG_FILE} | CONFIG_PATH={CONFIG_PATH}")
|
|
118
|
+
|
|
119
|
+
FFPROBE_AUTO_UPDATE = (
|
|
120
|
+
CONFIG.get("Settings.FFprobeAutoUpdate", fallback=True)
|
|
121
|
+
if ENVIRO_CONFIG.settings.ffprobe_auto_update is None
|
|
122
|
+
else ENVIRO_CONFIG.settings.ffprobe_auto_update
|
|
123
|
+
)
|
|
124
|
+
FAILED_CATEGORY = ENVIRO_CONFIG.settings.failed_category or CONFIG.get(
|
|
125
|
+
"Settings.FailedCategory", fallback="failed"
|
|
126
|
+
)
|
|
127
|
+
RECHECK_CATEGORY = ENVIRO_CONFIG.settings.recheck_category or CONFIG.get(
|
|
128
|
+
"Settings.RecheckCategory", fallback="recheck"
|
|
129
|
+
)
|
|
130
|
+
CONSOLE_LOGGING_LEVEL_STRING = ENVIRO_CONFIG.settings.console_level or CONFIG.get_or_raise(
|
|
131
|
+
"Settings.ConsoleLevel"
|
|
132
|
+
)
|
|
133
|
+
COMPLETED_DOWNLOAD_FOLDER = (
|
|
134
|
+
ENVIRO_CONFIG.settings.completed_download_folder
|
|
135
|
+
or CONFIG.get_or_raise("Settings.CompletedDownloadFolder")
|
|
136
|
+
)
|
|
137
|
+
NO_INTERNET_SLEEP_TIMER = ENVIRO_CONFIG.settings.no_internet_sleep_timer or CONFIG.get(
|
|
138
|
+
"Settings.NoInternetSleepTimer", fallback=60
|
|
139
|
+
)
|
|
140
|
+
LOOP_SLEEP_TIMER = ENVIRO_CONFIG.settings.loop_sleep_timer or CONFIG.get(
|
|
141
|
+
"Settings.LoopSleepTimer", fallback=5
|
|
142
|
+
)
|
|
143
|
+
PING_URLS = ENVIRO_CONFIG.settings.ping_urls or CONFIG.get(
|
|
144
|
+
"Settings.PingURLS", fallback=["one.one.one.one", "dns.google.com"]
|
|
145
|
+
)
|
|
146
|
+
IGNORE_TORRENTS_YOUNGER_THAN = ENVIRO_CONFIG.settings.ignore_torrents_younger_than or CONFIG.get(
|
|
147
|
+
"Settings.IgnoreTorrentsYoungerThan", fallback=600
|
|
148
|
+
)
|
|
149
|
+
QBIT_DISABLED = (
|
|
150
|
+
CONFIG.get("qBit.Disabled", fallback=False)
|
|
151
|
+
if ENVIRO_CONFIG.qbit.disabled is None
|
|
152
|
+
else ENVIRO_CONFIG.qbit.disabled
|
|
153
|
+
)
|
|
154
|
+
SEARCH_ONLY = ENVIRO_CONFIG.overrides.search_only
|
|
155
|
+
PROCESS_ONLY = ENVIRO_CONFIG.overrides.processing_only
|
|
156
|
+
|
|
157
|
+
if QBIT_DISABLED and PROCESS_ONLY:
|
|
158
|
+
print("qBittorrent is disabled yet QBITRR_OVERRIDES_PROCESSING_ONLY is enabled")
|
|
159
|
+
print(
|
|
160
|
+
"Processing monitors qBitTorrents downloads "
|
|
161
|
+
"therefore it depends on a health qBitTorrent connection"
|
|
162
|
+
)
|
|
163
|
+
print("Exiting...")
|
|
164
|
+
sys.exit(1)
|
|
165
|
+
|
|
166
|
+
if SEARCH_ONLY and QBIT_DISABLED is False:
|
|
167
|
+
QBIT_DISABLED = True
|
|
168
|
+
print("QBITRR_OVERRIDES_SEARCH_ONLY is enabled, forcing qBitTorrent setting off")
|
|
169
|
+
|
|
170
|
+
# Settings Config Values
|
|
171
|
+
FF_VERSION = APPDATA_FOLDER.joinpath("ffprobe_info.json")
|
|
172
|
+
FF_PROBE = APPDATA_FOLDER.joinpath("ffprobe")
|
qBitrr/env_config.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from distutils.util import strtobool
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import environ
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Converter:
|
|
8
|
+
@staticmethod
|
|
9
|
+
def int(value: Optional[str]) -> Optional[int]:
|
|
10
|
+
if value is None:
|
|
11
|
+
return None
|
|
12
|
+
return int(value)
|
|
13
|
+
|
|
14
|
+
@staticmethod
|
|
15
|
+
def list(value: Optional[str], delimiter=",", converter=str) -> Optional[list]:
|
|
16
|
+
if value is None:
|
|
17
|
+
return None
|
|
18
|
+
return list(map(converter, value.split(delimiter)))
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
def bool(value: Optional[str]) -> Optional[bool]:
|
|
22
|
+
if value is None:
|
|
23
|
+
return None
|
|
24
|
+
return strtobool(value) == 1
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@environ.config(prefix="QBITRR", frozen=True)
|
|
28
|
+
class AppConfig:
|
|
29
|
+
@environ.config(prefix="OVERRIDES", frozen=True)
|
|
30
|
+
class Overrides:
|
|
31
|
+
search_only = environ.var(None, converter=Converter.bool)
|
|
32
|
+
processing_only = environ.var(None, converter=Converter.bool)
|
|
33
|
+
data_path = environ.var(None)
|
|
34
|
+
|
|
35
|
+
@environ.config(prefix="SETTINGS", frozen=True)
|
|
36
|
+
class Settings:
|
|
37
|
+
console_level = environ.var(None)
|
|
38
|
+
completed_download_folder = environ.var(None)
|
|
39
|
+
no_internet_sleep_timer = environ.var(None, converter=Converter.int)
|
|
40
|
+
loop_sleep_timer = environ.var(None, converter=Converter.int)
|
|
41
|
+
failed_category = environ.var(None)
|
|
42
|
+
recheck_category = environ.var(None)
|
|
43
|
+
ignore_torrents_younger_than = environ.var(None, converter=Converter.int)
|
|
44
|
+
ping_urls = environ.var(None, converter=Converter.list)
|
|
45
|
+
ffprobe_auto_update = environ.var(None, converter=Converter.bool)
|
|
46
|
+
|
|
47
|
+
@environ.config(prefix="QBIT", frozen=True)
|
|
48
|
+
class qBit:
|
|
49
|
+
disabled = environ.var(None, converter=Converter.bool)
|
|
50
|
+
host = environ.var(None)
|
|
51
|
+
port = environ.var(None, converter=Converter.int)
|
|
52
|
+
username = environ.var(None)
|
|
53
|
+
password = environ.var(None)
|
|
54
|
+
|
|
55
|
+
overrides: Overrides = environ.group(Overrides)
|
|
56
|
+
settings: Settings = environ.group(Settings)
|
|
57
|
+
qbit: qBit = environ.group(qBit)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
ENVIRO_CONFIG: AppConfig = environ.to_config(AppConfig)
|
qBitrr/errors.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
class qBitManagerExceptions(Exception):
|
|
2
|
+
"""Base Exception"""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class UnhandledError(qBitManagerExceptions):
|
|
6
|
+
"""Use to raise when there an unhandled edge case"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ConfigException(qBitManagerExceptions):
|
|
10
|
+
"""Base Exception for Config related exceptions"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ArrManagerException(qBitManagerExceptions):
|
|
14
|
+
"""Base Exception for Arr related Exceptions"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SkipException(qBitManagerExceptions):
|
|
18
|
+
"""Dummy error to skip actions"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RequireConfigValue(qBitManagerExceptions):
|
|
22
|
+
"""Exception raised when a config value requires a value."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, config_class: str, config_key: str):
|
|
25
|
+
self.message = f"Config key '{config_key}' in '{config_class}' requires a value."
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NoConnectionrException(qBitManagerExceptions):
|
|
29
|
+
def __init__(self, message: str, type: str = "delay"):
|
|
30
|
+
self.message = message
|
|
31
|
+
self.type = type
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DelayLoopException(qBitManagerExceptions):
|
|
35
|
+
def __init__(self, length: int, type: str):
|
|
36
|
+
self.type = type
|
|
37
|
+
self.length = length
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RestartLoopException(ArrManagerException):
|
|
41
|
+
"""Exception to trigger a loop restart"""
|
qBitrr/ffprobe.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import sys
|
|
7
|
+
import zipfile
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from qBitrr.config import FF_PROBE, FF_VERSION, FFPROBE_AUTO_UPDATE
|
|
12
|
+
from qBitrr.logger import run_logs
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FFprobeDownloader:
|
|
16
|
+
def __init__(self):
|
|
17
|
+
self.api = "https://ffbinaries.com/api/v1/version/latest"
|
|
18
|
+
self.version_file = FF_VERSION
|
|
19
|
+
self.logger = logging.getLogger("qBitrr.FFprobe")
|
|
20
|
+
run_logs(self.logger)
|
|
21
|
+
self.platform = platform.system()
|
|
22
|
+
if self.platform == "Windows":
|
|
23
|
+
self.probe_path = FF_PROBE.with_suffix(".exe")
|
|
24
|
+
else:
|
|
25
|
+
self.probe_path = FF_PROBE
|
|
26
|
+
|
|
27
|
+
def get_upstream_version(self) -> dict:
|
|
28
|
+
with requests.Session() as session:
|
|
29
|
+
with session.get(self.api) as response:
|
|
30
|
+
if response.status_code != 200:
|
|
31
|
+
self.logger.warning("Failed to retrieve ffprobe version from API.")
|
|
32
|
+
return {}
|
|
33
|
+
return response.json()
|
|
34
|
+
|
|
35
|
+
def get_current_version(self):
|
|
36
|
+
try:
|
|
37
|
+
with self.version_file.open(mode="r") as file:
|
|
38
|
+
data = json.load(file)
|
|
39
|
+
return data.get("version")
|
|
40
|
+
except Exception: # If file can't be found or read or parsed
|
|
41
|
+
self.logger.warning("Failed to retrieve current ffprobe version.")
|
|
42
|
+
return ""
|
|
43
|
+
|
|
44
|
+
def update(self):
|
|
45
|
+
if not FFPROBE_AUTO_UPDATE:
|
|
46
|
+
return
|
|
47
|
+
current_version = self.get_current_version()
|
|
48
|
+
upstream_data = self.get_upstream_version()
|
|
49
|
+
upstream_version = upstream_data.get("version")
|
|
50
|
+
if upstream_version is None:
|
|
51
|
+
self.logger.debug(
|
|
52
|
+
"Failed to retrieve ffprobe version from API.'upstream_version' is None"
|
|
53
|
+
)
|
|
54
|
+
return
|
|
55
|
+
probe_file_exists = self.probe_path.exists()
|
|
56
|
+
if current_version == upstream_version and probe_file_exists:
|
|
57
|
+
self.logger.debug("Current FFprobe is up to date.")
|
|
58
|
+
return
|
|
59
|
+
arch_key = self.get_arch()
|
|
60
|
+
urls = upstream_data.get("bin", {}).get(arch_key)
|
|
61
|
+
if urls is None:
|
|
62
|
+
self.logger.debug("Failed to retrieve ffprobe version from API.'urls' is None")
|
|
63
|
+
return
|
|
64
|
+
ffprobe_url = urls.get("ffprobe")
|
|
65
|
+
self.logger.debug("Downloading newer FFprobe: %s", ffprobe_url)
|
|
66
|
+
self.download_and_extract(ffprobe_url)
|
|
67
|
+
self.logger.debug("Updating local version of FFprobe: %s", upstream_version)
|
|
68
|
+
self.version_file.write_text(json.dumps({"version": upstream_version}))
|
|
69
|
+
try:
|
|
70
|
+
os.chmod(self.probe_path, 0o777)
|
|
71
|
+
self.logger.debug("Successfully changed permissions for ffprobe")
|
|
72
|
+
except Exception as e:
|
|
73
|
+
self.logger.debug("Failed to change permissions for ffprobe, %s", e)
|
|
74
|
+
|
|
75
|
+
def download_and_extract(self, ffprobe_url):
|
|
76
|
+
r = requests.get(ffprobe_url)
|
|
77
|
+
z = zipfile.ZipFile(io.BytesIO(r.content))
|
|
78
|
+
self.logger.debug("Extracting downloaded FFprobe to: %s", FF_PROBE.parent)
|
|
79
|
+
z.extract(member=self.probe_path.name, path=FF_PROBE.parent)
|
|
80
|
+
|
|
81
|
+
def get_arch(self):
|
|
82
|
+
part1 = None
|
|
83
|
+
is_64bits = sys.maxsize > 2**32
|
|
84
|
+
part2 = "64" if is_64bits else "32"
|
|
85
|
+
if self.platform == "Windows":
|
|
86
|
+
part1 = "windows-"
|
|
87
|
+
elif self.platform == "Linux":
|
|
88
|
+
part1 = "linux-"
|
|
89
|
+
machine = platform.machine()
|
|
90
|
+
if machine == "armv6l":
|
|
91
|
+
part2 = "armhf"
|
|
92
|
+
elif ("arm" in machine and is_64bits) or machine == "aarch64":
|
|
93
|
+
part2 = "arm64"
|
|
94
|
+
# Else just 32/64, Not armel - because just no
|
|
95
|
+
elif self.platform == "Darwin":
|
|
96
|
+
part1 = "osx-"
|
|
97
|
+
part2 = "64"
|
|
98
|
+
if part1 is None:
|
|
99
|
+
raise RuntimeError(
|
|
100
|
+
"You are running in an unsupported platform, "
|
|
101
|
+
"if you expect this to be supported please open an issue on GitHub "
|
|
102
|
+
"https://github.com/Feramance/qBitrr."
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return part1 + part2
|