ytdl-sub 2024.9.30__py3-none-any.whl → 2026.1.24__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 (89) hide show
  1. ytdl_sub/__init__.py +1 -1
  2. ytdl_sub/cli/entrypoint.py +8 -3
  3. ytdl_sub/cli/output_summary.py +27 -23
  4. ytdl_sub/cli/parsers/cli_to_sub.py +64 -0
  5. ytdl_sub/cli/parsers/dl.py +5 -4
  6. ytdl_sub/cli/parsers/main.py +15 -2
  7. ytdl_sub/config/config_validator.py +23 -10
  8. ytdl_sub/config/defaults.py +3 -1
  9. ytdl_sub/config/overrides.py +89 -35
  10. ytdl_sub/config/plugin/plugin.py +19 -4
  11. ytdl_sub/config/plugin/plugin_mapping.py +14 -0
  12. ytdl_sub/config/plugin/plugin_operation.py +1 -0
  13. ytdl_sub/config/plugin/preset_plugins.py +33 -0
  14. ytdl_sub/config/preset.py +36 -8
  15. ytdl_sub/config/preset_options.py +78 -4
  16. ytdl_sub/config/validators/variable_validation.py +29 -145
  17. ytdl_sub/downloaders/url/downloader.py +46 -39
  18. ytdl_sub/downloaders/url/validators.py +39 -7
  19. ytdl_sub/downloaders/ytdlp.py +1 -1
  20. ytdl_sub/entries/entry.py +3 -0
  21. ytdl_sub/entries/script/variable_definitions.py +33 -0
  22. ytdl_sub/entries/script/variable_types.py +1 -9
  23. ytdl_sub/entries/variables/override_variables.py +11 -9
  24. ytdl_sub/plugins/date_range.py +31 -12
  25. ytdl_sub/plugins/embed_thumbnail.py +3 -3
  26. ytdl_sub/plugins/filter_exclude.py +9 -6
  27. ytdl_sub/plugins/filter_include.py +8 -6
  28. ytdl_sub/plugins/nfo_tags.py +6 -7
  29. ytdl_sub/plugins/square_thumbnail.py +77 -0
  30. ytdl_sub/plugins/static_nfo_tags.py +71 -0
  31. ytdl_sub/plugins/subtitles.py +38 -4
  32. ytdl_sub/plugins/throttle_protection.py +170 -42
  33. ytdl_sub/plugins/video_tags.py +1 -1
  34. ytdl_sub/prebuilt_presets/helpers/download_deletion_options.yaml +5 -4
  35. ytdl_sub/prebuilt_presets/helpers/filter_duration.yaml +34 -0
  36. ytdl_sub/prebuilt_presets/helpers/filter_keywords.yaml +72 -0
  37. ytdl_sub/prebuilt_presets/helpers/media_quality.yaml +33 -1
  38. ytdl_sub/prebuilt_presets/helpers/throttle_protection.yaml +84 -0
  39. ytdl_sub/prebuilt_presets/helpers/url.yaml +126 -697
  40. ytdl_sub/prebuilt_presets/helpers/url_categorized.yaml +1 -100
  41. ytdl_sub/prebuilt_presets/music/albums_from_chapters.yaml +7 -2
  42. ytdl_sub/prebuilt_presets/music/albums_from_playlists.yaml +4 -4
  43. ytdl_sub/prebuilt_presets/music/singles.yaml +1 -0
  44. ytdl_sub/prebuilt_presets/music/{other_websites.yaml → soundcloud.yaml} +1 -1
  45. ytdl_sub/prebuilt_presets/music_videos/__init__.py +1 -0
  46. ytdl_sub/prebuilt_presets/music_videos/music_video_base.yaml +3 -0
  47. ytdl_sub/prebuilt_presets/music_videos/music_videos.yaml +3 -0
  48. ytdl_sub/prebuilt_presets/tv_show/__init__.py +5 -46
  49. ytdl_sub/prebuilt_presets/tv_show/episode.yaml +14 -4
  50. ytdl_sub/prebuilt_presets/tv_show/tv_show_by_date.yaml +124 -13
  51. ytdl_sub/prebuilt_presets/tv_show/tv_show_collection.yaml +354 -88
  52. ytdl_sub/script/functions/__init__.py +2 -0
  53. ytdl_sub/script/functions/error_functions.py +10 -7
  54. ytdl_sub/script/functions/numeric_functions.py +17 -0
  55. ytdl_sub/script/functions/print_functions.py +69 -0
  56. ytdl_sub/script/functions/string_functions.py +73 -2
  57. ytdl_sub/script/parser.py +6 -1
  58. ytdl_sub/script/script.py +238 -67
  59. ytdl_sub/script/types/array.py +22 -1
  60. ytdl_sub/script/types/function.py +190 -8
  61. ytdl_sub/script/types/map.py +30 -1
  62. ytdl_sub/script/types/resolvable.py +8 -0
  63. ytdl_sub/script/types/syntax_tree.py +58 -3
  64. ytdl_sub/script/types/variable_dependency.py +99 -9
  65. ytdl_sub/script/utils/name_validation.py +21 -0
  66. ytdl_sub/script/utils/type_checking.py +6 -1
  67. ytdl_sub/subscriptions/base_subscription.py +29 -3
  68. ytdl_sub/subscriptions/subscription_download.py +57 -19
  69. ytdl_sub/subscriptions/subscription_ytdl_options.py +3 -1
  70. ytdl_sub/utils/chapters.py +8 -1
  71. ytdl_sub/utils/exceptions.py +4 -0
  72. ytdl_sub/utils/ffmpeg.py +5 -1
  73. ytdl_sub/utils/file_handler.py +49 -0
  74. ytdl_sub/utils/file_lock.py +1 -0
  75. ytdl_sub/utils/file_path.py +6 -0
  76. ytdl_sub/utils/script.py +73 -5
  77. ytdl_sub/validators/file_path_validators.py +5 -19
  78. ytdl_sub/validators/string_datetime.py +5 -3
  79. ytdl_sub/validators/string_formatter_validators.py +124 -38
  80. ytdl_sub/validators/string_select_validator.py +15 -0
  81. ytdl_sub/validators/validators.py +3 -13
  82. ytdl_sub/ytdl_additions/enhanced_download_archive.py +24 -6
  83. {ytdl_sub-2024.9.30.dist-info → ytdl_sub-2026.1.24.dist-info}/METADATA +29 -28
  84. ytdl_sub-2026.1.24.dist-info/RECORD +166 -0
  85. {ytdl_sub-2024.9.30.dist-info → ytdl_sub-2026.1.24.dist-info}/WHEEL +1 -1
  86. ytdl_sub-2024.9.30.dist-info/RECORD +0 -159
  87. {ytdl_sub-2024.9.30.dist-info → ytdl_sub-2026.1.24.dist-info}/entry_points.txt +0 -0
  88. {ytdl_sub-2024.9.30.dist-info → ytdl_sub-2026.1.24.dist-info/licenses}/LICENSE +0 -0
  89. {ytdl_sub-2024.9.30.dist-info → ytdl_sub-2026.1.24.dist-info}/top_level.txt +0 -0
