ytdl-sub 2023.4.20.post2__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 (156) hide show
  1. ytdl_sub/__init__.py +1 -1
  2. ytdl_sub/cli/entrypoint.py +281 -0
  3. ytdl_sub/cli/output_summary.py +117 -0
  4. ytdl_sub/cli/output_transaction_log.py +54 -0
  5. ytdl_sub/cli/parsers/__init__.py +0 -0
  6. ytdl_sub/cli/parsers/cli_to_sub.py +64 -0
  7. ytdl_sub/cli/{download_args_parser.py → parsers/dl.py} +27 -11
  8. ytdl_sub/cli/{main_args_parser.py → parsers/main.py} +47 -4
  9. ytdl_sub/config/config_file.py +30 -9
  10. ytdl_sub/config/config_validator.py +41 -22
  11. ytdl_sub/config/defaults.py +26 -3
  12. ytdl_sub/config/overrides.py +247 -0
  13. ytdl_sub/config/plugin/__init__.py +0 -0
  14. ytdl_sub/{plugins → config/plugin}/plugin.py +71 -55
  15. ytdl_sub/config/plugin/plugin_mapping.py +219 -0
  16. ytdl_sub/config/plugin/plugin_operation.py +9 -0
  17. ytdl_sub/config/plugin/preset_plugins.py +79 -0
  18. ytdl_sub/config/preset.py +49 -317
  19. ytdl_sub/config/preset_options.py +177 -175
  20. ytdl_sub/config/validators/__init__.py +0 -0
  21. ytdl_sub/config/validators/options.py +82 -0
  22. ytdl_sub/config/validators/variable_validation.py +94 -0
  23. ytdl_sub/downloaders/info_json/info_json_downloader.py +36 -16
  24. ytdl_sub/downloaders/source_plugin.py +72 -0
  25. ytdl_sub/downloaders/url/downloader.py +195 -199
  26. ytdl_sub/downloaders/url/validators.py +172 -48
  27. ytdl_sub/downloaders/ytdl_options_builder.py +1 -1
  28. ytdl_sub/downloaders/ytdlp.py +49 -7
  29. ytdl_sub/entries/base_entry.py +62 -298
  30. ytdl_sub/entries/entry.py +163 -17
  31. ytdl_sub/entries/entry_parent.py +82 -137
  32. ytdl_sub/entries/script/__init__.py +0 -0
  33. ytdl_sub/entries/script/custom_functions.py +178 -0
  34. ytdl_sub/entries/script/function_scripts.py +23 -0
  35. ytdl_sub/entries/script/variable_definitions.py +1216 -0
  36. ytdl_sub/entries/script/variable_types.py +321 -0
  37. ytdl_sub/entries/variables/override_variables.py +179 -0
  38. ytdl_sub/main.py +11 -9
  39. ytdl_sub/plugins/audio_extract.py +63 -18
  40. ytdl_sub/plugins/chapters.py +88 -68
  41. ytdl_sub/plugins/date_range.py +85 -33
  42. ytdl_sub/plugins/embed_thumbnail.py +100 -0
  43. ytdl_sub/plugins/file_convert.py +71 -52
  44. ytdl_sub/plugins/filter_exclude.py +80 -0
  45. ytdl_sub/plugins/filter_include.py +87 -0
  46. ytdl_sub/plugins/format.py +42 -0
  47. ytdl_sub/plugins/internal/view.py +10 -9
  48. ytdl_sub/plugins/match_filters.py +64 -35
  49. ytdl_sub/plugins/music_tags.py +73 -80
  50. ytdl_sub/plugins/nfo_tags.py +72 -60
  51. ytdl_sub/plugins/output_directory_nfo_tags.py +41 -49
  52. ytdl_sub/plugins/split_by_chapters.py +83 -84
  53. ytdl_sub/plugins/square_thumbnail.py +77 -0
  54. ytdl_sub/plugins/static_nfo_tags.py +71 -0
  55. ytdl_sub/plugins/subtitles.py +91 -43
  56. ytdl_sub/plugins/throttle_protection.py +370 -0
  57. ytdl_sub/plugins/video_tags.py +12 -35
  58. ytdl_sub/prebuilt_presets/__init__.py +14 -2
  59. ytdl_sub/prebuilt_presets/helpers/__init__.py +2 -219
  60. ytdl_sub/prebuilt_presets/helpers/download_deletion_options.yaml +47 -0
  61. ytdl_sub/prebuilt_presets/helpers/filter_duration.yaml +34 -0
  62. ytdl_sub/prebuilt_presets/helpers/filter_keywords.yaml +72 -0
  63. ytdl_sub/prebuilt_presets/helpers/media_quality.yaml +90 -0
  64. ytdl_sub/prebuilt_presets/helpers/players.yaml +5 -0
  65. ytdl_sub/prebuilt_presets/helpers/throttle_protection.yaml +84 -0
  66. ytdl_sub/prebuilt_presets/helpers/url.yaml +177 -0
  67. ytdl_sub/prebuilt_presets/helpers/url_bilateral.yaml +21 -0
  68. ytdl_sub/prebuilt_presets/helpers/url_categorized.yaml +111 -0
  69. ytdl_sub/prebuilt_presets/internal/__init__.py +3 -0
  70. ytdl_sub/prebuilt_presets/internal/view.yaml +1 -3
  71. ytdl_sub/prebuilt_presets/music/__init__.py +14 -0
  72. ytdl_sub/prebuilt_presets/music/albums_from_chapters.yaml +26 -0
  73. ytdl_sub/prebuilt_presets/music/albums_from_playlists.yaml +36 -0
  74. ytdl_sub/prebuilt_presets/music/singles.yaml +77 -0
  75. ytdl_sub/prebuilt_presets/music/soundcloud.yaml +37 -0
  76. ytdl_sub/prebuilt_presets/music_videos/__init__.py +21 -0
  77. ytdl_sub/prebuilt_presets/music_videos/music_video_base.yaml +66 -0
  78. ytdl_sub/prebuilt_presets/music_videos/music_video_extras.yaml +58 -0
  79. ytdl_sub/prebuilt_presets/music_videos/music_videos.yaml +46 -0
  80. ytdl_sub/prebuilt_presets/tv_show/__init__.py +15 -59
  81. ytdl_sub/prebuilt_presets/tv_show/episode.yaml +31 -104
  82. ytdl_sub/prebuilt_presets/tv_show/tv_show.yaml +36 -64
  83. ytdl_sub/prebuilt_presets/tv_show/tv_show_by_date.yaml +179 -1
  84. ytdl_sub/prebuilt_presets/tv_show/tv_show_collection.yaml +1243 -322
  85. ytdl_sub/script/__init__.py +0 -0
  86. ytdl_sub/script/functions/__init__.py +84 -0
  87. ytdl_sub/script/functions/array_functions.py +224 -0
  88. ytdl_sub/script/functions/boolean_functions.py +169 -0
  89. ytdl_sub/script/functions/conditional_functions.py +69 -0
  90. ytdl_sub/script/functions/date_functions.py +14 -0
  91. ytdl_sub/script/functions/error_functions.py +67 -0
  92. ytdl_sub/script/functions/json_functions.py +41 -0
  93. ytdl_sub/script/functions/map_functions.py +118 -0
  94. ytdl_sub/script/functions/numeric_functions.py +115 -0
  95. ytdl_sub/script/functions/print_functions.py +69 -0
  96. ytdl_sub/script/functions/regex_functions.py +176 -0
  97. ytdl_sub/script/functions/string_functions.py +216 -0
  98. ytdl_sub/script/parser.py +616 -0
  99. ytdl_sub/script/script.py +770 -0
  100. ytdl_sub/script/script_output.py +52 -0
  101. ytdl_sub/script/types/__init__.py +0 -0
  102. ytdl_sub/script/types/array.py +82 -0
  103. ytdl_sub/script/types/function.py +493 -0
  104. ytdl_sub/script/types/map.py +95 -0
  105. ytdl_sub/script/types/resolvable.py +265 -0
  106. ytdl_sub/script/types/syntax_tree.py +107 -0
  107. ytdl_sub/script/types/variable.py +28 -0
  108. ytdl_sub/script/types/variable_dependency.py +287 -0
  109. ytdl_sub/script/utils/__init__.py +0 -0
  110. ytdl_sub/script/utils/exception_formatters.py +131 -0
  111. ytdl_sub/script/utils/exceptions.py +96 -0
  112. ytdl_sub/script/utils/name_validation.py +81 -0
  113. ytdl_sub/script/utils/type_checking.py +312 -0
  114. ytdl_sub/subscriptions/base_subscription.py +105 -17
  115. ytdl_sub/subscriptions/subscription.py +60 -20
  116. ytdl_sub/subscriptions/subscription_download.py +166 -71
  117. ytdl_sub/subscriptions/subscription_validators.py +387 -0
  118. ytdl_sub/subscriptions/subscription_ytdl_options.py +87 -19
  119. ytdl_sub/utils/chapters.py +46 -18
  120. ytdl_sub/utils/datetime.py +12 -1
  121. ytdl_sub/utils/exceptions.py +4 -0
  122. ytdl_sub/utils/ffmpeg.py +14 -3
  123. ytdl_sub/utils/file_handler.py +66 -2
  124. ytdl_sub/utils/file_lock.py +14 -1
  125. ytdl_sub/utils/file_path.py +67 -0
  126. ytdl_sub/utils/logger.py +55 -26
  127. ytdl_sub/utils/retry.py +4 -2
  128. ytdl_sub/utils/script.py +210 -0
  129. ytdl_sub/utils/scriptable.py +96 -0
  130. ytdl_sub/utils/thumbnail.py +21 -19
  131. ytdl_sub/utils/yaml.py +2 -2
  132. ytdl_sub/validators/audo_codec_validator.py +1 -1
  133. ytdl_sub/validators/file_path_validators.py +10 -98
  134. ytdl_sub/validators/source_variable_validator.py +8 -5
  135. ytdl_sub/validators/string_datetime.py +8 -9
  136. ytdl_sub/validators/string_formatter_validators.py +240 -148
  137. ytdl_sub/validators/string_select_validator.py +15 -0
  138. ytdl_sub/validators/validators.py +27 -1
  139. ytdl_sub/ytdl_additions/enhanced_download_archive.py +130 -64
  140. ytdl_sub-2026.1.24.dist-info/METADATA +944 -0
  141. ytdl_sub-2026.1.24.dist-info/RECORD +166 -0
  142. {ytdl_sub-2023.4.20.post2.dist-info → ytdl_sub-2026.1.24.dist-info}/WHEEL +1 -1
  143. ytdl_sub/cli/main.py +0 -353
  144. ytdl_sub/config/preset_class_mappings.py +0 -153
  145. ytdl_sub/downloaders/base_downloader.py +0 -77
  146. ytdl_sub/downloaders/url/multi_url.py +0 -68
  147. ytdl_sub/downloaders/url/url.py +0 -40
  148. ytdl_sub/entries/variables/entry_variables.py +0 -670
  149. ytdl_sub/entries/variables/kwargs.py +0 -66
  150. ytdl_sub/plugins/regex.py +0 -333
  151. ytdl_sub/prebuilt_presets/helpers/common.yaml +0 -16
  152. ytdl_sub-2023.4.20.post2.dist-info/METADATA +0 -301
  153. ytdl_sub-2023.4.20.post2.dist-info/RECORD +0 -98
  154. {ytdl_sub-2023.4.20.post2.dist-info → ytdl_sub-2026.1.24.dist-info}/entry_points.txt +0 -0
  155. {ytdl_sub-2023.4.20.post2.dist-info → ytdl_sub-2026.1.24.dist-info/licenses}/LICENSE +0 -0
  156. {ytdl_sub-2023.4.20.post2.dist-info → ytdl_sub-2026.1.24.dist-info}/top_level.txt +0 -0
