ittup 1.2.3__tar.gz → 1.4.0__tar.gz

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 (119) hide show
  1. {ittup-1.2.3 → ittup-1.4.0}/PKG-INFO +1 -1
  2. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/command.py +6 -0
  3. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/mediaresult.py +8 -4
  4. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/theMovieDB/core/api.py +105 -6
  5. ittup-1.4.0/ittup/common/rename.py +129 -0
  6. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/settings.py +5 -2
  7. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/tags.py +100 -13
  8. ittup-1.4.0/ittup/common/trackers/trackers.py +107 -0
  9. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/utility.py +60 -0
  10. {ittup-1.2.3 → ittup-1.4.0}/ittup/core/automode.py +3 -2
  11. {ittup-1.2.3 → ittup-1.4.0}/ittup/core/bot.py +3 -1
  12. {ittup-1.2.3 → ittup-1.4.0}/ittup/core/media.py +42 -0
  13. {ittup-1.2.3 → ittup-1.4.0}/ittup/core/media_manager/ContentManager.py +25 -10
  14. {ittup-1.2.3 → ittup-1.4.0}/ittup/core/media_manager/VideoManager.py +101 -24
  15. {ittup-1.2.3 → ittup-1.4.0}/ittup/core/upload.py +10 -2
  16. {ittup-1.2.3 → ittup-1.4.0}/ittup.egg-info/PKG-INFO +1 -1
  17. {ittup-1.2.3 → ittup-1.4.0}/ittup.egg-info/SOURCES.txt +10 -0
  18. {ittup-1.2.3 → ittup-1.4.0}/pyproject.toml +1 -1
  19. ittup-1.4.0/tests/test_acodec.py +85 -0
  20. ittup-1.4.0/tests/test_command.py +35 -0
  21. ittup-1.4.0/tests/test_ids.py +42 -0
  22. ittup-1.4.0/tests/test_order.py +28 -0
  23. ittup-1.4.0/tests/test_pack.py +53 -0
  24. ittup-1.4.0/tests/test_rename.py +132 -0
  25. ittup-1.4.0/tests/test_title.py +59 -0
  26. ittup-1.4.0/tests/test_tmdb_search.py +87 -0
  27. ittup-1.4.0/tests/test_type.py +43 -0
  28. {ittup-1.2.3 → ittup-1.4.0}/tests/test_vcodec.py +8 -0
  29. ittup-1.2.3/ittup/common/trackers/trackers.py +0 -46
  30. {ittup-1.2.3 → ittup-1.4.0}/LICENSE +0 -0
  31. {ittup-1.2.3 → ittup-1.4.0}/README.rst +0 -0
  32. {ittup-1.2.3 → ittup-1.4.0}/ittup/__init__.py +0 -0
  33. {ittup-1.2.3 → ittup-1.4.0}/ittup/__main__.py +0 -0
  34. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/__init__.py +0 -0
  35. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/about.py +0 -0
  36. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/bdinfo_string.py +0 -0
  37. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/bittorrent.py +0 -0
  38. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/constants.py +0 -0
  39. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/database.py +0 -0
  40. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/__init__.py +0 -0
  41. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/ftpx/__init__.py +0 -0
  42. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/ftpx/client.py +0 -0
  43. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/ftpx/core/__init__.py +0 -0
  44. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/ftpx/core/ftpx_service.py +0 -0
  45. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/ftpx/core/ftpx_session.py +0 -0
  46. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/ftpx/core/menu.py +0 -0
  47. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/ftpx/core/models/__init__.py +0 -0
  48. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/ftpx/core/models/list.py +0 -0
  49. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/igdb/__init__.py +0 -0
  50. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/igdb/client.py +0 -0
  51. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/igdb/core/__init__.py +0 -0
  52. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/igdb/core/api.py +0 -0
  53. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/igdb/core/models/__init__.py +0 -0
  54. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/igdb/core/models/search.py +0 -0
  55. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/igdb/core/platformid.py +0 -0
  56. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/igdb/core/tags.py +0 -0
  57. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/imageHost.py +0 -0
  58. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/sessions/__init__.py +0 -0
  59. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/sessions/agents.py +0 -0
  60. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/sessions/exceptions.py +0 -0
  61. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/sessions/session.py +0 -0
  62. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/theMovieDB/__init__.py +0 -0
  63. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/theMovieDB/core/__init__.py +0 -0
  64. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/theMovieDB/core/keywords.py +0 -0
  65. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/theMovieDB/core/models/__init__.py +0 -0
  66. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/theMovieDB/core/models/movie/__init__.py +0 -0
  67. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/theMovieDB/core/models/movie/alternative.py +0 -0
  68. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/theMovieDB/core/models/movie/details.py +0 -0
  69. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/theMovieDB/core/models/movie/movie.py +0 -0
  70. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/theMovieDB/core/models/movie/nowplaying.py +0 -0
  71. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/theMovieDB/core/models/movie/release_info.py +0 -0
  72. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/theMovieDB/core/models/movie/translations.py +0 -0
  73. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/theMovieDB/core/models/tvshow/__init__.py +0 -0
  74. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/theMovieDB/core/models/tvshow/alternative.py +0 -0
  75. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/theMovieDB/core/models/tvshow/details.py +0 -0
  76. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/theMovieDB/core/models/tvshow/on_the_air.py +0 -0
  77. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/theMovieDB/core/models/tvshow/translations.py +0 -0
  78. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/theMovieDB/core/models/tvshow/tvshow.py +0 -0
  79. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/theMovieDB/core/videos.py +0 -0
  80. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/trailers/__init__.py +0 -0
  81. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/trailers/api.py +0 -0
  82. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/trailers/response.py +0 -0
  83. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/external_services/tvdb.py +0 -0
  84. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/extractor.py +0 -0
  85. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/frames.py +0 -0
  86. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/mediainfo.py +0 -0
  87. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/mediainfo_string.py +0 -0
  88. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/title.py +0 -0
  89. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/torrent_clients.py +0 -0
  90. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/trackers/__init__.py +0 -0
  91. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/trackers/ban_list.py +0 -0
  92. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/trackers/data.py +0 -0
  93. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/trackers/itt.py +0 -0
  94. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/trackers/signs_list.py +0 -0
  95. {ittup-1.2.3 → ittup-1.4.0}/ittup/common/trackers/tags_list.py +0 -0
  96. {ittup-1.2.3 → ittup-1.4.0}/ittup/core/__init__.py +0 -0
  97. {ittup-1.2.3 → ittup-1.4.0}/ittup/core/__main__.py +0 -0
  98. {ittup-1.2.3 → ittup-1.4.0}/ittup/core/duplicate.py +0 -0
  99. {ittup-1.2.3 → ittup-1.4.0}/ittup/core/exceptions.py +0 -0
  100. {ittup-1.2.3 → ittup-1.4.0}/ittup/core/media_manager/DocuManager.py +0 -0
  101. {ittup-1.2.3 → ittup-1.4.0}/ittup/core/media_manager/GameManager.py +0 -0
  102. {ittup-1.2.3 → ittup-1.4.0}/ittup/core/media_manager/MediaInfoManager.py +0 -0
  103. {ittup-1.2.3 → ittup-1.4.0}/ittup/core/media_manager/SeedManager.py +0 -0
  104. {ittup-1.2.3 → ittup-1.4.0}/ittup/core/media_manager/TorrentManager.py +0 -0
  105. {ittup-1.2.3 → ittup-1.4.0}/ittup/core/media_manager/__init__.py +0 -0
  106. {ittup-1.2.3 → ittup-1.4.0}/ittup/core/media_manager/common.py +0 -0
  107. {ittup-1.2.3 → ittup-1.4.0}/ittup/core/pvtDocu.py +0 -0
  108. {ittup-1.2.3 → ittup-1.4.0}/ittup/core/pvtTorrent.py +0 -0
  109. {ittup-1.2.3 → ittup-1.4.0}/ittup/core/pvtTracker.py +0 -0
  110. {ittup-1.2.3 → ittup-1.4.0}/ittup/core/pvtVideo.py +0 -0
  111. {ittup-1.2.3 → ittup-1.4.0}/ittup/core/torrent.py +0 -0
  112. {ittup-1.2.3 → ittup-1.4.0}/ittup/view/__init__.py +0 -0
  113. {ittup-1.2.3 → ittup-1.4.0}/ittup/view/custom_console.py +0 -0
  114. {ittup-1.2.3 → ittup-1.4.0}/ittup.egg-info/dependency_links.txt +0 -0
  115. {ittup-1.2.3 → ittup-1.4.0}/ittup.egg-info/entry_points.txt +0 -0
  116. {ittup-1.2.3 → ittup-1.4.0}/ittup.egg-info/requires.txt +0 -0
  117. {ittup-1.2.3 → ittup-1.4.0}/ittup.egg-info/top_level.txt +0 -0
  118. {ittup-1.2.3 → ittup-1.4.0}/requirements.txt +0 -0
  119. {ittup-1.2.3 → ittup-1.4.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ittup
3
- Version: 1.2.3
3
+ Version: 1.4.0
4
4
  Summary: ItaTorrents uploader
5
5
  Author: Parzival, TehMaat
6
6
  License-Expression: MIT
@@ -70,6 +70,12 @@ class CommandLine:
70
70
  upload_group.add_argument("-personal", "--personal", action="store_true", help="Personal release")
71
71
  upload_group.add_argument("-ftp", "--ftp", action="store_true", help="Connect FTP")
72
72
 
73
+ # Freeleech of the uploaded torrents
74
+ freeleech_group = upload_group.add_mutually_exclusive_group()
75
+ for percent in (25, 50, 75, 100):
76
+ freeleech_group.add_argument(f"-fl{percent}", f"--freeleech{percent}", dest="freeleech",
77
+ action="store_const", const=percent, help=f"Freeleech {percent}%%")
78
+
73
79
  # Removed flags: ITTup uploads only to ItaTorrents. Without this '-mt' would be parsed as '-m t'
74
80
  upload_group.add_argument("-mt", "--mt", "-tracker", "--tracker", nargs='?', action=RemovedFlag,
75
81
  help=argparse.SUPPRESS)
@@ -4,8 +4,9 @@ from datetime import datetime
4
4
 
5
5
  class MediaResult:
6
6
  def __init__(self, result=None, video_id: int = 0, imdb_id = None, tvdb_id = None, trailer_key: str = None,
7
- keywords_list: str = None, season_title = None):
7
+ keywords_list: str = None, season_title = None, original_language: str = None):
8
8
  self.result = result
