qBitrr2 5.1.1__py3-none-any.whl → 5.2.0__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 +59 -5
- qBitrr/bundled_data.py +2 -2
- qBitrr/config.py +5 -1
- qBitrr/gen_config.py +198 -23
- qBitrr/logger.py +22 -2
- qBitrr/main.py +35 -3
- qBitrr/webui.py +150 -12
- {qbitrr2-5.1.1.dist-info → qbitrr2-5.2.0.dist-info}/METADATA +4 -2
- {qbitrr2-5.1.1.dist-info → qbitrr2-5.2.0.dist-info}/RECORD +13 -13
- {qbitrr2-5.1.1.dist-info → qbitrr2-5.2.0.dist-info}/WHEEL +0 -0
- {qbitrr2-5.1.1.dist-info → qbitrr2-5.2.0.dist-info}/entry_points.txt +0 -0
- {qbitrr2-5.1.1.dist-info → qbitrr2-5.2.0.dist-info}/licenses/LICENSE +0 -0
- {qbitrr2-5.1.1.dist-info → qbitrr2-5.2.0.dist-info}/top_level.txt +0 -0
qBitrr/arss.py
CHANGED
|
@@ -312,7 +312,14 @@ class Arr:
|
|
|
312
312
|
self.overseerr_requests = CONFIG.get(
|
|
313
313
|
f"{name}.EntrySearch.Overseerr.SearchOverseerrRequests", fallback=False
|
|
314
314
|
)
|
|
315
|
-
|
|
315
|
+
# SearchBySeries can be: True (always series), False (always episode), or "smart" (automatic)
|
|
316
|
+
series_search_config = CONFIG.get(f"{name}.EntrySearch.SearchBySeries", fallback=False)
|
|
317
|
+
if isinstance(series_search_config, str) and series_search_config.lower() == "smart":
|
|
318
|
+
self.series_search = "smart"
|
|
319
|
+
elif series_search_config in (True, "true", "True", "TRUE", 1):
|
|
320
|
+
self.series_search = True
|
|
321
|
+
else:
|
|
322
|
+
self.series_search = False
|
|
316
323
|
if self.ombi_search_requests:
|
|
317
324
|
self.ombi_uri = CONFIG.get_or_raise(f"{name}.EntrySearch.Ombi.OmbiURI")
|
|
318
325
|
self.ombi_api_key = CONFIG.get_or_raise(f"{name}.EntrySearch.Ombi.OmbiAPIKey")
|
|
@@ -549,9 +556,12 @@ class Arr:
|
|
|
549
556
|
self.quality_unmet_search
|
|
550
557
|
or self.do_upgrade_search
|
|
551
558
|
or self.custom_format_unmet_search
|
|
552
|
-
or self.series_search
|
|
559
|
+
or self.series_search == True
|
|
553
560
|
):
|
|
554
561
|
self.search_api_command = "SeriesSearch"
|
|
562
|
+
elif self.series_search == "smart":
|
|
563
|
+
# In smart mode, the command will be determined dynamically
|
|
564
|
+
self.search_api_command = "SeriesSearch" # Default, will be overridden per search
|
|
555
565
|
else:
|
|
556
566
|
self.search_api_command = "MissingEpisodeSearch"
|
|
557
567
|
|
|
@@ -1499,11 +1509,55 @@ class Arr:
|
|
|
1499
1509
|
) -> Iterable[
|
|
1500
1510
|
tuple[MoviesFilesModel | EpisodeFilesModel | SeriesFilesModel, bool, bool, bool, int]
|
|
1501
1511
|
]:
|
|
1502
|
-
if self.type == "sonarr" and self.series_search:
|
|
1512
|
+
if self.type == "sonarr" and self.series_search == True:
|
|
1503
1513
|
serieslist = self.db_get_files_series()
|
|
1504
1514
|
for series in serieslist:
|
|
1505
1515
|
yield series[0], series[1], series[2], series[2] is not True, len(serieslist)
|
|
1506
|
-
elif self.type == "sonarr" and
|
|
1516
|
+
elif self.type == "sonarr" and self.series_search == "smart":
|
|
1517
|
+
# Smart mode: decide dynamically based on what needs to be searched
|
|
1518
|
+
episodelist = self.db_get_files_episodes()
|
|
1519
|
+
if episodelist:
|
|
1520
|
+
# Group episodes by series to determine if we should search by series or episode
|
|
1521
|
+
series_episodes_map = {}
|
|
1522
|
+
for episode_entry in episodelist:
|
|
1523
|
+
episode = episode_entry[0]
|
|
1524
|
+
series_id = episode.SeriesId
|
|
1525
|
+
if series_id not in series_episodes_map:
|
|
1526
|
+
series_episodes_map[series_id] = []
|
|
1527
|
+
series_episodes_map[series_id].append(episode_entry)
|
|
1528
|
+
|
|
1529
|
+
# Process each series
|
|
1530
|
+
for series_id, episodes in series_episodes_map.items():
|
|
1531
|
+
if len(episodes) > 1:
|
|
1532
|
+
# Multiple episodes from same series - use series search (smart decision)
|
|
1533
|
+
self.logger.info(
|
|
1534
|
+
"[SMART MODE] Using series search for %s episodes from series ID %s",
|
|
1535
|
+
len(episodes),
|
|
1536
|
+
series_id,
|
|
1537
|
+
)
|
|
1538
|
+
# Create a series entry for searching
|
|
1539
|
+
series_model = (
|
|
1540
|
+
self.series_file_model.select()
|
|
1541
|
+
.where(self.series_file_model.EntryId == series_id)
|
|
1542
|
+
.first()
|
|
1543
|
+
)
|
|
1544
|
+
if series_model:
|
|
1545
|
+
yield series_model, episodes[0][1], episodes[0][2], True, len(
|
|
1546
|
+
episodelist
|
|
1547
|
+
)
|
|
1548
|
+
else:
|
|
1549
|
+
# Single episode - use episode search (smart decision)
|
|
1550
|
+
episode = episodes[0][0]
|
|
1551
|
+
self.logger.info(
|
|
1552
|
+
"[SMART MODE] Using episode search for single episode: %s S%02dE%03d",
|
|
1553
|
+
episode.SeriesTitle,
|
|
1554
|
+
episode.SeasonNumber,
|
|
1555
|
+
episode.EpisodeNumber,
|
|
1556
|
+
)
|
|
1557
|
+
yield episodes[0][0], episodes[0][1], episodes[0][2], False, len(
|
|
1558
|
+
episodelist
|
|
1559
|
+
)
|
|
1560
|
+
elif self.type == "sonarr" and self.series_search == False:
|
|
1507
1561
|
episodelist = self.db_get_files_episodes()
|
|
1508
1562
|
for episodes in episodelist:
|
|
1509
1563
|
yield episodes[0], episodes[1], episodes[2], False, len(episodelist)
|
|
@@ -2842,7 +2896,7 @@ class Arr:
|
|
|
2842
2896
|
self.logger.debug(
|
|
2843
2897
|
"Updating quality profile for %s to %s",
|
|
2844
2898
|
db_entry["title"],
|
|
2845
|
-
|
|
2899
|
+
db_entry["qualityProfileId"],
|
|
2846
2900
|
)
|
|
2847
2901
|
while True:
|
|
2848
2902
|
try:
|
qBitrr/bundled_data.py
CHANGED
qBitrr/config.py
CHANGED
|
@@ -8,7 +8,7 @@ import sys
|
|
|
8
8
|
|
|
9
9
|
from qBitrr.bundled_data import license_text, patched_version
|
|
10
10
|
from qBitrr.env_config import ENVIRO_CONFIG
|
|
11
|
-
from qBitrr.gen_config import MyConfig, _write_config_file, generate_doc
|
|
11
|
+
from qBitrr.gen_config import MyConfig, _write_config_file, apply_config_migrations, generate_doc
|
|
12
12
|
from qBitrr.home_path import APPDATA_FOLDER, HOME_PATH
|
|
13
13
|
|
|
14
14
|
|
|
@@ -105,6 +105,10 @@ if COPIED_TO_NEW_DIR is not None:
|
|
|
105
105
|
else:
|
|
106
106
|
print(f"STARTING QBITRR | CONFIG_FILE={CONFIG_FILE} | CONFIG_PATH={CONFIG_PATH}")
|
|
107
107
|
|
|
108
|
+
# Apply configuration migrations and validations
|
|
109
|
+
if CONFIG_EXISTS:
|
|
110
|
+
apply_config_migrations(CONFIG)
|
|
111
|
+
|
|
108
112
|
FFPROBE_AUTO_UPDATE = (
|
|
109
113
|
CONFIG.get("Settings.FFprobeAutoUpdate", fallback=True)
|
|
110
114
|
if ENVIRO_CONFIG.settings.ffprobe_auto_update is None
|
qBitrr/gen_config.py
CHANGED
|
@@ -14,6 +14,50 @@ from qBitrr.home_path import APPDATA_FOLDER, HOME_PATH
|
|
|
14
14
|
T = TypeVar("T")
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def _add_web_settings_section(config: TOMLDocument):
|
|
18
|
+
web_settings = table()
|
|
19
|
+
_gen_default_line(
|
|
20
|
+
web_settings,
|
|
21
|
+
"WebUI listen host (default 0.0.0.0)",
|
|
22
|
+
"Host",
|
|
23
|
+
"0.0.0.0",
|
|
24
|
+
)
|
|
25
|
+
_gen_default_line(
|
|
26
|
+
web_settings,
|
|
27
|
+
"WebUI listen port (default 6969)",
|
|
28
|
+
"Port",
|
|
29
|
+
6969,
|
|
30
|
+
)
|
|
31
|
+
_gen_default_line(
|
|
32
|
+
web_settings,
|
|
33
|
+
[
|
|
34
|
+
"Optional bearer token to secure WebUI/API.",
|
|
35
|
+
"Set a non-empty value to require Authorization: Bearer <token>.",
|
|
36
|
+
],
|
|
37
|
+
"Token",
|
|
38
|
+
"",
|
|
39
|
+
)
|
|
40
|
+
_gen_default_line(
|
|
41
|
+
web_settings,
|
|
42
|
+
"Enable live updates for Arr views",
|
|
43
|
+
"LiveArr",
|
|
44
|
+
True,
|
|
45
|
+
)
|
|
46
|
+
_gen_default_line(
|
|
47
|
+
web_settings,
|
|
48
|
+
"Group Sonarr episodes by series in views",
|
|
49
|
+
"GroupSonarr",
|
|
50
|
+
True,
|
|
51
|
+
)
|
|
52
|
+
_gen_default_line(
|
|
53
|
+
web_settings,
|
|
54
|
+
"WebUI theme (light or dark)",
|
|
55
|
+
"Theme",
|
|
56
|
+
"dark",
|
|
57
|
+
)
|
|
58
|
+
config.add("WebUI", web_settings)
|
|
59
|
+
|
|
60
|
+
|
|
17
61
|
def generate_doc() -> TOMLDocument:
|
|
18
62
|
config = document()
|
|
19
63
|
config.add(
|
|
@@ -25,6 +69,7 @@ def generate_doc() -> TOMLDocument:
|
|
|
25
69
|
config.add(comment('This is a config file should be moved to "' f'{HOME_PATH}".'))
|
|
26
70
|
config.add(nl())
|
|
27
71
|
_add_settings_section(config)
|
|
72
|
+
_add_web_settings_section(config)
|
|
28
73
|
_add_qbit_section(config)
|
|
29
74
|
_add_category_sections(config)
|
|
30
75
|
return config
|
|
@@ -151,27 +196,6 @@ def _add_settings_section(config: TOMLDocument):
|
|
|
151
196
|
"AutoUpdateCron",
|
|
152
197
|
ENVIRO_CONFIG.settings.auto_update_cron or "0 3 * * 0",
|
|
153
198
|
)
|
|
154
|
-
_gen_default_line(
|
|
155
|
-
settings,
|
|
156
|
-
"WebUI listen port (default 6969)",
|
|
157
|
-
"WebUIPort",
|
|
158
|
-
6969,
|
|
159
|
-
)
|
|
160
|
-
_gen_default_line(
|
|
161
|
-
settings,
|
|
162
|
-
"WebUI listen host (default 0.0.0.0)",
|
|
163
|
-
"WebUIHost",
|
|
164
|
-
"0.0.0.0",
|
|
165
|
-
)
|
|
166
|
-
_gen_default_line(
|
|
167
|
-
settings,
|
|
168
|
-
[
|
|
169
|
-
"Optional bearer token to secure WebUI/API.",
|
|
170
|
-
"Set a non-empty value to require Authorization: Bearer <token>.",
|
|
171
|
-
],
|
|
172
|
-
"WebUIToken",
|
|
173
|
-
"",
|
|
174
|
-
)
|
|
175
199
|
config.add("Settings", settings)
|
|
176
200
|
|
|
177
201
|
|
|
@@ -680,9 +704,13 @@ def _gen_default_search_table(category: str, cat_default: Table):
|
|
|
680
704
|
if "sonarr" in category.lower():
|
|
681
705
|
_gen_default_line(
|
|
682
706
|
search_table,
|
|
683
|
-
|
|
707
|
+
[
|
|
708
|
+
"Search mode: true (always series search), false (always episode search), or 'smart' (automatic)",
|
|
709
|
+
"Smart mode: uses series search for entire seasons/series, episode search for single episodes",
|
|
710
|
+
"(Series search ignores QualityUnmetSearch and CustomFormatUnmetSearch settings)",
|
|
711
|
+
],
|
|
684
712
|
"SearchBySeries",
|
|
685
|
-
|
|
713
|
+
"smart",
|
|
686
714
|
)
|
|
687
715
|
_gen_default_line(
|
|
688
716
|
search_table,
|
|
@@ -813,6 +841,153 @@ class MyConfig:
|
|
|
813
841
|
return values if values is not ... else default
|
|
814
842
|
|
|
815
843
|
|
|
844
|
+
def _migrate_webui_config(config: MyConfig) -> bool:
|
|
845
|
+
"""
|
|
846
|
+
Migrate WebUI configuration from old location (Settings section) to new location (WebUI section).
|
|
847
|
+
Returns True if any migration was performed, False otherwise.
|
|
848
|
+
"""
|
|
849
|
+
migrated = False
|
|
850
|
+
|
|
851
|
+
# Check if WebUI section exists, if not create it
|
|
852
|
+
if "WebUI" not in config.config:
|
|
853
|
+
config.config["WebUI"] = table()
|
|
854
|
+
|
|
855
|
+
webui_section = config.config.get("WebUI", {})
|
|
856
|
+
|
|
857
|
+
# Migrate Host from Settings to WebUI
|
|
858
|
+
if "Host" not in webui_section:
|
|
859
|
+
old_host = config.get("Settings.Host", fallback=None)
|
|
860
|
+
if old_host is not None:
|
|
861
|
+
webui_section["Host"] = old_host
|
|
862
|
+
migrated = True
|
|
863
|
+
print(f"Migrated WebUI Host from Settings to WebUI section: {old_host}")
|
|
864
|
+
|
|
865
|
+
# Migrate Port from Settings to WebUI
|
|
866
|
+
if "Port" not in webui_section:
|
|
867
|
+
old_port = config.get("Settings.Port", fallback=None)
|
|
868
|
+
if old_port is not None:
|
|
869
|
+
webui_section["Port"] = old_port
|
|
870
|
+
migrated = True
|
|
871
|
+
print(f"Migrated WebUI Port from Settings to WebUI section: {old_port}")
|
|
872
|
+
|
|
873
|
+
# Migrate Token from Settings to WebUI
|
|
874
|
+
if "Token" not in webui_section:
|
|
875
|
+
old_token = config.get("Settings.Token", fallback=None)
|
|
876
|
+
if old_token is not None:
|
|
877
|
+
webui_section["Token"] = old_token
|
|
878
|
+
migrated = True
|
|
879
|
+
print(f"Migrated WebUI Token from Settings to WebUI section")
|
|
880
|
+
|
|
881
|
+
return migrated
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _validate_and_fill_config(config: MyConfig) -> bool:
|
|
885
|
+
"""
|
|
886
|
+
Validate configuration and fill in missing values with defaults.
|
|
887
|
+
Returns True if any changes were made, False otherwise.
|
|
888
|
+
"""
|
|
889
|
+
changed = False
|
|
890
|
+
defaults = config.defaults_config
|
|
891
|
+
|
|
892
|
+
# Helper function to ensure a config section exists
|
|
893
|
+
def ensure_section(section_name: str) -> None:
|
|
894
|
+
"""Ensure a config section exists."""
|
|
895
|
+
if section_name not in config.config:
|
|
896
|
+
config.config[section_name] = table()
|
|
897
|
+
|
|
898
|
+
# Helper function to check and fill config values
|
|
899
|
+
def ensure_value(config_section: str, key: str, default_value: Any) -> bool:
|
|
900
|
+
"""Ensure a config value exists, setting to default if missing."""
|
|
901
|
+
ensure_section(config_section)
|
|
902
|
+
section = config.config[config_section]
|
|
903
|
+
|
|
904
|
+
if key not in section or section[key] is None:
|
|
905
|
+
# Get the value from defaults if available
|
|
906
|
+
default_section = defaults.get(config_section, {})
|
|
907
|
+
if default_section and key in default_section:
|
|
908
|
+
default = default_section[key]
|
|
909
|
+
else:
|
|
910
|
+
default = default_value
|
|
911
|
+
section[key] = default
|
|
912
|
+
return True
|
|
913
|
+
return False
|
|
914
|
+
|
|
915
|
+
# Validate Settings section
|
|
916
|
+
settings_defaults = [
|
|
917
|
+
("ConsoleLevel", "INFO"),
|
|
918
|
+
("Logging", True),
|
|
919
|
+
("CompletedDownloadFolder", "CHANGE_ME"),
|
|
920
|
+
("FreeSpace", "-1"),
|
|
921
|
+
("FreeSpaceFolder", "CHANGE_ME"),
|
|
922
|
+
("AutoPauseResume", True),
|
|
923
|
+
("NoInternetSleepTimer", 15),
|
|
924
|
+
("LoopSleepTimer", 5),
|
|
925
|
+
("SearchLoopDelay", -1),
|
|
926
|
+
("FailedCategory", "failed"),
|
|
927
|
+
("RecheckCategory", "recheck"),
|
|
928
|
+
("Tagless", False),
|
|
929
|
+
("IgnoreTorrentsYoungerThan", 600),
|
|
930
|
+
("PingURLS", ["one.one.one.one", "dns.google.com"]),
|
|
931
|
+
("FFprobeAutoUpdate", True),
|
|
932
|
+
("AutoUpdateEnabled", False),
|
|
933
|
+
("AutoUpdateCron", "0 3 * * 0"),
|
|
934
|
+
]
|
|
935
|
+
|
|
936
|
+
for key, default in settings_defaults:
|
|
937
|
+
if ensure_value("Settings", key, default):
|
|
938
|
+
changed = True
|
|
939
|
+
|
|
940
|
+
# Validate WebUI section
|
|
941
|
+
webui_defaults = [
|
|
942
|
+
("Host", "0.0.0.0"),
|
|
943
|
+
("Port", 6969),
|
|
944
|
+
("Token", ""),
|
|
945
|
+
("LiveArr", True),
|
|
946
|
+
("GroupSonarr", True),
|
|
947
|
+
("Theme", "dark"),
|
|
948
|
+
]
|
|
949
|
+
|
|
950
|
+
for key, default in webui_defaults:
|
|
951
|
+
if ensure_value("WebUI", key, default):
|
|
952
|
+
changed = True
|
|
953
|
+
|
|
954
|
+
# Validate qBit section
|
|
955
|
+
qbit_defaults = [
|
|
956
|
+
("Disabled", False),
|
|
957
|
+
("Host", "localhost"),
|
|
958
|
+
("Port", 8105),
|
|
959
|
+
("UserName", ""),
|
|
960
|
+
("Password", ""),
|
|
961
|
+
]
|
|
962
|
+
|
|
963
|
+
for key, default in qbit_defaults:
|
|
964
|
+
if ensure_value("qBit", key, default):
|
|
965
|
+
changed = True
|
|
966
|
+
|
|
967
|
+
return changed
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def apply_config_migrations(config: MyConfig) -> None:
|
|
971
|
+
"""
|
|
972
|
+
Apply all configuration migrations and validations.
|
|
973
|
+
Saves the config if any changes were made.
|
|
974
|
+
"""
|
|
975
|
+
changes_made = False
|
|
976
|
+
|
|
977
|
+
# Apply migrations
|
|
978
|
+
if _migrate_webui_config(config):
|
|
979
|
+
changes_made = True
|
|
980
|
+
|
|
981
|
+
# Validate and fill config
|
|
982
|
+
if _validate_and_fill_config(config):
|
|
983
|
+
changes_made = True
|
|
984
|
+
|
|
985
|
+
# Save if changes were made
|
|
986
|
+
if changes_made:
|
|
987
|
+
config.save()
|
|
988
|
+
print("Configuration has been updated with migrations and defaults.")
|
|
989
|
+
|
|
990
|
+
|
|
816
991
|
def _write_config_file(docker: bool = False) -> pathlib.Path:
|
|
817
992
|
doc = generate_doc()
|
|
818
993
|
config_file = HOME_PATH.joinpath("config.toml")
|
qBitrr/logger.py
CHANGED
|
@@ -131,9 +131,29 @@ def run_logs(logger: Logger, _name: str = None) -> None:
|
|
|
131
131
|
logold.unlink()
|
|
132
132
|
logfile.rename(logold)
|
|
133
133
|
fh = logging.FileHandler(logfile)
|
|
134
|
+
# Use ColoredFormatter for file output to include ANSI colors in log files
|
|
134
135
|
fh.setFormatter(
|
|
135
|
-
|
|
136
|
-
fmt="[%(asctime)-15s] " f"%(levelname)-8s: %(name)-{key_length}s: %(message)s"
|
|
136
|
+
coloredlogs.ColoredFormatter(
|
|
137
|
+
fmt="[%(asctime)-15s] " f"%(levelname)-8s: %(name)-{key_length}s: %(message)s",
|
|
138
|
+
level_styles={
|
|
139
|
+
"trace": {"color": "black", "bold": True},
|
|
140
|
+
"debug": {"color": "magenta", "bold": True},
|
|
141
|
+
"verbose": {"color": "blue", "bold": True},
|
|
142
|
+
"info": {"color": "white"},
|
|
143
|
+
"notice": {"color": "cyan"},
|
|
144
|
+
"hnotice": {"color": "cyan", "bold": True},
|
|
145
|
+
"warning": {"color": "yellow", "bold": True},
|
|
146
|
+
"success": {"color": "green", "bold": True},
|
|
147
|
+
"error": {"color": "red"},
|
|
148
|
+
"critical": {"color": "red", "bold": True},
|
|
149
|
+
},
|
|
150
|
+
field_styles={
|
|
151
|
+
"asctime": {"color": "green"},
|
|
152
|
+
"process": {"color": "magenta"},
|
|
153
|
+
"levelname": {"color": "red", "bold": True},
|
|
154
|
+
"name": {"color": "blue", "bold": True},
|
|
155
|
+
"thread": {"color": "cyan"},
|
|
156
|
+
},
|
|
137
157
|
)
|
|
138
158
|
)
|
|
139
159
|
logger.addHandler(fh)
|
qBitrr/main.py
CHANGED
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import atexit
|
|
4
4
|
import contextlib
|
|
5
|
+
import glob
|
|
5
6
|
import logging
|
|
6
7
|
import os
|
|
7
8
|
import sys
|
|
@@ -31,6 +32,7 @@ from qBitrr.config import (
|
|
|
31
32
|
)
|
|
32
33
|
from qBitrr.env_config import ENVIRO_CONFIG
|
|
33
34
|
from qBitrr.ffprobe import FFprobeDownloader
|
|
35
|
+
from qBitrr.home_path import APPDATA_FOLDER
|
|
34
36
|
from qBitrr.logger import run_logs
|
|
35
37
|
from qBitrr.utils import ExpiringSet
|
|
36
38
|
from qBitrr.versioning import fetch_latest_release
|
|
@@ -49,6 +51,32 @@ def _mask_secret(value: str | None) -> str:
|
|
|
49
51
|
return "[redacted]" if value else ""
|
|
50
52
|
|
|
51
53
|
|
|
54
|
+
def _delete_all_databases() -> None:
|
|
55
|
+
"""
|
|
56
|
+
Delete all database files from the APPDATA_FOLDER on startup.
|
|
57
|
+
|
|
58
|
+
This includes:
|
|
59
|
+
- All .db files (SQLite databases)
|
|
60
|
+
- All .db-wal files (Write-Ahead Log files)
|
|
61
|
+
- All .db-shm files (Shared Memory files)
|
|
62
|
+
"""
|
|
63
|
+
db_patterns = ["*.db", "*.db-wal", "*.db-shm"]
|
|
64
|
+
deleted_files = []
|
|
65
|
+
|
|
66
|
+
for pattern in db_patterns:
|
|
67
|
+
for db_file in glob.glob(str(APPDATA_FOLDER.joinpath(pattern))):
|
|
68
|
+
try:
|
|
69
|
+
os.remove(db_file)
|
|
70
|
+
deleted_files.append(os.path.basename(db_file))
|
|
71
|
+
except Exception as e:
|
|
72
|
+
logger.error("Failed to delete database file %s: %s", db_file, e)
|
|
73
|
+
|
|
74
|
+
if deleted_files:
|
|
75
|
+
logger.info("Deleted database files on startup: %s", ", ".join(deleted_files))
|
|
76
|
+
else:
|
|
77
|
+
logger.debug("No database files found to delete on startup")
|
|
78
|
+
|
|
79
|
+
|
|
52
80
|
class qBitManager:
|
|
53
81
|
min_supported_version = VersionClass("4.3.9")
|
|
54
82
|
soft_not_supported_supported_version = VersionClass("4.4.4")
|
|
@@ -114,10 +142,10 @@ class qBitManager:
|
|
|
114
142
|
)
|
|
115
143
|
# Start WebUI as early as possible
|
|
116
144
|
try:
|
|
117
|
-
web_port = int(CONFIG.get("
|
|
145
|
+
web_port = int(CONFIG.get("WebUI.Port", fallback=6969) or 6969)
|
|
118
146
|
except Exception:
|
|
119
147
|
web_port = 6969
|
|
120
|
-
web_host = CONFIG.get("
|
|
148
|
+
web_host = CONFIG.get("WebUI.Host", fallback="127.0.0.1") or "127.0.0.1"
|
|
121
149
|
if os.environ.get("QBITRR_DOCKER_RUNNING") == "69420" and web_host in {
|
|
122
150
|
"127.0.0.1",
|
|
123
151
|
"localhost",
|
|
@@ -420,7 +448,7 @@ def _report_config_issues():
|
|
|
420
448
|
for key in CONFIG.sections():
|
|
421
449
|
import re
|
|
422
450
|
|
|
423
|
-
m = re.match(r"
|
|
451
|
+
m = re.match(r"radarr.*", key, re.IGNORECASE)
|
|
424
452
|
if not m:
|
|
425
453
|
continue
|
|
426
454
|
managed = CONFIG.get(f"{key}.Managed", fallback=False)
|
|
@@ -445,6 +473,10 @@ def run():
|
|
|
445
473
|
if early_exit is True:
|
|
446
474
|
sys.exit(0)
|
|
447
475
|
logger.info("Starting qBitrr: Version: %s.", patched_version)
|
|
476
|
+
|
|
477
|
+
# Delete all databases on startup
|
|
478
|
+
_delete_all_databases()
|
|
479
|
+
|
|
448
480
|
try:
|
|
449
481
|
manager = qBitManager()
|
|
450
482
|
except NameError:
|
qBitrr/webui.py
CHANGED
|
@@ -89,11 +89,11 @@ class WebUI:
|
|
|
89
89
|
werkzeug_logger.propagate = True
|
|
90
90
|
werkzeug_logger.setLevel(self.logger.level)
|
|
91
91
|
# Security token (optional) - auto-generate and persist if empty
|
|
92
|
-
self.token = CONFIG.get("
|
|
92
|
+
self.token = CONFIG.get("WebUI.Token", fallback=None)
|
|
93
93
|
if not self.token:
|
|
94
94
|
self.token = secrets.token_hex(32)
|
|
95
95
|
try:
|
|
96
|
-
_toml_set(CONFIG.config, "
|
|
96
|
+
_toml_set(CONFIG.config, "WebUI.Token", self.token)
|
|
97
97
|
CONFIG.save()
|
|
98
98
|
except Exception:
|
|
99
99
|
pass
|
|
@@ -270,11 +270,27 @@ class WebUI:
|
|
|
270
270
|
return bool(value) and str(value).lower() not in {"0", "false", "none"}
|
|
271
271
|
|
|
272
272
|
def _radarr_movies_from_db(
|
|
273
|
-
self,
|
|
273
|
+
self,
|
|
274
|
+
arr,
|
|
275
|
+
search: str | None,
|
|
276
|
+
page: int,
|
|
277
|
+
page_size: int,
|
|
278
|
+
year_min: int | None = None,
|
|
279
|
+
year_max: int | None = None,
|
|
280
|
+
monitored: bool | None = None,
|
|
281
|
+
has_file: bool | None = None,
|
|
282
|
+
quality_met: bool | None = None,
|
|
283
|
+
is_request: bool | None = None,
|
|
274
284
|
) -> dict[str, Any]:
|
|
275
285
|
if not self._ensure_arr_db(arr):
|
|
276
286
|
return {
|
|
277
|
-
"counts": {
|
|
287
|
+
"counts": {
|
|
288
|
+
"available": 0,
|
|
289
|
+
"monitored": 0,
|
|
290
|
+
"missing": 0,
|
|
291
|
+
"quality_met": 0,
|
|
292
|
+
"requests": 0,
|
|
293
|
+
},
|
|
278
294
|
"total": 0,
|
|
279
295
|
"page": max(page, 0),
|
|
280
296
|
"page_size": max(page_size, 1),
|
|
@@ -284,7 +300,13 @@ class WebUI:
|
|
|
284
300
|
db = getattr(arr, "db", None)
|
|
285
301
|
if model is None or db is None:
|
|
286
302
|
return {
|
|
287
|
-
"counts": {
|
|
303
|
+
"counts": {
|
|
304
|
+
"available": 0,
|
|
305
|
+
"monitored": 0,
|
|
306
|
+
"missing": 0,
|
|
307
|
+
"quality_met": 0,
|
|
308
|
+
"requests": 0,
|
|
309
|
+
},
|
|
288
310
|
"total": 0,
|
|
289
311
|
"page": max(page, 0),
|
|
290
312
|
"page_size": max(page_size, 1),
|
|
@@ -294,6 +316,8 @@ class WebUI:
|
|
|
294
316
|
page_size = max(page_size, 1)
|
|
295
317
|
with db.connection_context():
|
|
296
318
|
base_query = model.select()
|
|
319
|
+
|
|
320
|
+
# Calculate counts
|
|
297
321
|
monitored_count = (
|
|
298
322
|
model.select(fn.COUNT(model.EntryId))
|
|
299
323
|
.where(model.Monitored == True) # noqa: E712
|
|
@@ -310,9 +334,44 @@ class WebUI:
|
|
|
310
334
|
.scalar()
|
|
311
335
|
or 0
|
|
312
336
|
)
|
|
337
|
+
missing_count = max(monitored_count - available_count, 0)
|
|
338
|
+
quality_met_count = (
|
|
339
|
+
model.select(fn.COUNT(model.EntryId))
|
|
340
|
+
.where(model.QualityMet == True) # noqa: E712
|
|
341
|
+
.scalar()
|
|
342
|
+
or 0
|
|
343
|
+
)
|
|
344
|
+
request_count = (
|
|
345
|
+
model.select(fn.COUNT(model.EntryId))
|
|
346
|
+
.where(model.IsRequest == True) # noqa: E712
|
|
347
|
+
.scalar()
|
|
348
|
+
or 0
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
# Build filtered query
|
|
313
352
|
query = base_query
|
|
314
353
|
if search:
|
|
315
354
|
query = query.where(model.Title.contains(search))
|
|
355
|
+
if year_min is not None:
|
|
356
|
+
query = query.where(model.Year >= year_min)
|
|
357
|
+
if year_max is not None:
|
|
358
|
+
query = query.where(model.Year <= year_max)
|
|
359
|
+
if monitored is not None:
|
|
360
|
+
query = query.where(model.Monitored == monitored)
|
|
361
|
+
if has_file is not None:
|
|
362
|
+
if has_file:
|
|
363
|
+
query = query.where(
|
|
364
|
+
(model.MovieFileId.is_null(False)) & (model.MovieFileId != 0)
|
|
365
|
+
)
|
|
366
|
+
else:
|
|
367
|
+
query = query.where(
|
|
368
|
+
(model.MovieFileId.is_null(True)) | (model.MovieFileId == 0)
|
|
369
|
+
)
|
|
370
|
+
if quality_met is not None:
|
|
371
|
+
query = query.where(model.QualityMet == quality_met)
|
|
372
|
+
if is_request is not None:
|
|
373
|
+
query = query.where(model.IsRequest == is_request)
|
|
374
|
+
|
|
316
375
|
total = query.count()
|
|
317
376
|
page_items = query.order_by(model.Title.asc()).paginate(page + 1, page_size).iterator()
|
|
318
377
|
movies = []
|
|
@@ -324,10 +383,23 @@ class WebUI:
|
|
|
324
383
|
"year": movie.Year,
|
|
325
384
|
"monitored": self._safe_bool(movie.Monitored),
|
|
326
385
|
"hasFile": self._safe_bool(movie.MovieFileId),
|
|
386
|
+
"qualityMet": self._safe_bool(movie.QualityMet),
|
|
387
|
+
"isRequest": self._safe_bool(movie.IsRequest),
|
|
388
|
+
"upgrade": self._safe_bool(movie.Upgrade),
|
|
389
|
+
"customFormatScore": movie.CustomFormatScore,
|
|
390
|
+
"minCustomFormatScore": movie.MinCustomFormatScore,
|
|
391
|
+
"customFormatMet": self._safe_bool(movie.CustomFormatMet),
|
|
392
|
+
"reason": movie.Reason,
|
|
327
393
|
}
|
|
328
394
|
)
|
|
329
395
|
return {
|
|
330
|
-
"counts": {
|
|
396
|
+
"counts": {
|
|
397
|
+
"available": available_count,
|
|
398
|
+
"monitored": monitored_count,
|
|
399
|
+
"missing": missing_count,
|
|
400
|
+
"quality_met": quality_met_count,
|
|
401
|
+
"requests": request_count,
|
|
402
|
+
},
|
|
331
403
|
"total": total,
|
|
332
404
|
"page": page,
|
|
333
405
|
"page_size": page_size,
|
|
@@ -1177,7 +1249,40 @@ class WebUI:
|
|
|
1177
1249
|
q = request.args.get("q", default=None, type=str)
|
|
1178
1250
|
page = request.args.get("page", default=0, type=int)
|
|
1179
1251
|
page_size = request.args.get("page_size", default=50, type=int)
|
|
1180
|
-
|
|
1252
|
+
year_min = request.args.get("year_min", default=None, type=int)
|
|
1253
|
+
year_max = request.args.get("year_max", default=None, type=int)
|
|
1254
|
+
monitored = (
|
|
1255
|
+
self._safe_bool(request.args.get("monitored"))
|
|
1256
|
+
if "monitored" in request.args
|
|
1257
|
+
else None
|
|
1258
|
+
)
|
|
1259
|
+
has_file = (
|
|
1260
|
+
self._safe_bool(request.args.get("has_file"))
|
|
1261
|
+
if "has_file" in request.args
|
|
1262
|
+
else None
|
|
1263
|
+
)
|
|
1264
|
+
quality_met = (
|
|
1265
|
+
self._safe_bool(request.args.get("quality_met"))
|
|
1266
|
+
if "quality_met" in request.args
|
|
1267
|
+
else None
|
|
1268
|
+
)
|
|
1269
|
+
is_request = (
|
|
1270
|
+
self._safe_bool(request.args.get("is_request"))
|
|
1271
|
+
if "is_request" in request.args
|
|
1272
|
+
else None
|
|
1273
|
+
)
|
|
1274
|
+
payload = self._radarr_movies_from_db(
|
|
1275
|
+
arr,
|
|
1276
|
+
q,
|
|
1277
|
+
page,
|
|
1278
|
+
page_size,
|
|
1279
|
+
year_min=year_min,
|
|
1280
|
+
year_max=year_max,
|
|
1281
|
+
monitored=monitored,
|
|
1282
|
+
has_file=has_file,
|
|
1283
|
+
quality_met=quality_met,
|
|
1284
|
+
is_request=is_request,
|
|
1285
|
+
)
|
|
1181
1286
|
payload["category"] = category
|
|
1182
1287
|
return jsonify(payload)
|
|
1183
1288
|
|
|
@@ -1193,7 +1298,40 @@ class WebUI:
|
|
|
1193
1298
|
q = request.args.get("q", default=None, type=str)
|
|
1194
1299
|
page = request.args.get("page", default=0, type=int)
|
|
1195
1300
|
page_size = request.args.get("page_size", default=50, type=int)
|
|
1196
|
-
|
|
1301
|
+
year_min = request.args.get("year_min", default=None, type=int)
|
|
1302
|
+
year_max = request.args.get("year_max", default=None, type=int)
|
|
1303
|
+
monitored = (
|
|
1304
|
+
self._safe_bool(request.args.get("monitored"))
|
|
1305
|
+
if "monitored" in request.args
|
|
1306
|
+
else None
|
|
1307
|
+
)
|
|
1308
|
+
has_file = (
|
|
1309
|
+
self._safe_bool(request.args.get("has_file"))
|
|
1310
|
+
if "has_file" in request.args
|
|
1311
|
+
else None
|
|
1312
|
+
)
|
|
1313
|
+
quality_met = (
|
|
1314
|
+
self._safe_bool(request.args.get("quality_met"))
|
|
1315
|
+
if "quality_met" in request.args
|
|
1316
|
+
else None
|
|
1317
|
+
)
|
|
1318
|
+
is_request = (
|
|
1319
|
+
self._safe_bool(request.args.get("is_request"))
|
|
1320
|
+
if "is_request" in request.args
|
|
1321
|
+
else None
|
|
1322
|
+
)
|
|
1323
|
+
payload = self._radarr_movies_from_db(
|
|
1324
|
+
arr,
|
|
1325
|
+
q,
|
|
1326
|
+
page,
|
|
1327
|
+
page_size,
|
|
1328
|
+
year_min=year_min,
|
|
1329
|
+
year_max=year_max,
|
|
1330
|
+
monitored=monitored,
|
|
1331
|
+
has_file=has_file,
|
|
1332
|
+
quality_met=quality_met,
|
|
1333
|
+
is_request=is_request,
|
|
1334
|
+
)
|
|
1197
1335
|
payload["category"] = category
|
|
1198
1336
|
return jsonify(payload)
|
|
1199
1337
|
|
|
@@ -1457,11 +1595,11 @@ class WebUI:
|
|
|
1457
1595
|
for key, val in changes.items():
|
|
1458
1596
|
if val is None:
|
|
1459
1597
|
_toml_delete(CONFIG.config, key)
|
|
1460
|
-
if key == "
|
|
1598
|
+
if key == "WebUI.Token":
|
|
1461
1599
|
self.token = ""
|
|
1462
1600
|
continue
|
|
1463
1601
|
_toml_set(CONFIG.config, key, val)
|
|
1464
|
-
if key == "
|
|
1602
|
+
if key == "WebUI.Token":
|
|
1465
1603
|
# Update in-memory token immediately
|
|
1466
1604
|
self.token = str(val) if val is not None else ""
|
|
1467
1605
|
# Persist
|
|
@@ -1483,11 +1621,11 @@ class WebUI:
|
|
|
1483
1621
|
for key, val in changes.items():
|
|
1484
1622
|
if val is None:
|
|
1485
1623
|
_toml_delete(CONFIG.config, key)
|
|
1486
|
-
if key == "
|
|
1624
|
+
if key == "WebUI.Token":
|
|
1487
1625
|
self.token = ""
|
|
1488
1626
|
continue
|
|
1489
1627
|
_toml_set(CONFIG.config, key, val)
|
|
1490
|
-
if key == "
|
|
1628
|
+
if key == "WebUI.Token":
|
|
1491
1629
|
self.token = str(val) if val is not None else ""
|
|
1492
1630
|
CONFIG.save()
|
|
1493
1631
|
try:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qBitrr2
|
|
3
|
-
Version: 5.
|
|
3
|
+
Version: 5.2.0
|
|
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
|
|
@@ -137,13 +137,14 @@ Minimal setup:
|
|
|
137
137
|
```bash
|
|
138
138
|
docker run -d \
|
|
139
139
|
--name qbitrr \
|
|
140
|
+
--tty \
|
|
140
141
|
-e TZ=Europe/London \
|
|
141
142
|
-p 6969:6969 \
|
|
142
143
|
-v /etc/localtime:/etc/localtime:ro \
|
|
143
144
|
-v /path/to/appdata/qbitrr:/config \
|
|
144
145
|
-v /path/to/completed/downloads:/completed_downloads:rw \
|
|
145
146
|
--restart unless-stopped \
|
|
146
|
-
feramance/qbitrr:latest
|
|
147
|
+
feramance/qbitrr:latest
|
|
147
148
|
```
|
|
148
149
|
|
|
149
150
|
The container automatically binds its WebUI to `0.0.0.0`; exposing `6969` makes the dashboard reachable at `http://<host>:6969/ui`.
|
|
@@ -155,6 +156,7 @@ services:
|
|
|
155
156
|
image: feramance/qbitrr:latest
|
|
156
157
|
user: 1000:1000
|
|
157
158
|
restart: unless-stopped
|
|
159
|
+
tty: true
|
|
158
160
|
environment:
|
|
159
161
|
TZ: Europe/London
|
|
160
162
|
ports:
|
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
qBitrr/__init__.py,sha256=smiPIV7d2lMJ_KTtFdAVlxLEBobFTheILdgry1iqpjQ,405
|
|
2
|
-
qBitrr/arss.py,sha256=
|
|
2
|
+
qBitrr/arss.py,sha256=UeDAyZyl6GO2VcUT1wKLHug2QuNSV9TdTg4oWOwV3X8,266028
|
|
3
3
|
qBitrr/auto_update.py,sha256=hVAvAlKEdOHm6AJLlKvtkklbQhjotVcFOCH-MTigHQM,4419
|
|
4
|
-
qBitrr/bundled_data.py,sha256=
|
|
5
|
-
qBitrr/config.py,sha256=
|
|
4
|
+
qBitrr/bundled_data.py,sha256=EtewAQLDIoOSHMqzKq2dvgjYRYNJ4GazbwLqlWcqS9I,190
|
|
5
|
+
qBitrr/config.py,sha256=e_UL8Jjz2hWAhT53Du8XZpvyY4ULC5mpyus_7i2An18,6306
|
|
6
6
|
qBitrr/db_lock.py,sha256=SRCDIoqg-AFLU-VDChAmGdfx8nhgLGETn6XKF3RdJT4,2449
|
|
7
7
|
qBitrr/env_config.py,sha256=299u_uEoyxlM_ceTD0Z_i41JdYjSHmqO6FKe7qGFgTM,2866
|
|
8
8
|
qBitrr/errors.py,sha256=5_n1x0XX4UvMlieC_J1Hc5pq5JD17orfjJy9KfxDXA4,1107
|
|
9
9
|
qBitrr/ffprobe.py,sha256=2IM0iuPPTEb0xHmN1OetQoBd80-Nmv5Oq7P6o-mjBd0,4019
|
|
10
|
-
qBitrr/gen_config.py,sha256=
|
|
10
|
+
qBitrr/gen_config.py,sha256=oIsGIRDF0zHOQXS8Y8yiHiqPL4R0ckw9xw2uQQ2M14s,35368
|
|
11
11
|
qBitrr/home_path.py,sha256=zvBheAR2xvr8LBZRk1FyqfnALE-eFzsY9CyqyZDjxiE,626
|
|
12
|
-
qBitrr/logger.py,sha256=
|
|
13
|
-
qBitrr/main.py,sha256=
|
|
12
|
+
qBitrr/logger.py,sha256=jEmtSqmsxdvjuYSbgsu9u4jLY5xEfV0nhuW3NHD6zKg,6574
|
|
13
|
+
qBitrr/main.py,sha256=lv4y7Ew5xOCFt37koFGdoqQcv6xULZwojt2ne0LmkD4,19976
|
|
14
14
|
qBitrr/search_activity_store.py,sha256=_7MD7fFna4uTSo_pRT7DqoytSVz7tPoU9D2AV2mn-oc,2474
|
|
15
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
|
-
qBitrr/webui.py,sha256=
|
|
19
|
-
qbitrr2-5.
|
|
20
|
-
qbitrr2-5.
|
|
21
|
-
qbitrr2-5.
|
|
22
|
-
qbitrr2-5.
|
|
23
|
-
qbitrr2-5.
|
|
24
|
-
qbitrr2-5.
|
|
18
|
+
qBitrr/webui.py,sha256=qwKkB3fgJy0tXpaoGx1jomNRZ8qUKT4A1hWuZL2sxms,73299
|
|
19
|
+
qbitrr2-5.2.0.dist-info/licenses/LICENSE,sha256=P978aVGi7dPbKz8lfvdiryOS5IjTAU7AA47XhBhVBlI,1066
|
|
20
|
+
qbitrr2-5.2.0.dist-info/METADATA,sha256=tILNoBxyNwCRBRHmAFvfWhetFFyw27AVYDDx6KX3uYo,10148
|
|
21
|
+
qbitrr2-5.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
22
|
+
qbitrr2-5.2.0.dist-info/entry_points.txt,sha256=MIR-l5s31VBs9qlv3HiAaMdpOOyy0MNGfM7Ib1-fKeQ,43
|
|
23
|
+
qbitrr2-5.2.0.dist-info/top_level.txt,sha256=jIINodarzsPcQeTf-vvK8-_g7cQ8CvxEg41ms14K97g,7
|
|
24
|
+
qbitrr2-5.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|