ytdl_sub/__init__.py CHANGED
@@ -1 +1 @@
1
- __pypi_version__ = "2023.04.20.post2";__local_version__ = "2023.04.20+d5d9978"
1
+ __pypi_version__ = "2026.01.24";__local_version__ = "2026.01.24+60cdbad"
@@ -0,0 +1,281 @@
1
+ import gc
2
+ import os
3
+ import sys
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Dict
7
+ from typing import List
8
+ from typing import Optional
9
+
10
+ from yt_dlp.utils import sanitize_filename
11
+
12
+ from ytdl_sub.cli.output_summary import output_summary
13
+ from ytdl_sub.cli.output_transaction_log import _maybe_validate_transaction_log_file
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
16
+ from ytdl_sub.cli.parsers.dl import DownloadArgsParser
17
+ from ytdl_sub.cli.parsers.main import DEFAULT_CONFIG_FILE_NAME
18
+ from ytdl_sub.cli.parsers.main import parser
19
+ from ytdl_sub.config.config_file import ConfigFile
20
+ from ytdl_sub.subscriptions.subscription import Subscription
21
+ from ytdl_sub.utils.exceptions import ExperimentalFeatureNotEnabled
22
+ from ytdl_sub.utils.exceptions import ValidationException
23
+ from ytdl_sub.utils.file_handler import FileHandler
24
+ from ytdl_sub.utils.file_lock import working_directory_lock
25
+ from ytdl_sub.utils.logger import Logger
26
+
27
+ logger = Logger.get()
28
+
29
+ # View is a command to run a simple dry-run on a URL using the `_view` preset.
30
+ # Use ytdl-sub dl arguments to use the preset
31
+ _VIEW_EXTRA_ARGS_FORMATTER = "--preset _view --overrides.url {}"
32
+
33
+
34
+ def _log_time() -> str:
35
+ return datetime.now().strftime("%Y-%m-%d-%H%M%S")
36
+
37
+
38
+ def _maybe_write_subscription_log_file(
39
+ config: ConfigFile,
40
+ subscription: Subscription,
41
+ dry_run: bool,
42
+ exception: Optional[Exception] = None,
43
+ ) -> None:
44
+ success: bool = exception is None
45
+
46
+ # If dry-run, do nothing
47
+ if dry_run:
48
+ return
49
+
50
+ # If persisting logs is disabled, do nothing
51
+ if not config.config_options.persist_logs:
52
+ return
53
+
54
+ # If persisting successful logs is disabled, do nothing
55
+ if success and not config.config_options.persist_logs.keep_successful_logs:
56
+ return
57
+
58
+ log_subscription_name = sanitize_filename(subscription.name).lower().replace(" ", "_")
59
+ log_success = "success" if success else "error"
60
+
61
+ log_filename = f"{_log_time()}.{log_subscription_name}.{log_success}.log"
62
+ persist_log_path = Path(config.config_options.persist_logs.logs_directory) / log_filename
63
+
64
+ if not success:
65
+ Logger.log_exception(exception=exception, log_filepath=persist_log_path)
66
+
67
+ os.makedirs(os.path.dirname(persist_log_path), exist_ok=True)
68
+ FileHandler.copy(Logger.debug_log_filename(), persist_log_path)
69
+
70
+
71
+ def _download_subscriptions_from_yaml_files(
72
+ config: ConfigFile,
73
+ subscription_paths: List[str],
74
+ subscription_matches: List[str],
75
+ subscription_override_dict: Dict,
76
+ update_with_info_json: bool,
77
+ dry_run: bool,
78
+ ) -> List[Subscription]:
79
+ """
80
+ Downloads all subscriptions from one or many subscription yaml files.
81
+
82
+ Parameters
83
+ ----------
84
+ config
85
+ Configuration file
86
+ subscription_paths
87
+ Path to subscription files to download
88
+ subscription_matches
89
+ Optional list of substrings to match subscription names to (only run if matched)
90
+ update_with_info_json
91
+ Whether to actually download or update using existing info json
92
+ dry_run
93
+ Whether to dry run or not
94
+
95
+ Returns
96
+ -------
97
+ List of subscriptions processed
98
+
99
+ Raises
100
+ ------
101
+ Exception
102
+ Any exception during download
103
+ """
104
+ subscriptions: List[Subscription] = []
105
+
106
+ # Load all the subscriptions first to perform all validation before downloading
107
+ for path in subscription_paths:
108
+ subscriptions += Subscription.from_file_path(
109
+ config=config,
110
+ subscription_path=path,
111
+ subscription_matches=subscription_matches,
112
+ subscription_override_dict=subscription_override_dict,
113
+ )
114
+
115
+ for subscription in subscriptions:
116
+ with subscription.exception_handling():
117
+ logger.info(
118
+ "Beginning subscription %s for %s",
119
+ ("dry run" if dry_run else "download"),
120
+ subscription.name,
121
+ )
122
+ logger.debug("Subscription full yaml:\n%s", subscription.as_yaml())
123
+
124
+ if update_with_info_json:
125
+ subscription.update_with_info_json(dry_run=dry_run)
126
+ else:
127
+ subscription.download(dry_run=dry_run)
128
+
129
+ _maybe_write_subscription_log_file(
130
+ config=config,
131
+ subscription=subscription,
132
+ dry_run=dry_run,
133
+ exception=subscription.exception,
134
+ )
135
+
136
+ Logger.cleanup(has_error=False)
137
+ gc.collect() # Garbage collect after each subscription download
138
+
139
+ return subscriptions
140
+
141
+
142
+ def _download_subscription_from_cli(
143
+ config: ConfigFile, dry_run: bool, extra_args: List[str]
144
+ ) -> Subscription:
145
+ """
146
+ Downloads a one-off subscription using the CLI
147
+
148
+ Parameters
149
+ ----------
150
+ config
151
+ Configuration file
152
+ dry_run
153
+ Whether this is a dry-run
154
+ extra_args
155
+ Extra arguments from argparse that contain dynamic subscription options
156
+
157
+ Returns
158
+ -------
159
+ Subscription and its download transaction log
160
+ """
161
+ dl_args_parser = DownloadArgsParser(
162
+ extra_arguments=extra_args, config_options=config.config_options
163
+ )
164
+ subscription_args_dict = dl_args_parser.to_subscription_dict()
165
+ subscription_name = dl_args_parser.get_dl_subscription_name()
166
+
167
+ subscription = Subscription.from_dict(
168
+ config=config, preset_name=subscription_name, preset_dict=subscription_args_dict
169
+ )
170
+
171
+ logger.info("Beginning CLI %s", ("dry run" if dry_run else "download"))
172
+ subscription.download(dry_run=dry_run)
173
+
174
+ return subscription
175
+
176
+
177
+ def _view_url_from_cli(config: ConfigFile, url: str, split_chapters: bool) -> Subscription:
178
+ """
179
+ `ytdl-sub view` dry-runs a URL to print its source variables. Use the pre-built `_view` preset,
180
+ inject the URL argument, and dry-run.
181
+ """
182
+ preset = "_view_split_chapters" if split_chapters else "_view"
183
+ subscription = Subscription.from_dict(
184
+ config=config,
185
+ preset_name="ytdl-sub-view",
186
+ preset_dict={"preset": preset, "overrides": {"url": url}},
187
+ )
188
+
189
+ logger.info(
190
+ "Viewing source variables for URL '%s'%s",
191
+ url,
192
+ " with split chapters" if split_chapters else "",
193
+ )
194
+ subscription.download(dry_run=True)
195
+
196
+ return subscription
197
+
198
+
199
+ def main() -> List[Subscription]:
200
+ """
201
+ Entrypoint for ytdl-sub, without the error handling
202
+ """
203
+ # If no args are provided, print help and exit
204
+ if len(sys.argv) < 2:
205
+ parser.print_help()
206
+ return []
207
+
208
+ args, extra_args = parser.parse_known_args()
209
+
210
+ if args.subparser == "cli-to-sub":
211
+ print_cli_to_sub(args=extra_args)
212
+ return []
213
+
214
+ # Load the config
215
+ if args.config:
216
+ config = ConfigFile.from_file_path(args.config)
217
+ elif os.path.isfile(DEFAULT_CONFIG_FILE_NAME):
218
+ config = ConfigFile.from_file_path(DEFAULT_CONFIG_FILE_NAME)
219
+ else:
220
+ logger.info("No config specified, using defaults.")
221
+ config = ConfigFile.default()
222
+
223
+ subscriptions: List[Subscription] = []
224
+
225
+ # If transaction log file is specified, make sure we can open it
226
+ _maybe_validate_transaction_log_file(transaction_log_file_path=args.transaction_log)
227
+
228
+ with working_directory_lock(config=config):
229
+ if args.subparser == "sub":
230
+ if (
231
+ args.update_with_info_json
232
+ and not config.config_options.experimental.enable_update_with_info_json
233
+ ):
234
+ raise ExperimentalFeatureNotEnabled(
235
+ "--update-with-info-json requires setting"
236
+ " configuration.experimental.enable_update_with_info_json to True. This"
237
+ " feature is ",
238
+ "still being tested and has the ability to destroy files. Ensure you have a ",
239
+ "full backup before usage. You have been warned!",
240
+ )
241
+
242
+ subscription_override_dict = {}
243
+ if args.dl_override:
244
+ subscription_override_dict = DownloadArgsParser.from_dl_override(
245
+ override=args.dl_override, config=config
246
+ ).to_subscription_dict()
247
+
248
+ logger.info("Validating subscriptions...")
249
+ subscriptions = _download_subscriptions_from_yaml_files(
250
+ config=config,
251
+ subscription_paths=args.subscription_paths,
252
+ subscription_matches=args.match,
253
+ subscription_override_dict=subscription_override_dict,
254
+ update_with_info_json=args.update_with_info_json,
255
+ dry_run=args.dry_run,
256
+ )
257
+
258
+ # One-off download
259
+ elif args.subparser == "dl":
260
+ logger.info("Validating presets...")
261
+ subscriptions.append(
262
+ _download_subscription_from_cli(
263
+ config=config, dry_run=args.dry_run, extra_args=extra_args
264
+ )
265
+ )
266
+ elif args.subparser == "view":
267
+ subscriptions.append(
268
+ _view_url_from_cli(config=config, url=args.url, split_chapters=args.split_chapters)
269
+ )
270
+ else:
271
+ raise ValidationException("Must provide one of the commands: sub, dl, view, cli-to-sub")
272
+
273
+ if not args.suppress_transaction_log:
274
+ output_transaction_log(
275
+ subscriptions=subscriptions,
276
+ transaction_log_file_path=args.transaction_log,
277
+ )
278
+
279
+ output_summary(subscriptions, suppress_colors=args.suppress_colors)
280
+
281
+ return subscriptions
@@ -0,0 +1,117 @@
1
+ from typing import List
2
+
3
+ from colorama import Fore
4
+
5
+ from ytdl_sub.subscriptions.subscription import Subscription
6
+ from ytdl_sub.utils.logger import Logger
7
+
8
+ logger = Logger.get()
9
+
10
+
11
+ def _green(value: str, suppress_colors: bool = False) -> str:
12
+ return value if suppress_colors else Fore.GREEN + value + Fore.RESET
13
+
14
+
15
+ def _red(value: str, suppress_colors: bool = False) -> str:
16
+ return value if suppress_colors else Fore.RED + value + Fore.RESET
17
+
18
+
19
+ def _no_color(value: str, suppress_colors: bool = False) -> str:
20
+ return value if suppress_colors else Fore.RESET + value + Fore.RESET
21
+
22
+
23
+ def _str_int(value: int) -> str:
24
+ if value > 0:
25
+ return f"+{value}"
26
+ return str(value)
27
+
28
+
29
+ def _color_int(value: int, suppress_colors: bool = False) -> str:
30
+ str_int = _str_int(value)
31
+ if value > 0:
32
+ return _green(str_int, suppress_colors)
33
+ if value < 0:
34
+ return _red(str_int, suppress_colors)
35
+ return _no_color(str_int, suppress_colors)
36
+
37
+
38
+ def output_summary(subscriptions: List[Subscription], suppress_colors: bool) -> None:
39
+ """
40
+ Parameters
41
+ ----------
42
+ subscriptions
43
+ Processed subscriptions
44
+ suppress_colors
45
+ Whether to have color or not
46
+
47
+ Returns
48
+ -------
49
+ Output summary to print
50
+ """
51
+ # many locals for proper output printing
52
+ # pylint: disable=too-many-locals
53
+ if len(subscriptions) == 0:
54
+ logger.info("No subscriptions ran")
55
+ return
56
+
57
+ summary: List[str] = []
58
+
59
+ # Initialize totals to 0
60
+ total_subs: int = len(subscriptions)
61
+ total_subs_str = f"Total: {total_subs}"
62
+ total_added: int = sum(sub.num_entries_added for sub in subscriptions)
63
+ total_modified: int = sum(sub.num_entries_modified for sub in subscriptions)
64
+ total_removed: int = sum(sub.num_entries_removed for sub in subscriptions)
65
+ total_entries: int = sum(sub.num_entries for sub in subscriptions)
66
+ total_errors: int = sum(sub.exception is not None for sub in subscriptions)
67
+
68
+ # Initialize widths to 0
69
+ width_sub_name: int = max(len(sub.name) for sub in subscriptions) + 4 # aesthetics
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))
73
+ width_num_entries: int = len(str(total_entries)) + 4 # aesthetics
74
+
75
+ # Build the summary
76
+ for subscription in subscriptions:
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)
80
+ num_entries = str(subscription.num_entries)
81
+ status = (
82
+ _red(subscription.exception.__class__.__name__, suppress_colors)
83
+ if subscription.exception
84
+ else _green("✔", suppress_colors)
85
+ )
86
+
87
+ summary.append(
88
+ f"{subscription.name:<{width_sub_name}} "
89
+ f"{num_entries_added:>{width_num_entries_added}} "
90
+ f"{num_entries_modified:>{width_num_entries_modified}} "
91
+ f"{num_entries_removed:>{width_num_entries_removed}} "
92
+ f"{num_entries:>{width_num_entries}} "
93
+ f"{status}"
94
+ )
95
+
96
+ total_errors_str = (
97
+ _green("Success", suppress_colors)
98
+ if total_errors == 0
99
+ else _red(f"Error{'s' if total_errors > 1 else ''}", suppress_colors)
100
+ )
101
+
102
+ summary.append(
103
+ f"{total_subs_str:<{width_sub_name}} "
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}} "
107
+ f"{total_entries:>{width_num_entries}} "
108
+ f"{total_errors_str}"
109
+ )
110
+
111
+ if total_errors > 0:
112
+ summary.append("")
113
+ summary.append(f"See `{Logger.error_log_filename()}` for details on errors.")
114
+ summary.append("Consider making a GitHub issue including the uploaded log file.")
115
+
116
+ # Hack to always show download summary, even if logs are set to quiet
117
+ logger.warning("Download Summary:\n%s", "\n".join(summary))
@@ -0,0 +1,54 @@
1
+ from typing import List
2
+ from typing import Optional
3
+
4
+ from ytdl_sub.subscriptions.subscription import Subscription
5
+ from ytdl_sub.utils.exceptions import ValidationException
6
+ from ytdl_sub.utils.logger import Logger
7
+
8
+ logger = Logger.get()
9
+
10
+
11
+ def _maybe_validate_transaction_log_file(transaction_log_file_path: Optional[str]) -> None:
12
+ if transaction_log_file_path:
13
+ try:
14
+ with open(transaction_log_file_path, "w", encoding="utf-8"):
15
+ pass
16
+ except Exception as exc:
17
+ raise ValidationException(
18
+ f"Transaction log file '{transaction_log_file_path}' cannot be written to. "
19
+ f"Reason: {str(exc)}"
20
+ ) from exc
21
+
22
+
23
+ def output_transaction_log(
24
+ subscriptions: List[Subscription],
25
+ transaction_log_file_path: Optional[str],
26
+ ) -> None:
27
+ """
28
+ Maybe print and/or write transaction logs to a file
29
+
30
+ Parameters
31
+ ----------
32
+ subscriptions
33
+ Processed subscriptions
34
+ transaction_log_file_path
35
+ Optional file path to write to
36
+ """
37
+ transaction_log_file_contents = ""
38
+ for subscription in subscriptions:
39
+ if subscription.transaction_log.is_empty:
40
+ transaction_log_contents = f"\nNo files changed for {subscription.name}"
41
+ else:
42
+ transaction_log_contents = (
43
+ f"Transaction log for {subscription.name}:\n"
44
+ f"{subscription.transaction_log.to_output_message(subscription.output_directory)}"
45
+ )
46
+
47
+ if transaction_log_file_path:
48
+ transaction_log_file_contents += transaction_log_contents
49
+ else:
50
+ logger.info(transaction_log_contents)
51
+
52
+ if transaction_log_file_contents:
53
+ with open(transaction_log_file_path, "w", encoding="utf-8") as transaction_log_file:
54
+ transaction_log_file.write(transaction_log_file_contents)
File without changes
@@ -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}))
@@ -1,13 +1,15 @@
1
1
  import hashlib