ytdl_sub/__init__.py CHANGED
@@ -1 +1 @@
1
- __pypi_version__ = "2024.09.30";__local_version__ = "2024.09.30+f2ec26d"
1
+ __pypi_version__ = "2026.01.24";__local_version__ = "2026.01.24+60cdbad"
@@ -12,6 +12,7 @@ from yt_dlp.utils import sanitize_filename
12
12
  from ytdl_sub.cli.output_summary import output_summary
13
13
  from ytdl_sub.cli.output_transaction_log import _maybe_validate_transaction_log_file
14
14
  from ytdl_sub.cli.output_transaction_log import output_transaction_log
15
+ from ytdl_sub.cli.parsers.cli_to_sub import print_cli_to_sub
15
16
  from ytdl_sub.cli.parsers.dl import DownloadArgsParser
16
17
  from ytdl_sub.cli.parsers.main import DEFAULT_CONFIG_FILE_NAME
17
18
  from ytdl_sub.cli.parsers.main import parser
@@ -161,7 +162,7 @@ def _download_subscription_from_cli(
161
162
  extra_arguments=extra_args, config_options=config.config_options
162
163
  )
163
164
  subscription_args_dict = dl_args_parser.to_subscription_dict()
164
- subscription_name = f"cli-dl-{dl_args_parser.get_args_hash()}"
165
+ subscription_name = dl_args_parser.get_dl_subscription_name()
165
166
 
166
167
  subscription = Subscription.from_dict(
167
168
  config=config, preset_name=subscription_name, preset_dict=subscription_args_dict
@@ -206,6 +207,10 @@ def main() -> List[Subscription]:
206
207
 
207
208
  args, extra_args = parser.parse_known_args()
208
209
 
210
+ if args.subparser == "cli-to-sub":
211
+ print_cli_to_sub(args=extra_args)
212
+ return []
213
+
209
214
  # Load the config
210
215
  if args.config:
211
216
  config = ConfigFile.from_file_path(args.config)
@@ -263,7 +268,7 @@ def main() -> List[Subscription]:
263
268
  _view_url_from_cli(config=config, url=args.url, split_chapters=args.split_chapters)
264
269
  )
265
270
  else:
