qBitrr2 5.5.5__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.
Files changed (64) hide show
  1. qBitrr/__init__.py +14 -0
  2. qBitrr/arss.py +7100 -0
  3. qBitrr/auto_update.py +382 -0
  4. qBitrr/bundled_data.py +7 -0
  5. qBitrr/config.py +192 -0
  6. qBitrr/config_version.py +144 -0
  7. qBitrr/db_lock.py +400 -0
  8. qBitrr/db_recovery.py +202 -0
  9. qBitrr/env_config.py +73 -0
  10. qBitrr/errors.py +41 -0
  11. qBitrr/ffprobe.py +105 -0
  12. qBitrr/gen_config.py +1331 -0
  13. qBitrr/home_path.py +23 -0
  14. qBitrr/logger.py +235 -0
  15. qBitrr/main.py +790 -0
  16. qBitrr/search_activity_store.py +92 -0
  17. qBitrr/static/assets/ArrView.js +2 -0
  18. qBitrr/static/assets/ArrView.js.map +1 -0
  19. qBitrr/static/assets/ConfigView.js +4 -0
  20. qBitrr/static/assets/ConfigView.js.map +1 -0
  21. qBitrr/static/assets/LogsView.js +2 -0
  22. qBitrr/static/assets/LogsView.js.map +1 -0
  23. qBitrr/static/assets/ProcessesView.js +2 -0
  24. qBitrr/static/assets/ProcessesView.js.map +1 -0
  25. qBitrr/static/assets/app.css +1 -0
  26. qBitrr/static/assets/app.js +11 -0
  27. qBitrr/static/assets/app.js.map +1 -0
  28. qBitrr/static/assets/build.svg +3 -0
  29. qBitrr/static/assets/check-mark.svg +5 -0
  30. qBitrr/static/assets/close.svg +4 -0
  31. qBitrr/static/assets/download.svg +5 -0
  32. qBitrr/static/assets/gear.svg +5 -0
  33. qBitrr/static/assets/live-streaming.svg +8 -0
  34. qBitrr/static/assets/log.svg +3 -0
  35. qBitrr/static/assets/logo.svg +48 -0
  36. qBitrr/static/assets/plus.svg +4 -0
  37. qBitrr/static/assets/process.svg +15 -0
  38. qBitrr/static/assets/react-select.esm.js +7 -0
  39. qBitrr/static/assets/react-select.esm.js.map +1 -0
  40. qBitrr/static/assets/refresh-arrow.svg +3 -0
  41. qBitrr/static/assets/table.js +5 -0
  42. qBitrr/static/assets/table.js.map +1 -0
  43. qBitrr/static/assets/trash.svg +8 -0
  44. qBitrr/static/assets/up-arrow.svg +3 -0
  45. qBitrr/static/assets/useInterval.js +2 -0
  46. qBitrr/static/assets/useInterval.js.map +1 -0
  47. qBitrr/static/assets/vendor.js +2 -0
  48. qBitrr/static/assets/vendor.js.map +1 -0
  49. qBitrr/static/assets/visibility.svg +9 -0
  50. qBitrr/static/index.html +33 -0
  51. qBitrr/static/logov2-clean.svg +48 -0
  52. qBitrr/static/manifest.json +23 -0
  53. qBitrr/static/sw.js +87 -0
  54. qBitrr/static/vite.svg +1 -0
  55. qBitrr/tables.py +143 -0
  56. qBitrr/utils.py +274 -0
  57. qBitrr/versioning.py +136 -0
  58. qBitrr/webui.py +3114 -0
  59. qbitrr2-5.5.5.dist-info/METADATA +1191 -0
  60. qbitrr2-5.5.5.dist-info/RECORD +64 -0
  61. qbitrr2-5.5.5.dist-info/WHEEL +5 -0
  62. qbitrr2-5.5.5.dist-info/entry_points.txt +2 -0
  63. qbitrr2-5.5.5.dist-info/licenses/LICENSE +21 -0
  64. qbitrr2-5.5.5.dist-info/top_level.txt +1 -0
