quasarr 1.20.6__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.

Potentially problematic release.


This version of quasarr might be problematic. Click here for more details.

Files changed (72) hide show
  1. quasarr/__init__.py +460 -0
  2. quasarr/api/__init__.py +187 -0
  3. quasarr/api/arr/__init__.py +373 -0
  4. quasarr/api/captcha/__init__.py +1075 -0
  5. quasarr/api/config/__init__.py +23 -0
  6. quasarr/api/sponsors_helper/__init__.py +166 -0
  7. quasarr/api/statistics/__init__.py +196 -0
  8. quasarr/downloads/__init__.py +267 -0
  9. quasarr/downloads/linkcrypters/__init__.py +0 -0
  10. quasarr/downloads/linkcrypters/al.py +237 -0
  11. quasarr/downloads/linkcrypters/filecrypt.py +444 -0
  12. quasarr/downloads/linkcrypters/hide.py +123 -0
  13. quasarr/downloads/packages/__init__.py +467 -0
  14. quasarr/downloads/sources/__init__.py +0 -0
  15. quasarr/downloads/sources/al.py +697 -0
  16. quasarr/downloads/sources/by.py +106 -0
  17. quasarr/downloads/sources/dd.py +76 -0
  18. quasarr/downloads/sources/dj.py +7 -0
  19. quasarr/downloads/sources/dt.py +66 -0
  20. quasarr/downloads/sources/dw.py +65 -0
  21. quasarr/downloads/sources/he.py +112 -0
  22. quasarr/downloads/sources/mb.py +47 -0
  23. quasarr/downloads/sources/nk.py +51 -0
  24. quasarr/downloads/sources/nx.py +105 -0
  25. quasarr/downloads/sources/sf.py +159 -0
  26. quasarr/downloads/sources/sj.py +7 -0
  27. quasarr/downloads/sources/sl.py +90 -0
  28. quasarr/downloads/sources/wd.py +110 -0
  29. quasarr/providers/__init__.py +0 -0
  30. quasarr/providers/cloudflare.py +204 -0
  31. quasarr/providers/html_images.py +20 -0
  32. quasarr/providers/html_templates.py +241 -0
  33. quasarr/providers/imdb_metadata.py +142 -0
  34. quasarr/providers/log.py +19 -0
  35. quasarr/providers/myjd_api.py +917 -0
  36. quasarr/providers/notifications.py +124 -0
  37. quasarr/providers/obfuscated.py +51 -0
  38. quasarr/providers/sessions/__init__.py +0 -0
  39. quasarr/providers/sessions/al.py +286 -0
  40. quasarr/providers/sessions/dd.py +78 -0
  41. quasarr/providers/sessions/nx.py +76 -0
  42. quasarr/providers/shared_state.py +826 -0
  43. quasarr/providers/statistics.py +154 -0
  44. quasarr/providers/version.py +118 -0
  45. quasarr/providers/web_server.py +49 -0
  46. quasarr/search/__init__.py +153 -0
  47. quasarr/search/sources/__init__.py +0 -0
  48. quasarr/search/sources/al.py +448 -0
  49. quasarr/search/sources/by.py +203 -0
  50. quasarr/search/sources/dd.py +135 -0
  51. quasarr/search/sources/dj.py +213 -0
  52. quasarr/search/sources/dt.py +265 -0
  53. quasarr/search/sources/dw.py +214 -0
  54. quasarr/search/sources/fx.py +223 -0
  55. quasarr/search/sources/he.py +196 -0
  56. quasarr/search/sources/mb.py +195 -0
  57. quasarr/search/sources/nk.py +188 -0
  58. quasarr/search/sources/nx.py +197 -0
  59. quasarr/search/sources/sf.py +374 -0
  60. quasarr/search/sources/sj.py +213 -0
  61. quasarr/search/sources/sl.py +246 -0
  62. quasarr/search/sources/wd.py +208 -0
  63. quasarr/storage/__init__.py +0 -0
  64. quasarr/storage/config.py +163 -0
  65. quasarr/storage/setup.py +458 -0
  66. quasarr/storage/sqlite_database.py +80 -0
  67. quasarr-1.20.6.dist-info/METADATA +304 -0
  68. quasarr-1.20.6.dist-info/RECORD +72 -0
  69. quasarr-1.20.6.dist-info/WHEEL +5 -0
  70. quasarr-1.20.6.dist-info/entry_points.txt +2 -0
  71. quasarr-1.20.6.dist-info/licenses/LICENSE +21 -0
  72. quasarr-1.20.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,826 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Quasarr