266
- raise ValidationException("Must provide one of the commands: sub, dl, view")
271
+ raise ValidationException("Must provide one of the commands: sub, dl, view, cli-to-sub")
267
272
 
268
273
  if not args.suppress_transaction_log:
269
274
  output_transaction_log(
@@ -271,6 +276,6 @@ def main() -> List[Subscription]:
271
276
  transaction_log_file_path=args.transaction_log,
272
277
  )
273
278
 
274
- output_summary(subscriptions)
279
+ output_summary(subscriptions, suppress_colors=args.suppress_colors)
275
280
 
276
281
  return subscriptions
@@ -8,16 +8,16 @@ from ytdl_sub.utils.logger import Logger
8
8
  logger = Logger.get()
9
9
 
10
10
 
11
- def _green(value: str) -> str:
12
- return Fore.GREEN + value + Fore.RESET
11
+ def _green(value: str, suppress_colors: bool = False) -> str:
12
+ return value if suppress_colors else Fore.GREEN + value + Fore.RESET
13
13
 
14
14
 
15
- def _red(value: str) -> str:
16
- return Fore.RED + value + Fore.RESET
15
+ def _red(value: str, suppress_colors: bool = False) -> str:
16
+ return value if suppress_colors else Fore.RED + value + Fore.RESET
17
17
 
18
18
 
19
- def _no_color(value: str) -> str:
20
- return Fore.RESET + value + Fore.RESET
19
+ def _no_color(value: str, suppress_colors: bool = False) -> str:
20
+ return value if suppress_colors else Fore.RESET + value + Fore.RESET
21
21
 
22
22
 
23
23
  def _str_int(value: int) -> str:
@@ -26,21 +26,23 @@ def _str_int(value: int) -> str:
26
26
  return str(value)
27
27
 
28
28
 
29
- def _color_int(value: int) -> str:
29
+ def _color_int(value: int, suppress_colors: bool = False) -> str:
30
30
  str_int = _str_int(value)
31
31
  if value > 0:
32
- return _green(str_int)
32
+ return _green(str_int, suppress_colors)
33
33
  if value < 0:
34
- return _red(str_int)
35
- return _no_color(str_int)
34
+ return _red(str_int, suppress_colors)
35
+ return _no_color(str_int, suppress_colors)
36
36
 
37
37
 
