Compare commits

..

No commits in common. "master" and "2026.03.13" have entirely different histories.

46 changed files with 123 additions and 567 deletions

View file

@ -837,7 +837,7 @@ behavior.
sanitize
~~~~~~~~
:spec: ``sanitize(value: AnyArgument, ...) -> String``
:spec: ``sanitize(value: AnyArgument) -> String``
Sanitize a string using yt-dlp's ``sanitize_filename`` method to ensure it's safe to use
for file/directory names on any OS.

View file

@ -242,22 +242,3 @@ Be sure to tell ytdl-sub to use your config by using the argument ``--config
If you run ytdl-sub in the same directory, and the config file is named ``config.yaml``,
it will use it by default.
Visualizing a subscription in Preset form
-----------------------------------------
Subscription file syntax is designed to minimize boiler-plate when authoring new subscriptions.
You can unpack any subscription using the ``inspect`` sub-command to see its boiler-plate *preset format*.
.. code-block:: bash
ytdl-sub inspect --match "BBC News" /path/to/subscriptions.yaml
This can be utilized for numerous purposes including:
* Ensuring your custom preset is getting applied correctly.
* Figuring out which variables set things like file names, metadata, etc.
* Understanding how subscription syntax translates to preset representation.
The default ``--level`` of inspect will fill in defined variables. Using ``--level original`` will
present the subscription's raw layout with no fill.

View file

@ -112,35 +112,3 @@ Convert yt-dlp cli arguments to ytdl-sub `ytdl_options` arguments.
.. code-block::
ytdl-sub cli-to-sub [YT-DLP ARGS]
Inspect
-------
Inspect a single subscription's underlying preset representation.
This can be utilized for numerous purposes including:
* Ensuring your custom preset is getting applied correctly.
* Figuring out which variables set things like file names, metadata, etc.
* Understanding how subscription syntax translates to preset representation.
Usage:
.. code-block:: bash
ytdl-sub inspect --match "Game Chops" --mock 'title=Lets Play' examples/music_subscriptions.yaml
.. code-block:: text
:caption: Additional Options
-l 0,1,2,3, --level 0,1,2,3
level of inspection to perform:
0 - original present the subscription as-is
1 - fill fill in defined values
2 - resolve resolve all possible variables (default)
3 - internal resolve all variables to their internal representation
-m MATCH [MATCH ...], --match MATCH [MATCH ...]
match subscription names to one or more substrings, and only run those subscriptions
-o DL_OVERRIDE, --dl-override DL_OVERRIDE
override all subscription config values using `dl` syntax, i.e. --dl-override='--ytdl_options.max_downloads 3'
-k VAR=VALUE, --mock VAR=VALUE
ability to mock one or more variable values, i.e. --mock 'title=Lets Play'

View file

@ -15,7 +15,7 @@ classifiers = [
"Programming Language :: Python :: 3.11",
]
dependencies = [
"yt-dlp[default]==2026.6.9",
"yt-dlp[default]==2026.3.13",
"colorama~=0.4",
"mergedeep~=1.3",
"mediafile~=0.12",
@ -48,7 +48,7 @@ test = [
]
lint = [
"pylint==4.0.5",
"ruff==0.15.16",
"ruff==0.15.5",
]
docs = [
"sphinx>=7,<10",

View file

@ -17,15 +17,12 @@ from ytdl_sub.cli.parsers.cli_to_sub import print_cli_to_sub
from ytdl_sub.cli.parsers.dl import DownloadArgsParser
from ytdl_sub.cli.parsers.main import DEFAULT_CONFIG_FILE_NAME, parser
from ytdl_sub.config.config_file import ConfigFile
from ytdl_sub.config.validators.variable_validation import ResolutionLevel
from ytdl_sub.subscriptions.subscription import Subscription
from ytdl_sub.utils.exceptions import ExperimentalFeatureNotEnabled, ValidationException
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.file_lock import working_directory_lock
from ytdl_sub.utils.logger import Logger
# pylint: disable=too-many-branches
logger = Logger.get()
# View is a command to run a simple dry-run on a URL using the `_view` preset.
@ -205,44 +202,6 @@ def _view_url_from_cli(config: ConfigFile, url: str, split_chapters: bool) -> Su
return subscription
def _parse_inspect_mocks(mocks: Optional[List[str]]) -> Dict[str, str]:
out: Dict[str, str] = {}
for mock in mocks or []:
spl = mock.split("=", 1)
if len(spl) == 1:
raise ValidationException("inspect mock must be in the form of VAR=VALUE")
out[spl[0].strip()] = spl[1]
return out
def _inspect(
config: ConfigFile,
subscription_paths: List[str],
subscription_matches: List[str],
subscription_override_dict: Dict,
inspection_level: int,
mocks: Dict[str, str],
) -> None:
subscriptions: List[Subscription] = []
for path in subscription_paths:
subscriptions += Subscription.from_file_path(
config=config,
subscription_path=path,
subscription_matches=subscription_matches,
subscription_override_dict=subscription_override_dict,
)
if len(subscriptions) > 1:
print(
"inspect can only inspect a single subscription. Use --match to filter for a single one"
)
return
print(subscriptions[0].resolved_yaml(resolution_level=inspection_level, mocks=mocks))
def main() -> List[Subscription]:
"""
Entrypoint for ytdl-sub, without the error handling
@ -269,23 +228,6 @@ def main() -> List[Subscription]:
subscriptions: List[Subscription] = []
if args.subparser == "inspect":
subscription_override_dict = {}
if args.dl_override:
subscription_override_dict = DownloadArgsParser.from_dl_override(
override=args.dl_override, config=config
).to_subscription_dict()
_inspect(
config=config,
subscription_paths=args.subscription_paths,
subscription_matches=args.match,
subscription_override_dict=subscription_override_dict,
inspection_level=ResolutionLevel.level_number(args.inspection_level),
mocks=_parse_inspect_mocks(args.mock),
)
return []
# If transaction log file is specified, make sure we can open it
_maybe_validate_transaction_log_file(transaction_log_file_path=args.transaction_log)

View file

@ -1,6 +1,6 @@
import argparse
import dataclasses
from typing import Dict, List
from typing import List
from ytdl_sub import __local_version__
from ytdl_sub.utils.logger import LoggerLevels
@ -236,77 +236,3 @@ view_parser.add_argument("url", help="URL to view source variables for")
###################################################################################################
# CLI-TO-SUB PARSER
cli_to_sub_parser = subparsers.add_parser("cli-to-sub")
###################################################################################################
# INSPECT PARSER
class InspectArguments:
LEVEL = CLIArgument(
short="-l",
long="--level",
)
LevelChoices: Dict[str, str] = {
"0": "original",
"1": "fill",
"2": "resolve",
"3": "internal",
}
MOCK = CLIArgument(
short="-k",
long="--mock",
)
inspect_parser = subparsers.add_parser("inspect", formatter_class=argparse.RawTextHelpFormatter)
inspect_parser.add_argument(
InspectArguments.LEVEL.short,
InspectArguments.LEVEL.long,
metavar=",".join(str(i) for i in range(4)),
type=str,
help="""level of inspection to perform:
0 - original present the subscription as-is
1 - fill fill in defined values
2 - resolve resolve all possible variables (default)
3 - internal resolve all variables to their internal representation
""",
default="resolve",
choices=list(InspectArguments.LevelChoices.keys())
+ list(InspectArguments.LevelChoices.values()),
dest="inspection_level",
)
inspect_parser.add_argument(
MainArguments.MATCH.short,
MainArguments.MATCH.long,
dest="match",
nargs="+",
action="extend",
type=str,
help="match subscription names to one or more substrings, and only run those subscriptions",
default=[],
)
inspect_parser.add_argument(
"subscription_paths",
metavar="SUBPATH",
nargs="*",
help="path to subscription files, uses subscriptions.yaml if not provided",
default=["subscriptions.yaml"],
)
inspect_parser.add_argument(
SubArguments.OVERRIDE.short,
SubArguments.OVERRIDE.long,
type=str,
help="override all subscription config values using `dl` syntax, "
"i.e. --dl-override='--ytdl_options.max_downloads 3'",
)
inspect_parser.add_argument(
InspectArguments.MOCK.short,
InspectArguments.MOCK.long,
metavar="VAR=VALUE",
action="append",
help="ability to mock one or more variable values, i.e. --mock 'title=Lets Play'",
)

View file

@ -1,4 +1,4 @@
from typing import Dict, List, Optional, Set
from typing import Dict, List, Set
from ytdl_sub.config.overrides import Overrides
from ytdl_sub.config.plugin.plugin_mapping import PluginMapping
@ -35,21 +35,6 @@ class ResolutionLevel:
return "internal"
raise ValueError("Invalid resolution level")
@classmethod
def level_number(cls, resolution_arg: str) -> int:
"""
Numeric resolution level
"""
if resolution_arg in ("0", "original"):
return 0
if resolution_arg in ("1", "fill"):
return 1
if resolution_arg in ("2", "resolve"):
return 2
if resolution_arg in ("3", "internal"):
return 3
raise ValueError("Invalid resolution level")
@classmethod
def all(cls) -> List[int]:
"""
@ -68,7 +53,7 @@ class VariableValidation:
if name not in self.unresolved_variables and not name.endswith("_sanitized")
}
def _apply_resolution_level(self, mocks: Optional[Dict[str, str]]) -> None:
def _apply_resolution_level(self) -> None:
if self._resolution_level == ResolutionLevel.FILL:
self.unresolved_variables |= VARIABLES.variable_names(include_sanitized=True)
# Only partial resolve definitions that are already resolved
@ -86,16 +71,6 @@ class VariableValidation:
else:
raise ValueError("Invalid resolution level for validation")
if mocks is not None:
for mock_name in mocks.keys():
if mock_name in self.unresolved_variables:
self.unresolved_variables.remove(mock_name)
self.script.add(
variables=mocks,
unresolvable=self.unresolved_variables,
)
self.script = self.script.resolve_partial(
unresolvable=self.unresolved_variables,
output_filter=self._get_resolve_partial_filter(),
@ -108,7 +83,6 @@ class VariableValidation:
output_options: OutputOptions,
plugins: PresetPlugins,
resolution_level: int = ResolutionLevel.RESOLVE,
mocks: Optional[Dict[str, str]] = None,
):
self.overrides = overrides
self.downloader_options = downloader_options
@ -126,7 +100,8 @@ class VariableValidation:
additional_options=[self.output_options, self.downloader_options]
)
self._resolution_level = resolution_level
self._apply_resolution_level(mocks=mocks)
self._apply_resolution_level()
def _add_runtime_variables(self, plugin_op: PluginOperation, options: OptionsValidator) -> None:
"""
@ -139,20 +114,6 @@ class VariableValidation:
self.unresolved_runtime_variables -= added_variables | modified_variables
def _output_override_variables(self) -> Dict:
output = {}
for name in self.overrides.keys:
value = self.script.definition_of(name)
if name in self.script.function_names:
# Keep custom functions as-is
output[name] = self.overrides.dict_with_format_strings[name]
elif resolved := value.maybe_resolvable:
output[name] = resolved.native
else:
output[name] = ScriptUtils.to_native_script(value)
return output
def ensure_proper_usage(self, partial_resolve_formatters: bool = False) -> Dict:
"""
Validate variables resolve as plugins are executed, and return
@ -213,6 +174,19 @@ class VariableValidation:
if url_output["url"]:
resolved_subscription["download"].append(url_output)
resolved_subscription["overrides"] = self._output_override_variables()
# TODO: make function
resolved_subscription["overrides"] = {}
for name in self.overrides.keys:
value = self.script.definition_of(name)
if name in self.script.function_names:
# Keep custom functions as-is
resolved_subscription["overrides"][name] = self.overrides.dict_with_format_strings[
name
]
elif resolved := value.maybe_resolvable:
resolved_subscription["overrides"][name] = resolved.native
else:
resolved_subscription["overrides"][name] = ScriptUtils.to_native_script(value)
assert not self.unresolved_runtime_variables
return resolved_subscription