2
2
  import re
3
3
  import shlex
4
+ from typing import Any
4
5
  from typing import Dict
5
6
  from typing import List
6
7
  from typing import Tuple
7
8
 
8
9
  from mergedeep import mergedeep
9
10
 
10
- from ytdl_sub.cli.main_args_parser import MainArguments
11
+ from ytdl_sub.cli.parsers.main import MainArguments
12
+ from ytdl_sub.config.config_file import ConfigFile
11
13
  from ytdl_sub.config.config_validator import ConfigOptions
12
14
  from ytdl_sub.utils.exceptions import InvalidDlArguments
13
15
 
@@ -116,7 +118,7 @@ class DownloadArgsParser:
116
118
  return largest_consecutive + 1
117
119
 
118
120
  @classmethod
119
- def _argument_name_and_value_to_dict(cls, arg_name: str, arg_value: str) -> Dict:
121
+ def _argument_name_and_value_to_dict(cls, arg_name: str, arg_value: Any) -> Dict:
120
122
  """
121
123
  :param arg_name: Argument name in the form of 'key1.key2.key3'
122
124
  :param arg_value: Argument value
@@ -134,11 +136,15 @@ class DownloadArgsParser:
134
136
 
135
137
  next_dict[arg_name_split[-1]] = arg_value
136
138
 
137
- # TODO: handle ints/floats
138
- if arg_value == "True":
139
- next_dict[arg_name_split[-1]] = True
140
- elif arg_value == "False":
141
- next_dict[arg_name_split[-1]] = False
139
+ if isinstance(arg_value, str):
140
+ if arg_value == "True":
141
+ next_dict[arg_name_split[-1]] = True
142
+ elif arg_value == "False":
143
+ next_dict[arg_name_split[-1]] = False
144
+ elif arg_value.isdigit():
145
+ next_dict[arg_name_split[-1]] = int(arg_value)
146
+ elif arg_value.replace(".", "", 1).isdigit():
147
+ next_dict[arg_name_split[-1]] = float(arg_value)
142
148
 
143
149
  return argument_dict
144
150
 
@@ -236,9 +242,19 @@ class DownloadArgsParser:
236
242
 
237
243
  return subscription_dict
238
244
 
239
- def get_args_hash(self) -> str:
245
+ def get_dl_subscription_name(self) -> str:
240
246
  """
241
- :return: Hash of the arguments provided
247
+ Returns a deterministic name based on input args
242
248
  """
243
- hash_string = str(sorted(self._unknown_arguments))
244
- 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}"
252
+
253
+ @classmethod
254
+ def from_dl_override(cls, override: str, config: ConfigFile) -> "DownloadArgsParser":
255
+ """
256
+ Create a DownloadArgsParser from a sub --override argument value
257
+ """
258
+ return DownloadArgsParser(
259
+ extra_arguments=override.split(), config_options=config.config_options
260
+ )