3
+ # Project by https://github.com/rix1337
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import time
9
+ import traceback
10
+ from datetime import datetime, timedelta, date
11
+ from urllib import parse
12
+
13
+ import quasarr
14
+ from quasarr.providers.log import info, debug
15
+ from quasarr.providers.myjd_api import Myjdapi, TokenExpiredException, RequestTimeoutException, MYJDException, Jddevice
16
+ from quasarr.storage.config import Config
17
+ from quasarr.storage.sqlite_database import DataBase
18
+
19
+ values = {}
20
+ lock = None
21
+
22
+ # regex to detect season/episode tags for series filtering during search
23
+ SEASON_EP_REGEX = re.compile(r"(?i)(?:S\d{1,3}(?:E\d{1,3}(?:-\d{1,3})?)?|S\d{1,3}-\d{1,3})")
24
+ # regex to filter out season/episode tags for movies
25
+ MOVIE_REGEX = re.compile(r"^(?!.*(?:S\d{1,3}(?:E\d{1,3}(?:-\d{1,3})?)?|S\d{1,3}-\d{1,3})).*$", re.IGNORECASE)
26
+ # List of known file hosters that should not be used as search/feed sites
27
+ SHARE_HOSTERS = {
28
+ "rapidgator",
29
+ "ddownload",
30
+ "keep2share",
31
+ "1fichier",
32
+ "katfile",
33
+ "filer",
34
+ "turbobit",
35
+ "nitroflare",
36
+ "filefactory",
37
+ "uptobox",
38
+ "mediafire",
39
+ "mega",
40
+ }
41
+
42
+
43
+ def set_state(manager_dict, manager_lock):
44
+ global values
45
+ global lock
46
+ values = manager_dict
47
+ lock = manager_lock
48
+
49
+
50
+ def update(key, value):
51
+ global values
52
+ global lock
53
+ lock.acquire()
54
+ try:
55
+ values[key] = value
56
+ finally:
57
+ lock.release()
58
+
59
+
60
+ def set_connection_info(internal_address, external_address, port):
61
+ if internal_address.count(":") < 2:
62
+ internal_address = f"{internal_address}:{port}"
63
+ update("internal_address", internal_address)
64
+ update("external_address", external_address)
65
+ update("port", port)
66
+
67
+
68
+ def set_files(config_path):
69
+ update("configfile", os.path.join(config_path, "Quasarr.ini"))
70
+ update("dbfile", os.path.join(config_path, "Quasarr.db"))
71
+
72
+
73
+ def generate_api_key():
74
+ api_key = os.urandom(32).hex()
75
+ Config('API').save("key", api_key)
76
+ info(f'API key replaced with: "{api_key}!"')
77
+ return api_key
78
+
79
+
80
+ def extract_valid_hostname(url, shorthand):
81
+ try:
82
+ if '://' not in url:
83
+ url = 'http://' + url
84
+ result = parse.urlparse(url)
85
+ domain = result.netloc
86
+ parts = domain.split('.')
87
+
88
+ if domain.startswith(".") or domain.endswith(".") or "." not in domain[1:-1]:
89
+ message = f'Error: "{domain}" must contain a "." somewhere in the middle – you need to provide a full domain name!'
90
+ domain = None
91
+
92
+ elif any(hoster in parts for hoster in SHARE_HOSTERS):
93
+ offending = next(host for host in parts if host in SHARE_HOSTERS)
94
+ message = (
95
+ f'Error: "{domain}" is a file‑hosting domain and cannot be used here directly! '
96
+ f'Instead please provide a valid hostname that serves direct file links (including "{offending}").'
97
+ )
98
+ domain = None
99
+
100
+ elif all(char in domain for char in shorthand):
101
+ message = f'"{domain}" contains both characters from shorthand "{shorthand}". Continuing...'
102
+
103
+ else:
104
+ message = f'Error: "{domain}" does not contain both characters from shorthand "{shorthand}".'
105
+ domain = None
106
+ except Exception as e:
107
+ message = f"Error: {e}. Please provide a valid URL."
108
+ domain = None
109
+
110
+ print(message)
111
+ return {"domain": domain, "message": message}
112
+
113
+
114
+ def connect_to_jd(jd, user, password, device_name):
115
+ try:
116
+ jd.connect(user, password)
117
+ jd.update_devices()
118
+ device = jd.get_device(device_name)
119
+ except (TokenExpiredException, RequestTimeoutException, MYJDException) as e:
120
+ info("Error connecting to JDownloader: " + str(e).strip())
121
+ return False
122
+ if not device or not isinstance(device, (type, Jddevice)):
123
+ return False
124
+ else:
125
+ device.downloadcontroller.get_current_state() # request forces direct_connection info update
126
+ connection_info = device.check_direct_connection()
127
+ if connection_info["status"]:
128
+ info(f'Direct connection to JDownloader established: "{connection_info['ip']}"')
129
+ else:
130
+ info("Could not establish direct connection to JDownloader.")
131
+ update("device", device)
132
+ return True
133
+
134
+
135
+ def set_device(user, password, device):
136
+ jd = Myjdapi()
137
+ jd.set_app_key('Quasarr')
138
+ return connect_to_jd(jd, user, password, device)
139
+
140
+
141
+ def set_device_from_config():
142
+ config = Config('JDownloader')
143
+ user = str(config.get('user'))
144
+ password = str(config.get('password'))
145
+ device = str(config.get('device'))
146
+
147
+ update("device", device)
148
+
149
+ if user and password and device:
150
+ jd = Myjdapi()
151
+ jd.set_app_key('Quasarr')
152
+ return connect_to_jd(jd, user, password, device)
153
+ return False
154
+
155
+
156
+ def check_device(device):
157
+ try:
158
+ valid = isinstance(device,
159
+ (type, Jddevice)) and device.downloadcontroller.get_current_state()
160
+ except (AttributeError, KeyError, TokenExpiredException, RequestTimeoutException, MYJDException):
161
+ valid = False
162
+ return valid
163
+
164
+
165
+ def connect_device():
166
+ config = Config('JDownloader')
167
+ user = str(config.get('user'))
168
+ password = str(config.get('password'))
169
+ device = str(config.get('device'))
170
+
171
+ jd = Myjdapi()
172
+ jd.set_app_key('Quasarr')
173
+
174
+ if user and password and device:
175
+ try:
176
+ jd.connect(user, password)
177
+ jd.update_devices()
178
+ device = jd.get_device(device)
179
+ except (TokenExpiredException, RequestTimeoutException, MYJDException):
180
+ pass
181
+
182
+ if check_device(device):
183
+ update("device", device)
184
+ return True
185
+ else:
186
+ return False
187
+
188
+
189
+ def get_device():
190
+ attempts = 0
191
+
192
+ while True:
193
+ try:
194
+ if check_device(values["device"]):
195
+ break
196
+ except (AttributeError, KeyError, TokenExpiredException, RequestTimeoutException, MYJDException):
197
+ pass
198
+ attempts += 1
199
+
200
+ update("device", False)
201
+
202
+ if attempts % 10 == 0:
203
+ info(
204
+ f"WARNING: {attempts} consecutive JDownloader connection errors. Please check your credentials!")
205
+ time.sleep(3)
206
+
207
+ if connect_device():
208
+ break
209
+
210
+ return values["device"]
211
+
212
+
213
+ def get_devices(user, password):
214
+ jd = Myjdapi()
215
+ jd.set_app_key('Quasarr')
216
+ try:
217
+ jd.connect(user, password)
218
+ jd.update_devices()
219
+ devices = jd.list_devices()
220
+ return devices
221
+ except (TokenExpiredException, RequestTimeoutException, MYJDException) as e:
222
+ info("Error connecting to JDownloader: " + str(e))
223
+ return []
224
+
225
+
226
+ def set_device_settings():
227
+ device = get_device()
228
+
229
+ settings_to_enforce = [
230
+ {
231
+ "namespace": "org.jdownloader.settings.GeneralSettings",
232
+ "storage": None,
233
+ "setting": "AutoStartDownloadOption",
234
+ "expected_value": "ALWAYS", # Downloads must start automatically for Quasarr to work
235
+ },
236
+ {
237
+ "namespace": "org.jdownloader.settings.GeneralSettings",
238
+ "storage": None,
239
+ "setting": "IfFileExistsAction",
240
+ "expected_value": "SKIP_FILE", # Prevents popups during download
241
+ },
242
+ {
243
+ "namespace": "org.jdownloader.settings.GeneralSettings",
244
+ "storage": None,
245
+ "setting": "CleanupAfterDownloadAction",
246
+ "expected_value": "NEVER", # Links must be kept after download for Quasarr to work
247
+ },
248
+ {
249
+ "namespace": "org.jdownloader.settings.GraphicalUserInterfaceSettings",
250
+ "storage": None,
251
+ "setting": "BannerEnabled",
252
+ "expected_value": False, # Removes UI clutter in JDownloader
253
+ },
254
+ {
255
+ "namespace": "org.jdownloader.settings.GraphicalUserInterfaceSettings",
256
+ "storage": None,
257
+ "setting": "DonateButtonState",
258
+ "expected_value": "CUSTOM_HIDDEN", # Removes UI clutter in JDownloader
259
+ },
260
+ {
261
+ "namespace": "org.jdownloader.extensions.extraction.ExtractionConfig",
262
+ "storage": "cfg/org.jdownloader.extensions.extraction.ExtractionExtension",
263
+ "setting": "DeleteArchiveFilesAfterExtractionAction",
264
+ "expected_value": "NULL", # "NULL" is the ENUM for "Delete files from Harddisk"
265
+ },
266
+ {
267
+ "namespace": "org.jdownloader.extensions.extraction.ExtractionConfig",
268
+ "storage": "cfg/org.jdownloader.extensions.extraction.ExtractionExtension",
269
+ "setting": "IfFileExistsAction",
270
+ "expected_value": "OVERWRITE_FILE", # Prevents popups during extraction
271
+ },
272
+ {
273
+ "namespace": "org.jdownloader.extensions.extraction.ExtractionConfig",
274
+ "storage": "cfg/org.jdownloader.extensions.extraction.ExtractionExtension",
275
+ "setting": "DeleteArchiveDownloadlinksAfterExtraction",
276
+ "expected_value": False, # Links must be kept after extraction for Quasarr to work
277
+ },
278
+ {
279
+ "namespace": "org.jdownloader.gui.views.linkgrabber.addlinksdialog.LinkgrabberSettings",
280
+ "storage": None,
281
+ "setting": "OfflinePackageEnabled",
282
+ "expected_value": False, # Don't move offline links to extra package
283
+ },
284
+ {
285
+ "namespace": "org.jdownloader.gui.views.linkgrabber.addlinksdialog.LinkgrabberSettings",
286
+ "storage": None,
287
+ "setting": "HandleOfflineOnConfirmLatestSelection",
288
+ "expected_value": "INCLUDE_OFFLINE", # Offline links must always be kept for Quasarr to handle packages
289
+ },
290
+ {
291
+ "namespace": "org.jdownloader.gui.views.linkgrabber.addlinksdialog.LinkgrabberSettings",
292
+ "storage": None,
293
+ "setting": "AutoConfirmManagerHandleOffline",
294
+ "expected_value": "INCLUDE_OFFLINE", # Offline links must always be kept for Quasarr to handle packages
295
+ },
296
+ {
297
+ "namespace": "org.jdownloader.gui.views.linkgrabber.addlinksdialog.LinkgrabberSettings",
298
+ "storage": None,
299
+ "setting": "DefaultOnAddedOfflineLinksAction",
300
+ "expected_value": "INCLUDE_OFFLINE", # Offline links must always be kept for Quasarr to handle packages
301
+ },
302
+ ]
303
+
304
+ for setting in settings_to_enforce:
305
+ namespace = setting["namespace"]
306
+ storage = setting["storage"] or "null"
307
+ name = setting["setting"]
308
+ expected_value = setting["expected_value"]
309
+
310
+ settings = device.config.get(namespace, storage, name)
311
+
312
+ if settings != expected_value:
313
+ success = device.config.set(namespace, storage, name, expected_value)
314
+
315
+ location = f"{namespace}/{storage}" if storage != "null" else namespace
316
+ status = "Updated" if success else "Failed to update"
317
+ info(f'{status} "{name}" in "{location}" to "{expected_value}".')
318
+
319
+ settings_to_add = [
320
+ {
321
+ "namespace": "org.jdownloader.extensions.extraction.ExtractionConfig",
322
+ "storage": "cfg/org.jdownloader.extensions.extraction.ExtractionExtension",
323
+ "setting": "BlacklistPatterns",
324
+ "expected_values": [
325
+ '.*sample/.*',
326
+ '.*Sample/.*',
327
+ '.*\\.jpe?g',
328
+ '.*\\.idx',
329
+ '.*\\.sub',
330
+ '.*\\.srt',
331
+ '.*\\.nfo',
332
+ '.*\\.bat',
333
+ '.*\\.txt',
334
+ '.*\\.exe',
335
+ '.*\\.sfv'
336
+ ]
337
+ },
338
+ {
339
+ "namespace": "org.jdownloader.controlling.filter.LinkFilterSettings",
340
+ "storage": "null",
341
+ "setting": "FilterList",
342
+ "expected_values": [
343
+ {'conditionFilter':
344
+ {'conditions': [], 'enabled': False, 'matchType': 'IS_TRUE'}, 'created': 0,
345
+ 'enabled': True,
346
+ 'filenameFilter': {
347
+ 'enabled': True,
348
+ 'matchType': 'CONTAINS',
349
+ 'regex': '.*\\.(sfv|jpe?g|idx|srt|nfo|bat|txt|exe)',
350
+ 'useRegex': True
351
+ },
352
+ 'filesizeFilter': {'enabled': False, 'from': 0, 'matchType': 'BETWEEN', 'to': 0},
353
+ 'filetypeFilter': {'archivesEnabled': False, 'audioFilesEnabled': False, 'customs': None,
354
+ 'docFilesEnabled': False, 'enabled': False, 'exeFilesEnabled': False,
355
+ 'hashEnabled': False, 'imagesEnabled': False, 'matchType': 'IS',
356
+ 'subFilesEnabled': False, 'useRegex': False, 'videoFilesEnabled': False},
357
+ 'hosterURLFilter': {'enabled': False, 'matchType': 'CONTAINS', 'regex': '', 'useRegex': False},
358
+ 'matchAlwaysFilter': {'enabled': False}, 'name': 'Quasarr_Block_Files',
359
+ 'onlineStatusFilter': {'enabled': False, 'matchType': 'IS', 'onlineStatus': 'OFFLINE'},
360
+ 'originFilter': {'enabled': False, 'matchType': 'IS', 'origins': []},
361
+ 'packagenameFilter': {'enabled': False, 'matchType': 'CONTAINS', 'regex': '', 'useRegex': False},
362
+ 'pluginStatusFilter': {'enabled': False, 'matchType': 'IS', 'pluginStatus': 'PREMIUM'},
363
+ 'sourceURLFilter': {'enabled': False, 'matchType': 'CONTAINS', 'regex': '', 'useRegex': False},
364
+ 'testUrl': ''}]
365
+ },
366
+ ]
367
+
368
+ for setting in settings_to_add:
369
+ namespace = setting["namespace"]
370
+ storage = setting["storage"] or "null"
371
+ name = setting["setting"]
372
+ expected_values = setting["expected_values"]
373
+
374
+ added_items = 0
375
+ settings = device.config.get(namespace, storage, name)
376
+ for item in expected_values:
377
+ if item not in settings:
378
+ settings.append(item)
379
+ added_items += 1
380
+
381
+ if added_items:
382
+ success = device.config.set(namespace, storage, name, json.dumps(settings))
383
+
384
+ location = f"{namespace}/{storage}" if storage != "null" else namespace
385
+ status = "Added" if success else "Failed to add"
386
+ info(f'{status} {added_items} items to "{name}" in "{location}".')
387
+
388
+
389
+ def update_jdownloader():
390
+ try:
391
+ if not get_device():
392
+ set_device_from_config()
393
+ device = get_device()
394
+
395
+ if device:
396
+ try:
397
+ current_state = device.downloadcontroller.get_current_state()
398
+ is_collecting = device.linkgrabber.is_collecting()
399
+ update_available = device.update.update_available()
400
+
401
+ if (current_state.lower() == "idle") and (not is_collecting and update_available):
402
+ info("JDownloader update ready. Starting update...")
403
+ device.update.restart_and_update()
404
+ except quasarr.providers.myjd_api.TokenExpiredException:
405
+ return False
406
+ return True
407
+ else:
408
+ return False
409
+ except quasarr.providers.myjd_api.MYJDException as e:
410
+ info(f"Error updating JDownloader: {e}")
411
+ return False
412
+
413
+
414
+ def start_downloads():
415
+ try:
416
+ if not get_device():
417
+ set_device_from_config()
418
+ device = get_device()
419
+
420
+ if device:
421
+ try:
422
+ return device.downloadcontroller.start_downloads()
423
+ except quasarr.providers.myjd_api.TokenExpiredException:
424
+ return False
425
+ else:
426
+ return False
427
+ except quasarr.providers.myjd_api.MYJDException as e:
428
+ info(f"Error starting Downloads: {e}")
429
+ return False
430
+
431
+
432
+ def get_db(table):
433
+ return DataBase(table)
434
+
435
+
436
+ def convert_to_mb(item):
437
+ size = float(item['size'])
438
+ unit = item['sizeunit'].upper()
439
+
440
+ if unit == 'B':
441
+ size_b = size
442
+ elif unit == 'KB':
443
+ size_b = size * 1024
444
+ elif unit == 'MB':
445
+ size_b = size * 1024 * 1024
446
+ elif unit == 'GB':
447
+ size_b = size * 1024 * 1024 * 1024
448
+ elif unit == 'TB':
449
+ size_b = size * 1024 * 1024 * 1024 * 1024
450
+ else:
451
+ raise ValueError(f"Unsupported size unit {item['name']} {item['size']} {item['sizeunit']}")
452
+
453
+ size_mb = size_b / (1024 * 1024)
454
+ return int(size_mb)
455
+
456
+
457
+ def sanitize_title(title: str) -> str:
458
+ umlaut_map = {
459
+ "Ä": "Ae", "ä": "ae",
460
+ "Ö": "Oe", "ö": "oe",
461
+ "Ü": "Ue", "ü": "ue",
462
+ "ß": "ss"
463
+ }
464
+ for umlaut, replacement in umlaut_map.items():
465
+ title = title.replace(umlaut, replacement)
466
+
467
+ title = title.encode("ascii", errors="ignore").decode()
468
+
469
+ # Replace slashes and spaces with dots
470
+ title = title.replace("/", "").replace(" ", ".")
471
+ title = title.strip(".") # no leading/trailing dots
472
+ title = title.replace(".-.", "-") # .-. → -
473
+
474
+ # Finally, drop any chars except letters, digits, dots, hyphens, ampersands
475
+ title = re.sub(r"[^A-Za-z0-9.\-&]", "", title)
476
+
477
+ # remove any repeated dots
478
+ title = re.sub(r"\.{2,}", ".", title)
479
+ return title
480
+
481
+
482
+ def sanitize_string(s):
483
+ s = s.lower()
484
+
485
+ # Remove dots / pluses
486
+ s = s.replace('.', ' ')
487
+ s = s.replace('+', ' ')
488
+ s = s.replace('_', ' ')
489
+ s = s.replace('-', ' ')
490
+
491
+ # Umlauts
492
+ s = re.sub(r'ä', 'ae', s)
493
+ s = re.sub(r'ö', 'oe', s)
494
+ s = re.sub(r'ü', 'ue', s)
495
+ s = re.sub(r'ß', 'ss', s)
496
+
497
+ # Remove special characters
498
+ s = re.sub(r'[^a-zA-Z0-9\s]', '', s)
499
+
500
+ # Remove season and episode patterns
501
+ s = re.sub(r'\bs\d{1,3}(e\d{1,3})?\b', '', s)
502
+
503
+ # Remove German and English articles
504
+ articles = r'\b(?:der|die|das|ein|eine|einer|eines|einem|einen|the|a|an|and)\b'
505
+ s = re.sub(articles, '', s, re.IGNORECASE)
506
+
507
+ # Replace obsolete titles
508
+ s = s.replace('navy cis', 'ncis')
509
+
510
+ # Remove extra whitespace
511
+ s = ' '.join(s.split())
512
+
513
+ return s
514
+
515
+
516
+ def search_string_in_sanitized_title(search_string, title):
517
+ sanitized_search_string = sanitize_string(search_string)
518
+ sanitized_title = sanitize_string(title)
519
+
520
+ # Use word boundaries to ensure full word/phrase match
521
+ if re.search(rf'\b{re.escape(sanitized_search_string)}\b', sanitized_title):
522
+ debug(f"Matched search string: {sanitized_search_string} with title: {sanitized_title}")
523
+ return True
524
+ else:
525
+ debug(f"Skipping {title} as it doesn't match search string: {sanitized_search_string}")
526
+ return False
527
+
528
+
529
+ def is_imdb_id(search_string):
530
+ if bool(re.fullmatch(r"tt\d{7,}", search_string)):
531
+ return search_string
532
+ else:
533
+ return None
534
+
535
+
536
+ def match_in_title(title: str, season: int = None, episode: int = None) -> bool:
537
+ # ensure season/episode are ints (or None)
538
+ if isinstance(season, str):
539
+ try:
540
+ season = int(season)
541
+ except ValueError:
542
+ season = None
543
+ if isinstance(episode, str):
544
+ try:
545
+ episode = int(episode)
546
+ except ValueError:
547
+ episode = None
548
+
549
+ pattern = re.compile(
550
+ r"(?i)(?:\.|^)[sS](\d+)(?:-(\d+))?" # season or season‑range
551
+ r"(?:[eE](\d+)(?:-(?:[eE]?)(\d+))?)?" # episode or episode‑range
552
+ r"(?=[\.-]|$)"
553
+ )
554
+
555
+ matches = pattern.findall(title)
556
+ if not matches:
557
+ return False
558
+
559
+ for s_start, s_end, e_start, e_end in matches:
560
+ se_start, se_end = int(s_start), int(s_end or s_start)
561
+
562
+ # if a season was requested, ensure it falls in the range
563
+ if season is not None and not (se_start <= season <= se_end):
564
+ continue
565
+
566
+ # if no episode requested, only accept if the title itself had no episode tag
567
+ if episode is None:
568
+ if not e_start:
569
+ return True
570
+ else:
571
+ # title did specify an episode — skip this match
572
+ continue
573
+
574
+ # episode was requested, so title must supply one
575
+ if not e_start:
576
+ continue
577
+
578
+ ep_start, ep_end = int(e_start), int(e_end or e_start)
579
+ if ep_start <= episode <= ep_end:
580
+ return True
581
+
582
+ return False
583
+
584
+
585
+ def is_valid_release(title: str,
586
+ request_from: str,
587
+ search_string: str,
588
+ season: int = None,
589
+ episode: int = None) -> bool:
590
+ """
591
+ Return True if the given release title is valid for the given search parameters.
592
+ - title: the release title to test
593
+ - request_from: user agent, contains 'Radarr' for movie searches or 'Sonarr' for TV searches
594
+ - search_string: the original search phrase (could be an IMDb id or plain text)
595
+ - season: desired season number (or None)
596
+ - episode: desired episode number (or None)
597
+ """
598
+ try:
599
+ # Determine whether this is a movie or TV search
600
+ rf = request_from.lower()
601
+ is_movie_search = 'radarr' in rf
602
+ is_tv_search = 'sonarr' in rf
603
+ is_docs_search = 'lazylibrarian' in rf
604
+
605
+ # if search string is NOT an imdb id check search_string_in_sanitized_title - if not match, its not valid
606
+ if not is_docs_search and not is_imdb_id(search_string):
607
+ if not search_string_in_sanitized_title(search_string, title):
608
+ debug(f"Skipping {title!r} as it doesn't match sanitized search string: {search_string!r}")
609
+ return False
610
+
611
+
612
+ # if it's a movie search, don't allow any TV show titles (check for NO season or episode tags in the title)
613
+ if is_movie_search:
614
+ if not MOVIE_REGEX.match(title):
615
+ debug(f"Skipping {title!r} as title doesn't match movie regex: {MOVIE_REGEX.pattern}")
616
+ return False
617
+ return True
618
+
619
+ # if it's a TV show search, don't allow any movies (check for season or episode tags in the title)
620
+ if is_tv_search:
621
+ # must have some S/E tag present
622
+ if not SEASON_EP_REGEX.search(title):
623
+ debug(f"Skipping {title!r} as title doesn't match TV show regex: {SEASON_EP_REGEX.pattern}")
624
+ return False
625
+ # if caller specified a season or episode, double‑check the match
626
+ if season is not None or episode is not None:
627
+ if not match_in_title(title, season, episode):
628
+ debug(f"Skipping {title!r} as it doesn't match season {season} and episode {episode}")
629
+ return False
630
+ return True
631
+
632
+ # if it's a document search, it should not contain Movie or TV show tags
633
+ if is_docs_search:
634
+ # must NOT have any S/E tag present
635
+ if SEASON_EP_REGEX.search(title):
636
+ debug(f"Skipping {title!r} as title matches TV show regex: {SEASON_EP_REGEX.pattern}")
637
+ return False
638
+ return True
639
+
640
+ # unknown search source — reject by default
641
+ debug(f"Skipping {title!r} as search source is unknown: {request_from!r}")
642
+ return False
643
+
644
+ except Exception as e:
645
+ # log exception message and short stack trace
646
+ tb = traceback.format_exc()
647
+ debug(f"Exception in is_valid_release: {e!r}\n{tb}"
648
+ f"is_valid_release called with "
649
+ f"title={title!r}, request_from={request_from!r}, "
650
+ f"search_string={search_string!r}, season={season!r}, episode={episode!r}")
651
+ return False
652
+
653
+
654
+ def normalize_magazine_title(title: str) -> str:
655
+ """
656
+ Massage magazine titles so LazyLibrarian's parser can pick up dates reliably:
657
+ - Convert date-like patterns into space-delimited numeric tokens (YYYY MM DD or YYYY MM).
658
+ - Handle malformed "DD.YYYY.YYYY" cases (e.g., 04.2006.2025 → 2025 06 04).
659
+ - Convert two-part month-year like "3.25" into YYYY MM.
660
+ - Convert "No/Nr/Sonderheft X.YYYY" when X≤12 into YYYY MM.
661
+ - Preserve pure issue/volume prefixes and other digit runs untouched.
662
+ """
663
+ title = title.strip()
664
+
665
+ # 0) Bug: DD.YYYY.YYYY -> treat second YYYY's last two digits as month
666
+ def repl_bug(match):
667
+ d = int(match.group(1))
668
+ m_hint = match.group(2)
669
+ y = int(match.group(3))
670
+ m = int(m_hint[-2:])
671
+ try:
672
+ date(y, m, d)
673
+ return f"{y:04d} {m:02d} {d:02d}"
674
+ except ValueError:
675
+ return match.group(0)
676
+
677
+ title = re.sub(r"\b(\d{1,2})\.(20\d{2})\.(20\d{2})\b", repl_bug, title)
678
+
679
+ # 1) DD.MM.YYYY -> "YYYY MM DD"
680
+ def repl_dmy(match):
681
+ d, m, y = map(int, match.groups())
682
+ try:
683
+ date(y, m, d)
684
+ return f"{y:04d} {m:02d} {d:02d}"
685
+ except ValueError:
686
+ return match.group(0)
687
+
688
+ title = re.sub(r"\b(\d{1,2})\.(\d{1,2})\.(\d{4})\b", repl_dmy, title)
689
+
690
+ # 2) DD[.]? MonthName YYYY (optional 'vom') -> "YYYY MM DD"
691
+ def repl_dmony(match):
692
+ d = int(match.group(1))
693
+ name = match.group(2)
694
+ y = int(match.group(3))
695
+ mm = _month_num(name)
696
+ if mm:
697
+ try:
698
+ date(y, mm, d)
699
+ return f"{y:04d} {mm:02d} {d:02d}"
700
+ except ValueError:
701
+ pass
702
+ return match.group(0)
703
+
704
+ title = re.sub(
705
+ r"\b(?:vom\s*)?(\d{1,2})\.?\s+([A-Za-zÄÖÜäöüß]+)\s+(\d{4})\b",
706
+ repl_dmony,
707
+ title,
708
+ flags=re.IGNORECASE
709
+ )
710
+
711
+ # 3) MonthName YYYY -> "YYYY MM"
712
+ def repl_mony(match):
713
+ name = match.group(1)
714
+ y = int(match.group(2))
715
+ mm = _month_num(name)
716
+ if mm:
717
+ try:
718
+ date(y, mm, 1)
719
+ return f"{y:04d} {mm:02d}"
720
+ except ValueError:
721
+ pass
722
+ return match.group(0)
723
+
724
+ title = re.sub(r"\b([A-Za-zÄÖÜäöüß]+)\s+(\d{4})\b", repl_mony, title, flags=re.IGNORECASE)
725
+
726
+ # 4) YYYYMMDD -> "YYYY MM DD"
727
+ def repl_ymd(match):
728
+ y = int(match.group(1))
729
+ m = int(match.group(2))
730
+ d = int(match.group(3))
731
+ try:
732
+ date(y, m, d)
733
+ return f"{y:04d} {m:02d} {d:02d}"
734
+ except ValueError:
735
+ return match.group(0)
736
+
737
+ title = re.sub(r"\b(20\d{2})(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\b", repl_ymd, title)
738
+
739
+ # 5) YYYYMM -> "YYYY MM"
740
+ def repl_ym(match):
741
+ y = int(match.group(1))
742
+ m = int(match.group(2))
743
+ try:
744
+ date(y, m, 1)
745
+ return f"{y:04d} {m:02d}"
746
+ except ValueError:
747
+ return match.group(0)
748
+
749
+ title = re.sub(r"\b(20\d{2})(0[1-9]|1[0-2])\b", repl_ym, title)
750
+
751
+ # 6) X.YY (month.two-digit-year) -> "YYYY MM" (e.g., 3.25 -> 2025 03)
752
+ def repl_my2(match):
753
+ mm = int(match.group(1))
754
+ yy = int(match.group(2))
755
+ y = 2000 + yy
756
+ if 1 <= mm <= 12:
757
+ try:
758
+ date(y, mm, 1)
759
+ return f"{y:04d} {mm:02d}"
760
+ except ValueError:
761
+ pass
762
+ return match.group(0)
763
+
764
+ title = re.sub(r"\b([1-9]|1[0-2])\.(\d{2})\b", repl_my2, title)
765
+
766
+ # 7) No/Nr/Sonderheft <1-12>.<YYYY> -> "YYYY MM"
767
+ def repl_nmy(match):
768
+ num = int(match.group(1))
769
+ y = int(match.group(2))
770
+ if 1 <= num <= 12:
771
+ try:
772
+ date(y, num, 1)
773
+ return f"{y:04d} {num:02d}"
774
+ except ValueError:
775
+ pass
776
+ return match.group(0)
777
+
778
+ title = re.sub(
779
+ r"\b(?:No|Nr|Sonderheft)\s*(\d{1,2})\.(\d{4})\b",
780
+ repl_nmy,
781
+ title,
782
+ flags=re.IGNORECASE
783
+ )
784
+
785
+ return title
786
+
787
+
788
+ # Helper for month name mapping
789
+ def _month_num(name: str) -> int:
790
+ name = name.lower()
791
+ mmap = {
792
+ 'januar': 1, 'jan': 1, 'februar': 2, 'feb': 2, 'märz': 3, 'maerz': 3, 'mär': 3, 'mrz': 3, 'mae': 3,
793
+ 'april': 4, 'apr': 4, 'mai': 5, 'juni': 6, 'jun': 6, 'juli': 7, 'jul': 7, 'august': 8, 'aug': 8,
794
+ 'september': 9, 'sep': 9, 'oktober': 10, 'okt': 10, 'november': 11, 'nov': 11, 'dezember': 12, 'dez': 12,
795
+ 'january': 1, 'february': 2, 'march': 3, 'april': 4, 'may': 5, 'june': 6, 'july': 7, 'august': 8,
796
+ 'september': 9, 'october': 10, 'november': 11, 'december': 12
797
+ }
798
+ return mmap.get(name)
799
+
800
+
801
+ def get_recently_searched(shared_state, context, timeout_seconds):
802
+ recently_searched = shared_state.values.get(context, {})
803
+ threshold = datetime.now() - timedelta(seconds=timeout_seconds)
804
+ keys_to_remove = [key for key, value in recently_searched.items() if value["timestamp"] <= threshold]
805
+ for key in keys_to_remove:
806
+ debug(f"Removing '{key}' from recently searched memory ({context})...")
807
+ del recently_searched[key]
808
+ return recently_searched
809
+
810
+
811
+ def download_package(links, title, password, package_id):
812
+ device = get_device()
813
+ downloaded = device.linkgrabber.add_links(params=[
814
+ {
815
+ "autostart": False,
816
+ "links": json.dumps(links),
817
+ "packageName": title,
818
+ "extractPassword": password,
819
+ "priority": "DEFAULT",
820
+ "downloadPassword": password,
821
+ "destinationFolder": "Quasarr/<jd:packagename>",
822
+ "comment": package_id,
823
+ "overwritePackagizerRules": True
824
+ }
825
+ ])
826
+ return downloaded