qBitrr/gen_config.py ADDED
@@ -0,0 +1,1331 @@
1
+ from __future__ import annotations
2
+
3
+ import pathlib
4
+ from functools import reduce
5
+ from typing import Any, TypeVar
6
+
7
+ from tomlkit import comment, document, inline_table, nl, parse, table
8
+ from tomlkit.items import Table
9
+ from tomlkit.toml_document import TOMLDocument
10
+
11
+ from qBitrr.env_config import ENVIRO_CONFIG
12
+ from qBitrr.home_path import APPDATA_FOLDER, HOME_PATH
13
+
14
+ T = TypeVar("T")
15
+
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
+ "Group Lidarr albums by artist in views",
55
+ "GroupLidarr",
56
+ True,
57
+ )
58
+ _gen_default_line(
59
+ web_settings,
60
+ "WebUI theme (Light or Dark)",
61
+ "Theme",
62
+ "Dark",
63
+ )
64
+ config.add("WebUI", web_settings)
65
+
66
+
67
+ def generate_doc() -> TOMLDocument:
68
+ config = document()
69
+ config.add(
70
+ comment(
71
+ "This is a config file for the qBitrr Script - "
72
+ 'Make sure to change all entries of "CHANGE_ME".'
73
+ )
74
+ )
75
+ config.add(comment('This is a config file should be moved to "' f'{HOME_PATH}".'))
76
+ config.add(nl())
77
+ _add_settings_section(config)
78
+ _add_web_settings_section(config)
79
+ _add_qbit_section(config)
80
+ _add_category_sections(config)
81
+ return config
82
+
83
+
84
+ def _add_settings_section(config: TOMLDocument):
85
+ settings = table()
86
+ _gen_default_line(
87
+ settings,
88
+ [
89
+ "Internal config schema version - DO NOT MODIFY",
90
+ "This is managed automatically by qBitrr for config migrations",
91
+ ],
92
+ "ConfigVersion",
93
+ 3,
94
+ )
95
+ _gen_default_line(
96
+ settings,
97
+ "Level of logging; One of CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG, TRACE",
98
+ "ConsoleLevel",
99
+ ENVIRO_CONFIG.settings.console_level or "INFO",
100
+ )
101
+ _gen_default_line(
102
+ settings, "Enable logging to files", "Logging", ENVIRO_CONFIG.settings.logging or True
103
+ )
104
+ _gen_default_line(
105
+ settings,
106
+ "Folder where your completed downloads are put into. Can be found in qBitTorrent -> Options -> Downloads -> Default Save Path (Please note, replace all '\\' with '/')",
107
+ "CompletedDownloadFolder",
108
+ ENVIRO_CONFIG.settings.completed_download_folder or "CHANGE_ME",
109
+ )
110
+ _gen_default_line(
111
+ settings,
112
+ "The desired amount of free space in the downloads directory [K=kilobytes, M=megabytes, G=gigabytes, T=terabytes] (set to -1 to disable, this bypasses AutoPauseResume)",
113
+ "FreeSpace",
114
+ ENVIRO_CONFIG.settings.free_space or "-1",
115
+ )
116
+ _gen_default_line(
117
+ settings,
118
+ "Folder where the free space handler will check for free space (Please note, replace all '' with '/')",
119
+ "FreeSpaceFolder",
120
+ ENVIRO_CONFIG.settings.free_space_folder or "CHANGE_ME",
121
+ )
122
+ _gen_default_line(
123
+ settings,
124
+ "Enable automation of pausing and resuming torrents as needed (Required enabled for the FreeSpace logic to function)",
125
+ "AutoPauseResume",
126
+ ENVIRO_CONFIG.settings.auto_pause_resume or True,
127
+ )
128
+ _gen_default_line(
129
+ settings,
130
+ "Time to sleep for if there is no internet (in seconds: 600 = 10 Minutes)",
131
+ "NoInternetSleepTimer",
132
+ ENVIRO_CONFIG.settings.no_internet_sleep_timer or 15,
133
+ )
134
+ _gen_default_line(
135
+ settings,
136
+ "Time to sleep between reprocessing torrents (in seconds: 600 = 10 Minutes)",
137
+ "LoopSleepTimer",
138
+ ENVIRO_CONFIG.settings.loop_sleep_timer or 5,
139
+ )
140
+ _gen_default_line(
141
+ settings,
142
+ "Time to sleep between posting search commands (in seconds: 600 = 10 Minutes)",
143
+ "SearchLoopDelay",
144
+ ENVIRO_CONFIG.settings.search_loop_delay or -1,
145
+ )
146
+ _gen_default_line(
147
+ settings,
148
+ "Add torrents to this category to mark them as failed",
149
+ "FailedCategory",
150
+ ENVIRO_CONFIG.settings.failed_category or "failed",
151
+ )
152
+ _gen_default_line(
153
+ settings,
154
+ "Add torrents to this category to trigger them to be rechecked properly",
155
+ "RecheckCategory",
156
+ ENVIRO_CONFIG.settings.recheck_category or "recheck",
157
+ )
158
+ _gen_default_line(
159
+ settings, "Tagless operation", "Tagless", ENVIRO_CONFIG.settings.tagless or False
160
+ )
161
+ _gen_default_line(
162
+ settings,
163
+ [
164
+ "Ignore Torrents which are younger than this value (in seconds: 600 = 10 Minutes)",
165
+ "Only applicable to Re-check and failed categories",
166
+ ],
167
+ "IgnoreTorrentsYoungerThan",
168
+ ENVIRO_CONFIG.settings.ignore_torrents_younger_than or 180,
169
+ )
170
+ _gen_default_line(
171
+ settings,
172
+ [
173
+ "URL to be pinged to check if you have a valid internet connection",
174
+ "These will be pinged a **LOT** make sure the service is okay with you sending all the continuous pings.",
175
+ ],
176
+ "PingURLS",
177
+ ENVIRO_CONFIG.settings.ping_urls or ["one.one.one.one", "dns.google.com"],
178
+ )
179
+ _gen_default_line(
180
+ settings,
181
+ [
182
+ "FFprobe auto updates, binaries are downloaded from https://ffbinaries.com/downloads",
183
+ "If this is disabled and you want ffprobe to work",
184
+ "Ensure that you add the ffprobe binary to the folder"
185
+ f"\"{APPDATA_FOLDER.joinpath('ffprobe.exe')}\"",
186
+ "If no `ffprobe` binary is found in the folder above all ffprobe functionality will be disabled.",
187
+ "By default this will always be on even if config does not have these key - to disable you need to explicitly set it to `False`",
188
+ ],
189
+ "FFprobeAutoUpdate",
190
+ True if ENVIRO_CONFIG.settings.ping_urls is None else ENVIRO_CONFIG.settings.ping_urls,
191
+ )
192
+ _gen_default_line(
193
+ settings,
194
+ [
195
+ "Automatically attempt to update qBitrr on a schedule",
196
+ "Set to true to enable the auto-update worker.",
197
+ ],
198
+ "AutoUpdateEnabled",
199
+ (
200
+ ENVIRO_CONFIG.settings.auto_update_enabled
201
+ if ENVIRO_CONFIG.settings.auto_update_enabled is not None
202
+ else False
203
+ ),
204
+ )
205
+ _gen_default_line(
206
+ settings,
207
+ [
208
+ "Cron expression describing when to check for updates",
209
+ "Default is weekly Sunday at 03:00 (0 3 * * 0).",
210
+ ],
211
+ "AutoUpdateCron",
212
+ ENVIRO_CONFIG.settings.auto_update_cron or "0 3 * * 0",
213
+ )
214
+ _gen_default_line(
215
+ settings,
216
+ [
217
+ "Automatically restart worker processes that fail or crash",
218
+ "Set to false to disable auto-restart (processes will only log failures)",
219
+ ],
220
+ "AutoRestartProcesses",
221
+ True,
222
+ )
223
+ _gen_default_line(
224
+ settings,
225
+ [
226
+ "Maximum number of restart attempts per process within the restart window",
227
+ "Prevents infinite restart loops for processes that crash immediately",
228
+ ],
229
+ "MaxProcessRestarts",
230
+ 5,
231
+ )
232
+ _gen_default_line(
233
+ settings,
234
+ [
235
+ "Time window (seconds) for tracking restart attempts",
236
+ "If a process restarts MaxProcessRestarts times within this window, auto-restart is disabled for that process",
237
+ ],
238
+ "ProcessRestartWindow",
239
+ 300,
240
+ )
241
+ _gen_default_line(
242
+ settings,
243
+ "Delay (seconds) before attempting to restart a failed process",
244
+ "ProcessRestartDelay",
245
+ 5,
246
+ )
247
+ config.add("Settings", settings)
248
+
249
+
250
+ def _add_qbit_section(config: TOMLDocument):
251
+ qbit = table()
252
+ _gen_default_line(
253
+ qbit,
254
+ [
255
+ "If this is enabled qBitrr can run in headless mode where it will only process searches.",
256
+ "If media search is enabled in their individual categories",
257
+ "This is useful if you use for example Sabnzbd/NZBGet for downloading content but still want the faster media searches provided by qbit",
258
+ ],
259
+ "Disabled",
260
+ False if ENVIRO_CONFIG.qbit.disabled is None else ENVIRO_CONFIG.qbit.disabled,
261
+ )
262
+ _gen_default_line(
263
+ qbit,
264
+ 'qbittorrent WebUI URL/IP - Can be found in Options > Web UI (called "IP Address")',
265
+ "Host",
266
+ ENVIRO_CONFIG.qbit.host or "CHANGE_ME",
267
+ )
268
+ _gen_default_line(
269
+ qbit,
270
+ 'qbittorrent WebUI Port - Can be found in Options > Web UI (called "Port" on top right corner of the window)',
271
+ "Port",
272
+ ENVIRO_CONFIG.qbit.port or 8080,
273
+ )
274
+ _gen_default_line(
275
+ qbit,
276
+ "qbittorrent WebUI Authentication - Can be found in Options > Web UI > Authentication",
277
+ "UserName",
278
+ ENVIRO_CONFIG.qbit.username or "CHANGE_ME",
279
+ )
280
+ _gen_default_line(
281
+ qbit,
282
+ 'If you set "Bypass authentication on localhost or whitelisted IPs" remove this field.',
283
+ "Password",
284
+ ENVIRO_CONFIG.qbit.password or "CHANGE_ME",
285
+ )
286
+ config.add("qBit", qbit)
287
+
288
+
289
+ def _add_category_sections(config: TOMLDocument):
290
+ for c in ["Sonarr-TV", "Sonarr-Anime", "Radarr-1080", "Radarr-4K", "Lidarr-Music"]:
291
+ _gen_default_cat(c, config)
292
+
293
+
294
+ def _gen_default_cat(category: str, config: TOMLDocument):
295
+ cat_default = table()
296
+ cat_default.add(nl())
297
+ _gen_default_line(
298
+ cat_default, "Toggle whether to manage the Servarr instance torrents.", "Managed", True
299
+ )
300
+ _gen_default_line(
301
+ cat_default,
302
+ "The URL used to access Servarr interface eg. http://ip:port"
303
+ "(if you use a domain enter the domain without a port)",
304
+ "URI",
305
+ "CHANGE_ME",
306
+ )
307
+ _gen_default_line(
308
+ cat_default,
309
+ "The Servarr API Key, Can be found it Settings > General > Security",
310
+ "APIKey",
311
+ "CHANGE_ME",
312
+ )
313
+ _gen_default_line(
314
+ cat_default,
315
+ "Category applied by Servarr to torrents in qBitTorrent, can be found in Settings > Download Clients > qBit > Category",
316
+ "Category",
317
+ category.lower(),
318
+ )
319
+ _gen_default_line(
320
+ cat_default,
321
+ "Toggle whether to send a query to Servarr to search any failed torrents",
322
+ "ReSearch",
323
+ True,
324
+ )
325
+ _gen_default_line(
326
+ cat_default, "The Servarr's Import Mode(one of Move, Copy or Auto)", "importMode", "Auto"
327
+ )
328
+ _gen_default_line(
329
+ cat_default,
330
+ "Timer to call RSSSync (In minutes) - Set to 0 to disable (Values below 5 can cause errors for maximum retires)",
331
+ "RssSyncTimer",
332
+ 1,
333
+ )
334
+ _gen_default_line(
335
+ cat_default,
336
+ "Timer to call RefreshDownloads to update the queue. (In minutes) - Set to 0 to disable (Values below 5 can cause errors for maximum retires)",
337
+ "RefreshDownloadsTimer",
338
+ 1,
339
+ )
340
+ messages = []
341
+ if "radarr" in category.lower():
342
+ messages.extend(
343
+ [
344
+ "Not a preferred word upgrade for existing movie file(s)",
345
+ "Not an upgrade for existing movie file(s)",
346
+ "Unable to determine if file is a sample",
347
+ ]
348
+ )
349
+ elif "sonarr" in category.lower():
350
+ messages.extend(
351
+ [
352
+ "Not a preferred word upgrade for existing episode file(s)",
353
+ "Not an upgrade for existing episode file(s)",
354
+ "Unable to determine if file is a sample",
355
+ ]
356
+ )
357
+ elif "lidarr" in category.lower():
358
+ messages.extend(
359
+ [
360
+ "Not a preferred word upgrade for existing track file(s)",
361
+ "Not an upgrade for existing track file(s)",
362
+ "Unable to determine if file is a sample",
363
+ ]
364
+ )
365
+ _gen_default_line(
366
+ cat_default,
367
+ [
368
+ "Error messages shown my the Arr instance which should be considered failures.",
369
+ "This entry should be a list, leave it empty if you want to disable this error handling.",
370
+ "If enabled qBitrr will remove the failed files and tell the Arr instance the download failed",
371
+ ],
372
+ "ArrErrorCodesToBlocklist",
373
+ list(set(messages)),
374
+ )
375
+ _gen_default_search_table(category, cat_default)
376
+ _gen_default_torrent_table(category, cat_default)
377
+ config.add(category, cat_default)
378
+
379
+
380
+ def _gen_default_torrent_table(category: str, cat_default: Table):
381
+ torrent_table = table()
382
+ _gen_default_line(
383
+ torrent_table,
384
+ "Set it to regex matches to respect/ignore case.",
385
+ "CaseSensitiveMatches",
386
+ False,
387
+ )
388
+ if "anime" not in category.lower():
389
+ _gen_default_line(
390
+ torrent_table,
391
+ [
392
+ "These regex values will match any folder where the full name matches the specified values here, comma separated strings.",
393
+ "These regex need to be escaped, that's why you see so many backslashes.",
394
+ ],
395
+ "FolderExclusionRegex",
396
+ [
397
+ r"\bextras?\b",
398
+ r"\bfeaturettes?\b",
399
+ r"\bsamples?\b",
400
+ r"\bscreens?\b",
401
+ r"\bnc(ed|op)?(\\d+)?\b",
402
+ ],
403
+ )
404
+ else:
405
+ _gen_default_line(
406
+ torrent_table,
407
+ [
408
+ "These regex values will match any folder where the full name matches the specified values here, comma separated strings.",
409
+ "These regex need to be escaped, that's why you see so many backslashes.",
410
+ ],
411
+ "FolderExclusionRegex",
412
+ [
413
+ r"\bextras?\b",
414
+ r"\bfeaturettes?\b",
415
+ r"\bsamples?\b",
416
+ r"\bscreens?\b",
417
+ r"\bspecials?\b",
418
+ r"\bova\b",
419
+ r"\bnc(ed|op)?(\\d+)?\b",
420
+ ],
421
+ )
422
+ _gen_default_line(
423
+ torrent_table,
424
+ [
425
+ "These regex values will match any folder where the full name matches the specified values here, comma separated strings.",
426
+ "These regex need to be escaped, that's why you see so many backslashes.",
427
+ ],
428
+ "FileNameExclusionRegex",
429
+ [
430
+ r"\bncop\\d+?\b",
431
+ r"\bnced\\d+?\b",
432
+ r"\bsample\b",
433
+ r"brarbg.com\b",
434
+ r"\btrailer\b",
435
+ r"music video",
436
+ r"comandotorrents.com",
437
+ ],
438
+ )
439
+ _gen_default_line(
440
+ torrent_table,
441
+ "Only files with these extensions will be allowed to be downloaded, comma separated strings or regex, leave it empty to allow all extensions",
442
+ "FileExtensionAllowlist",
443
+ [".mp4", ".mkv", ".sub", ".ass", ".srt", ".!qB", ".parts"],
444
+ )
445
+ _gen_default_line(
446
+ torrent_table,
447
+ "Auto delete files that can't be playable (i.e .exe, .png)",
448
+ "AutoDelete",
449
+ False,
450
+ )
451
+ _gen_default_line(
452
+ torrent_table,
453
+ "Ignore Torrents which are younger than this value (in seconds: 600 = 10 Minutes)",
454
+ "IgnoreTorrentsYoungerThan",
455
+ 180,
456
+ )
457
+ _gen_default_line(
458
+ torrent_table,
459
+ [
460
+ "Maximum allowed remaining ETA for torrent completion (in seconds: 3600 = 1 Hour)",
461
+ "Note that if you set the MaximumETA on a tracker basis that value is favoured over this value",
462
+ ],
463
+ "MaximumETA",
464
+ -1,
465
+ )
466
+ _gen_default_line(
467
+ torrent_table,
468
+ "Do not delete torrents with higher completion percentage than this setting (0.5 = 50%, 1.0 = 100%)",
469
+ "MaximumDeletablePercentage",
470
+ 0.99,
471
+ )
472
+ _gen_default_line(torrent_table, "Ignore slow torrents.", "DoNotRemoveSlow", True)
473
+ _gen_default_line(
474
+ torrent_table,
475
+ "Maximum allowed time for allowed stalled torrents in minutes (-1 = Disabled, 0 = Infinite)",
476
+ "StalledDelay",
477
+ 15,
478
+ )
479
+ _gen_default_line(
480
+ torrent_table,
481
+ "Re-search stalled torrents when StalledDelay is enabled and you want to re-search before removing the stalled torrent, or only after the torrent is removed.",
482
+ "ReSearchStalled",
483
+ False,
484
+ )
485
+ _gen_default_seeding_table(category, torrent_table)
486
+ _gen_default_tracker_tables(category, torrent_table)
487
+
488
+ cat_default.add("Torrent", torrent_table)
489
+
490
+
491
+ def _gen_default_seeding_table(category: str, torrent_table: Table):
492
+ seeding_table = table()
493
+ _gen_default_line(
494
+ seeding_table,
495
+ [
496
+ "Set the maximum allowed download rate for torrents",
497
+ "Set this value to -1 to disabled it",
498
+ "Note that if you set the DownloadRateLimit on a tracker basis that value is favoured over this value",
499
+ ],
500
+ "DownloadRateLimitPerTorrent",
501
+ -1,
502
+ )
503
+ _gen_default_line(
504
+ seeding_table,
505
+ [
506
+ "Set the maximum allowed upload rate for torrents",
507
+ "Set this value to -1 to disabled it",
508
+ "Note that if you set the UploadRateLimit on a tracker basis that value is favoured over this value",
509
+ ],
510
+ "UploadRateLimitPerTorrent",
511
+ -1,
512
+ )
513
+ _gen_default_line(
514
+ seeding_table,
515
+ [
516
+ "Set the maximum allowed upload ratio for torrents",
517
+ "Set this value to -1 to disabled it",
518
+ "Note that if you set the MaxUploadRatio on a tracker basis that value is favoured over this value",
519
+ ],
520
+ "MaxUploadRatio",
521
+ -1,
522
+ )
523
+ _gen_default_line(
524
+ seeding_table,
525
+ [
526
+ "Set the maximum seeding time in seconds for torrents",
527
+ "Set this value to -1 to disabled it",
528
+ "Note that if you set the MaxSeedingTime on a tracker basis that value is favoured over this value",
529
+ ],
530
+ "MaxSeedingTime",
531
+ -1,
532
+ )
533
+ _gen_default_line(
534
+ seeding_table,
535
+ "Remove torrent condition (-1=Do not remove, 1=Remove on MaxUploadRatio, 2=Remove on MaxSeedingTime, 3=Remove on MaxUploadRatio or MaxSeedingTime, 4=Remove on MaxUploadRatio and MaxSeedingTime)",
536
+ "RemoveTorrent",
537
+ -1,
538
+ )
539
+ _gen_default_line(
540
+ seeding_table, "Enable if you want to remove dead trackers", "RemoveDeadTrackers", False
541
+ )
542
+ _gen_default_line(
543
+ seeding_table,
544
+ 'If "RemoveDeadTrackers" is set to true then remove trackers with the following messages',
545
+ "RemoveTrackerWithMessage",
546
+ [
547
+ "skipping tracker announce (unreachable)",
548
+ "No such host is known",
549
+ "unsupported URL protocol",
550
+ "info hash is not authorized with this tracker",
551
+ ],
552
+ )
553
+
554
+ torrent_table.add("SeedingMode", seeding_table)
555
+
556
+
557
+ def _gen_default_tracker_tables(category: str, torrent_table: Table):
558
+ tracker_table_list = []
559
+ tracker_list = []
560
+ if "anime" in category.lower():
561
+ tracker_list.append(("Nyaa", "http://nyaa.tracker.wf:7777/announce", ["qBitrr-anime"], 10))
562
+ elif "radarr" in category.lower():
563
+ t = ["qBitrr-Rarbg", "Movies and TV"]
564
+ t2 = []
565
+ if "4k" in category.lower():
566
+ t.append("4K")
567
+ t2.append("4K")
568
+ tracker_list.extend(
569
+ (
570
+ ("Rarbg-2810", "udp://9.rarbg.com:2810/announce", t, 1),
571
+ ("Rarbg-2740", "udp://9.rarbg.to:2740/announce", t2, 2),
572
+ )
573
+ )
574
+ for name, url, tags, priority in tracker_list:
575
+ tracker_table = table()
576
+ _gen_default_line(
577
+ tracker_table,
578
+ "This is only for your own benefit, it is not currently used anywhere, but one day it may be.",
579
+ "Name",
580
+ name,
581
+ )
582
+ tracker_table.add(
583
+ comment("This is used when multiple trackers are in one single torrent.")
584
+ )
585
+ _gen_default_line(
586
+ tracker_table,
587
+ "the tracker with the highest priority will have all its settings applied to the torrent.",
588
+ "Priority",
589
+ priority,
590
+ )
591
+ _gen_default_line(tracker_table, "The tracker URI used by qBit.", "URI", url)
592
+ _gen_default_line(
593
+ tracker_table,
594
+ "Maximum allowed remaining ETA for torrent completion (in seconds: 3600 = 1 Hour).",
595
+ "MaximumETA",
596
+ 18000,
597
+ )
598
+ tracker_table.add(comment("Set the maximum allowed download rate for torrents"))
599
+ _gen_default_line(
600
+ tracker_table, "Set this value to -1 to disabled it", "DownloadRateLimit", -1
601
+ )
602
+ tracker_table.add(comment("Set the maximum allowed upload rate for torrents"))
603
+ _gen_default_line(
604
+ tracker_table, "Set this value to -1 to disabled it", "UploadRateLimit", -1
605
+ )
606
+ tracker_table.add(comment("Set the maximum allowed download rate for torrents"))
607
+ _gen_default_line(
608
+ tracker_table, "Set this value to -1 to disabled it", "MaxUploadRatio", -1
609
+ )
610
+ tracker_table.add(comment("Set the maximum allowed download rate for torrents"))
611
+ _gen_default_line(
612
+ tracker_table, "Set this value to -1 to disabled it", "MaxSeedingTime", -1
613
+ )
614
+ _gen_default_line(
615
+ tracker_table,
616
+ "Add this tracker from any torrent that does not contains it.",
617
+ "AddTrackerIfMissing",
618
+ False,
619
+ )
620
+ _gen_default_line(
621
+ tracker_table,
622
+ "Remove this tracker from any torrent that contains it.",
623
+ "RemoveIfExists",
624
+ False,
625
+ )
626
+ _gen_default_line(
627
+ tracker_table,
628
+ "Enable Super Seeding setting for torrents with this tracker.",
629
+ "SuperSeedMode",
630
+ False,
631
+ )
632
+ if tags:
633
+ _gen_default_line(
634
+ tracker_table,
635
+ "Adds these tags to any torrents containing this tracker.",
636
+ "AddTags",
637
+ tags,
638
+ )
639
+ tracker_table_list.append(tracker_table)
640
+ torrent_table.add(
641
+ comment("You can have multiple trackers set here or none just add more subsections.")
642
+ )
643
+ torrent_table.add("Trackers", tracker_table_list)
644
+
645
+
646
+ def _gen_default_line(table, comments, field, value):
647
+ if isinstance(comments, list):
648
+ for c in comments:
649
+ table.add(comment(c))
650
+ else:
651
+ table.add(comment(comments))
652
+ table.add(field, value)
653
+ table.add(nl())
654
+
655
+
656
+ def _gen_default_search_table(category: str, cat_default: Table):
657
+ search_table = table()
658
+ _gen_default_line(search_table, "Should search for Missing files?", "SearchMissing", True)
659
+ if "sonarr" in category.lower():
660
+ _gen_default_line(
661
+ search_table,
662
+ "Should search for specials episodes? (Season 00)",
663
+ "AlsoSearchSpecials",
664
+ False,
665
+ )
666
+ _gen_default_line(
667
+ search_table,
668
+ "Should search for unmonitored episodes/series?",
669
+ "Unmonitored",
670
+ False,
671
+ )
672
+ _gen_default_line(
673
+ search_table,
674
+ [
675
+ "Maximum allowed Searches at any one points (I wouldn't recommend settings this too high)",
676
+ "Sonarr has a hardcoded cap of 3 simultaneous tasks",
677
+ ],
678
+ "SearchLimit",
679
+ 5,
680
+ )
681
+ elif "radarr" in category.lower():
682
+ _gen_default_line(
683
+ search_table,
684
+ "Should search for unmonitored movies?",
685
+ "Unmonitored",
686
+ False,
687
+ )
688
+ _gen_default_line(
689
+ search_table,
690
+ [
691
+ "Radarr has a default of 3 simultaneous tasks, which can be increased up to 10 tasks",
692
+ 'If you set the environment variable of "THREAD_LIMIT" to a number between and including 2-10',
693
+ "Radarr devs have stated that this is an unsupported feature so you will not get any support for doing so from them.",
694
+ "That being said I've been daily driving 10 simultaneous tasks for quite a while now with no issues.",
695
+ ],
696
+ "SearchLimit",
697
+ 5,
698
+ )
699
+ # SearchByYear doesn't apply to Lidarr (music albums)
700
+ if "lidarr" not in category.lower():
701
+ _gen_default_line(
702
+ search_table,
703
+ "It will order searches by the year the EPISODE was first aired",
704
+ "SearchByYear",
705
+ True,
706
+ )
707
+ _gen_default_line(
708
+ search_table,
709
+ "Reverse search order (Start searching oldest to newest)",
710
+ "SearchInReverse",
711
+ False,
712
+ )
713
+ _gen_default_line(
714
+ search_table, "Delay between request searches in seconds", "SearchRequestsEvery", 300
715
+ )
716
+ _gen_default_line(
717
+ search_table,
718
+ "Search movies which already have a file in the database in hopes of finding a "
719
+ "better quality version.",
720
+ "DoUpgradeSearch",
721
+ False,
722
+ )
723
+ _gen_default_line(
724
+ search_table,
725
+ "Do a quality unmet search for existing entries.",
726
+ "QualityUnmetSearch",
727
+ False,
728
+ )
729
+ _gen_default_line(
730
+ search_table,
731
+ "Do a minimum custom format score unmet search for existing entries.",
732
+ "CustomFormatUnmetSearch",
733
+ False,
734
+ )
735
+ _gen_default_line(
736
+ search_table,
737
+ "Automatically remove torrents that do not mee the minimum custom format score.",
738
+ "ForceMinimumCustomFormat",
739
+ False,
740
+ )
741
+ _gen_default_line(
742
+ search_table,
743
+ "Once you have search all files on your specified year range restart the loop and "
744
+ "search again.",
745
+ "SearchAgainOnSearchCompletion",
746
+ True,
747
+ )
748
+ _gen_default_line(search_table, "Use Temp profile for missing", "UseTempForMissing", False)
749
+ _gen_default_line(search_table, "Don't change back to main profile", "KeepTempProfile", False)
750
+ _gen_default_line(
751
+ search_table,
752
+ [
753
+ "Quality profile mappings for temp profile switching (Main Profile Name -> Temp Profile Name)",
754
+ "Profile names must match exactly as they appear in your Arr instance",
755
+ 'Example: QualityProfileMappings = {"HD-1080p" = "SD", "HD-720p" = "SD"}',
756
+ ],
757
+ "QualityProfileMappings",
758
+ inline_table(),
759
+ )
760
+ _gen_default_line(
761
+ search_table,
762
+ "Reset all items using temp profiles to their original main profile on qBitrr startup",
763
+ "ForceResetTempProfiles",
764
+ False,
765
+ )
766
+ _gen_default_line(
767
+ search_table,
768
+ "Timeout in minutes after which items with temp profiles are automatically reset to main profile (0 = disabled)",
769
+ "TempProfileResetTimeoutMinutes",
770
+ 0,
771
+ )
772
+ _gen_default_line(
773
+ search_table,
774
+ "Number of retry attempts for profile switch API calls (default: 3)",
775
+ "ProfileSwitchRetryAttempts",
776
+ 3,
777
+ )
778
+ _gen_default_line(
779
+ search_table,
780
+ "Main quality profile (To pair quality profiles, ensure they are in the same order as in the temp profiles)",
781
+ "MainQualityProfile",
782
+ [],
783
+ )
784
+ _gen_default_line(
785
+ search_table,
786
+ "Temp quality profile (To pair quality profiles, ensure they are in the same order as in the main profiles)",
787
+ "TempQualityProfile",
788
+ [],
789
+ )
790
+ if "sonarr" in category.lower():
791
+ _gen_default_line(
792
+ search_table,
793
+ [
794
+ "Search mode: true (always series search), false (always episode search), or 'smart' (automatic)",
795
+ "Smart mode: uses series search for entire seasons/series, episode search for single episodes",
796
+ "(Series search ignores QualityUnmetSearch and CustomFormatUnmetSearch settings)",
797
+ ],
798
+ "SearchBySeries",
799
+ "smart",
800
+ )
801
+ _gen_default_line(
802
+ search_table,
803
+ "Prioritize Today's releases (Similar effect as RSS Sync, where it searches "
804
+ "today's release episodes first, only works on Sonarr).",
805
+ "PrioritizeTodaysReleases",
806
+ True,
807
+ )
808
+ # Ombi and Overseerr don't support music requests
809
+ if "lidarr" not in category.lower():
810
+ _gen_default_ombi_table(category, search_table)
811
+ _gen_default_overseerr_table(category, search_table)
812
+ cat_default.add("EntrySearch", search_table)
813
+
814
+
815
+ def _gen_default_ombi_table(category: str, search_table: Table):
816
+ ombi_table = table()
817
+ _gen_default_line(
818
+ ombi_table,
819
+ "Search Ombi for pending requests (Will only work if 'SearchMissing' is enabled.)",
820
+ "SearchOmbiRequests",
821
+ False,
822
+ )
823
+ _gen_default_line(
824
+ ombi_table,
825
+ "Ombi URI eg. http://ip:port (Note that this has to be the instance of Ombi which manage the Arr instance request (If you have multiple Ombi instances)",
826
+ "OmbiURI",
827
+ "CHANGE_ME",
828
+ )
829
+ _gen_default_line(ombi_table, "Ombi's API Key", "OmbiAPIKey", "CHANGE_ME")
830
+ _gen_default_line(ombi_table, "Only process approved requests", "ApprovedOnly", True)
831
+ search_table.add("Ombi", ombi_table)
832
+
833
+
834
+ def _gen_default_overseerr_table(category: str, search_table: Table):
835
+ overseerr_table = table()
836
+ _gen_default_line(
837
+ overseerr_table,
838
+ [
839
+ "Search Overseerr for pending requests (Will only work if 'SearchMissing' is enabled.)",
840
+ "If this and Ombi are both enable, Ombi will be ignored",
841
+ ],
842
+ "SearchOverseerrRequests",
843
+ False,
844
+ )
845
+ _gen_default_line(
846
+ overseerr_table, "Overseerr's URI eg. http://ip:port", "OverseerrURI", "CHANGE_ME"
847
+ )
848
+ _gen_default_line(overseerr_table, "Overseerr's API Key", "OverseerrAPIKey", "CHANGE_ME")
849
+ _gen_default_line(overseerr_table, "Only process approved requests", "ApprovedOnly", True)
850
+ overseerr_table.add(comment("Only for 4K Instances"))
851
+ if "radarr-4k" in category.lower():
852
+ _gen_default_line(overseerr_table, "Only for 4K Instances", "Is4K", True)
853
+ else:
854
+ _gen_default_line(overseerr_table, "Only for 4K Instances", "Is4K", False)
855
+ search_table.add("Overseerr", overseerr_table)
856
+
857
+
858
+ class MyConfig:
859
+ # Original code taken from https://github.com/SemenovAV/toml_config
860
+ # Licence is MIT, can be located at
861
+ # https://github.com/SemenovAV/toml_config/blob/master/LICENSE.txt
862
+
863
+ path: pathlib.Path
864
+ config: TOMLDocument
865
+ defaults_config: TOMLDocument
866
+
867
+ def __init__(self, path: pathlib.Path | str, config: TOMLDocument | None = None):
868
+ self.path = pathlib.Path(path)
869
+ self._giving_data = bool(config)
870
+ self.config = config or document()
871
+ self.defaults_config = generate_doc()
872
+ self.err = None
873
+ self.state = True
874
+ self.load()
875
+
876
+ def __str__(self):
877
+ return self.config.as_string()
878
+
879
+ def load(self) -> MyConfig:
880
+ if self.state:
881
+ try:
882
+ if self._giving_data:
883
+ return self
884
+ with self.path.open() as file:
885
+ self.config = parse(file.read())
886
+ return self
887
+ except (OSError, TypeError) as err:
888
+ self.state = False
889
+ self.err = err
890
+ return self
891
+
892
+ def save(self) -> MyConfig:
893
+ if self.state:
894
+ try:
895
+ with open(self.path, "w", encoding="utf8") as file:
896
+ file.write(self.config.as_string())
897
+ return self
898
+ except OSError as err:
899
+ self._value_error(
900
+ err, "Possible permissions while attempting to read the config file.\n"
901
+ )
902
+ except TypeError as err:
903
+ self._value_error(err, "While attempting to read the config file.\n")
904
+ return self
905
+
906
+ def _value_error(self, err, arg1):
907
+ self.state = False
908
+ self.err = err
909
+ raise ValueError(f"{arg1}{err}")
910
+
911
+ def get(self, section: str, fallback: Any = None) -> T:
912
+ return self._deep_get(section, default=fallback)
913
+
914
+ def get_or_raise(self, section: str) -> T:
915
+ if (r := self._deep_get(section, default=KeyError)) is KeyError:
916
+ raise KeyError(f"{section} does not exist")
917
+ return r
918
+
919
+ def sections(self):
920
+ return self.config.keys()
921
+
922
+ def _deep_get(self, keys, default=...):
923
+ values = reduce(
924
+ lambda d, key: d.get(key, ...) if isinstance(d, dict) else ...,
925
+ keys.split("."),
926
+ self.config,
927
+ )
928
+
929
+ return values if values is not ... else default
930
+
931
+
932
+ def _migrate_webui_config(config: MyConfig) -> bool:
933
+ """
934
+ Migrate WebUI configuration from old location (Settings section) to new location (WebUI section).
935
+ Returns True if any migration was performed, False otherwise.
936
+ """
937
+ migrated = False
938
+
939
+ # Check if WebUI section exists, if not create it
940
+ if "WebUI" not in config.config:
941
+ config.config["WebUI"] = table()
942
+
943
+ webui_section = config.config.get("WebUI", {})
944
+
945
+ # Migrate Host from Settings to WebUI
946
+ if "Host" not in webui_section:
947
+ old_host = config.get("Settings.Host", fallback=None)
948
+ if old_host is not None:
949
+ webui_section["Host"] = old_host
950
+ migrated = True
951
+ print(f"Migrated WebUI Host from Settings to WebUI section: {old_host}")
952
+
953
+ # Migrate Port from Settings to WebUI
954
+ if "Port" not in webui_section:
955
+ old_port = config.get("Settings.Port", fallback=None)
956
+ if old_port is not None:
957
+ webui_section["Port"] = old_port
958
+ migrated = True
959
+ print(f"Migrated WebUI Port from Settings to WebUI section: {old_port}")
960
+
961
+ # Migrate Token from Settings to WebUI
962
+ if "Token" not in webui_section:
963
+ old_token = config.get("Settings.Token", fallback=None)
964
+ if old_token is not None:
965
+ webui_section["Token"] = old_token
966
+ migrated = True
967
+ print(f"Migrated WebUI Token from Settings to WebUI section")
968
+
969
+ return migrated
970
+
971
+
972
+ def _migrate_process_restart_settings(config: MyConfig) -> bool:
973
+ """
974
+ Add process auto-restart settings to existing configs.
975
+
976
+ Migration runs if:
977
+ - ConfigVersion < 3 (versions 1 or 2)
978
+
979
+ After migration, ConfigVersion will be set to 3 by apply_config_migrations().
980
+
981
+ Returns:
982
+ True if changes were made, False otherwise
983
+ """
984
+ import logging
985
+
986
+ from qBitrr.config_version import get_config_version
987
+
988
+ logger = logging.getLogger(__name__)
989
+
990
+ # Check if migration already applied
991
+ current_version = get_config_version(config)
992
+ if current_version >= 3:
993
+ return False # Already migrated
994
+
995
+ # Ensure Settings section exists
996
+ if "Settings" not in config.config:
997
+ config.config["Settings"] = table()
998
+
999
+ settings = config.config["Settings"]
1000
+ changes_made = False
1001
+
1002
+ # Add AutoRestartProcesses if missing
1003
+ if "AutoRestartProcesses" not in settings:
1004
+ settings["AutoRestartProcesses"] = True
1005
+ changes_made = True
1006
+ logger.info("Added AutoRestartProcesses = true (default: enabled)")
1007
+
1008
+ # Add MaxProcessRestarts if missing
1009
+ if "MaxProcessRestarts" not in settings:
1010
+ settings["MaxProcessRestarts"] = 5
1011
+ changes_made = True
1012
+ logger.info("Added MaxProcessRestarts = 5 (default)")
1013
+
1014
+ # Add ProcessRestartWindow if missing
1015
+ if "ProcessRestartWindow" not in settings:
1016
+ settings["ProcessRestartWindow"] = 300
1017
+ changes_made = True
1018
+ logger.info("Added ProcessRestartWindow = 300 seconds (5 minutes)")
1019
+
1020
+ # Add ProcessRestartDelay if missing
1021
+ if "ProcessRestartDelay" not in settings:
1022
+ settings["ProcessRestartDelay"] = 5
1023
+ changes_made = True
1024
+ logger.info("Added ProcessRestartDelay = 5 seconds")
1025
+
1026
+ if changes_made:
1027
+ print("Migration v2→v3: Added process auto-restart configuration settings")
1028
+
1029
+ return changes_made
1030
+
1031
+
1032
+ def _migrate_quality_profile_mappings(config: MyConfig) -> bool:
1033
+ """
1034
+ Migrate from list-based profile config to dict-based mappings.
1035
+
1036
+ Migration runs if:
1037
+ - ConfigVersion is missing (old config), OR
1038
+ - ConfigVersion == 1 (baseline version before this feature)
1039
+
1040
+ After migration, ConfigVersion will be set to 2 by apply_config_migrations().
1041
+
1042
+ Returns:
1043
+ True if changes were made, False otherwise
1044
+ """
1045
+ import logging
1046
+
1047
+ from qBitrr.config_version import get_config_version
1048
+
1049
+ logger = logging.getLogger(__name__)
1050
+
1051
+ # Check if migration already applied
1052
+ current_version = get_config_version(config)
1053
+ if current_version >= 2:
1054
+ return False # Already migrated
1055
+
1056
+ # At this point, ConfigVersion is either missing (returns 1 as default) or explicitly 1
1057
+ # Both cases need migration if old format exists
1058
+
1059
+ changes_made = False
1060
+ arr_types = ["Radarr", "Sonarr", "Lidarr", "Animarr"]
1061
+
1062
+ for arr_type in arr_types:
1063
+ # Find all Arr instances (e.g., "Radarr-Movies", "Sonarr-TV")
1064
+ for key in list(config.config.keys()):
1065
+ if not str(key).startswith(arr_type):
1066
+ continue
1067
+
1068
+ entry_search_key = f"{key}.EntrySearch"
1069
+ entry_search_section = config.get(entry_search_key, fallback=None)
1070
+ if not entry_search_section:
1071
+ continue
1072
+
1073
+ # Check for old format
1074
+ main_profiles = config.get(f"{entry_search_key}.MainQualityProfile", fallback=None)
1075
+ temp_profiles = config.get(f"{entry_search_key}.TempQualityProfile", fallback=None)
1076
+
1077
+ # Skip if no old format found
1078
+ if not main_profiles or not temp_profiles:
1079
+ continue
1080
+
1081
+ # Validate list lengths match
1082
+ if len(main_profiles) != len(temp_profiles):
1083
+ logger.error(
1084
+ f"Cannot migrate {key}: MainQualityProfile ({len(main_profiles)}) "
1085
+ f"and TempQualityProfile ({len(temp_profiles)}) have different lengths"
1086
+ )
1087
+ continue
1088
+
1089
+ # Create mappings dict, filtering out empty/None values
1090
+ mappings = {
1091
+ str(main).strip(): str(temp).strip()
1092
+ for main, temp in zip(main_profiles, temp_profiles)
1093
+ if main and temp and str(main).strip() and str(temp).strip()
1094
+ }
1095
+
1096
+ if mappings:
1097
+ # Set new format - use tomlkit's inline_table to ensure it's rendered as inline dict
1098
+ inline_mappings = inline_table()
1099
+ inline_mappings.update(mappings)
1100
+ config.config[str(key)]["EntrySearch"]["QualityProfileMappings"] = inline_mappings
1101
+ changes_made = True
1102
+ logger.info(f"Migrated {key} to QualityProfileMappings: {mappings}")
1103
+
1104
+ # Remove old format
1105
+ del config.config[str(key)]["EntrySearch"]["MainQualityProfile"]
1106
+ del config.config[str(key)]["EntrySearch"]["TempQualityProfile"]
1107
+ logger.debug(f"Removed legacy profile lists from {key}")
1108
+
1109
+ return changes_made
1110
+
1111
+
1112
+ def _normalize_theme_value(value: Any) -> str:
1113
+ """
1114
+ Normalize theme value to always be 'Light' or 'Dark' (case insensitive input).
1115
+ """
1116
+ if value is None:
1117
+ return "Dark"
1118
+ value_str = str(value).strip().lower()
1119
+ if value_str == "light":
1120
+ return "Light"
1121
+ elif value_str == "dark":
1122
+ return "Dark"
1123
+ else:
1124
+ # Default to Dark if invalid value
1125
+ return "Dark"
1126
+
1127
+
1128
+ def _validate_and_fill_config(config: MyConfig) -> bool:
1129
+ """
1130
+ Validate configuration and fill in missing values with defaults.
1131
+ Returns True if any changes were made, False otherwise.
1132
+ """
1133
+ changed = False
1134
+ defaults = config.defaults_config
1135
+
1136
+ # Helper function to ensure a config section exists
1137
+ def ensure_section(section_name: str) -> None:
1138
+ """Ensure a config section exists."""
1139
+ if section_name not in config.config:
1140
+ config.config[section_name] = table()
1141
+
1142
+ # Helper function to check and fill config values
1143
+ def ensure_value(config_section: str, key: str, default_value: Any) -> bool:
1144
+ """Ensure a config value exists, setting to default if missing."""
1145
+ ensure_section(config_section)
1146
+ section = config.config[config_section]
1147
+
1148
+ if key not in section or section[key] is None:
1149
+ # Get the value from defaults if available
1150
+ default_section = defaults.get(config_section, {})
1151
+ if default_section and key in default_section:
1152
+ default = default_section[key]
1153
+ else:
1154
+ default = default_value
1155
+ section[key] = default
1156
+ return True
1157
+ return False
1158
+
1159
+ # Validate Settings section
1160
+ settings_defaults = [
1161
+ ("ConfigVersion", 1), # Internal version, DO NOT expose to WebUI
1162
+ ("ConsoleLevel", "INFO"),
1163
+ ("Logging", True),
1164
+ ("CompletedDownloadFolder", "CHANGE_ME"),
1165
+ ("FreeSpace", "-1"),
1166
+ ("FreeSpaceFolder", "CHANGE_ME"),
1167
+ ("AutoPauseResume", True),
1168
+ ("NoInternetSleepTimer", 15),
1169
+ ("LoopSleepTimer", 5),
1170
+ ("SearchLoopDelay", -1),
1171
+ ("FailedCategory", "failed"),
1172
+ ("RecheckCategory", "recheck"),
1173
+ ("Tagless", False),
1174
+ ("IgnoreTorrentsYoungerThan", 600),
1175
+ ("PingURLS", ["one.one.one.one", "dns.google.com"]),
1176
+ ("FFprobeAutoUpdate", True),
1177
+ ("AutoUpdateEnabled", False),
1178
+ ("AutoUpdateCron", "0 3 * * 0"),
1179
+ ]
1180
+
1181
+ for key, default in settings_defaults:
1182
+ if ensure_value("Settings", key, default):
1183
+ changed = True
1184
+
1185
+ # Validate WebUI section
1186
+ webui_defaults = [
1187
+ ("Host", "0.0.0.0"),
1188
+ ("Port", 6969),
1189
+ ("Token", ""),
1190
+ ("LiveArr", True),
1191
+ ("GroupSonarr", True),
1192
+ ("GroupLidarr", True),
1193
+ ("Theme", "Dark"),
1194
+ ]
1195
+
1196
+ for key, default in webui_defaults:
1197
+ if ensure_value("WebUI", key, default):
1198
+ changed = True
1199
+
1200
+ # Normalize Theme value to always be capitalized (Light or Dark)
1201
+ ensure_section("WebUI")
1202
+ webui_section = config.config["WebUI"]
1203
+ if "Theme" in webui_section:
1204
+ current_theme = webui_section["Theme"]
1205
+ normalized_theme = _normalize_theme_value(current_theme)
1206
+ if current_theme != normalized_theme:
1207
+ webui_section["Theme"] = normalized_theme
1208
+ changed = True
1209
+
1210
+ # Validate qBit section
1211
+ qbit_defaults = [
1212
+ ("Disabled", False),
1213
+ ("Host", "localhost"),
1214
+ ("Port", 8105),
1215
+ ("UserName", ""),
1216
+ ("Password", ""),
1217
+ ]
1218
+
1219
+ for key, default in qbit_defaults:
1220
+ if ensure_value("qBit", key, default):
1221
+ changed = True
1222
+
1223
+ # Validate EntrySearch sections for all Arr instances
1224
+ arr_types = ["Radarr", "Sonarr", "Lidarr", "Animarr"]
1225
+ entry_search_defaults = {
1226
+ "QualityProfileMappings": inline_table(),
1227
+ "ForceResetTempProfiles": False,
1228
+ "TempProfileResetTimeoutMinutes": 0,
1229
+ "ProfileSwitchRetryAttempts": 3,
1230
+ }
1231
+
1232
+ for arr_type in arr_types:
1233
+ for key in list(config.config.keys()):
1234
+ if not str(key).startswith(arr_type):
1235
+ continue
1236
+
1237
+ # Check if this Arr instance has an EntrySearch section
1238
+ if "EntrySearch" in config.config[str(key)]:
1239
+ entry_search = config.config[str(key)]["EntrySearch"]
1240
+
1241
+ # Add missing fields directly to the existing section
1242
+ for field, default in entry_search_defaults.items():
1243
+ if field not in entry_search:
1244
+ if field == "QualityProfileMappings":
1245
+ # Create as inline table (inline dict) not a section
1246
+ entry_search[field] = inline_table()
1247
+ else:
1248
+ # Add as a simple value
1249
+ entry_search[field] = default
1250
+ changed = True
1251
+
1252
+ return changed
1253
+
1254
+
1255
+ def apply_config_migrations(config: MyConfig) -> None:
1256
+ """
1257
+ Apply all configuration migrations and validations.
1258
+ Saves the config if any changes were made.
1259
+ """
1260
+ from qBitrr.config_version import (
1261
+ EXPECTED_CONFIG_VERSION,
1262
+ backup_config,
1263
+ get_config_version,
1264
+ set_config_version,
1265
+ validate_config_version,
1266
+ )
1267
+
1268
+ changes_made = False
1269
+
1270
+ # Validate config version
1271
+ is_valid, validation_result = validate_config_version(config)
1272
+
1273
+ if not is_valid:
1274
+ # Config version is newer than expected - log error but continue
1275
+ print(f"WARNING: {validation_result}")
1276
+ print("Continuing with potentially incompatible config...")
1277
+
1278
+ # Check if migration is needed
1279
+ current_version = get_config_version(config)
1280
+ needs_migration = current_version < EXPECTED_CONFIG_VERSION
1281
+
1282
+ if needs_migration:
1283
+ print(f"Config schema upgrade needed (v{current_version} -> v{EXPECTED_CONFIG_VERSION})")
1284
+ # Create backup before migration
1285
+ backup_path = backup_config(config.path)
1286
+ if backup_path:
1287
+ print(f"Config backup created: {backup_path}")
1288
+ else:
1289
+ print("WARNING: Could not create config backup, proceeding with migration anyway")
1290
+
1291
+ # Apply migrations in order
1292
+ if _migrate_webui_config(config):
1293
+ changes_made = True
1294
+
1295
+ # NEW: Migrate quality profile mappings from list to dict format (v1 → v2)
1296
+ if _migrate_quality_profile_mappings(config):
1297
+ changes_made = True
1298
+
1299
+ # NEW: Add process auto-restart settings (v2 → v3)
1300
+ if _migrate_process_restart_settings(config):
1301
+ changes_made = True
1302
+
1303
+ # Validate and fill config (this also ensures ConfigVersion field exists)
1304
+ if _validate_and_fill_config(config):
1305
+ changes_made = True
1306
+
1307
+ # Update config version if migration was needed
1308
+ if needs_migration and current_version < EXPECTED_CONFIG_VERSION:
1309
+ set_config_version(config, EXPECTED_CONFIG_VERSION)
1310
+ changes_made = True
1311
+
1312
+ # Save if changes were made
1313
+ if changes_made:
1314
+ config.save()
1315
+ print("Configuration has been updated with migrations and defaults.")
1316
+
1317
+
1318
+ def _write_config_file(docker: bool = False) -> pathlib.Path:
1319
+ doc = generate_doc()
1320
+ config_file = HOME_PATH.joinpath("config.toml")
1321
+ if docker:
1322
+ if config_file.exists():
1323
+ print(f"{config_file} already exists, keeping current configuration.")
1324
+ return config_file
1325
+ elif config_file.exists():
1326
+ print(f"{config_file} already exists, File is not being replaced.")
1327
+ config_file = pathlib.Path.cwd().joinpath("config_new.toml")
1328
+ config = MyConfig(config_file, config=doc)
1329
+ config.save()
1330
+ print(f'New config file has been saved to "{config_file}"')
1331
+ return config_file