9
+ self._original_language = original_language
9
10
  self.trailer_key = trailer_key
10
11
  self.keywords_list = keywords_list
11
12
  self.video_id = video_id
@@ -20,6 +21,9 @@ class MediaResult:
20
21
  except ValueError:
21
22
  pass
22
23
 
23
-
24
-
25
-
24
+ @property
25
+ def original_language(self) -> str | None:
26
+ """ TMDB original language (ISO 639-1, es. 'en'), None when unknown """
27
+ # Results cached by older versions have no _original_language
28
+ return (getattr(self, '_original_language', None) or getattr(self.result, 'original_language', None)
29
+ or None)
@@ -1,5 +1,6 @@
1
1
  # -*- coding: utf-8 -*-
2
2
  import os
3
+ import re
3
4
  import hashlib
4
5
  from datetime import datetime
5
6
 
@@ -32,6 +33,10 @@ from ittup.view import custom_console
32
33
  from ittup.core import config_settings
33
34
 
34
35
  base_url = "https://api.themoviedb.org/3"
36
+
37
+ # The title ends at the year, the season or the resolution
38
+ TITLE_END = re.compile(r"\(?(?<![0-9])(?:19|20)\d{2}(?![0-9])\)?|(?<![a-z0-9])S\d{1,2}(?:E\d+)?(?![a-z0-9])|"
39
+ r"(?<![0-9])\d{3,4}[pi](?![a-z0-9])", re.IGNORECASE)
35
40
  ENABLE_LOG = True
