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.
- ytdl_sub/__init__.py +1 -1
- ytdl_sub/cli/entrypoint.py +8 -3
- ytdl_sub/cli/output_summary.py +27 -23
- ytdl_sub/cli/parsers/cli_to_sub.py +64 -0
- ytdl_sub/cli/parsers/dl.py +5 -4
- ytdl_sub/cli/parsers/main.py +15 -2
- ytdl_sub/config/config_validator.py +23 -10
- ytdl_sub/config/defaults.py +3 -1
- ytdl_sub/config/overrides.py +89 -35
- ytdl_sub/config/plugin/plugin.py +19 -4
- ytdl_sub/config/plugin/plugin_mapping.py +14 -0
- ytdl_sub/config/plugin/plugin_operation.py +1 -0
- ytdl_sub/config/plugin/preset_plugins.py +33 -0
- ytdl_sub/config/preset.py +36 -8
- ytdl_sub/config/preset_options.py +78 -4
- ytdl_sub/config/validators/variable_validation.py +29 -145
- ytdl_sub/downloaders/url/downloader.py +46 -39
- ytdl_sub/downloaders/url/validators.py +39 -7
- ytdl_sub/downloaders/ytdlp.py +1 -1
- ytdl_sub/entries/entry.py +3 -0
- ytdl_sub/entries/script/variable_definitions.py +33 -0
- ytdl_sub/entries/script/variable_types.py +1 -9
- ytdl_sub/entries/variables/override_variables.py +11 -9
- ytdl_sub/plugins/date_range.py +31 -12
- ytdl_sub/plugins/embed_thumbnail.py +3 -3
- ytdl_sub/plugins/filter_exclude.py +9 -6
- ytdl_sub/plugins/filter_include.py +8 -6
- ytdl_sub/plugins/nfo_tags.py +6 -7
- ytdl_sub/plugins/square_thumbnail.py +77 -0
- ytdl_sub/plugins/static_nfo_tags.py +71 -0
- ytdl_sub/plugins/subtitles.py +38 -4
- ytdl_sub/plugins/throttle_protection.py +170 -42
- ytdl_sub/plugins/video_tags.py +1 -1
- ytdl_sub/prebuilt_presets/helpers/download_deletion_options.yaml +5 -4
- 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 +33 -1
- ytdl_sub/prebuilt_presets/helpers/throttle_protection.yaml +84 -0
- ytdl_sub/prebuilt_presets/helpers/url.yaml +126 -697
- ytdl_sub/prebuilt_presets/helpers/url_categorized.yaml +1 -100
- ytdl_sub/prebuilt_presets/music/albums_from_chapters.yaml +7 -2
- ytdl_sub/prebuilt_presets/music/albums_from_playlists.yaml +4 -4
- ytdl_sub/prebuilt_presets/music/singles.yaml +1 -0
- ytdl_sub/prebuilt_presets/music/{other_websites.yaml → soundcloud.yaml} +1 -1
- ytdl_sub/prebuilt_presets/music_videos/__init__.py +1 -0
- ytdl_sub/prebuilt_presets/music_videos/music_video_base.yaml +3 -0
- ytdl_sub/prebuilt_presets/music_videos/music_videos.yaml +3 -0
- ytdl_sub/prebuilt_presets/tv_show/__init__.py +5 -46
- ytdl_sub/prebuilt_presets/tv_show/episode.yaml +14 -4
- ytdl_sub/prebuilt_presets/tv_show/tv_show_by_date.yaml +124 -13
- ytdl_sub/prebuilt_presets/tv_show/tv_show_collection.yaml +354 -88
- ytdl_sub/script/functions/__init__.py +2 -0
- ytdl_sub/script/functions/error_functions.py +10 -7
- ytdl_sub/script/functions/numeric_functions.py +17 -0
- ytdl_sub/script/functions/print_functions.py +69 -0
- ytdl_sub/script/functions/string_functions.py +73 -2
- ytdl_sub/script/parser.py +6 -1
- ytdl_sub/script/script.py +238 -67
- ytdl_sub/script/types/array.py +22 -1
- ytdl_sub/script/types/function.py +190 -8
- ytdl_sub/script/types/map.py +30 -1
- ytdl_sub/script/types/resolvable.py +8 -0
- ytdl_sub/script/types/syntax_tree.py +58 -3
- ytdl_sub/script/types/variable_dependency.py +99 -9
- ytdl_sub/script/utils/name_validation.py +21 -0
- ytdl_sub/script/utils/type_checking.py +6 -1
- ytdl_sub/subscriptions/base_subscription.py +29 -3
- ytdl_sub/subscriptions/subscription_download.py +57 -19
- ytdl_sub/subscriptions/subscription_ytdl_options.py +3 -1
- ytdl_sub/utils/chapters.py +8 -1
- ytdl_sub/utils/exceptions.py +4 -0
- ytdl_sub/utils/ffmpeg.py +5 -1
- ytdl_sub/utils/file_handler.py +49 -0
- ytdl_sub/utils/file_lock.py +1 -0
- ytdl_sub/utils/file_path.py +6 -0
- ytdl_sub/utils/script.py +73 -5
- ytdl_sub/validators/file_path_validators.py +5 -19
- ytdl_sub/validators/string_datetime.py +5 -3
- ytdl_sub/validators/string_formatter_validators.py +124 -38
- ytdl_sub/validators/string_select_validator.py +15 -0
- ytdl_sub/validators/validators.py +3 -13
- ytdl_sub/ytdl_additions/enhanced_download_archive.py +24 -6
- {ytdl_sub-2024.9.30.dist-info → ytdl_sub-2026.1.24.dist-info}/METADATA +29 -28
- ytdl_sub-2026.1.24.dist-info/RECORD +166 -0
- {ytdl_sub-2024.9.30.dist-info → ytdl_sub-2026.1.24.dist-info}/WHEEL +1 -1
- ytdl_sub-2024.9.30.dist-info/RECORD +0 -159
- {ytdl_sub-2024.9.30.dist-info → ytdl_sub-2026.1.24.dist-info}/entry_points.txt +0 -0
- {ytdl_sub-2024.9.30.dist-info → ytdl_sub-2026.1.24.dist-info/licenses}/LICENSE +0 -0
- {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__ = "
|
|
1
|
+
__pypi_version__ = "2026.01.24";__local_version__ = "2026.01.24+60cdbad"
|
ytdl_sub/cli/entrypoint.py
CHANGED
|
@@ -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 =
|
|
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
|
ytdl_sub/cli/output_summary.py
CHANGED
|
@@ -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"
|
|
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}))
|
ytdl_sub/cli/parsers/dl.py
CHANGED
|
@@ -242,12 +242,13 @@ class DownloadArgsParser:
|
|
|
242
242
|
|
|
243
243
|
return subscription_dict
|
|
244
244
|
|
|
245
|
-
def
|
|
245
|
+
def get_dl_subscription_name(self) -> str:
|
|
246
246
|
"""
|
|
247
|
-
|
|
247
|
+
Returns a deterministic name based on input args
|
|
248
248
|
"""
|
|
249
|
-
|
|
250
|
-
|
|
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":
|
ytdl_sub/cli/parsers/main.py
CHANGED
|
@@ -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
|
|
113
|
-
default=argparse.SUPPRESS if suppress_defaults else LoggerLevels.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
218
|
-
directories. Ensure that this directory resides on the host
|
|
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.
|
|
226
|
-
|
|
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.
|
|
234
|
-
|
|
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
|
|
ytdl_sub/config/defaults.py
CHANGED
|
@@ -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 = "
|
|
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
|
)
|
ytdl_sub/config/overrides.py
CHANGED
|
@@ -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
|
|
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,
|
|
117
|
-
) -> Dict[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,
|
|
124
|
-
|
|
125
|
-
initial_variables
|
|
126
|
-
|
|
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.
|
|
157
|
+
self.script.add_parsed(
|
|
136
158
|
self.initial_variables(
|
|
137
159
|
unresolved_variables={
|
|
138
|
-
var_name:
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
) from exc
|
|
244
|
+
f"Expected type {expected_type.__name__}, but received '{out.__class__.__name__}'"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
return out
|
ytdl_sub/config/plugin/plugin.py
CHANGED
|
@@ -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
|
|
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
|