38
- def output_summary(subscriptions: List[Subscription]) -> None:
38
+ def output_summary(subscriptions: List[Subscription], suppress_colors: bool) -> None:
39
39
  """
40
40
  Parameters
41
41
  ----------
42
42
  subscriptions
43
43
  Processed subscriptions
44
+ suppress_colors
45
+ Whether to have color or not
44
46
 
45
47
  Returns
46
48
  -------
@@ -65,21 +67,21 @@ def output_summary(subscriptions: List[Subscription]) -> None:
65
67
 
66
68
  # Initialize widths to 0
67
69
  width_sub_name: int = max(len(sub.name) for sub in subscriptions) + 4 # aesthetics
68
- width_num_entries_added: int = len(_color_int(total_added))
69
- width_num_entries_modified: int = len(_color_int(total_modified))
70
- width_num_entries_removed: int = len(_color_int(total_removed))
70
+ width_num_entries_added: int = len(_color_int(total_added, suppress_colors))
71
+ width_num_entries_modified: int = len(_color_int(total_modified, suppress_colors))
72
+ width_num_entries_removed: int = len(_color_int(total_removed, suppress_colors))
71
73
  width_num_entries: int = len(str(total_entries)) + 4 # aesthetics
72
74
 
73
75
  # Build the summary
74
76
  for subscription in subscriptions:
75
- num_entries_added = _color_int(subscription.num_entries_added)
76
- num_entries_modified = _color_int(subscription.num_entries_modified)
77
- num_entries_removed = _color_int(subscription.num_entries_removed * -1)
77
+ num_entries_added = _color_int(subscription.num_entries_added, suppress_colors)
78
+ num_entries_modified = _color_int(subscription.num_entries_modified, suppress_colors)
79
+ num_entries_removed = _color_int(subscription.num_entries_removed * -1, suppress_colors)
78
80
  num_entries = str(subscription.num_entries)
79
81
  status = (
80
- _red(subscription.exception.__class__.__name__)
82
+ _red(subscription.exception.__class__.__name__, suppress_colors)
81
83
  if subscription.exception
82
- else _green("✔")
84
+ else _green("✔", suppress_colors)
83
85
  )
84
86
 
85
87
  summary.append(
@@ -92,14 +94,16 @@ def output_summary(subscriptions: List[Subscription]) -> None:
92
94
  )
93
95
 
94
96
  total_errors_str = (
95
- _green("Success") if total_errors == 0 else _red(f"Error{'s' if total_errors > 1 else ''}")
97
+ _green("Success", suppress_colors)
98
+ if total_errors == 0
99
+ else _red(f"Error{'s' if total_errors > 1 else ''}", suppress_colors)
96
100
  )
97
101
 
98
102
  summary.append(
99
103
  f"{total_subs_str:<{width_sub_name}} "
100
- f"{_color_int(total_added):>{width_num_entries_added}} "
101
- f"{_color_int(total_modified):>{width_num_entries_modified}} "
102
- f"{_color_int(total_removed):>{width_num_entries_removed}} "
104
+ f"{_color_int(total_added, suppress_colors):>{width_num_entries_added}} "
105
+ f"{_color_int(total_modified, suppress_colors):>{width_num_entries_modified}} "
106
+ f"{_color_int(total_removed * -1, suppress_colors):>{width_num_entries_removed}} "
103
107
  f"{total_entries:>{width_num_entries}} "
104
108
  f"{total_errors_str}"
105
109
  )
@@ -0,0 +1,64 @@
1
+ from typing import List
2
+
3
+ import yt_dlp
4
+ import yt_dlp.options
5
+
6
+ from ytdl_sub.utils.logger import Logger
7
+ from ytdl_sub.utils.yaml import dump_yaml
8
+
9
+ logger = Logger.get()
10
+
11
+ # pylint: disable=missing-function-docstring
12
+
13
+ ##############################################################
14
+ # --- BEGIN ----
15
+ # Copy of https://github.com/yt-dlp/yt-dlp/blob/master/devscripts/cli_to_api.py
16
+
17
+ create_parser = yt_dlp.options.create_parser
18
+
19
+
20
+ def parse_patched_options(opts):
21
+
22
+ patched_parser = create_parser()
23
+ patched_parser.defaults.update(
24
+ {
25
+ "ignoreerrors": False,
26
+ "retries": 0,
27
+ "fragment_retries": 0,
28
+ "extract_flat": False,
29
+ "concat_playlist": "never",
30
+ "update_self": False,
31
+ }
32
+ )
33
+ yt_dlp.options.create_parser = lambda: patched_parser
34
+ try:
35
+ return yt_dlp.parse_options(opts)
36
+ finally:
37
+ yt_dlp.options.create_parser = create_parser
38
+
39
+
40
+ default_opts = parse_patched_options([]).ydl_opts
41
+
42
+
43
+ def cli_to_api(opts, cli_defaults=False):
44
+ opts = (yt_dlp.parse_options if cli_defaults else parse_patched_options)(opts).ydl_opts
45
+
46
+ diff = {k: v for k, v in opts.items() if default_opts[k] != v}
47
+ if "postprocessors" in diff:
48
+ diff["postprocessors"] = [
49
+ pp for pp in diff["postprocessors"] if pp not in default_opts["postprocessors"]
50
+ ]
51
+ return diff
52
+
53
+
54
+ # --- END ----
55
+ ##############################################################
56
+
57
+
58
+ def print_cli_to_sub(args: List[str]) -> None:
59
+ api_args = cli_to_api(args)
60
+ if not api_args:
61
+ logger.info("Does not resolve to any yt-dlp args")
62
+ return
63
+
64
+ print(dump_yaml({"ytdl_options": api_args}))
@@ -242,12 +242,13 @@ class DownloadArgsParser:
242
242
 
243
243
  return subscription_dict
244
244
 
245
- def get_args_hash(self) -> str:
245
+ def get_dl_subscription_name(self) -> str:
246
246
  """