36
41
  T = TypeVar('T')
37
42
 
@@ -235,6 +240,26 @@ class TmdbAPI(MyHttp):
235
240
  return results[0].get('id')
236
241
  return None
237
242
 
243
+ def original_language(self, video_id: int, category: str) -> str | None:
244
+ """ Original language (ISO 639-1) of the movie or tv show """
245
+ if endpoint_class := self.ENDPOINTS.get(category):
246
+ request = {**endpoint_class.details(video_id), 'datatype': dict}
247
+ response = self.request(endpoint=request)
248
+ if response:
249
+ return response[0].get('original_language') or None
250
+ return None
251
+
252
+ def italian_details(self, video_id: int, category: str) -> dict | None:
253
+ """ Details of the movie or tv show in Italian (title, seasons..), whatever the preferred language """
254
+ if not video_id:
255
+ return None
256
+ if endpoint_class := self.ENDPOINTS.get(category):
257
+ request = {**endpoint_class.details(video_id), 'datatype': dict, 'params': {'language': 'it-IT'}}
258
+ response = self.request(endpoint=request)
259
+ if response:
260
+ return response[0]
261
+ return None
262
+
238
263
  def external_ids(self, video_id: int, category: str) -> dict | None:
239
264
  if endpoint_class := self.ENDPOINTS.get(category):
240
265
  request = endpoint_class.external_ids(video_id)
@@ -273,6 +298,8 @@ class DbOnline(TmdbAPI):
273
298
  self.category = category
274
299
  self.imdb_id = None
275
300
  self.tvdb_id = None
301
+ self.no_title = no_title
302
+ self.relaxed_year = False
276
303
 
277
304
  # Load the cache file
278
305
  self.cache = None
@@ -366,9 +393,18 @@ class DbOnline(TmdbAPI):
366
393
  return False
367
394
 
368
395
  def year_matches(self, result: T) -> bool:
369
- """ False only when both years are known and they differ """
396
+ """
397
+ False only when both years are known and they differ
398
+ With relaxed_year a movie may differ by one year and a tv show may have the year of a later season
399
+ """
370
400
  if result.get_date() and self.media.guess_filename.guessit_year:
371
- return datetime.strptime(result.get_date(), '%Y-%m-%d').year == self.media.guess_filename.guessit_year
401
+ tmdb_year = datetime.strptime(result.get_date(), '%Y-%m-%d').year
402
+ year = self.media.guess_filename.guessit_year
403
+ if not self.relaxed_year:
404
+ return tmdb_year == year
405
+ if self.category == 'tv':
406
+ return year >= tmdb_year - 1
407
+ return abs(tmdb_year - year) <= 1
372
408
  return True
373
409
 
374
410
  def debug_scores(self, result: T, alternatives: list[T] | None, translations: list[T] | None) -> None:
@@ -406,7 +442,9 @@ class DbOnline(TmdbAPI):
406
442
  if not tmdb_id:
407
443
  custom_console.bot_warning_log(f"TMDB ID not found for IMDB ID '{imdb_id}'")
408
444
 
445
+ original_language = None
409
446
  if tmdb_id > 0:
447
+ original_language = self.original_language(video_id=tmdb_id, category=self.category)
410
448
  # Fill the IDs missing from the title string
411
449
  self.add_external_ids(tmdb_id)
412
450
  # Request trailer and keywords
@@ -414,7 +452,8 @@ class DbOnline(TmdbAPI):
414
452
  keywords_list = self.keywords(tmdb_id) if trailer_key else ''
415
453
 
416
454
  search_results = MediaResult(video_id=tmdb_id, imdb_id=self.imdb_id, tvdb_id=self.tvdb_id,
417
- trailer_key=trailer_key, keywords_list=keywords_list)
455
+ trailer_key=trailer_key, keywords_list=keywords_list,
456
+ original_language=original_language)
418
457
  self.print_results(results=search_results)
419
458
  return search_results
420
459
 
