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.
- ytdl_sub/__init__.py +1 -1
- ytdl_sub/cli/entrypoint.py +281 -0
- ytdl_sub/cli/output_summary.py +117 -0
- ytdl_sub/cli/output_transaction_log.py +54 -0
- ytdl_sub/cli/parsers/__init__.py +0 -0
- ytdl_sub/cli/parsers/cli_to_sub.py +64 -0
- ytdl_sub/cli/{download_args_parser.py → parsers/dl.py} +27 -11
- ytdl_sub/cli/{main_args_parser.py → parsers/main.py} +47 -4
- ytdl_sub/config/config_file.py +30 -9
- ytdl_sub/config/config_validator.py +41 -22
- ytdl_sub/config/defaults.py +26 -3
- ytdl_sub/config/overrides.py +247 -0
- ytdl_sub/config/plugin/__init__.py +0 -0
- ytdl_sub/{plugins → config/plugin}/plugin.py +71 -55
- ytdl_sub/config/plugin/plugin_mapping.py +219 -0
- ytdl_sub/config/plugin/plugin_operation.py +9 -0
- ytdl_sub/config/plugin/preset_plugins.py +79 -0
- ytdl_sub/config/preset.py +49 -317
- ytdl_sub/config/preset_options.py +177 -175
- ytdl_sub/config/validators/__init__.py +0 -0
- ytdl_sub/config/validators/options.py +82 -0
- ytdl_sub/config/validators/variable_validation.py +94 -0
- ytdl_sub/downloaders/info_json/info_json_downloader.py +36 -16
- ytdl_sub/downloaders/source_plugin.py +72 -0
- ytdl_sub/downloaders/url/downloader.py +195 -199
- ytdl_sub/downloaders/url/validators.py +172 -48
- ytdl_sub/downloaders/ytdl_options_builder.py +1 -1
- ytdl_sub/downloaders/ytdlp.py +49 -7
- ytdl_sub/entries/base_entry.py +62 -298
- ytdl_sub/entries/entry.py +163 -17
- ytdl_sub/entries/entry_parent.py +82 -137
- ytdl_sub/entries/script/__init__.py +0 -0
- ytdl_sub/entries/script/custom_functions.py +178 -0
- ytdl_sub/entries/script/function_scripts.py +23 -0
- ytdl_sub/entries/script/variable_definitions.py +1216 -0
- ytdl_sub/entries/script/variable_types.py +321 -0
- ytdl_sub/entries/variables/override_variables.py +179 -0
- ytdl_sub/main.py +11 -9
- ytdl_sub/plugins/audio_extract.py +63 -18
- ytdl_sub/plugins/chapters.py +88 -68
- ytdl_sub/plugins/date_range.py +85 -33
- ytdl_sub/plugins/embed_thumbnail.py +100 -0
- ytdl_sub/plugins/file_convert.py +71 -52
- ytdl_sub/plugins/filter_exclude.py +80 -0
- ytdl_sub/plugins/filter_include.py +87 -0
- ytdl_sub/plugins/format.py +42 -0
- ytdl_sub/plugins/internal/view.py +10 -9
- ytdl_sub/plugins/match_filters.py +64 -35
- ytdl_sub/plugins/music_tags.py +73 -80
- ytdl_sub/plugins/nfo_tags.py +72 -60
- ytdl_sub/plugins/output_directory_nfo_tags.py +41 -49
- ytdl_sub/plugins/split_by_chapters.py +83 -84
- ytdl_sub/plugins/square_thumbnail.py +77 -0
- ytdl_sub/plugins/static_nfo_tags.py +71 -0
- ytdl_sub/plugins/subtitles.py +91 -43
- ytdl_sub/plugins/throttle_protection.py +370 -0
- ytdl_sub/plugins/video_tags.py +12 -35
- ytdl_sub/prebuilt_presets/__init__.py +14 -2
- ytdl_sub/prebuilt_presets/helpers/__init__.py +2 -219
- ytdl_sub/prebuilt_presets/helpers/download_deletion_options.yaml +47 -0
- ytdl_sub/prebuilt_presets/helpers/filter_duration.yaml +34 -0
- ytdl_sub/prebuilt_presets/helpers/filter_keywords.yaml +72 -0
- ytdl_sub/prebuilt_presets/helpers/media_quality.yaml +90 -0
- ytdl_sub/prebuilt_presets/helpers/players.yaml +5 -0
- ytdl_sub/prebuilt_presets/helpers/throttle_protection.yaml +84 -0
- ytdl_sub/prebuilt_presets/helpers/url.yaml +177 -0
- ytdl_sub/prebuilt_presets/helpers/url_bilateral.yaml +21 -0
- ytdl_sub/prebuilt_presets/helpers/url_categorized.yaml +111 -0
- ytdl_sub/prebuilt_presets/internal/__init__.py +3 -0
- ytdl_sub/prebuilt_presets/internal/view.yaml +1 -3
- ytdl_sub/prebuilt_presets/music/__init__.py +14 -0
- ytdl_sub/prebuilt_presets/music/albums_from_chapters.yaml +26 -0
- ytdl_sub/prebuilt_presets/music/albums_from_playlists.yaml +36 -0
- ytdl_sub/prebuilt_presets/music/singles.yaml +77 -0
- ytdl_sub/prebuilt_presets/music/soundcloud.yaml +37 -0
- ytdl_sub/prebuilt_presets/music_videos/__init__.py +21 -0
- ytdl_sub/prebuilt_presets/music_videos/music_video_base.yaml +66 -0
- ytdl_sub/prebuilt_presets/music_videos/music_video_extras.yaml +58 -0
- ytdl_sub/prebuilt_presets/music_videos/music_videos.yaml +46 -0
- ytdl_sub/prebuilt_presets/tv_show/__init__.py +15 -59
- ytdl_sub/prebuilt_presets/tv_show/episode.yaml +31 -104
- ytdl_sub/prebuilt_presets/tv_show/tv_show.yaml +36 -64
- ytdl_sub/prebuilt_presets/tv_show/tv_show_by_date.yaml +179 -1
- ytdl_sub/prebuilt_presets/tv_show/tv_show_collection.yaml +1243 -322
- ytdl_sub/script/__init__.py +0 -0
- ytdl_sub/script/functions/__init__.py +84 -0
- ytdl_sub/script/functions/array_functions.py +224 -0
- ytdl_sub/script/functions/boolean_functions.py +169 -0
- ytdl_sub/script/functions/conditional_functions.py +69 -0
- ytdl_sub/script/functions/date_functions.py +14 -0
- ytdl_sub/script/functions/error_functions.py +67 -0
- ytdl_sub/script/functions/json_functions.py +41 -0
- ytdl_sub/script/functions/map_functions.py +118 -0
- ytdl_sub/script/functions/numeric_functions.py +115 -0
- ytdl_sub/script/functions/print_functions.py +69 -0
- ytdl_sub/script/functions/regex_functions.py +176 -0
- ytdl_sub/script/functions/string_functions.py +216 -0
- ytdl_sub/script/parser.py +616 -0
- ytdl_sub/script/script.py +770 -0
- ytdl_sub/script/script_output.py +52 -0
- ytdl_sub/script/types/__init__.py +0 -0
- ytdl_sub/script/types/array.py +82 -0
- ytdl_sub/script/types/function.py +493 -0
- ytdl_sub/script/types/map.py +95 -0
- ytdl_sub/script/types/resolvable.py +265 -0
- ytdl_sub/script/types/syntax_tree.py +107 -0
- ytdl_sub/script/types/variable.py +28 -0
- ytdl_sub/script/types/variable_dependency.py +287 -0
- ytdl_sub/script/utils/__init__.py +0 -0
- ytdl_sub/script/utils/exception_formatters.py +131 -0
- ytdl_sub/script/utils/exceptions.py +96 -0
- ytdl_sub/script/utils/name_validation.py +81 -0
- ytdl_sub/script/utils/type_checking.py +312 -0
- ytdl_sub/subscriptions/base_subscription.py +105 -17
- ytdl_sub/subscriptions/subscription.py +60 -20
- ytdl_sub/subscriptions/subscription_download.py +166 -71
- ytdl_sub/subscriptions/subscription_validators.py +387 -0
- ytdl_sub/subscriptions/subscription_ytdl_options.py +87 -19
- ytdl_sub/utils/chapters.py +46 -18
- ytdl_sub/utils/datetime.py +12 -1
- ytdl_sub/utils/exceptions.py +4 -0
- ytdl_sub/utils/ffmpeg.py +14 -3
- ytdl_sub/utils/file_handler.py +66 -2
- ytdl_sub/utils/file_lock.py +14 -1
- ytdl_sub/utils/file_path.py +67 -0
- ytdl_sub/utils/logger.py +55 -26
- ytdl_sub/utils/retry.py +4 -2
- ytdl_sub/utils/script.py +210 -0
- ytdl_sub/utils/scriptable.py +96 -0
- ytdl_sub/utils/thumbnail.py +21 -19
- ytdl_sub/utils/yaml.py +2 -2
- ytdl_sub/validators/audo_codec_validator.py +1 -1
- ytdl_sub/validators/file_path_validators.py +10 -98
- ytdl_sub/validators/source_variable_validator.py +8 -5
- ytdl_sub/validators/string_datetime.py +8 -9
- ytdl_sub/validators/string_formatter_validators.py +240 -148
- ytdl_sub/validators/string_select_validator.py +15 -0
- ytdl_sub/validators/validators.py +27 -1
- ytdl_sub/ytdl_additions/enhanced_download_archive.py +130 -64
- ytdl_sub-2026.1.24.dist-info/METADATA +944 -0
- ytdl_sub-2026.1.24.dist-info/RECORD +166 -0
- {ytdl_sub-2023.4.20.post2.dist-info → ytdl_sub-2026.1.24.dist-info}/WHEEL +1 -1
- ytdl_sub/cli/main.py +0 -353
- ytdl_sub/config/preset_class_mappings.py +0 -153
- ytdl_sub/downloaders/base_downloader.py +0 -77
- ytdl_sub/downloaders/url/multi_url.py +0 -68
- ytdl_sub/downloaders/url/url.py +0 -40
- ytdl_sub/entries/variables/entry_variables.py +0 -670
- ytdl_sub/entries/variables/kwargs.py +0 -66
- ytdl_sub/plugins/regex.py +0 -333
- ytdl_sub/prebuilt_presets/helpers/common.yaml +0 -16
- ytdl_sub-2023.4.20.post2.dist-info/METADATA +0 -301
- ytdl_sub-2023.4.20.post2.dist-info/RECORD +0 -98
- {ytdl_sub-2023.4.20.post2.dist-info → ytdl_sub-2026.1.24.dist-info}/entry_points.txt +0 -0
- {ytdl_sub-2023.4.20.post2.dist-info → ytdl_sub-2026.1.24.dist-info/licenses}/LICENSE +0 -0
- {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__ = "
|
|
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.
|
|
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:
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
245
|
+
def get_dl_subscription_name(self) -> str:
|
|
240
246
|
"""
|
|
241
|
-
|
|
247
|
+
Returns a deterministic name based on input args
|
|
242
248
|
"""
|
|
243
|
-
|
|
244
|
-
|
|
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
|
+
)
|