247
- :return: Hash of the arguments provided
247
+ Returns a deterministic name based on input args
248
248
  """
249
- hash_string = str(sorted(self._unknown_arguments))
250
- return hashlib.sha256(hash_string.encode()).hexdigest()[-8:]
249
+ to_hash = str(sorted(self._unknown_arguments))
250
+ hash_str = hashlib.sha256(to_hash.encode()).hexdigest()[-8:]
251
+ return f"cli-dl-{hash_str}"
251
252
 
252
253
  @classmethod
253
254
  def from_dl_override(cls, override: str, config: ConfigFile) -> "DownloadArgsParser":
@@ -44,6 +44,7 @@ class MainArguments:
44
44
  short="-m",
45
45
  long="--match",
46
46
  )
47
+ SUPPRESS_COLORS = CLIArgument(short="-nc", long="--suppress-colors")
47
48
 
48
49
  @classmethod
49
50
  def all(cls) -> List[CLIArgument]:
@@ -59,6 +60,7 @@ class MainArguments:
59
60
  cls.TRANSACTION_LOG,
60
61
  cls.SUPPRESS_TRANSACTION_LOG,
61
62
  cls.MATCH,
63
+ cls.SUPPRESS_COLORS,
62
64
  ]
63
65
 
64
66
  @classmethod
@@ -109,8 +111,8 @@ def _add_shared_arguments(arg_parser: argparse.ArgumentParser, suppress_defaults
109
111
  MainArguments.LOG_LEVEL.long,
110
112
  metavar="|".join(LoggerLevels.names()),
111
113
  type=str,
112
- help="level of logs to print to console, defaults to info",
113
- default=argparse.SUPPRESS if suppress_defaults else LoggerLevels.INFO.name,
114
+ help="level of logs to print to console, defaults to verbose",
115
+ default=argparse.SUPPRESS if suppress_defaults else LoggerLevels.VERBOSE.name,
114
116
  choices=LoggerLevels.names(),
115
117
  dest="ytdl_sub_log_level",
116
118
  )
@@ -129,6 +131,13 @@ def _add_shared_arguments(arg_parser: argparse.ArgumentParser, suppress_defaults
129
131
  help="do not output transaction logs to console or file",
130
132
  default=argparse.SUPPRESS if suppress_defaults else False,
131
133
  )
134
+ arg_parser.add_argument(
135
+ MainArguments.SUPPRESS_COLORS.short,
136
+ MainArguments.SUPPRESS_COLORS.long,
137
+ action="store_true",
138
+ help="do not use colors in ytdl-sub output",
139
+ default=argparse.SUPPRESS if suppress_defaults else False,
140
+ )
132
141
  arg_parser.add_argument(
133
142
  MainArguments.MATCH.short,
134
143
  MainArguments.MATCH.long,
@@ -212,3 +221,7 @@ view_parser.add_argument(
212
221
  help="View source variables after splitting by chapters",
213
222
  )
214
223
  view_parser.add_argument("url", help="URL to view source variables for")
224
+
225
+ ###################################################################################################
226
+ # CLI-TO-SUB PARSER
227
+ cli_to_sub_parser = subparsers.add_parser("cli-to-sub")
@@ -12,6 +12,8 @@ from ytdl_sub.config.defaults import DEFAULT_FFPROBE_PATH
12
12
  from ytdl_sub.config.defaults import DEFAULT_LOCK_DIRECTORY
13
13
  from ytdl_sub.config.defaults import MAX_FILE_NAME_BYTES
14
14
  from ytdl_sub.prebuilt_presets import PREBUILT_PRESETS
15
+ from ytdl_sub.utils.exceptions import SubscriptionPermissionError
16
+ from ytdl_sub.utils.file_handler import FileHandler
15
17
  from ytdl_sub.validators.file_path_validators import FFmpegFileValidator
16
18
  from ytdl_sub.validators.file_path_validators import FFprobeFileValidator
17
19
  from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
@@ -67,7 +69,8 @@ class PersistLogsValidator(StrictDictValidator):
67
69
  @property
68
70
  def logs_directory(self) -> str:
69
71
  """
70
- Required. The directory to store the logs in.
72
+ Write log files to this directory with names like
73
+ ``YYYY-mm-dd-HHMMSS.subscription_name.(success|error).log``. (required)
71
74
  """
72
75
  return self._logs_directory.value
73
76
 
@@ -92,7 +95,10 @@ class PersistLogsValidator(StrictDictValidator):
92
95
  @property
93
96
  def keep_successful_logs(self) -> bool:
94
97
  """
95
- Optional. Whether to store logs when downloading is successful. Defaults to True.
98
+ If the ``persist_logs:`` key is in the configuration, then ``ytdl-sub`` *always*
99
+ writes log files for the subscription both for successful downloads and when it
100
+ encounters an error while downloading. When this key is ``False``, only write
101
+ log files for errors. (default ``True``)
96
102
  """
97
103
  return self._keep_successful_logs.value
98
104
 
@@ -143,11 +149,17 @@ class ConfigOptions(StrictDictValidator):
143
149
  key="file_name_max_bytes", validator=IntValidator, default=MAX_FILE_NAME_BYTES
144
150
  )
145
151
 
152
+ if not FileHandler.is_path_writable(self.working_directory):
153
+ raise SubscriptionPermissionError(
154
+ "ytdl-sub does not have permissions to the working directory: "
155
+ f"{self.working_directory}"
156
+ )
157
+
146
158
  @property
147
159
  def working_directory(self) -> str:
148
160
  """
149
161
  The directory to temporarily store downloaded files before moving them into their final
150
- directory. Defaults to .ytdl-sub-working-directory
162
+ directory. (default ``./.ytdl-sub-working-directory``)
151
163
  """
152
164
  # Expands tildas to actual paths, use native os sep
153
165
  return os.path.expanduser(self._working_directory.value.replace(posixpath.sep, os.sep))
@@ -155,7 +167,7 @@ class ConfigOptions(StrictDictValidator):
155
167
  @property
156
168
  def umask(self) -> Optional[str]:
157
169
  """
