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 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
- self.series_search = CONFIG.get(f"{name}.EntrySearch.SearchBySeries", fallback=False)
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 not self.series_search:
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
- self.temp_quality_profile_ids[db_entry["qualityProfileId"]],
2899
+ db_entry["qualityProfileId"],
2846
2900
  )
2847
2901
  while True:
2848
2902
  try:
qBitrr/bundled_data.py CHANGED
@@ -1,5 +1,5 @@
1
- version = "5.1.1"
2
- git_hash = "203e3ef"
1
+ version = "5.2.0"
2
+ git_hash = "be5f39c"
3
3
  license_text = (
4
4
  "Licence can be found on:\n\nhttps://github.com/Feramance/qBitrr/blob/master/LICENSE"
5
5
  )
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
- "Search by series instead of by episode (This ignored the QualityUnmetSearch and CustomFormatUnmetSearch setting)",
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
- True,
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
- logging.Formatter(
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("Settings.WebUIPort", fallback=6969) or 6969)
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("Settings.WebUIHost", fallback="127.0.0.1") or "127.0.0.1"
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"(rad|son|anim)arr.*", key, re.IGNORECASE)
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("Settings.WebUIToken", fallback=None)
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, "Settings.WebUIToken", self.token)
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, arr, search: str | None, page: int, page_size: int
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": {"available": 0, "monitored": 0},
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": {"available": 0, "monitored": 0},
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": {"available": available_count, "monitored": monitored_count},
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
- payload = self._radarr_movies_from_db(arr, q, page, page_size)
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
- payload = self._radarr_movies_from_db(arr, q, page, page_size)
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 == "Settings.WebUIToken":
1598
+ if key == "WebUI.Token":
1461
1599
  self.token = ""
1462
1600
  continue
1463
1601
  _toml_set(CONFIG.config, key, val)
1464
- if key == "Settings.WebUIToken":
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 == "Settings.WebUIToken":
1624
+ if key == "WebUI.Token":
1487
1625
  self.token = ""
1488
1626
  continue
1489
1627
  _toml_set(CONFIG.config, key, val)
1490
- if key == "Settings.WebUIToken":
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.1.1
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=WwDobdQcGcHSvQdh9tiDaza0S8wnPgOMtiRLpmd3Ans,263064
2
+ qBitrr/arss.py,sha256=UeDAyZyl6GO2VcUT1wKLHug2QuNSV9TdTg4oWOwV3X8,266028
3
3
  qBitrr/auto_update.py,sha256=hVAvAlKEdOHm6AJLlKvtkklbQhjotVcFOCH-MTigHQM,4419
4
- qBitrr/bundled_data.py,sha256=qkprw9cE-4KVdWUx1-7xn2heRUSPOIMW9LZd04dOzuo,190
5
- qBitrr/config.py,sha256=brGy1PQJY6D0HG1V6gpuTi1gPbMH3zIvfozASkvPZR8,6177
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=lDRbCzjWoJuUyOZNnOmNjChuZoR5K6fuwKCJ2qxzu78,29862
10
+ qBitrr/gen_config.py,sha256=oIsGIRDF0zHOQXS8Y8yiHiqPL4R0ckw9xw2uQQ2M14s,35368
11
11
  qBitrr/home_path.py,sha256=zvBheAR2xvr8LBZRk1FyqfnALE-eFzsY9CyqyZDjxiE,626
12
- qBitrr/logger.py,sha256=os7cHbJ3sbkxDh6Nno9o_41aCwsLp-Y963nZe-rglKA,5505
13
- qBitrr/main.py,sha256=x1jzrOBX3PziARnRY5UaSgrRmbCGwG6s2AnoUI6M-Zk,19003
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=HaM3w-rzuvVyGtphRCROY2GDXZtRmny3blkC5WoTOSk,68298
19
- qbitrr2-5.1.1.dist-info/licenses/LICENSE,sha256=P978aVGi7dPbKz8lfvdiryOS5IjTAU7AA47XhBhVBlI,1066
20
- qbitrr2-5.1.1.dist-info/METADATA,sha256=DFS1E6dKhTG132BhkflK2scrC_kGR5XeonsNOsX0Nk4,10122
21
- qbitrr2-5.1.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
22
- qbitrr2-5.1.1.dist-info/entry_points.txt,sha256=MIR-l5s31VBs9qlv3HiAaMdpOOyy0MNGfM7Ib1-fKeQ,43
23
- qbitrr2-5.1.1.dist-info/top_level.txt,sha256=jIINodarzsPcQeTf-vvK8-_g7cQ8CvxEg41ms14K97g,7
24
- qbitrr2-5.1.1.dist-info/RECORD,,
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,,