[FEATURE] release_date support for date_range (#1231)

Adds the ability to toggle whether to use `upload_date` or `release_date` in the date_range plugin, which is used to cull older videos.

```
date_range:
  type: "upload_date"
```

If using the Only Recent preset, it will be automatically applied based on whether your season/episode ordering uses release date or upload date.

Closes https://github.com/jmbannon/ytdl-sub/issues/1182
This commit is contained in:
Jesse Bannon 2025-06-01 09:28:42 -07:00 committed by GitHub
parent 66fd7cf41c
commit 0bd97dbe96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 237 additions and 22 deletions

View file

@ -151,6 +151,8 @@ granularity possible.
date_range: date_range:
before: "now" before: "now"
after: "today-2weeks" after: "today-2weeks"
breaks: True
type: "upload_date"
``after`` ``after``
@ -183,6 +185,14 @@ granularity possible.
is enabled or not via Boolean. is enabled or not via Boolean.
``type``
:expected type: Optional[OverridesFormatter]
:description:
Which type of date to use. Must be either ``upload_date`` or ``release_date``.
Defaults to ``upload_date``.
---------------------------------------------------------------------------------------------------- ----------------------------------------------------------------------------------------------------
download download

View file

@ -1,5 +1,6 @@
from typing import List from typing import List
from typing import Optional from typing import Optional
from typing import Set
from typing import Tuple from typing import Tuple
from ytdl_sub.config.plugin.plugin import Plugin from ytdl_sub.config.plugin.plugin import Plugin
@ -7,6 +8,11 @@ from ytdl_sub.config.validators.options import ToggleableOptionsDictValidator
from ytdl_sub.utils.datetime import to_date_str from ytdl_sub.utils.datetime import to_date_str
from ytdl_sub.validators.string_datetime import StringDatetimeValidator from ytdl_sub.validators.string_datetime import StringDatetimeValidator
from ytdl_sub.validators.string_formatter_validators import OverridesBooleanFormatterValidator from ytdl_sub.validators.string_formatter_validators import OverridesBooleanFormatterValidator
from ytdl_sub.validators.string_select_validator import OverridesStringSelectValidator
class DateRangeType(OverridesStringSelectValidator):
_select_values: Set[str] = {"upload_date", "release_date"}
class DateRangeOptions(ToggleableOptionsDictValidator): class DateRangeOptions(ToggleableOptionsDictValidator):
@ -30,9 +36,11 @@ class DateRangeOptions(ToggleableOptionsDictValidator):
date_range: date_range:
before: "now" before: "now"
after: "today-2weeks" after: "today-2weeks"
breaks: True
type: "upload_date"
""" """
_optional_keys = {"enable", "before", "after", "breaks"} _optional_keys = {"enable", "before", "after", "breaks", "type"}
def __init__(self, name, value): def __init__(self, name, value):
super().__init__(name, value) super().__init__(name, value)
@ -41,6 +49,7 @@ class DateRangeOptions(ToggleableOptionsDictValidator):
self._breaks = self._validate_key_if_present( self._breaks = self._validate_key_if_present(
"breaks", OverridesBooleanFormatterValidator, default="True" "breaks", OverridesBooleanFormatterValidator, default="True"
) )
self._type = self._validate_key("type", DateRangeType, default="upload_date")
@property @property
def before(self) -> Optional[StringDatetimeValidator]: def before(self) -> Optional[StringDatetimeValidator]:
@ -70,6 +79,16 @@ class DateRangeOptions(ToggleableOptionsDictValidator):
""" """
return self._breaks return self._breaks
@property
def type(self) -> DateRangeType:
"""
:expected type: Optional[OverridesFormatter]
:description:
Which type of date to use. Must be either ``upload_date`` or ``release_date``.
Defaults to ``upload_date``.
"""
return self._type
class DateRangePlugin(Plugin[DateRangeOptions]): class DateRangePlugin(Plugin[DateRangeOptions]):
plugin_options_type = DateRangeOptions plugin_options_type = DateRangeOptions
@ -83,17 +102,18 @@ class DateRangePlugin(Plugin[DateRangeOptions]):
match_filters: List[str] = [] match_filters: List[str] = []
breaking_match_filters: List[str] = [] breaking_match_filters: List[str] = []
date_type: str = self.overrides.apply_formatter(formatter=self.plugin_options.type)
if self.plugin_options.before: if self.plugin_options.before:
before_str = to_date_str( before_str = to_date_str(
date_validator=self.plugin_options.before, overrides=self.overrides date_validator=self.plugin_options.before, overrides=self.overrides
) )
match_filters.append(f"upload_date < {before_str}") match_filters.append(f"{date_type} < {before_str}")
if self.plugin_options.after: if self.plugin_options.after:
after_str = to_date_str( after_str = to_date_str(
date_validator=self.plugin_options.after, overrides=self.overrides date_validator=self.plugin_options.after, overrides=self.overrides
) )
after_filter = f"upload_date >= {after_str}" after_filter = f"{date_type} >= {after_str}"
if self.overrides.evaluate_boolean(self.plugin_options.breaks): if self.overrides.evaluate_boolean(self.plugin_options.breaks):
breaking_match_filters.append(after_filter) breaking_match_filters.append(after_filter)
else: else:

View file

@ -17,6 +17,9 @@ presets:
chapters: chapters:
embed_chapters: True embed_chapters: True
date_range:
type: "{tv_show_date_range_type}"
overrides: overrides:
# MUST DEFINE: # MUST DEFINE:
# tv_show_directory # tv_show_directory
@ -45,3 +48,7 @@ presets:
episode_file_path: "{season_directory_name_sanitized}/{episode_file_name_sanitized}" episode_file_path: "{season_directory_name_sanitized}/{episode_file_name_sanitized}"
thumbnail_file_name: "{episode_file_path}-thumb.jpg" thumbnail_file_name: "{episode_file_path}-thumb.jpg"
episode_year: "{%slice(episode_date_standardized, 0, 4)}" episode_year: "{%slice(episode_date_standardized, 0, 4)}"
# Used to determine which date type to use if `Only Recent` preset
# is applied
tv_show_date_range_type: "upload_date"

View file

@ -117,6 +117,15 @@ presets:
) )
} }
tv_show_date_range_type: >-
{
%if(
%contains(tv_show_by_date_season_ordering, "release"),
"release_date",
"upload_date"
)
}
##################################### season/episode pairing validation ##################################### season/episode pairing validation
"%ordering_pair_eq": >- "%ordering_pair_eq": >-