158
- Umask (octal format) to apply to every created file. Defaults to "022".
170
+ Umask in octal format to apply to every created file. (default ``022``)
159
171
  """
160
172
  return self._umask.value
161
173
 
@@ -214,24 +226,25 @@ class ConfigOptions(StrictDictValidator):
214
226
  def lock_directory(self) -> str:
215
227
  """
216
228
  The directory to temporarily store file locks, which prevents multiple instances
217
- of ``ytdl-sub`` from running. Note that file locks do not work on network-mounted
218
- directories. Ensure that this directory resides on the host machine. Defaults to ``/tmp``.
229
+ of ``ytdl-sub`` from running. Note that file locks do not work on
230
+ network-mounted directories. Ensure that this directory resides on the host
231
+ machine. (default ``/tmp``)
219
232
  """
220
233
  return self._lock_directory.value
221
234
 
222
235
  @property
223
236
  def ffmpeg_path(self) -> str:
224
237
  """
225
- Path to ffmpeg executable. Defaults to ``/usr/bin/ffmpeg`` for Linux, and
226
- ``ffmpeg.exe`` for Windows (in the same directory as ytdl-sub).
238
+ Path to ffmpeg executable. (default ``/usr/bin/ffmpeg`` for Linux,
239
+ ``./ffmpeg.exe`` in the same directory as ytdl-sub for Windows)
227
240
  """
228
241
  return self._ffmpeg_path.value
229
242
 
230
243
  @property
231
244
  def ffprobe_path(self) -> str:
232
245
  """
233
- Path to ffprobe executable. Defaults to ``/usr/bin/ffprobe`` for Linux, and
234
- ``ffprobe.exe`` for Windows (in the same directory as ytdl-sub).
246
+ Path to ffprobe executable. (default ``/usr/bin/ffprobe`` for Linux,
247
+ ``./ffprobe.exe`` in the same directory as ytdl-sub for Windows)
235
248
  """
236
249
  return self._ffprobe_path.value
237
250
 
@@ -2,6 +2,8 @@ import os
2
2
 
3
3
  from ytdl_sub.utils.system import IS_WINDOWS
4
4
 
5
+ # pylint: disable=invalid-name
6
+
5
7
 
6
8
  def _existing_path(*paths: str) -> str:
7
9
  """
@@ -22,7 +24,7 @@ if IS_WINDOWS:
22
24
 
23
25
  MAX_FILE_NAME_BYTES = 255
24
26
  else:
25
- DEFAULT_LOCK_DIRECTORY = "/tmp"
27
+ DEFAULT_LOCK_DIRECTORY = ".ytdl-sub-lock"
26
28
  DEFAULT_FFMPEG_PATH = os.getenv(
27
29
  "YTDL_SUB_FFMPEG_PATH", _existing_path("/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg")
28
30
  )
@@ -1,9 +1,10 @@
1
1
  from typing import Any
2
2
  from typing import Dict
3
+ from typing import Iterable
3
4
  from typing import Optional
4
5
  from typing import Set
5
-
6
- import mergedeep
6
+ from typing import Type
7
+ from typing import TypeVar
7
8
 
8
9
  from ytdl_sub.entries.entry import Entry
9
10
  from ytdl_sub.entries.script.variable_definitions import VARIABLES
@@ -11,6 +12,10 @@ from ytdl_sub.entries.variables.override_variables import REQUIRED_OVERRIDE_VARI
11
12
  from ytdl_sub.entries.variables.override_variables import OverrideHelpers
12
13
  from ytdl_sub.script.parser import parse
13
14
  from ytdl_sub.script.script import Script
15
+ from ytdl_sub.script.types.function import BuiltInFunction
16
+ from ytdl_sub.script.types.resolvable import Resolvable
17
+ from ytdl_sub.script.types.resolvable import String
18
+ from ytdl_sub.script.types.syntax_tree import SyntaxTree
14
19
  from ytdl_sub.script.utils.exceptions import ScriptVariableNotResolved
15
20
  from ytdl_sub.utils.exceptions import InvalidVariableNameException
16
21
  from ytdl_sub.utils.exceptions import StringFormattingException
@@ -20,6 +25,8 @@ from ytdl_sub.utils.scriptable import Scriptable
20
25
  from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
21
26
  from ytdl_sub.validators.string_formatter_validators import UnstructuredDictFormatterValidator
22
27
 
28
+ ExpectedT = TypeVar("ExpectedT")
29
+
23
30
 