@@ -440,7 +479,8 @@ class DbOnline(TmdbAPI):
440
479
  return search_results
441
480
 
442
481
  # or start an on-line search
443
- results = self._search(self.query, self.category)
482
+ main_query = self.query
483
+ result, results = self.search_queries()
444
484
 
445
485
  # Use imdb_id when tmdb_id is not available
446
486
  tvdb_result = self.tvdb_search()
@@ -449,7 +489,7 @@ class DbOnline(TmdbAPI):
449
489
  self.imdb_id = tvdb_result.get('imdb_id', None)
450
490
 
451
491
  if results:
452
- if result := self.is_like(results):
492
+ if result:
453
493
  # Get the trailer
454
494
  trailer_key = self.trailer(result.id)
455
495
  keywords_list = self.keywords(result.id)
@@ -463,7 +503,7 @@ class DbOnline(TmdbAPI):
463
503
  self.print_results(results=search_results)
464
504
  # Write to the cache if it is enabled
465
505
  if config_settings.user_preferences.CACHE_DBONLINE:
466
- self.cache[self.hash_key(self.query)] = search_results
506
+ self.cache[self.hash_key(main_query)] = search_results
467
507
  # return the result
468
508
  return search_results
469
509
 
@@ -490,6 +530,65 @@ class DbOnline(TmdbAPI):
490
530
  self.cache[self.hash_key(self.query)] = search_results
491
531
  return search_results
492
532
 
533
+ def search_queries(self) -> tuple[T | None, list[T] | None]:
534
+ """
535
+ Search TMDB with the title from the file name, then with the other titles written in the name
536
+ (es. '(500) Days of Summer', the title in parentheses). Each query is checked first with the exact year,
537
+ then with a tolerance (movies released at the end of the year, the year of a season..)
538
+ Returns the result found and the results of the first query (None if TMDB doesn't answer)
539
+ On success self.query is the query that found the result, otherwise the first query
540
+ """
541
+ queries = [self.query] if self.no_title else self.title_queries(self.query, self.media.title)
542
+ responses = {}
543
+ for relaxed in (False, True):
544
+ self.relaxed_year = relaxed
545
+ for query in queries:
546
+ if query not in responses:
547
+ responses[query] = self._search(query, self.category)
548
+ # No response from TMDB
549
+ if responses[query] is None:
550
+ if query == queries[0]:
551
+ return None, None
552
+ responses[query] = []
553
+ self.query = query
554
+ result = self.is_like(responses[query]) if responses[query] else None
555
+ if result:
556
+ if query != queries[0] or relaxed:
557
+ custom_console.bot_log(f"TMDB: found with the title '{query}'"
558
+ f"{' and a year tolerance' if relaxed else ''}")
559
+ self.relaxed_year = False
560
+ return result, responses[query]
561
+ self.relaxed_year = False
562
+ self.query = queries[0]
563
+ return None, responses[queries[0]]
564
+
565
+ @staticmethod
566
+ def title_queries(query: str, name: str) -> list[str]:
567
+ """
568
+ TMDB queries: the title from guessit, then the name before the year, the season or the resolution,
569
+ and the titles in parentheses ('Lo Chiamavano Jeeg Robot (They Call Me Jeeg) (2015)')
570
+ """
571
+ queries = [query]
572
+ name = re.sub(r"\.(mkv|mp4|avi|m2ts|ts|mov|wmv|m4v|mpg|mpeg|webm)$", "", name, flags=re.IGNORECASE)
573
+ # Dots and underscores as spaces, but not in the acronyms (S.W.A.T.)
574
+ name = re.sub(r"(?<=[^\W\d_]{2})[._]|[._](?=[^\W\d_]{2})|_", " ", name)
575
+ prefix = TITLE_END.split(name, maxsplit=1)[0]
576
+ prefix = re.sub(r"[\[{].*", "", prefix).strip(" -.")
577
+ # Titles in parentheses, not numbers like '(500)'
578
+ groups = [group for group in re.findall(r"\(([^()]+)\)", prefix) if re.search(r"[^\W\d_]{2}", group)]
579
+ # The whole prefix, e.g. '(500) Days of Summer'
580
+ queries.append(re.sub(r"[()]", "", prefix))
581
+ # The prefix without the parentheses and each title in parentheses
582
+ queries.append(re.sub(r"\([^()]*\)", "", prefix))
583
+ queries.extend(groups)
584
+
585
+ unique = []
586
+ for item in queries:
587
+ item = re.sub(r"\s+", " ", item or "").strip(" -.")
588
+ if item and item.lower() not in [u.lower() for u in unique]:
589
+ unique.append(item)
590
+ return unique[:4]
591
+
493
592
  def add_external_ids(self, video_id: int, override: bool = False) -> None:
494
593
  """ Set the IMDB and TVDB IDs from TMDB; override=False keeps the ones already known """
495
594
  ext = self.external_ids(video_id=video_id, category=self.category)