View file

@ -71,6 +71,16 @@ presets:
) )
} }
tv_show_date_range_type: >-
{
%if(
%contains(tv_show_collection_episode_ordering, "release"),
"release_date",
"upload_date"
)
}
### LEGACY PRESETS ### LEGACY PRESETS
season_by_collection__episode_by_year_month_day: season_by_collection__episode_by_year_month_day:

View file

@ -349,6 +349,34 @@ class SubscriptionDownload(BaseSubscription, ABC):
return self.download_archive.get_file_handler_transaction_log() return self.download_archive.get_file_handler_transaction_log()
def get_ytdl_options(
self, plugins: Optional[List[Plugin]], dry_run: bool
) -> SubscriptionYTDLOptions:
"""
Parameters
----------
plugins
Optional. If not provided, will reinitialize them
dry_run
Whether its dry run or not
Returns
-------
SubscriptionYTDLOptions
Both metadata and download ytdl-options
"""
if plugins is None:
plugins = self._initialize_plugins()
return SubscriptionYTDLOptions(
preset=self._preset_options,
plugins=plugins,
enhanced_download_archive=self.download_archive,
overrides=self.overrides,
working_directory=self.working_directory,
dry_run=dry_run,
)
def download(self, dry_run: bool = False) -> FileHandlerTransactionLog: def download(self, dry_run: bool = False) -> FileHandlerTransactionLog:
""" """
Performs the subscription download Performs the subscription download
@ -368,14 +396,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
logging.info("Skipping %s", self.name) logging.info("Skipping %s", self.name)
return FileHandlerTransactionLog() return FileHandlerTransactionLog()
subscription_ytdl_options = SubscriptionYTDLOptions( subscription_ytdl_options = self.get_ytdl_options(plugins=plugins, dry_run=dry_run)
preset=self._preset_options,
plugins=plugins,
enhanced_download_archive=self.download_archive,
overrides=self.overrides,
working_directory=self.working_directory,
dry_run=dry_run,
)
downloader = MultiUrlDownloader( downloader = MultiUrlDownloader(
options=self.downloader_options, options=self.downloader_options,
@ -420,15 +441,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
self.download_archive.reinitialize(dry_run=dry_run) self.download_archive.reinitialize(dry_run=dry_run)
plugins = self._initialize_plugins() plugins = self._initialize_plugins()
subscription_ytdl_options = self.get_ytdl_options(plugins=plugins, dry_run=dry_run)
subscription_ytdl_options = SubscriptionYTDLOptions(
preset=self._preset_options,
plugins=plugins,
enhanced_download_archive=self.download_archive,
overrides=self.overrides,
working_directory=self.working_directory,
dry_run=dry_run,
)
# Re-add the original downloader class' plugins # Re-add the original downloader class' plugins
plugins.extend( plugins.extend(

View file

@ -1,5 +1,6 @@
from typing import Set from typing import Set
from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator
from ytdl_sub.validators.validators import StringValidator from ytdl_sub.validators.validators import StringValidator
@ -17,3 +18,17 @@ class StringSelectValidator(StringValidator):
raise self._validation_exception( raise self._validation_exception(
f"Must be one of the following values: {', '.join(self._select_values)}" f"Must be one of the following values: {', '.join(self._select_values)}"
) )
class OverridesStringSelectValidator(OverridesStringFormatterValidator):
_expected_value_type_name = "overrides select"
_select_values: Set[str] = set()
def post_process(self, resolved: str) -> str:
if resolved not in self._select_values:
raise self._validation_exception(
f"Must be one of the following values: {', '.join(sorted(self._select_values))}"
)
return resolved

View file

@ -12,6 +12,7 @@ from typing import Callable
from typing import Dict from typing import Dict
from typing import List from typing import List
from typing import Optional from typing import Optional
from typing import Tuple
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
@ -272,3 +273,27 @@ def mock_run_from_cli(args: str) -> List[Subscription]:
args_list = ["ytdl-sub"] + shlex.split(args) args_list = ["ytdl-sub"] + shlex.split(args)
with patch.object(sys, "argv", args_list): with patch.object(sys, "argv", args_list):
return main() return main()
def get_match_filters(
subscription: Subscription, dry_run: bool, download_filters: bool
) -> Tuple[List[str], List[str]]:
"""
Util function to get match filters from a subscription.
Returns
-------
match_filters, breaking_match_filters
"""
options = subscription.get_ytdl_options(plugins=None, dry_run=dry_run)
options_dict = (
options.download_builder().to_dict()
if download_filters
else options.metadata_builder().to_dict()
)
if "match_filter" not in options_dict:
return [], []
match_filter_str = repr(options_dict["match_filter"])
out = eval(match_filter_str.split("(", maxsplit=1)[-1].split(")")[0])
return out

View file

@ -1,6 +1,5 @@
import contextlib import contextlib
import os import os
import shutil
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable
from typing import Dict from typing import Dict

View file

@ -4,7 +4,6 @@ import pytest
from expected_download import assert_expected_downloads from expected_download import assert_expected_downloads
from expected_transaction_log import assert_transaction_log_matches from expected_transaction_log import assert_transaction_log_matches
from ytdl_sub.entries.entry import ytdl_sub_chapters_from_comments
from ytdl_sub.subscriptions.subscription import Subscription from ytdl_sub.subscriptions.subscription import Subscription

View file

View file

@ -0,0 +1,108 @@
import re
from typing import Any
from typing import Dict
import pytest
from conftest import get_match_filters
from ytdl_sub.config.config_file import ConfigFile
from ytdl_sub.subscriptions.subscription import Subscription
from ytdl_sub.utils.exceptions import ValidationException
@pytest.fixture
def preset_dict(output_directory) -> Dict[str, Any]:
return {
"download": "https://your.name.here",
"output_options": {"output_directory": output_directory, "file_name": "will_error.mp4"},
}
class TestDateRange:
@pytest.mark.parametrize("date_range_type", ["upload_date", "release_date"])
def test_date_range_type(
self,
default_config: ConfigFile,
preset_dict: Dict[str, Any],
output_directory: str,
date_range_type: str,
):
preset_dict["date_range"] = {
"before": "20250530",
"after": "20250510",
"type": date_range_type,
}
sub = Subscription.from_dict(
config=default_config,
preset_name="test_date_range",
preset_dict=preset_dict,
)
metadata_filter, metadata_breaking_filter = get_match_filters(
subscription=sub, dry_run=False, download_filters=False
)
assert metadata_filter == [
f"!is_live & !is_upcoming & !post_live & {date_range_type} < 20250530"
]
assert metadata_breaking_filter == [f"{date_range_type} >= 20250510"]
download_filter, download_breaking_filter = get_match_filters(
subscription=sub, dry_run=False, download_filters=True
)
assert not download_filter
assert not download_breaking_filter
def test_date_range_breaks_false(
self,
default_config: ConfigFile,
preset_dict: Dict[str, Any],
output_directory: str,
):
preset_dict["date_range"] = {
"before": "20250530",
"after": "20250510",
"breaks": False,
}
sub = Subscription.from_dict(
config=default_config,
preset_name="test_date_range",
preset_dict=preset_dict,
)
metadata_filter, metadata_breaking_filter = get_match_filters(
subscription=sub, dry_run=False, download_filters=False
)
assert metadata_filter == [
f"!is_live & !is_upcoming & !post_live & upload_date < 20250530 & upload_date >= 20250510"
]
assert not metadata_breaking_filter
download_filter, download_breaking_filter = get_match_filters(
subscription=sub, dry_run=False, download_filters=True
)
assert not download_filter
assert not download_breaking_filter
def test_date_range_invalid_type(
self,
default_config: ConfigFile,
preset_dict: Dict[str, Any],
output_directory: str,
):
preset_dict["date_range"] = {
"before": "20250530",
"after": "20250510",
"type": "no",
}
error_msg = (
"Validation error in test_date_range.date_range.type: "
"Must be one of the following values: release_date, upload_date"
)
with pytest.raises(ValidationException, match=re.escape(error_msg)):
Subscription.from_dict(
config=default_config,
preset_name="test_date_range",
preset_dict=preset_dict,
).download(dry_run=False)