24
31
  class Overrides(UnstructuredDictFormatterValidator, Scriptable):
25
32
  """
@@ -86,6 +93,24 @@ class Overrides(UnstructuredDictFormatterValidator, Scriptable):
86
93
 
87
94
  return True
88
95
 
96
+ def ensure_variable_names_not_a_plugin(self, plugin_names: Iterable[str]) -> None:
97
+ """
98
+ Throws an error if an override variable or function has the same name as a
99
+ preset key. This is to avoid confusion when accidentally defining things in
100
+ overrides that are meant to be in the preset.
101
+ """
102
+ for name in self.keys:
103
+ if name.startswith("%"):
104
+ name = name[1:]
105
+
106
+ if name in plugin_names:
107
+ raise self._validation_exception(
108
+ f"Override variable with name {name} cannot be used since it is"
109
+ " the name of a plugin. Perhaps you meant to define it as a plugin? If so,"
110
+ " indent it left to make it at the same level as overrides.",
111
+ exception_class=InvalidVariableNameException,
112
+ )
113
+
89
114
  def ensure_variable_name_valid(self, name: str) -> None:
90
115
  """
91
116
  Ensures the variable name does not collide with any entry variables or built-in functions.
@@ -113,29 +138,35 @@ class Overrides(UnstructuredDictFormatterValidator, Scriptable):
113
138
  )
114
139
 
115
140
  def initial_variables(
116
- self, unresolved_variables: Optional[Dict[str, str]] = None
117
- ) -> Dict[str, str]:
141
+ self, unresolved_variables: Optional[Dict[str, SyntaxTree]] = None
142
+ ) -> Dict[str, SyntaxTree]:
118
143
  """
119
144
  Returns
120
145
  -------
121
146
  Variables and format strings for all Override variables + additional variables (Optional)
122
147
  """
123
- initial_variables: Dict[str, str] = {}
124
- mergedeep.merge(
125
- initial_variables,
126
- self.dict_with_format_strings,
127
- unresolved_variables if unresolved_variables else {},
128
- )
129
- return ScriptUtils.add_sanitized_variables(initial_variables)
148
+ initial_variables: Dict[str, SyntaxTree] = self.dict_with_parsed_format_strings
149
+ if unresolved_variables:
150
+ initial_variables |= unresolved_variables
151
+ return ScriptUtils.add_sanitized_parsed_variables(initial_variables)
130
152
 
131
153
  def initialize_script(self, unresolved_variables: Set[str]) -> "Overrides":
132
154
  """
133
155
  Initialize the override script with any unresolved variables
134
156
  """