@@ -0,0 +1,129 @@
1
+ # -*- coding: utf-8 -*-
2
+ import os
3
+ import re
4
+
5
+ from ittup.view import custom_console
6
+
7
+ # Characters the tracker (UNIT3D) doesn't accept in the names of the torrent files
8
+ INVALID_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
9
+
10
+
11
+ def safe_name(name: str) -> str:
12
+ """
13
+ Name without the characters refused by the tracker: 'Titolo: Sub' -> 'Titolo - Sub'
14
+ The extension is kept, the trailing dots and spaces are removed
15
+ """
16
+ stem, ext = os.path.splitext(name)
17
+ # Keep only a real extension (es. '.mkv'), not the end of 'Mr. Robot'
18
+ if not re.fullmatch(r"\.[A-Za-z0-9]{1,5}", ext):
19
+ stem, ext = name, ""
20
+ if " " in stem:
21
+ stem = re.sub(r"\s*:\s*", " - ", stem)
22
+ else:
23
+ stem = stem.replace(":", ".")
24
+ stem = INVALID_CHARS.sub("", stem)
25
+ stem = re.sub(r"\s{2,}", " ", stem)
26
+ stem = re.sub(r"\.{2,}", ".", stem)
27
+ stem = re.sub(r"(\s-)+\s", " - ", stem)
28
+ return stem.strip(" .") + ext
29
+
30
+
31
+ def is_valid_name(name: str) -> bool:
32
+ return not INVALID_CHARS.search(name) and name == name.rstrip(" .")
33
+
34
+
35
+ def rename_path(path: str, new_name: str) -> str:
36
+ """
37
+ Rename a file or a folder in the same folder. Returns the new path, the old one if it can't be renamed
38
+ """
39
+ new_path = os.path.join(os.path.dirname(path), new_name)
40
+ if new_path == path:
41
+ return path
42
+ if os.path.exists(new_path):
43
+ custom_console.bot_error_log(f"Can't rename '{path}': '{new_name}' already exists")
44
+ return path
45
+ try:
46
+ os.rename(path, new_path)
47
+ except OSError as e:
48
+ custom_console.bot_error_log(f"Can't rename '{path}': {e}")
49
+ return path
50
+ custom_console.bot_warning_log(f"Renamed '{os.path.basename(path)}' -> '{new_name}'")
51
+ return new_path
52
+
53
+
54
+ def rename_invalid(path: str) -> str:
55
+ """
56
+ Rename the file, or the folder and everything in it, whose names have characters refused by the tracker
57
+ Returns the new path
58
+ """
59
+ path = os.path.normpath(path)
60
+ if os.path.isdir(path):
61
+ for root, dirs, files in os.walk(path, topdown=False):
62
+ for name in files + dirs:
63
+ if not is_valid_name(name):
64
+ rename_path(os.path.join(root, name), safe_name(name))
65
+ name = os.path.basename(path)
66
+ if is_valid_name(name):
67
+ return path
68
+ return rename_path(path, safe_name(name))
69
+
70
+
71
+ def title_name(display_name: str) -> str:
72
+ """
73
+ File name from the title shown on the tracker, with dots:
74
+ 'Spider-Man: No Way Home 2021 1080p WEB-DL H.264-SIGN' -> 'Spider-Man.No.Way.Home.2021.1080p.WEB-DL.H.264-SIGN'
75
+ """
76
+ name = INVALID_CHARS.sub(" ", display_name)
77
+ name = re.sub(r"\s+-\s+", ".", name.strip())
78
+ name = re.sub(r"\s+", ".", name)
79
+ name = re.sub(r"\.{2,}", ".", name)
80
+ return name.strip(".")
81
+
82
+
83
+ def episode_name(name: str, pack: str | None, season: int | None, episode: int | None) -> str | None:
84
+ """
85
+ Name of an episode of a pack: the pack tag of the title is replaced by its SxxExx
86
+ None if the episode or the pack tag are unknown
87
+ """
88
+ if not pack or season is None or episode is None:
89
+ return None
90
+ pattern = rf"(?<![^.]){re.escape(pack)}(?![^.])"
91
+ if not re.search(pattern, name):
92
+ return None
93
+ return re.sub(pattern, f"S{int(season):02d}E{int(episode):02d}", name, count=1)
94
+
95
+
96
+ def rename_to_title(path: str, display_name: str, video_files: list[str], pack: str | None,
97
+ episodes: dict[str, tuple[int | None, int | None]]) -> tuple[str, dict[str, str]]:
98
+ """
99
+ Rename a file, or a folder and its video files, as the title shown on the tracker
100
+
101
+ Args:
102
+ path: the file or the folder of the torrent
103
+ display_name: the title shown on the tracker
104
+ video_files: the video files in the folder (names)
105
+ pack: the season tag of the title (es. 'S04') for a tv pack
106
+ episodes: (season, episode) of each video file of a pack
107
+
108
+ Returns:
109
+ the new path and the new name of each renamed file of the folder
110
+ """
111
+ name = title_name(display_name)
112
+ if os.path.isfile(path):
113
+ _, ext = os.path.splitext(path)
114
+ return rename_path(path, name + ext), {}
115
+
116
+ renamed = {}
117
+ for file in video_files:
118
+ _, ext = os.path.splitext(file)
119
+ if pack:
120
+ new_name = episode_name(name, pack, *episodes.get(file, (None, None)))
121
+ else:
122
+ # A movie folder: only its single video file takes the title
123
+ new_name = name if len(video_files) == 1 else None
124
+ if not new_name:
125
+ continue
126
+ new_path = rename_path(os.path.join(path, file), new_name + ext)
127
+ if new_path != os.path.join(path, file):
128
+ renamed[file] = os.path.basename(new_path)
129
+ return rename_path(path, name), renamed
@@ -147,6 +147,7 @@ class UserPreferences(BaseModel):
147
147
  CACHE_DBONLINE: bool = False