View file

@ -46,12 +46,12 @@ class CustomFunctions:
return String(FilePathTruncater.maybe_truncate_file_path(filepath.value))
@staticmethod
def sanitize(*value: AnyArgument) -> String:
def sanitize(value: AnyArgument) -> String:
"""
Sanitize a string using yt-dlp's ``sanitize_filename`` method to ensure it's safe to use
for file/directory names on any OS.
"""
return String("".join(sanitize_filename(str(val)) for val in value))
return String(sanitize_filename(str(value)))
@staticmethod
def sanitize_plex_episode(string: String) -> String:

View file

@ -1,6 +1,6 @@
from abc import ABC
from functools import cache, cached_property
from typing import Dict, Optional, Set
from typing import Dict, Set
from ytdl_sub.entries.script.custom_functions import CustomFunctions
from ytdl_sub.entries.script.variable_types import (
@ -1215,14 +1215,6 @@ class VariableDefinitions(
VARIABLES.entry_metadata,
} | self.injected_variables()
def get(self, name: str) -> Optional[Variable]:
"""
Returns the variable attribute if it exists. None otherwise.
"""
if not hasattr(self, name):
return None
return getattr(self, name)
# Singletons to use externally
VARIABLES: VariableDefinitions = VariableDefinitions()

View file

@ -1,7 +1,7 @@
import sys
from ytdl_sub.cli.parsers.main import parser
from ytdl_sub.utils.logger import Logger, LoggerLevels
from ytdl_sub.utils.logger import Logger
def _main() -> int:
@ -10,11 +10,6 @@ def _main() -> int:
args, _ = parser.parse_known_args()
Logger.set_log_level(log_level_name=args.ytdl_sub_log_level)
# Suppress all logs during inspection since the output of the subcommand itself
# is all that is necessary
if args.subparser == "inspect":
Logger.set_log_level(log_level_name=LoggerLevels.QUIET.name)
# pylint: disable=import-outside-toplevel
import ytdl_sub.cli.entrypoint

View file

@ -7,11 +7,8 @@ from typing import Callable, List, Optional, Type, TypeVar, Union, get_origin
from ytdl_sub.script.types.resolvable import (
Argument,
Boolean,
BuiltInFunctionType,
Float,
FutureResolvable,
Integer,
Lambda,
LambdaReduce,
LambdaThree,
@ -251,17 +248,6 @@ class FunctionSpec:
return l_type
return None
def has_sanitized_output(self) -> bool:
"""
Returns
-------
If this function were to be sanitized, whether it's redundant or not based on its
output
"""
return inspect.isclass(self.return_type) and issubclass(
self.return_type, (Integer, Float, Boolean)
)
@classmethod
def _to_human_readable_name(cls, python_type: Type[NamedType] | Type[Union[NamedType]]) -> str:
if is_optional(python_type):

View file

@ -1,6 +1,6 @@
from abc import ABC
from pathlib import Path
from typing import Dict, Optional
from typing import Optional
from ytdl_sub.config.config_validator import ConfigOptions
from ytdl_sub.config.overrides import Overrides
@ -254,11 +254,7 @@ class BaseSubscription(ABC):
"""
return self._preset_options.yaml(subscription_only=False)
def resolved_yaml(
self,
resolution_level: int = ResolutionLevel.RESOLVE,
mocks: Optional[Dict[str, str]] = None,
) -> str:
def resolved_yaml(self, resolution_level: int = ResolutionLevel.RESOLVE) -> str:
"""
Returns
-------
@ -273,6 +269,5 @@ class BaseSubscription(ABC):
output_options=self.output_options,
plugins=self.plugins,
resolution_level=resolution_level,
mocks=mocks,
).ensure_proper_usage(partial_resolve_formatters=True)
return dump_yaml(out)

View file

@ -51,13 +51,6 @@ class FilePathTruncater:
return f"{file_sub_name}{delimiter}{file_ext}"
@classmethod
def _truncate_directory_name(cls, directory_name: str) -> str:
while len(directory_name.encode("utf-8")) > cls._MAX_BASE_FILE_NAME_BYTES:
directory_name = directory_name[:-1]
return directory_name
@classmethod
def maybe_truncate_file_path(cls, file_path: str) -> str:
"""Turn into a Path, then a string, to get correct directory separators"""
@ -68,30 +61,6 @@ class FilePathTruncater:
return str(file_path)
@classmethod
def maybe_truncate_file_name_path(cls, file_name_path: str) -> str:
"""
Truncates each component of a relative output file name path. Both the
subdirectories (which can be derived from metadata such as the title) and the
final file name are truncated if they exceed the OS limit. The file name's
extension is always preserved.
"""
parts = Path(file_name_path).parts
if not parts:
return file_name_path
*directory_parts, file_name = parts
truncated_parts = [
cls._truncate_directory_name(part) if cls._is_file_name_too_long(part) else part
for part in directory_parts
]
if cls._is_file_name_too_long(file_name):
file_name = cls._truncate_file_name(file_name)
return str(Path(*truncated_parts, file_name))
@classmethod
def to_native_filepath(cls, file_path: str) -> str:
"""Ensures file paths use the correct separator"""

View file

@ -2,9 +2,6 @@ import json
import re
from typing import Any, Dict, Optional
from ytdl_sub.entries.script.custom_functions import CustomFunctions
from ytdl_sub.entries.script.variable_definitions import VARIABLES
from ytdl_sub.entries.script.variable_types import BooleanVariable, IntegerVariable
from ytdl_sub.script.parser import parse
from ytdl_sub.script.types.array import Array, UnresolvedArray
from ytdl_sub.script.types.function import BuiltInFunction, Function
@ -16,7 +13,6 @@ from ytdl_sub.script.utils.exceptions import UNREACHABLE
from ytdl_sub.script.utils.name_validation import is_function
# pylint: disable=too-many-return-statements
# pylint: disable=too-many-branches
class ScriptUtils:
@ -114,61 +110,6 @@ class ScriptUtils:
return '"'
return "'''"
@classmethod
def _maybe_to_optimized_sanitize(cls, arg: Argument) -> Argument:
# If it is %sanitize(%concat(...)), return %sanitize(...)
if (
isinstance(arg, Function)
and arg.name == "sanitize"
and len(arg.args) == 1
and isinstance(arg.args[0], Function)
and arg.args[0].name == "concat"
):
return BuiltInFunction(name="sanitize", args=arg.args[0].args)
return arg
@classmethod
def _maybe_sanitized_script_code(cls, arg: Argument) -> Optional[str]:
if not (isinstance(arg, Function) and arg.name == "sanitize"):
return None
output = ""
for sub_arg in arg.args:
if isinstance(sub_arg, Variable):
# No need to sanitize built-in integer variables
if isinstance(VARIABLES.get(sub_arg.name), (IntegerVariable, BooleanVariable)):
output += f"{{ {sub_arg.name} }}"
else:
output += f"{{ {sub_arg.name}_sanitized }}"
elif isinstance(sub_arg, (Integer, Float, Boolean)):
output += str(sub_arg.native)
elif isinstance(sub_arg, String):
output += CustomFunctions.sanitize(sub_arg).native
elif isinstance(sub_arg, BuiltInFunction) and (
sub_arg.function_spec.has_sanitized_output() or sub_arg.name == "pad_zero"
):
# If we know the function's output is sanitized, let's not wrap it
output += cls._to_script_code(sub_arg, top_level=True)
else:
# Purposefully do not set top_level to True so we do not recurse
output += (
f"{{ {cls._to_script_code(BuiltInFunction(name='sanitize', args=[sub_arg]))} }}"
)
return output
@classmethod
def _maybe_concat_script_code(cls, arg: Argument) -> Optional[str]:
if not (isinstance(arg, Function) and arg.name == "concat"):
return None
out = ""
for sub_arg in arg.args:
out += cls._to_script_code(sub_arg, top_level=True)
return out
@classmethod
def _to_script_code(cls, arg: Argument, top_level: bool = False) -> str:
if not top_level and isinstance(arg, (Integer, Boolean, Float)):
@ -179,16 +120,9 @@ class ScriptUtils:
return "" if top_level else "''"
quote = cls._get_quote_char(arg.native)
return arg.native if top_level else f"{quote}{arg.native}{quote}"
arg = cls._maybe_to_optimized_sanitize(arg)
if top_level:
if (out := cls._maybe_sanitized_script_code(arg)) is not None:
return out
if (out := cls._maybe_concat_script_code(arg)) is not None:
return out
if isinstance(arg, Integer):
out = f"%int({arg.native})"
elif isinstance(arg, Boolean):

View file

@ -48,7 +48,7 @@ class StringFormatterFileNameValidator(StringFormatterValidator):
def post_process(self, resolved: str) -> str:
return FilePathTruncater.to_native_filepath(
FilePathTruncater.maybe_truncate_file_name_path(resolved)
FilePathTruncater.maybe_truncate_file_path(resolved)
)

View file

@ -293,7 +293,7 @@ def _validate_formatter(
)
if maybe_resolved := parsed.maybe_resolvable:
return formatter_validator.post_process(maybe_resolved.native)
return formatter_validator.post_process(maybe_resolved)
return ScriptUtils.to_native_script(parsed)
except RuntimeException as exc:

View file

@ -79,7 +79,7 @@ def working_directory() -> str:
@pytest.fixture()
def output_directory() -> str:
with tempfile.TemporaryDirectory() as temp_dir:
yield os.path.normpath(temp_dir)
yield temp_dir
@pytest.fixture()
@ -165,11 +165,10 @@ def subscription_yaml_file_generator() -> Callable:
with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as tmp_file:
tmp_file.write(json.dumps(yaml_dict).encode("utf-8"))
file_path = os.path.normpath(tmp_file.name)
try:
yield file_path
yield tmp_file.name
finally:
FileHandler.delete(file_path)
FileHandler.delete(tmp_file.name)
return _subscription_yaml_file_generator
@ -270,11 +269,10 @@ def default_config_path(default_config) -> str:
with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as tmp_file:
tmp_file.write(json.dumps(default_config._value).encode("utf-8"))
file_path = os.path.normpath(tmp_file.name)
try:
yield file_path
yield tmp_file.name
finally:
FileHandler.delete(file_path)
FileHandler.delete(tmp_file.name)
@pytest.fixture()

View file

@ -2,12 +2,12 @@ from typing import Optional
import pytest
from conftest import mock_run_from_cli
from resources import DISABLE_E2E_TESTS
from resources import DISABLE_YOUTUBE_TESTS
from ytdl_sub.utils.file_handler import FileMetadata
@pytest.mark.skipif(DISABLE_E2E_TESTS, reason="YouTube tests cannot run in GH")
@pytest.mark.skipif(DISABLE_YOUTUBE_TESTS, reason="YouTube tests cannot run in GH")
class TestView:
@pytest.mark.parametrize("split_chapters", [True, False])
def test_view_from_cli(

View file

@ -1,7 +1,7 @@
import pytest
from expected_download import assert_expected_downloads
from expected_transaction_log import assert_transaction_log_matches
from resources import DISABLE_E2E_TESTS
from resources import DISABLE_YOUTUBE_TESTS
from ytdl_sub.subscriptions.subscription import Subscription
@ -47,7 +47,7 @@ def youtube_release_preset_dict(output_directory):
}
@pytest.mark.skipif(DISABLE_E2E_TESTS, reason="YouTube tests cannot run in GH")
@pytest.mark.skipif(DISABLE_YOUTUBE_TESTS, reason="YouTube tests cannot run in GH")
class TestAudioExtract:
@pytest.mark.parametrize("dry_run", [False])
def test_audio_extract_single_song(

View file

@ -3,7 +3,7 @@ from typing import Dict
import pytest
from expected_download import assert_expected_downloads
from expected_transaction_log import assert_transaction_log_matches
from resources import DISABLE_E2E_TESTS
from resources import DISABLE_YOUTUBE_TESTS
from ytdl_sub.subscriptions.subscription import Subscription
@ -54,7 +54,7 @@ def chapters_from_comments_preset_dict(sponsorblock_and_subs_preset_dict: Dict)
return sponsorblock_and_subs_preset_dict
@pytest.mark.skipif(DISABLE_E2E_TESTS, reason="YouTube tests cannot run in GH")
@pytest.mark.skipif(DISABLE_YOUTUBE_TESTS, reason="YouTube tests cannot run in GH")
class TestChapters:
@pytest.mark.parametrize("dry_run", [True, False])
def test_chapters_sponsorblock_and_removal_with_subs(

View file

@ -5,7 +5,7 @@ import pytest
from conftest import assert_logs
from expected_download import assert_expected_downloads
from expected_transaction_log import assert_transaction_log_matches
from resources import DISABLE_E2E_TESTS
from resources import DISABLE_YOUTUBE_TESTS
from ytdl_sub.downloaders.ytdlp import YTDLP
from ytdl_sub.subscriptions.subscription import Subscription
@ -39,7 +39,7 @@ def rolling_recent_channel_preset_dict(recent_preset_dict):
)
@pytest.mark.skipif(DISABLE_E2E_TESTS, reason="YouTube tests cannot run in GH")
@pytest.mark.skipif(DISABLE_YOUTUBE_TESTS, reason="YouTube tests cannot run in GH")
class TestDateRange:
@pytest.mark.parametrize("dry_run", [True, False])
@pytest.mark.parametrize("date_range_breaks", [True, False])

View file

@ -1,7 +1,7 @@
import pytest
from expected_download import assert_expected_downloads
from expected_transaction_log import assert_transaction_log_matches
from resources import DISABLE_E2E_TESTS
from resources import DISABLE_YOUTUBE_TESTS
from ytdl_sub.subscriptions.subscription import Subscription
@ -20,7 +20,7 @@ def preset_dict(output_directory):
}
@pytest.mark.skipif(DISABLE_E2E_TESTS, reason="YouTube tests cannot run in GH")
@pytest.mark.skipif(DISABLE_YOUTUBE_TESTS, reason="YouTube tests cannot run in GH")
class TestFileConvert:
@pytest.mark.parametrize("dry_run", [True, False])
def test_file_convert(

View file

@ -1,7 +1,7 @@
import pytest
from expected_download import assert_expected_downloads
from expected_transaction_log import assert_transaction_log_matches
from resources import DISABLE_E2E_TESTS
from resources import DISABLE_YOUTUBE_TESTS
from ytdl_sub.subscriptions.subscription import Subscription
@ -46,7 +46,7 @@ def livestream_preset_dict(output_directory):
}
@pytest.mark.skipif(DISABLE_E2E_TESTS, reason="YouTube tests cannot run in GH")
@pytest.mark.skipif(DISABLE_YOUTUBE_TESTS, reason="YouTube tests cannot run in GH")
class TestFileConvert:
def test_livestreams_download_filtered(
self,

View file

@ -4,7 +4,7 @@ import mergedeep
import pytest
from expected_download import assert_expected_downloads
from expected_transaction_log import assert_transaction_log_matches
from resources import DISABLE_E2E_TESTS
from resources import DISABLE_YOUTUBE_TESTS
from ytdl_sub.subscriptions.subscription import Subscription
from ytdl_sub.utils.exceptions import ValidationException
@ -58,7 +58,7 @@ def yt_album_as_chapters_with_regex_preset_dict(yt_album_as_chapters_preset_dict
return yt_album_as_chapters_preset_dict
@pytest.mark.skipif(DISABLE_E2E_TESTS, reason="YouTube tests cannot run in GH")
@pytest.mark.skipif(DISABLE_YOUTUBE_TESTS, reason="YouTube tests cannot run in GH")
class TestSplitByChapters:
@pytest.mark.parametrize("dry_run", [True, False])
def test_video_with_chapters(

View file

@ -1,7 +1,7 @@
import pytest
from expected_download import assert_expected_downloads
from expected_transaction_log import assert_transaction_log_matches
from resources import DISABLE_E2E_TESTS
from resources import DISABLE_YOUTUBE_TESTS
from ytdl_sub.config.config_file import ConfigFile
from ytdl_sub.subscriptions.subscription import Subscription
@ -32,7 +32,7 @@ def test_single_video_subs_embed_and_file_preset_dict(single_video_subs_embed_pr
return single_video_subs_embed_preset_dict
@pytest.mark.skipif(DISABLE_E2E_TESTS, reason="YouTube tests cannot run in GH")
@pytest.mark.skipif(DISABLE_YOUTUBE_TESTS, reason="YouTube tests cannot run in GH")
class TestSubtitles:
def test_subtitle_lang_variable_partial_validates(self, default_config):
default_config_dict = default_config.as_dict()

View file

@ -2,7 +2,7 @@ from typing import Dict
import pytest
from expected_transaction_log import assert_transaction_log_matches
from resources import DISABLE_E2E_TESTS
from resources import DISABLE_YOUTUBE_TESTS
from ytdl_sub.config.config_file import ConfigFile
from ytdl_sub.subscriptions.subscription import Subscription
@ -51,7 +51,7 @@ def tv_show_collection_bilateral_dict(output_directory):
}
@pytest.mark.skipif(DISABLE_E2E_TESTS, reason="YouTube tests cannot run in GH")
@pytest.mark.skipif(DISABLE_YOUTUBE_TESTS, reason="YouTube tests cannot run in GH")
class TestBilateral:
def test_tv_show_by_date_downloads_bilateral(
self,

View file

@ -4,7 +4,6 @@ import pytest
from conftest import assert_logs
from expected_download import assert_expected_downloads
from expected_transaction_log import assert_transaction_log_matches
from resources import DISABLE_E2E_TESTS
from ytdl_sub.downloaders.ytdlp import YTDLP
from ytdl_sub.subscriptions.subscription import Subscription
@ -24,7 +23,6 @@ def subscription_dict(output_directory):
}
@pytest.mark.skipif(DISABLE_E2E_TESTS, reason="Soundcloud tests cannot run in GH")
class TestSoundcloudDiscography:
"""
Downloads my (bad) SC recordings I made. Ensure the above files exist and have the

View file

@ -3,7 +3,7 @@ from typing import Callable, Dict
import pytest
from expected_download import assert_expected_downloads
from expected_transaction_log import assert_transaction_log_matches
from resources import DISABLE_E2E_TESTS
from resources import DISABLE_YOUTUBE_TESTS
from ytdl_sub.config.config_file import ConfigFile
from ytdl_sub.subscriptions.subscription import Subscription
@ -42,7 +42,7 @@ def channel_preset_dict(output_directory):
}
@pytest.mark.skipif(DISABLE_E2E_TESTS, reason="YouTube tests cannot run in GH")
@pytest.mark.skipif(DISABLE_YOUTUBE_TESTS, reason="YouTube tests cannot run in GH")
class TestChannel:
"""
Downloads my old minecraft youtube channel. Ensure the above files exist and have the

View file

@ -1,7 +1,7 @@
import pytest
from expected_download import assert_expected_downloads
from expected_transaction_log import assert_transaction_log_matches
from resources import DISABLE_E2E_TESTS
from resources import DISABLE_YOUTUBE_TESTS
from ytdl_sub.subscriptions.subscription import Subscription
@ -30,7 +30,7 @@ def single_video_preset_dict(output_directory):
}
@pytest.mark.skipif(DISABLE_E2E_TESTS, reason="YouTube tests cannot run in GH")
@pytest.mark.skipif(DISABLE_YOUTUBE_TESTS, reason="YouTube tests cannot run in GH")
class TestYoutubeVideo:
@pytest.mark.parametrize("dry_run", [True, False])
def test_single_video_download(

View file

@ -1,45 +0,0 @@
from pathlib import Path
import pytest
import yaml
from conftest import mock_run_from_cli
from unit.config.test_subscription_resolution import compare_resolved_yaml
from ytdl_sub.config.validators.variable_validation import ResolutionLevel
from ytdl_sub.utils.system import IS_WINDOWS
class TestInspect:
@pytest.mark.parametrize("config_provided", [True, False])
@pytest.mark.parametrize(
"inspect_level", ["0", "original", "1", "fill", "2", "resolve", "3", "internal"]
)
def test_inspect_command(
self,
capsys,
default_config_path: str,
tv_show_subscriptions_path: Path,
output_directory: str,
config_provided: bool,
inspect_level: str,
):
# TODO: fix mock_run_from_cli in windows to handle file paths correctly
if IS_WINDOWS:
return
# Shares same test fixture as `test_subscription_resolution.py`
args = f"--config {default_config_path} " if config_provided else ""
args += f"inspect {tv_show_subscriptions_path} --match 'NOVA PBS' "
args += f"--level {inspect_level} "
subscriptions = mock_run_from_cli(args=args)
assert len(subscriptions) == 0
out = yaml.safe_load(capsys.readouterr().out)
compare_resolved_yaml(
out=out,
output_directory=output_directory,
subscription_name="NOVA PBS",
preset_type="tv_show",
resolution_level=ResolutionLevel.level_number(resolution_arg=inspect_level),
)

View file

@ -4,7 +4,7 @@ import shutil
from pathlib import Path
from typing import Dict
DISABLE_E2E_TESTS: bool = True
DISABLE_YOUTUBE_TESTS: bool = True
REGENERATE_FIXTURES: bool = False
RESOURCE_PATH: Path = Path("tests") / "resources"

View file

@ -79,13 +79,13 @@
"file_name": "{ track_full_path }",
"keep_files_date_eval": "{ upload_date_standardized }",
"maintain_download_archive": true,
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpv_h5mst_",
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpzald2h7x",
"preserve_mtime": false,
"thumbnail_name": "{ album_cover_path }"
},
"overrides": {
"album_cover_path": "Lester Young/[{ playlist_max_upload_year }] { playlist_title_sanitized }/folder.{ thumbnail_ext }",
"album_dir": "[{ playlist_max_upload_year }] { playlist_title_sanitized }",
"album_cover_path": "Lester Young/{ %concat( \"[\", playlist_max_upload_year, \"] \", %sanitize( playlist_title ) ) }/folder.{ thumbnail_ext }",
"album_dir": "[{ playlist_max_upload_year }] { %sanitize( playlist_title ) }",
"artist_dir": "Lester Young",
"avatar_uncropped_thumbnail_file_name": "",
"banner_uncropped_thumbnail_file_name": "",
@ -93,7 +93,7 @@
"enable_throttle_protection": true,
"include_sibling_metadata": true,
"modified_webpage_url": "{ webpage_url }",
"music_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpv_h5mst_",
"music_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpzald2h7x",
"resolution_assert": "{ %if( %and( enable_resolution_assert, %ne( height, 0 ), %not( resolution_assert_is_ignored ) ), %assert( %gte( height, resolution_assert_height_gte ), %concat( \"Entry \", title, \" downloaded at a low resolution (\", resolution_readable, \"), you've probably been throttled. \", \"Stopping further downloads, wait a few hours and try again. \", \"Disable using the override variable `enable_resolution_assert: False`.\" ) ), \"false is no-op\" ) }",
"resolution_assert_height_gte": 361,
"resolution_assert_ignore_titles": "{ [ ] }",
@ -110,8 +110,8 @@
"track_album_artist": "Lester Young",
"track_artist": "Lester Young",
"track_date": "{ upload_date_standardized }",
"track_file_name": "{ playlist_index_padded } - { title_sanitized }.{ ext }",
"track_full_path": "Lester Young/[{ playlist_max_upload_year }] { playlist_title_sanitized }/{ playlist_index_padded } - { title_sanitized }.{ ext }",
"track_file_name": "{ playlist_index_padded } - { %sanitize( title ) }.{ ext }",
"track_full_path": "Lester Young/{ %concat( \"[\", playlist_max_upload_year, \"] \", %sanitize( playlist_title ) ) }/{ %concat( playlist_index_padded, \" - \", %sanitize( title ), \".\", ext ) }",
"track_genre": "Jazz",
"track_genre_default": "Unset",
"track_number": "{ playlist_index }",

View file

@ -76,15 +76,15 @@
},
"output_options": {
"download_archive_name": ".ytdl-sub-Lester Young-download-archive.json",
"file_name": "Lester Young/[{ %int( %map_get( %to_date_metadata( %array_reduce( %if_passthrough( %extract_field_from_siblings( \"upload_date\" ), [ upload_date ] ), %max ) ), \"year\" ) ) }] { %sanitize( %map_get_non_empty( entry_metadata, \"playlist_title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) }/{ %pad_zero( %map_get_non_empty( entry_metadata, \"playlist_index\", 1 ), 2 ) } - { %sanitize( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) }.{ ext }",
"file_name": "Lester Young/{ %concat( \"[\", %int( %map_get( %to_date_metadata( %array_reduce( %if_passthrough( %extract_field_from_siblings( \"upload_date\" ), [ upload_date ] ), %max ) ), \"year\" ) ), \"] \", %sanitize( %map_get_non_empty( entry_metadata, \"playlist_title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) ) }/{ %concat( %pad_zero( %map_get_non_empty( entry_metadata, \"playlist_index\", 1 ), 2 ), \" - \", %sanitize( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ), \".\", ext ) }",
"keep_files_date_eval": "{ %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"date_standardized\" ) ) }",
"maintain_download_archive": true,
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmp7z9ceu_d",
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpdu1vad67",
"preserve_mtime": false,
"thumbnail_name": "Lester Young/[{ %int( %map_get( %to_date_metadata( %array_reduce( %if_passthrough( %extract_field_from_siblings( \"upload_date\" ), [ upload_date ] ), %max ) ), \"year\" ) ) }] { %sanitize( %map_get_non_empty( entry_metadata, \"playlist_title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) }/folder.jpg"
"thumbnail_name": "Lester Young/{ %concat( \"[\", %int( %map_get( %to_date_metadata( %array_reduce( %if_passthrough( %extract_field_from_siblings( \"upload_date\" ), [ upload_date ] ), %max ) ), \"year\" ) ), \"] \", %sanitize( %map_get_non_empty( entry_metadata, \"playlist_title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) ) }/folder.jpg"
},
"overrides": {
"album_cover_path": "Lester Young/[{ %int( %map_get( %to_date_metadata( %array_reduce( %if_passthrough( %extract_field_from_siblings( \"upload_date\" ), [ upload_date ] ), %max ) ), \"year\" ) ) }] { %sanitize( %map_get_non_empty( entry_metadata, \"playlist_title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) }/folder.jpg",
"album_cover_path": "Lester Young/{ %concat( \"[\", %int( %map_get( %to_date_metadata( %array_reduce( %if_passthrough( %extract_field_from_siblings( \"upload_date\" ), [ upload_date ] ), %max ) ), \"year\" ) ), \"] \", %sanitize( %map_get_non_empty( entry_metadata, \"playlist_title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) ) }/folder.jpg",
"album_dir": "[{ %int( %map_get( %to_date_metadata( %array_reduce( %if_passthrough( %extract_field_from_siblings( \"upload_date\" ), [ upload_date ] ), %max ) ), \"year\" ) ) }] { %sanitize( %map_get_non_empty( entry_metadata, \"playlist_title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) }",
"artist_dir": "Lester Young",
"avatar_uncropped_thumbnail_file_name": "",
@ -93,7 +93,7 @@
"enable_throttle_protection": true,
"include_sibling_metadata": true,
"modified_webpage_url": "{ %map_get( entry_metadata, \"webpage_url\" ) }",
"music_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmp7z9ceu_d",
"music_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpdu1vad67",
"resolution_assert": "{ %if( %and( enable_resolution_assert, %ne( height, 0 ), %not( resolution_assert_is_ignored ) ), %assert( %gte( height, resolution_assert_height_gte ), %concat( \"Entry \", title, \" downloaded at a low resolution (\", resolution_readable, \"), you've probably been throttled. \", \"Stopping further downloads, wait a few hours and try again. \", \"Disable using the override variable `enable_resolution_assert: False`.\" ) ), \"false is no-op\" ) }",
"resolution_assert_height_gte": 361,
"resolution_assert_ignore_titles": "{ [ ] }",
@ -111,7 +111,7 @@
"track_artist": "Lester Young",
"track_date": "{ %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"date_standardized\" ) ) }",
"track_file_name": "{ %pad_zero( %map_get_non_empty( entry_metadata, \"playlist_index\", 1 ), 2 ) } - { %sanitize( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) }.{ ext }",
"track_full_path": "Lester Young/[{ %int( %map_get( %to_date_metadata( %array_reduce( %if_passthrough( %extract_field_from_siblings( \"upload_date\" ), [ upload_date ] ), %max ) ), \"year\" ) ) }] { %sanitize( %map_get_non_empty( entry_metadata, \"playlist_title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) }/{ %pad_zero( %map_get_non_empty( entry_metadata, \"playlist_index\", 1 ), 2 ) } - { %sanitize( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) }.{ ext }",
"track_full_path": "Lester Young/{ %concat( \"[\", %int( %map_get( %to_date_metadata( %array_reduce( %if_passthrough( %extract_field_from_siblings( \"upload_date\" ), [ upload_date ] ), %max ) ), \"year\" ) ), \"] \", %sanitize( %map_get_non_empty( entry_metadata, \"playlist_title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) ) }/{ %concat( %pad_zero( %map_get_non_empty( entry_metadata, \"playlist_index\", 1 ), 2 ), \" - \", %sanitize( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ), \".\", ext ) }",
"track_genre": "Jazz",
"track_genre_default": "Unset",
"track_number": "{ %map_get_non_empty( entry_metadata, \"playlist_index\", 1 ) }",

View file

@ -71,7 +71,7 @@
"enable_throttle_protection": true,
"include_sibling_metadata": true,
"modified_webpage_url": "{webpage_url}",
"music_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpgcphf_8p",
"music_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpk6coazyn",
"resolution_assert": "{\n %if(\n %and(\n enable_resolution_assert,\n %ne( height, 0 ),\n %not(resolution_assert_is_ignored)\n ),\n %assert(\n %gte( height, resolution_assert_height_gte ),\n %concat(\n \"Entry \",\n title,\n \" downloaded at a low resolution (\",\n resolution_readable,\n \"), you've probably been throttled. \",\n \"Stopping further downloads, wait a few hours and try again. \",\n \"Disable using the override variable `enable_resolution_assert: False`.\"\n )\n ),\n \"false is no-op\"\n )\n}",
"resolution_assert_height_gte": 361,
"resolution_assert_ignore_titles": "{ [] }",

View file

@ -76,16 +76,16 @@
},
"output_options": {
"download_archive_name": ".ytdl-sub-Lester Young-download-archive.json",
"file_name": "Lester Young/[{ playlist_max_upload_year }] { playlist_title_sanitized }/{ playlist_index_padded } - { title_sanitized }.{ ext }",
"file_name": "Lester Young/{ %concat( \"[\", playlist_max_upload_year, \"] \", %sanitize( playlist_title ) ) }/{ %concat( playlist_index_padded, \" - \", %sanitize( title ), \".\", ext ) }",
"keep_files_date_eval": "{ upload_date_standardized }",
"maintain_download_archive": true,
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmplsyhpyfi",
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpd5oeacb3",
"preserve_mtime": false,
"thumbnail_name": "Lester Young/[{ playlist_max_upload_year }] { playlist_title_sanitized }/folder.{ thumbnail_ext }"
"thumbnail_name": "Lester Young/{ %concat( \"[\", playlist_max_upload_year, \"] \", %sanitize( playlist_title ) ) }/folder.{ thumbnail_ext }"
},
"overrides": {
"album_cover_path": "Lester Young/[{ playlist_max_upload_year }] { playlist_title_sanitized }/folder.{ thumbnail_ext }",
"album_dir": "[{ playlist_max_upload_year }] { playlist_title_sanitized }",
"album_cover_path": "Lester Young/{ %concat( \"[\", playlist_max_upload_year, \"] \", %sanitize( playlist_title ) ) }/folder.{ thumbnail_ext }",
"album_dir": "[{ playlist_max_upload_year }] { %sanitize( playlist_title ) }",
"artist_dir": "Lester Young",
"avatar_uncropped_thumbnail_file_name": "",
"banner_uncropped_thumbnail_file_name": "",
@ -93,7 +93,7 @@
"enable_throttle_protection": true,
"include_sibling_metadata": true,
"modified_webpage_url": "{ webpage_url }",
"music_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmplsyhpyfi",
"music_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpd5oeacb3",
"resolution_assert": "{ %if( %and( enable_resolution_assert, %ne( height, 0 ), %not( resolution_assert_is_ignored ) ), %assert( %gte( height, resolution_assert_height_gte ), %concat( \"Entry \", title, \" downloaded at a low resolution (\", resolution_readable, \"), you've probably been throttled. \", \"Stopping further downloads, wait a few hours and try again. \", \"Disable using the override variable `enable_resolution_assert: False`.\" ) ), \"false is no-op\" ) }",
"resolution_assert_height_gte": 361,
"resolution_assert_ignore_titles": "{ [ ] }",
@ -110,8 +110,8 @@
"track_album_artist": "Lester Young",
"track_artist": "Lester Young",
"track_date": "{ upload_date_standardized }",
"track_file_name": "{ playlist_index_padded } - { title_sanitized }.{ ext }",
"track_full_path": "Lester Young/[{ playlist_max_upload_year }] { playlist_title_sanitized }/{ playlist_index_padded } - { title_sanitized }.{ ext }",
"track_file_name": "{ playlist_index_padded } - { %sanitize( title ) }.{ ext }",
"track_full_path": "Lester Young/{ %concat( \"[\", playlist_max_upload_year, \"] \", %sanitize( playlist_title ) ) }/{ %concat( playlist_index_padded, \" - \", %sanitize( title ), \".\", ext ) }",
"track_genre": "Jazz",
"track_genre_default": "Unset",
"track_number": "{ playlist_index }",

View file

@ -38,7 +38,7 @@
"info_json_name": "{ music_video_file_name }.{ info_json_ext }",
"keep_files_date_eval": "{ upload_date_standardized }",
"maintain_download_archive": true,
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmp23dslv3t",
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpyukbh6ta",
"preserve_mtime": false,
"thumbnail_name": "{ music_video_file_name }.jpg"
},
@ -66,7 +66,7 @@
"music_video_album_default": "Music Videos",
"music_video_artist": "Rick Astley",
"music_video_date": "{ %elif( %contains_url_field( \"date\" ), %get_url_field( \"date\", upload_date_standardized ), %contains_url_field( \"year\" ), %concat( %get_url_field( \"date\", upload_year ), \"-01-01\" ), upload_date_standardized ) }",
"music_video_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmp23dslv3t",
"music_video_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpyukbh6ta",
"music_video_file_name": "Rick Astley/{ %sanitize( %get_url_field( \"title\", title ) ) }",
"music_video_file_name_suffix": "",
"music_video_genre": "Pop",

View file

@ -34,13 +34,13 @@
"format": "(bv*[ext=mp4][vcodec~='^((he|a)vc|h26[45])']+ba[ext=m4a]) / (bv[ext=mp4]*+ba[ext=m4a]/b)",
"output_options": {
"download_archive_name": ".ytdl-sub-Rick Astley-download-archive.json",
"file_name": "Rick Astley/{ %sanitize( %get_url_field( \"title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) }.{ %map_get( entry_metadata, \"ext\" ) }",
"info_json_name": "Rick Astley/{ %sanitize( %get_url_field( \"title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) }.info.json",
"file_name": "{ %concat( \"Rick Astley\", \"/\", %sanitize( %get_url_field( \"title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ), '' ) }.{ %map_get( entry_metadata, \"ext\" ) }",
"info_json_name": "{ %concat( \"Rick Astley\", \"/\", %sanitize( %get_url_field( \"title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ), '' ) }.info.json",
"keep_files_date_eval": "{ %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"date_standardized\" ) ) }",
"maintain_download_archive": true,
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpyoug1csk",
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmput7fc_rs",
"preserve_mtime": false,
"thumbnail_name": "Rick Astley/{ %sanitize( %get_url_field( \"title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) }.jpg"
"thumbnail_name": "{ %concat( \"Rick Astley\", \"/\", %sanitize( %get_url_field( \"title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ), '' ) }.jpg"
},
"overrides": {
"%bilateral_url": "{ %if( %and( enable_bilateral_scraping, subscription_has_download_archive, %is_bilateral_url( $0 ) ), $0, '' ) }",
@ -66,7 +66,7 @@
"music_video_album_default": "Music Videos",
"music_video_artist": "Rick Astley",
"music_video_date": "{ %elif( %contains_url_field( \"date\" ), %get_url_field( \"date\", upload_date_standardized ), %contains_url_field( \"year\" ), %concat( %get_url_field( \"date\", upload_year ), \"-01-01\" ), upload_date_standardized ) }",
"music_video_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpyoug1csk",
"music_video_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmput7fc_rs",
"music_video_file_name": "Rick Astley/{ %sanitize( %get_url_field( \"title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) }",
"music_video_file_name_suffix": "",
"music_video_genre": "Pop",

View file

@ -71,7 +71,7 @@
"music_video_album_default": "Music Videos",
"music_video_artist": "{subscription_name}",
"music_video_date": "{ \n %elif(\n %contains_url_field(\"date\"),\n %get_url_field(\"date\", upload_date_standardized),\n\n %contains_url_field(\"year\"),\n %concat( %get_url_field(\"date\", upload_year), \"-01-01\"),\n\n upload_date_standardized\n )\n}",
"music_video_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpsl6aj5hf",
"music_video_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmp3bmucwsg",
"music_video_file_name": "{music_video_artist_sanitized}/{music_video_title_sanitized}{music_video_file_name_suffix}",
"music_video_file_name_suffix": "",
"music_video_genre": "{subscription_indent_1}",

View file

@ -34,13 +34,13 @@
"format": "(bv*[ext=mp4][vcodec~='^((he|a)vc|h26[45])']+ba[ext=m4a]) / (bv[ext=mp4]*+ba[ext=m4a]/b)",
"output_options": {
"download_archive_name": ".ytdl-sub-Rick Astley-download-archive.json",
"file_name": "Rick Astley/{ %sanitize( %get_url_field( \"title\", title ) ) }.{ ext }",
"info_json_name": "Rick Astley/{ %sanitize( %get_url_field( \"title\", title ) ) }.{ info_json_ext }",
"file_name": "{ %concat( \"Rick Astley\", \"/\", %sanitize( %get_url_field( \"title\", title ) ), '' ) }.{ ext }",
"info_json_name": "{ %concat( \"Rick Astley\", \"/\", %sanitize( %get_url_field( \"title\", title ) ), '' ) }.{ info_json_ext }",
"keep_files_date_eval": "{ upload_date_standardized }",
"maintain_download_archive": true,
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmprdmerciw",
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpstvsa5ot",
"preserve_mtime": false,
"thumbnail_name": "Rick Astley/{ %sanitize( %get_url_field( \"title\", title ) ) }.jpg"
"thumbnail_name": "{ %concat( \"Rick Astley\", \"/\", %sanitize( %get_url_field( \"title\", title ) ), '' ) }.jpg"
},
"overrides": {
"%bilateral_url": "{ %if( %and( enable_bilateral_scraping, subscription_has_download_archive, %is_bilateral_url( $0 ) ), $0, '' ) }",
@ -66,7 +66,7 @@
"music_video_album_default": "Music Videos",
"music_video_artist": "Rick Astley",
"music_video_date": "{ %elif( %contains_url_field( \"date\" ), %get_url_field( \"date\", upload_date_standardized ), %contains_url_field( \"year\" ), %concat( %get_url_field( \"date\", upload_year ), \"-01-01\" ), upload_date_standardized ) }",
"music_video_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmprdmerciw",
"music_video_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpstvsa5ot",
"music_video_file_name": "Rick Astley/{ %sanitize( %get_url_field( \"title\", title ) ) }",
"music_video_file_name_suffix": "",
"music_video_genre": "Pop",

View file

@ -54,7 +54,7 @@
"info_json_name": "{ episode_file_path }.{ info_json_ext }",
"keep_files_date_eval": "{ episode_date_standardized }",
"maintain_download_archive": true,
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmp8p_iu2pp/NOVA PBS",
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpc_p6gjjw/NOVA PBS",
"preserve_mtime": false,
"thumbnail_name": "{ thumbnail_file_name }"
},
@ -73,7 +73,7 @@
"episode_content_rating": "TV-14",
"episode_date_standardized": "{ upload_date_standardized }",
"episode_file_name": "s{ upload_year }.e{ %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ) } - { title_sanitized_plex }",
"episode_file_path": "Season { upload_year }/s{ upload_year }.e{ %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ) } - { title_sanitized_plex_sanitized }",
"episode_file_path": "{ %sanitize( %concat( \"Season \", upload_year ) ) }/{ %sanitize( %concat( \"s\", upload_year, \".e\", %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ), \" - \", title_sanitized_plex ) ) }",
"episode_number": "{ upload_month }{ upload_day_padded }{ upload_date_index_padded }",
"episode_number_and_padded_": "{ [ %concat( upload_month, upload_day_padded, upload_date_index_padded ), 6 ] }",
"episode_number_padded": "{ %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ) }",
@ -97,7 +97,7 @@
"season_directory_name": "Season { upload_year }",
"season_number": "{ upload_year }",
"season_number_padded": "{ upload_year }",
"season_poster_file_name": "Season { upload_year }/Season{ upload_year }.jpg",
"season_poster_file_name": "{ %sanitize( %concat( \"Season \", upload_year ) ) }/Season{ upload_year }.jpg",
"subscription_array": [
"https://www.youtube.com/@novapbs"
],
@ -105,14 +105,14 @@
"subscription_indent_2": "TV-14",
"subscription_value": "https://www.youtube.com/@novapbs",
"subscription_value_1": "https://www.youtube.com/@novapbs",
"thumbnail_file_name": "Season { upload_year }/s{ upload_year }.e{ %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ) } - { title_sanitized_plex_sanitized }-thumb.jpg",
"thumbnail_file_name": "{ %concat( %sanitize( %concat( \"Season \", upload_year ) ), \"/\", %sanitize( %concat( \"s\", upload_year, \".e\", %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ), \" - \", title_sanitized_plex ) ) ) }-thumb.jpg",
"tv_show_by_date_episode_ordering": "upload-month-day",
"tv_show_by_date_ordering_pair_validation_": "{ [ %concat( upload_month, upload_day_padded, upload_date_index_padded ), 6 ] }",
"tv_show_by_date_season_ordering": "upload-year",
"tv_show_content_rating": "TV-14",
"tv_show_content_rating_default": "TV-14",
"tv_show_date_range_type": "upload_date",
"tv_show_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmp8p_iu2pp",
"tv_show_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpc_p6gjjw",
"tv_show_fanart_file_name": "fanart.jpg",
"tv_show_genre": "Documentaries",
"tv_show_genre_default": "ytdl-sub",

View file

@ -50,13 +50,13 @@
"format": "(bv*[ext=mp4][vcodec~='^((he|a)vc|h26[45])']+ba[ext=m4a]) / (bv[ext=mp4]*+ba[ext=m4a]/b)",
"output_options": {
"download_archive_name": ".ytdl-sub-NOVA PBS-download-archive.json",
"file_name": "Season { %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) }/s{ %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) }.e{ %pad_zero( %int( %concat( %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"month\" ) ), %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"day_padded\" ) ), %pad_zero( upload_date_index, 2 ) ) ), 6 ) } - { %sanitize( %sanitize_plex_episode( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) }.{ ext }",
"info_json_name": "Season { %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) }/s{ %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) }.e{ %pad_zero( %int( %concat( %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"month\" ) ), %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"day_padded\" ) ), %pad_zero( upload_date_index, 2 ) ) ), 6 ) } - { %sanitize( %sanitize_plex_episode( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) }.info.json",
"file_name": "{ %concat( %sanitize( %concat( \"Season \", %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) ) ), \"/\", %sanitize( %concat( \"s\", %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ), \".e\", %pad_zero( %int( %concat( %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"month\" ) ), %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"day_padded\" ) ), %pad_zero( upload_date_index, 2 ) ) ), 6 ), \" - \", %sanitize_plex_episode( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) ) ) }.{ ext }",
"info_json_name": "{ %concat( %sanitize( %concat( \"Season \", %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) ) ), \"/\", %sanitize( %concat( \"s\", %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ), \".e\", %pad_zero( %int( %concat( %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"month\" ) ), %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"day_padded\" ) ), %pad_zero( upload_date_index, 2 ) ) ), 6 ), \" - \", %sanitize_plex_episode( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) ) ) }.info.json",
"keep_files_date_eval": "{ %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"date_standardized\" ) ) }",
"maintain_download_archive": true,
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpup1qibc_/NOVA PBS",
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpps6shcpt/NOVA PBS",
"preserve_mtime": false,
"thumbnail_name": "Season { %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) }/s{ %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) }.e{ %pad_zero( %int( %concat( %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"month\" ) ), %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"day_padded\" ) ), %pad_zero( upload_date_index, 2 ) ) ), 6 ) } - { %sanitize( %sanitize_plex_episode( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) }-thumb.jpg"
"thumbnail_name": "{ %concat( %sanitize( %concat( \"Season \", %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) ) ), \"/\", %sanitize( %concat( \"s\", %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ), \".e\", %pad_zero( %int( %concat( %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"month\" ) ), %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"day_padded\" ) ), %pad_zero( upload_date_index, 2 ) ) ), 6 ), \" - \", %sanitize_plex_episode( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) ) ) }-thumb.jpg"
},
"overrides": {
"%bilateral_url": "{ %if( %and( enable_bilateral_scraping, subscription_has_download_archive, %is_bilateral_url( $0 ) ), $0, '' ) }",
@ -73,7 +73,7 @@
"episode_content_rating": "TV-14",
"episode_date_standardized": "{ %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"date_standardized\" ) ) }",
"episode_file_name": "s{ %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) }.e{ %pad_zero( %int( %concat( %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"month\" ) ), %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"day_padded\" ) ), %pad_zero( upload_date_index, 2 ) ) ), 6 ) } - { %sanitize_plex_episode( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) }",
"episode_file_path": "Season { %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) }/s{ %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) }.e{ %pad_zero( %int( %concat( %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"month\" ) ), %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"day_padded\" ) ), %pad_zero( upload_date_index, 2 ) ) ), 6 ) } - { %sanitize( %sanitize_plex_episode( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) }",
"episode_file_path": "{ %sanitize( %concat( \"Season \", %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) ) ) }/{ %sanitize( %concat( \"s\", %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ), \".e\", %pad_zero( %int( %concat( %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"month\" ) ), %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"day_padded\" ) ), %pad_zero( upload_date_index, 2 ) ) ), 6 ), \" - \", %sanitize_plex_episode( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) ) }",
"episode_number": "{ %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"month\" ) ) }{ %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"day_padded\" ) }{ %pad_zero( upload_date_index, 2 ) }",
"episode_number_and_padded_": "{ [ %concat( %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"month\" ) ), %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"day_padded\" ) ), %pad_zero( upload_date_index, 2 ) ), 6 ] }",
"episode_number_padded": "{ %pad_zero( %int( %concat( %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"month\" ) ), %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"day_padded\" ) ), %pad_zero( upload_date_index, 2 ) ) ), 6 ) }",
@ -97,7 +97,7 @@
"season_directory_name": "Season { %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) }",
"season_number": "{ %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) }",
"season_number_padded": "{ %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) }",
"season_poster_file_name": "Season { %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) }/Season{ %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) }.jpg",
"season_poster_file_name": "{ %sanitize( %concat( \"Season \", %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) ) ) }/Season{ %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) }.jpg",
"subscription_array": [
"https://www.youtube.com/@novapbs"
],
@ -105,14 +105,14 @@
"subscription_indent_2": "TV-14",
"subscription_value": "https://www.youtube.com/@novapbs",
"subscription_value_1": "https://www.youtube.com/@novapbs",
"thumbnail_file_name": "Season { %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) }/s{ %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) }.e{ %pad_zero( %int( %concat( %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"month\" ) ), %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"day_padded\" ) ), %pad_zero( upload_date_index, 2 ) ) ), 6 ) } - { %sanitize( %sanitize_plex_episode( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) }-thumb.jpg",
"thumbnail_file_name": "{ %concat( %sanitize( %concat( \"Season \", %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) ) ), \"/\", %sanitize( %concat( \"s\", %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ), \".e\", %pad_zero( %int( %concat( %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"month\" ) ), %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"day_padded\" ) ), %pad_zero( upload_date_index, 2 ) ) ), 6 ), \" - \", %sanitize_plex_episode( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) ) ) }-thumb.jpg",
"tv_show_by_date_episode_ordering": "upload-month-day",
"tv_show_by_date_ordering_pair_validation_": "{ [ %concat( %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"month\" ) ), %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"day_padded\" ) ), %pad_zero( upload_date_index, 2 ) ), 6 ] }",
"tv_show_by_date_season_ordering": "upload-year",
"tv_show_content_rating": "TV-14",
"tv_show_content_rating_default": "TV-14",
"tv_show_date_range_type": "upload_date",
"tv_show_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpup1qibc_",
"tv_show_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpps6shcpt",
"tv_show_fanart_file_name": "fanart.jpg",
"tv_show_genre": "Documentaries",
"tv_show_genre_default": "ytdl-sub",

View file

@ -109,7 +109,7 @@
"tv_show_content_rating": "{subscription_indent_2}",
"tv_show_content_rating_default": "TV-14",
"tv_show_date_range_type": "{\n %if(\n %contains(tv_show_by_date_season_ordering, \"release\"),\n \"release_date\",\n \"upload_date\"\n )\n}",
"tv_show_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpq08uqzot",
"tv_show_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmp5yx9y73k",
"tv_show_fanart_file_name": "fanart.jpg",
"tv_show_genre": "{subscription_indent_1}",
"tv_show_genre_default": "ytdl-sub",

View file

@ -50,13 +50,13 @@
"format": "(bv*[ext=mp4][vcodec~='^((he|a)vc|h26[45])']+ba[ext=m4a]) / (bv[ext=mp4]*+ba[ext=m4a]/b)",
"output_options": {
"download_archive_name": ".ytdl-sub-NOVA PBS-download-archive.json",
"file_name": "Season { upload_year }/s{ upload_year }.e{ %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ) } - { title_sanitized_plex_sanitized }.{ ext }",
"info_json_name": "Season { upload_year }/s{ upload_year }.e{ %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ) } - { title_sanitized_plex_sanitized }.{ info_json_ext }",
"file_name": "{ %concat( %sanitize( %concat( \"Season \", upload_year ) ), \"/\", %sanitize( %concat( \"s\", upload_year, \".e\", %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ), \" - \", title_sanitized_plex ) ) ) }.{ ext }",
"info_json_name": "{ %concat( %sanitize( %concat( \"Season \", upload_year ) ), \"/\", %sanitize( %concat( \"s\", upload_year, \".e\", %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ), \" - \", title_sanitized_plex ) ) ) }.{ info_json_ext }",
"keep_files_date_eval": "{ upload_date_standardized }",
"maintain_download_archive": true,
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmp723nds68/NOVA PBS",
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmp3j0cm_tw/NOVA PBS",
"preserve_mtime": false,
"thumbnail_name": "Season { upload_year }/s{ upload_year }.e{ %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ) } - { title_sanitized_plex_sanitized }-thumb.jpg"
"thumbnail_name": "{ %concat( %sanitize( %concat( \"Season \", upload_year ) ), \"/\", %sanitize( %concat( \"s\", upload_year, \".e\", %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ), \" - \", title_sanitized_plex ) ) ) }-thumb.jpg"
},
"overrides": {
"%bilateral_url": "{ %if( %and( enable_bilateral_scraping, subscription_has_download_archive, %is_bilateral_url( $0 ) ), $0, '' ) }",
@ -73,7 +73,7 @@
"episode_content_rating": "TV-14",
"episode_date_standardized": "{ upload_date_standardized }",
"episode_file_name": "s{ upload_year }.e{ %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ) } - { title_sanitized_plex }",
"episode_file_path": "Season { upload_year }/s{ upload_year }.e{ %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ) } - { title_sanitized_plex_sanitized }",
"episode_file_path": "{ %sanitize( %concat( \"Season \", upload_year ) ) }/{ %sanitize( %concat( \"s\", upload_year, \".e\", %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ), \" - \", title_sanitized_plex ) ) }",
"episode_number": "{ upload_month }{ upload_day_padded }{ upload_date_index_padded }",
"episode_number_and_padded_": "{ [ %concat( upload_month, upload_day_padded, upload_date_index_padded ), 6 ] }",
"episode_number_padded": "{ %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ) }",
@ -97,7 +97,7 @@
"season_directory_name": "Season { upload_year }",
"season_number": "{ upload_year }",
"season_number_padded": "{ upload_year }",
"season_poster_file_name": "Season { upload_year }/Season{ upload_year }.jpg",
"season_poster_file_name": "{ %sanitize( %concat( \"Season \", upload_year ) ) }/Season{ upload_year }.jpg",
"subscription_array": [
"https://www.youtube.com/@novapbs"
],
@ -105,14 +105,14 @@
"subscription_indent_2": "TV-14",
"subscription_value": "https://www.youtube.com/@novapbs",
"subscription_value_1": "https://www.youtube.com/@novapbs",
"thumbnail_file_name": "Season { upload_year }/s{ upload_year }.e{ %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ) } - { title_sanitized_plex_sanitized }-thumb.jpg",
"thumbnail_file_name": "{ %concat( %sanitize( %concat( \"Season \", upload_year ) ), \"/\", %sanitize( %concat( \"s\", upload_year, \".e\", %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ), \" - \", title_sanitized_plex ) ) ) }-thumb.jpg",
"tv_show_by_date_episode_ordering": "upload-month-day",
"tv_show_by_date_ordering_pair_validation_": "{ [ %concat( upload_month, upload_day_padded, upload_date_index_padded ), 6 ] }",
"tv_show_by_date_season_ordering": "upload-year",
"tv_show_content_rating": "TV-14",
"tv_show_content_rating_default": "TV-14",
"tv_show_date_range_type": "upload_date",
"tv_show_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmp723nds68",
"tv_show_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmp3j0cm_tw",
"tv_show_fanart_file_name": "fanart.jpg",
"tv_show_genre": "Documentaries",
"tv_show_genre_default": "ytdl-sub",

View file

@ -1,5 +1,4 @@
from pathlib import Path
from typing import Dict
import pytest
import yaml
@ -11,13 +10,12 @@ from ytdl_sub.subscriptions.subscription import Subscription
from ytdl_sub.utils.file_path import FilePathTruncater
def compare_resolved_yaml(
out: Dict,
output_directory: str,
subscription_name: str,
preset_type: str,
resolution_level: int,
def _ensure_resolved_yaml(
sub: Subscription, output_directory: str, preset_type: str, resolution_level: int
) -> None:
output_yaml = sub.resolved_yaml(resolution_level=resolution_level)
out = yaml.safe_load(output_yaml)
expected_out_filename = (
f"{preset_type}/inspect_sub_{ResolutionLevel.name_of(resolution_level)}.json"
)
@ -26,7 +24,7 @@ def compare_resolved_yaml(
if resolution_level > ResolutionLevel.ORIGINAL:
output_path = Path(output_directory)
if "tv_show_directory" in expected_out["overrides"]:
output_path = output_path / subscription_name
output_path = output_path / sub.name
expected_out["output_options"]["output_directory"] = FilePathTruncater.to_native_filepath(
str(output_path)
@ -42,21 +40,6 @@ def compare_resolved_yaml(
assert out == expected_out
def _ensure_resolved_yaml(
sub: Subscription, output_directory: str, preset_type: str, resolution_level: int
) -> None:
output_yaml = sub.resolved_yaml(resolution_level=resolution_level)
out = yaml.safe_load(output_yaml)
compare_resolved_yaml(
out=out,
output_directory=output_directory,
subscription_name=sub.name,
preset_type=preset_type,
resolution_level=resolution_level,
)
@pytest.mark.parametrize("resolution_level", ResolutionLevel.all())
class TestResolution:
def test_resolution_tv_show(

View file

@ -77,46 +77,6 @@ class TestStringFormatterFilePathValidator:
assert len(dir_paths) == 1
assert Path(truncated_file_path) == dir_paths[0]
@pytest.mark.parametrize("ext", ["mp4", "-thumb.jpg"])
def test_truncates_subdirectory_in_file_name_path(self, ext: str):
if "thumb" not in ext: # do not put . in front of -thumb
ext = f".{ext}"
# Mathematical bold unicode characters are 4 bytes each in UTF-8, so a title used
# as a subdirectory can easily exceed the OS file name limit (typically 255 bytes).
long_directory = (
"[2026] 𝗿𝘁𝗶𝘀𝘁 𝗢𝗻𝗲 - 𝗔 𝗩𝗲𝗿𝘆 𝗟𝗼𝗻𝗴 𝗦𝗼𝗻𝗴 𝗧𝗶𝘁𝗹𝗲 "
"𝗧𝗵𝗮𝘁 𝗘𝘅𝗰𝗲𝗲𝗱𝘀 𝗧𝗵𝗲 𝗟𝗶𝗺𝗶𝘁 (𝗘𝘅𝘁𝗲𝗻𝗱𝗲𝗱 𝗠𝗶𝘅 𝘅 "
"𝗦𝗲𝗰𝗼𝗻𝗱 𝗔𝗿𝘁𝗶𝘀𝘁 & 𝗧𝗵𝗶𝗿𝗱 𝗔𝗿𝘁𝗶𝘀𝘁)"
)
# Sanity check that the directory really does exceed the limit
assert len(long_directory.encode("utf-8")) > FilePathTruncater._MAX_BASE_FILE_NAME_BYTES
with tempfile.TemporaryDirectory() as temp_dir:
file_path = str(Path(temp_dir) / long_directory / f"video{ext}")
formatter = StringFormatterFileNameValidator(name="test", value="")
truncated_file_path = formatter.post_process(file_path)
truncated_directory, truncated_file_name = os.path.split(truncated_file_path)
_, truncated_directory_name = os.path.split(truncated_directory)
# Both the subdirectory and the file name must fit within the OS limit
assert (
len(truncated_directory_name.encode("utf-8"))
<= FilePathTruncater._MAX_BASE_FILE_NAME_BYTES
)
assert (
len(truncated_file_name.encode("utf-8"))
<= FilePathTruncater._MAX_BASE_FILE_NAME_BYTES
)
assert truncated_file_name.endswith(ext)
# The original bug raised "OSError: [Errno 36] Filename too long" here because
# the subdirectory component was never truncated.
os.makedirs(truncated_directory, exist_ok=True)
assert os.path.isdir(truncated_directory)
@pytest.mark.parametrize(
"file_name_max_bytes, expected_max",
[

View file

@ -113,12 +113,12 @@ class TestUnstructuredDictFormatterValidator(object):
assert len(validator.dict) == 8
assert all(isinstance(val, expected_formatter_class) for val in validator.dict.values())
assert validator.dict_with_format_strings == {
"key1": "string with { variable }",
"key1": '{ %concat( "string with ", variable ) }',
"key2": "no variables",
"key3": "{ %int(3) }",
"key4": "{ %float(4.132) }",
"key5": "{ %bool(True) }",
"key6": '{ { %concat( variable, "_key" ): "value", "static_key": %concat( variable, "_value" ) } }',
"key7": '{ [ "list_1", %concat( "list_", variable_2 ) ] }',
"key8": "string { variable1 } with multiple { variable2 }",
"key8": '{ %concat( "string ", variable1, " with multiple ", variable2 ) }',
}