135
- self.script.add(
157
+ self.script.add_parsed(
136
158
  self.initial_variables(
137
159
  unresolved_variables={
138
- var_name: f"{{%throw('Plugin variable {var_name} has not been created yet')}}"
160
+ var_name: SyntaxTree(
161
+ ast=[
162
+ BuiltInFunction(
163
+ name="throw",
164
+ args=[
165
+ String(f"Plugin variable {var_name} has not been created yet")
166
+ ],
167
+ )
168
+ ]
169
+ )
139
170
  for var_name in unresolved_variables
140
171
  }
141
172
  )
@@ -144,12 +175,43 @@ class Overrides(UnstructuredDictFormatterValidator, Scriptable):
144
175
  self.update_script()
145
176
  return self
146
177
 
178
+ def _apply_to_resolvable(
179
+ self,
180
+ formatter: StringFormatterValidator,
181
+ entry: Optional[Entry],
182
+ function_overrides: Optional[Dict[str, str]],
183
+ ) -> Resolvable:
184
+ script: Script = self.script
185
+ unresolvable: Set[str] = self.unresolvable
186
+ if entry:
187
+ script = entry.script
188
+ unresolvable = entry.unresolvable
189
+
190
+ # Update the script internally so long as we are not supplying overrides
191
+ # that could alter the script with one-off state
192
+ update = function_overrides is None
193
+
194
+ try:
195
+ return script.resolve_once(
196
+ dict({"tmp_var": formatter.format_string}, **(function_overrides or {})),
197
+ unresolvable=unresolvable,
198
+ update=update,
199
+ )["tmp_var"]
200
+ except ScriptVariableNotResolved as exc:
201
+ raise StringFormattingException(
202
+ "Tried to resolve the following script, but could not due to unresolved "
203
+ f"variables:\n {formatter.format_string}\n"
204
+ "This is most likely due to circular dependencies in variables. "
205
+ "If you think otherwise, please file a bug on GitHub and post your config. Thanks!"
206
+ ) from exc
207
+
147
208
  def apply_formatter(
148
209
  self,
149
210
  formatter: StringFormatterValidator,
150
211
  entry: Optional[Entry] = None,
151
- function_overrides: Dict[str, str] = None,
152
- ) -> str:
212
+ function_overrides: Optional[Dict[str, str]] = None,
213
+ expected_type: Type[ExpectedT] = str,
214
+ ) -> ExpectedT:
153
215
  """
154
216
  Parameters
155
217
  ----------
@@ -159,6 +221,8 @@ class Overrides(UnstructuredDictFormatterValidator, Scriptable):
159
221
  Optional. Entry to add source variables to the formatter
160
222
  function_overrides
161
223
  Optional. Explicit values to override the overrides themselves and source variables
224
+ expected_type
225
+ The expected type that should return. Defaults to string.
162
226
 
163
227
  Returns
164
228
  -------
@@ -169,25 +233,15 @@ class Overrides(UnstructuredDictFormatterValidator, Scriptable):
169
233
  StringFormattingException
170
234
  If the formatter that is trying to be resolved cannot
171
235
  """
172
- script: Script = self.script
173
- unresolvable: Set[str] = self.unresolvable
174
- if entry:
175
- script = entry.script
176
- unresolvable = entry.unresolvable
236
+ out = formatter.post_process(
237
+ self._apply_to_resolvable(
238
+ formatter=formatter, entry=entry, function_overrides=function_overrides
239
+ ).native
240
+ )
177
241
 
178
- try:
179
- return formatter.post_process(
180
- str(
181
- script.resolve_once(
182
- dict({"tmp_var": formatter.format_string}, **(function_overrides or {})),
183
- unresolvable=unresolvable,
184
- )["tmp_var"]
185
- )
186
- )
187
- except ScriptVariableNotResolved as exc:
242
+ if not isinstance(out, expected_type):
188
243
  raise StringFormattingException(
189
- "Tried to resolve the following script, but could not due to unresolved "
190
- f"variables:\n {formatter.format_string}\n"
191
- "This is most likely due to circular dependencies in variables. "
192
- "If you think otherwise, please file a bug on GitHub and post your config. Thanks!"
193
- ) from exc
244
+ f"Expected type {expected_type.__name__}, but received '{out.__class__.__name__}'"
245
+ )
246
+
247
+ return out
@@ -13,7 +13,6 @@ from ytdl_sub.config.validators.options import OptionsValidatorT
13
13
  from ytdl_sub.config.validators.options import ToggleableOptionsDictValidator
14
14
  from ytdl_sub.entries.entry import Entry
15
15
  from ytdl_sub.utils.file_handler import FileMetadata
16
- from ytdl_sub.utils.script import ScriptUtils
17
16
  from ytdl_sub.ytdl_additions.enhanced_download_archive import DownloadArchiver
18
17
  from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
19
18
 
@@ -49,9 +48,7 @@ class Plugin(BasePlugin[OptionsValidatorT], Generic[OptionsValidatorT], ABC):
49
48
  Returns True if enabled, False if disabled.
50
49
  """
51
50
  if isinstance(self.plugin_options, ToggleableOptionsDictValidator):
52
- return ScriptUtils.bool_formatter_output(
53
- self.overrides.apply_formatter(self.plugin_options.enable)
54
- )
51
+ return self.overrides.apply_formatter(self.plugin_options.enable, expected_type=bool)
55
52
  return True
56
53
 
57
54
  def ytdl_options_match_filters(self) -> Tuple[List[str], List[str]]:
@@ -69,6 +66,13 @@ class Plugin(BasePlugin[OptionsValidatorT], Generic[OptionsValidatorT], ABC):
69
66
  ytdl options to enable/disable when downloading entries for this specific plugin
70
67
  """
71
68
 
69
+ def initialize_subscription(self) -> bool:
70
+ """
71
+ Before any downloading begins, perform initialization before the subscription runs.
72
+ Returns true if this subscription should run, false otherwise.
73
+ """
74
+ return True
75
+
72
76
  def modify_entry_metadata(self, entry: Entry) -> Optional[Entry]:
73
77
  """
74
78
  After entry metadata has been gathered, perform preprocessing on the metadata
@@ -115,6 +119,17 @@ class Plugin(BasePlugin[OptionsValidatorT], Generic[OptionsValidatorT], ABC):
115
119
  """
116
120
  return None
117
121
 
122
+ def post_completion_entry(self, file_metadata: FileMetadata) -> None:
123
+ """
124
+ After the entry file is moved to its final location, run this hook.
125
+
126
+ Parameters
127
+ ----------
128
+ file_metadata
129
+ Metadata about the completed entry's file download
130
+ """
131
+ return None
132
+
118
133
  def post_process_subscription(self):
119
134
  """
120
135
  After all downloaded files have been post-processed, apply a subscription-wide post process