148
148
  TMDB_DEBUG: bool = False
149
149
  PERSONAL_RELEASE: bool = False
150
+ RENAME_FILES: bool = False
150
151
  FAST_LOAD: int = 0
151
152
  MY_SIGN: str | None = None
152
153
  MY_URL: str | None = None
@@ -460,13 +461,14 @@ class Config(BaseModel):
460
461
  field = field.upper()
461
462
 
462
463
  # New attribute added to an existing json file with an empty value: use the default
463
- if field == 'TMDB_DEBUG' and section[field] == '':
464
+ if field in ['TMDB_DEBUG', 'RENAME_FILES'] and section[field] == '':
464
465
  section[field] = 'False'
465
466
  if field == 'WATCHER_TIMEOUT' and section[field] == '':
466
467
  section[field] = UserPreferences.model_fields['WATCHER_TIMEOUT'].default
467
468
 
468
469
  if field in ['DUPLICATE_ON', 'SKIP_DUPLICATE', 'SKIP_TMDB', 'SKIP_YOUTUBE', 'RESIZE_SCSHOT', 'ANON',
469
- 'WEBP_ENABLED', 'CACHE_SCR', 'CACHE_DBONLINE', 'TMDB_DEBUG', 'PERSONAL_RELEASE']:
470
+ 'WEBP_ENABLED', 'CACHE_SCR', 'CACHE_DBONLINE', 'TMDB_DEBUG', 'PERSONAL_RELEASE',
471
+ 'RENAME_FILES']:
470
472
  section[field] = Validate.boolean(value=section[field], field_name=field)
471
473
 
472
474
  if field in ['TORRENT_COMMENT', 'WATCHER_PATH']:
@@ -653,6 +655,7 @@ class Load:
653
655
  "CACHE_DBONLINE": "False",
654
656
  "TMDB_DEBUG": "False",
655
657
  "PERSONAL_RELEASE": "False",
658
+ "RENAME_FILES": "False",
656
659
  "FAST_LOAD": "0",
657
660
  "RELEASER_SIGN": "",
658
661
  "MY_SIGN": "",
@@ -46,6 +46,14 @@ audio_translate = {
46
46
  "MPEG Audio": "MPEG",
47
47
  }
48
48
 
49
+ # Audio codecs from the best to the worst: only the best ones go in the title
50
+ audio_rank = ["TrueHD", "DTS-HD MA", "FLAC", "PCM", "DTS-ES", "DTS", "DD+", "DD", "AAC", "HE-AAC", "Opus", "MPEG"]
51
+ # The title keeps the best ITA codec and the best codec in the original language, then fills up to
52
+ # MAX_AUDIO_CODECS with the tracks with no language. Tracks in other languages only add their flag
53
+ MAX_AUDIO_CODECS = 2
54
+ # Original language (TMDB ISO 639-1) when it is not known yet
55
+ DEFAULT_ORIGINAL_LANGUAGE = "en"
56
+
49
57
  # Codec family from the MediaInfo 'format' of the video track
50
58
  video_family = {
51
59
  "AVC": "264",
@@ -97,6 +105,7 @@ class SearchTags(object):
97
105
  self.tags_dict = {}
98
106
  self.release_type: str | None = None
99
107
  self.name_family: str | None = None
108
+ self.original_language: str = DEFAULT_ORIGINAL_LANGUAGE
100
109
 
101
110
  self.TAG_TYPES: dict = tags_list
102
111
  self.SIGNS_LIST: dict = sign_list
@@ -142,6 +151,16 @@ class SearchTags(object):
142
151
  tag_esc = re.sub(r'([A-Z])(\d+)', r'\1[._-]?\2', tag_esc)
143
152
  return tag_esc
144
153
 
154
+ def expand_platform_rip(self, filename: str) -> str:
155
+ """
156
+ Split a platform rip into the platform and the WEBRip source (es. 'NFRip' -> 'NF WEBRip')
157
+ """
158
+ platforms = [tag for tag, category in self.TAG_TYPES.items() if category == "platform"]
159
+ if not platforms:
160
+ return filename
161
+ pattern = r'(?<![a-z0-9])(' + "|".join(re.escape(tag) for tag in platforms) + r')rip(?![a-z0-9])'
162
+ return re.sub(pattern, r'\1 WEBRip', filename, flags=re.IGNORECASE)
163
+
145
164
  def build_title(self, dictionary: dict) -> str:
146
165
  # /// Build the title
147
166
  build = []
@@ -179,10 +198,13 @@ class SearchTags(object):
179
198
  # Save a regex pattern for each category
180
199
  patterns.append([norm, category])
181
200
 
201
+ # NFRip -> NF WEBRip: the streaming platform and the source
202
+ filename = self.expand_platform_rip(self.filename)
203
+
182
204
  # Run regex
183
205
  for p, category in patterns:
184
206
  regex = re.compile(r'(?<!\w)' + p + r'(?!\w)', re.IGNORECASE)
185
- matches = regex.findall(self.filename)
207
+ matches = regex.findall(filename)
186
208
  if matches:
187
209
  # Normalize Tag_list
188
210
  normalized_tag = TAG_NORMALIZE.get(matches[0].upper(), None)
@@ -267,6 +289,36 @@ class SearchTags(object):
267
289
  # try to detect a known sign from SIGN_LIST
268
290
  self.releaser_sign = self.detect_releaser(self.filename, self.SIGNS_LIST)
269
291
 
292
+ return self.ordered_title()
293
+
294
+ def set_original_language(self, original_language: str | None) -> tuple[str, dict]:
295
+ """
296
+ Rebuild the audio tags of the title processed before with the original language from TMDB (es. 'ja')
297
+ """
298
+ self.original_language = (original_language or DEFAULT_ORIGINAL_LANGUAGE).lower()
299
+ if "acodec" in self.tags_position:
300
+ self.tags_dict.update(self.mediainfo_audio(category="acodec"))
301
+ return self.ordered_title()
302
+
303
+ def set_title(self, title: str) -> tuple[str, dict]:
304
+ """
305
+ Rebuild the title with a new media title (es. the Italian title from TMDB)
306
+ """
307
+ self.title = title
308
+ self.tags_dict.update({'title': title})
309
+ # The part is already in the new title (es. 'Wicked - Parte 2')
310
+ if re.search(r'(?<![a-z])part[ei]?(?![a-z])', title, re.IGNORECASE):
311
+ self.tags_dict.pop('part', None)
312
+ return self.ordered_title()
313
+
314
+ def set_season(self, se_str: str) -> tuple[str, dict]:
315
+ """
316
+ Rebuild the title with a new season tag (es. 'S04' for a pack with every episode of the season)
317
+ """
318
+ self.tags_dict.update({'season': se_str})
319
+ return self.ordered_title()
320
+
321
+ def ordered_title(self) -> tuple[str, dict]:
270
322
  # /// Order according to tag position
271
323
  tags_dict = {
272
324
  k: self.tags_dict[k]
@@ -276,11 +328,38 @@ class SearchTags(object):
276
328
  new_title = self.build_title(tags_dict)
277
329
  return new_title, tags_dict
278
330
 
331
+ @staticmethod
332
+ def audio_language(audio: dict) -> str | None:
333
+ """
334
+ ISO alpha-3 language of an audio track, None when it is not tagged
335
+ """
336
+ for l in audio.get('other_language', []):
337
+ c = ManageTitles.convert_iso(l)
338
+ if c:
339
+ return c[0] if isinstance(c, list) else c
340
+ return None
341
+
342
+ @staticmethod
343
+ def audio_score(codec: str, atmos: bool, channel_s: int) -> tuple:
344
+ """
345
+ Sort key of an audio track: codec, then Atmos, then channels
346
+ """
347
+ rank = len(audio_rank) - audio_rank.index(codec) if codec in audio_rank else 0
348
+ return rank, atmos, channel_s
349
+
350
+ def is_original_language(self, audio: dict) -> bool:
351
+ codes = [str(code).lower() for code in audio.get('other_language', []) + [audio.get('language') or '']]
352
+ return self.original_language in codes
353
+
279
354
  def mediainfo_audio(self, category: str) -> dict:
280
355
  languages = []
281
- audio_codecs = []
356
+ original_languages = []
357
+ # [(score, label, language, original language)]
358
+ tracks = []
282
359
  if self.mediafile.audio_track:
283
360
  for audio in self.mediafile.audio_track:
361
+ language = self.audio_language(audio)
362
+ original = self.is_original_language(audio)
284
363
  other_format = audio.get('other_format', [])
285
364
  if other_format:
286
365
  codec_translated = audio_translate.get(other_format[0], '')
@@ -293,24 +372,32 @@ class SearchTags(object):
293
372
  channel_s = audio.get('channel_s', 0)
294
373
  # Add channels
295
374
  ch = {2: "2.0", 6: "5.1", 8: "7.1"}.get(channel_s, "")
296
- if f"{codec_translated} {ch} {atmos}".strip() not in audio_codecs:
297
- audio_codecs.append(f"{codec_translated} {ch} {atmos}".strip())
375
+ tracks.append((self.audio_score(codec_translated, bool(atmos), channel_s),
376
+ f"{codec_translated} {ch} {atmos}".strip(), language, original))
298
377
 
299
378
  # Add flags
300
- for l in audio.get('other_language', []):
301
- c = ManageTitles.convert_iso(l)
302
- if c:
303
- if isinstance(c, list):
304
- languages.append(c[0])
305
- else:
306
- languages.append(c)
307
- break
379
+ if language:
380
+ languages.append(language)
381
+ if original:
382
+ original_languages.append(language)
308
383
  languages = list(dict.fromkeys(languages))
309
384
  # Add multilanguage tag when languages > 2
310
385
  if len(languages) > 2:
311
386
  self.tags_dict.update({'multi': 'MULTI'})
312
387
 
313
- audio_codecs.extend(languages)
388
+ # Best ITA codec + best original language codec, then the tracks with no language up to MAX_AUDIO_CODECS
389
+ best_first = sorted(tracks, key=lambda track: track[0], reverse=True)
390
+ picked = [track for track in best_first if track[2] == "ITA"][:1]
391
+ picked += [track for track in best_first if track[3]][:1]
392
+ picked += [track for track in best_first if track[2] is None]
393
+ audio_codecs = []
394
+ for _, label, _, _ in picked:
395
+ if label not in audio_codecs and len(audio_codecs) < MAX_AUDIO_CODECS:
396
+ audio_codecs.append(label)
397
+
398
+ # Flags: ITA, the original language, then the other languages with no codec
399
+ first = [language for language in ["ITA"] + original_languages[:1] if language in languages]
400
+ audio_codecs.extend(dict.fromkeys(first + languages))
314
401
  return {category: audio_codecs}
315
402
 
316
403
  @staticmethod
@@ -0,0 +1,107 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ import os
4
+ import re
5
+
6
+ from dataclasses import dataclass
7
+ from ittup.common.utility import ManageTitles
8
+ from ittup.common.trackers.tags_list import TAGS_LIST
9
+ from . import tracker_list
10
+
11
+ @dataclass
12
+ class TRACKData:
13
+ category: dict[str, int]
14
+ freelech: dict[str, int]
15
+ type_id: dict[str, int]
16
+ resolution: dict[str, int]
17
+ codec: list
18
+
19
+ @classmethod
20
+ def load_from_module(cls, tracker_name: str) -> "TRACKData":
21
+ """
22
+ Load tracker data from module
23
+ """
24
+ tracker_data= tracker_list[tracker_name.upper()]
25
+
26
+ return cls(
27
+ category=tracker_data.get("CATEGORY"),
28
+ freelech=tracker_data.get("FREELECH"),
29
+ type_id=tracker_data.get("TYPE_ID"),
30
+ resolution=tracker_data.get("RESOLUTION"),
31
+ codec=tracker_data.get("CODEC"),
32
+ )
33
+
34
+ def filter_type(self, *names: str) -> int:
35
+ """
36
+ Tracker type ID from the release names (es. the folder name, then the file name)
37
+ The first name with a source (WEB-DL, REMUX, WEBRip..) wins, so the type always follows the source
38
+ written in the title. Without a source, a codec in the name means 'encode'
39
+ """
40
+ names = [os.path.basename(str(name)) for name in names if name]
41
+
42
+ # Caso 1: Cerca la sorgente nel nome, dalla piu' importante
43
+ for name in names:
44
+ found = self.type_tags(name)
45
+ if found:
46
+ return self.type_id[min(found, key=type_priority)]
47
+
48
+ # Caso 2: Se non trova un TYPE_ID, cerca un codec e ritorna 'encode'
49
+ for name in names:
50
+ word_list = ManageTitles.clean(name).replace("-", " ").lower().split(" ")
51
+ if any(word in self.codec for word in word_list):
52
+ return self.type_id.get("encode", -1)
53
+ return self.type_id.get("altro", -1)
54
+
55
+ def type_tags(self, name: str) -> list[str]:
56
+ """
57
+ TYPE_ID tags written in the name. A longer tag hides the shorter ones inside it (WEB-DLMux is not DLMux)
58
+ """
59
+ name = re.sub(r"[._\s()\[\]{}+,]+", " ", name.lower())
60
+ # Skip the title: the tags start from the year, the season or the resolution ('Charlotte's Web 2006 BluRay')
61
+ start = TITLE_END.search(name)
62
+ if start and start.start() > 0:
63
+ name = name[start.start():]
64
+ # NFRip, AMZNRip.. are WEBRip from a streaming platform
65
+ name = PLATFORM_RIP.sub("webrip", name)
66
+ name = re.sub(r"(?<![a-z0-9])full disc(?![a-z0-9])", "full-disc", name)
67
+
68
+ found = []
69
+ for tag in sorted(self.type_id, key=len, reverse=True):
70
+ # 'altro' is the default type, not a tag (es. 'Un altro giro')
71
+ if tag == "altro":
72
+ continue
73
+ pattern = re.compile(rf"(?<![a-z0-9]){re.escape(tag)}(?![a-z0-9])")
74
+ if pattern.search(name):
75
+ found.append(tag)
76
+ # Hide the matched tag from the shorter ones
77
+ name = pattern.sub(" ", name)
78
+ return found
79
+
80
+
81
+ # Type tags from the most to the least important: the release source before the generic tags
82
+ # (BluRay, HEVC, FullHD..) and before the file extensions. Tags not in the list come last
83
+ TYPE_PRIORITY = [
84
+ "full-disc", "fulldisc",
85
+ "bdmux", "web-dlmux", "dlmux", "webmux",
86
+ "bd-untouched", "untouched", "bdremux", "remux", "vu", "vh",
87
+ "web-dl", "webdl", "web",
88
+ "webrip",
89
+ "hdtv",
90
+ "bdrip", "dvdrip",
91
+ "cinema-md", "hdts", "wrs", "md",
92
+ "3d",
93
+ "encode", "hdrip", "bluray", "hevc", "fullhd",
94
+ ]
95
+
96
+
97
+ def type_priority(tag: str) -> int:
98
+ return TYPE_PRIORITY.index(tag) if tag in TYPE_PRIORITY else len(TYPE_PRIORITY)
99
+
100
+
101
+ # Year, season or resolution after the title
102
+ TITLE_END = re.compile(r"(?<![a-z0-9])(?:(?:19|20)\d{2}|s\d{1,2}(?:e\d+)?|\d{3,4}[pi])(?![a-z0-9])")
103
+
104
+ # NFRip, AMZNRip, DSNPRip..
105
+ PLATFORM_RIP = re.compile(
106
+ r"(?<![a-z0-9])(?:" + "|".join(re.escape(tag.lower()) for tag, category in TAGS_LIST.items()
107
+ if category == "platform") + r")rip(?![a-z0-9])")