From 00e9ceaf3d4fa90a980e74339b278c226521017a Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Fri, 4 Jul 2025 13:41:54 -0700 Subject: [PATCH] [FEATURE] `Filter Duration` prebuilt preset (#1259) Adds the prebuilt preset ``Filter Duration``, which can include/exclude media based on its duration. Supports the following override variables: * ``filter_duration_min_s`` * ``filter_duration_max_s`` Usage: ``` Plex TV Show by Date | Filter Duration: = Documentaries: "~NOVA PBS": url: "https://www.youtube.com/@novapbs" filter_duration_min_s: 120 # Only download videos at least 2m long = Sports: "~Maple Leafs Highlights": url: "https://www.youtube.com/@NHL" filter_duration_max_s: 180 # Only get highlight videos less than 3m long ``` --- .../prebuilt_presets/common.rst | 11 +- docs/source/prebuilt_presets/helpers.rst | 29 +++++ .../helpers/filter_duration.yaml | 35 ++++++ .../{filtering.yaml => filter_keywords.yaml} | 0 .../prebuilt_presets/test_filter_duration.py | 111 ++++++++++++++++++ .../prebuilt_presets/filter_duration.txt | 66 +++++++++++ 6 files changed, 249 insertions(+), 3 deletions(-) create mode 100644 src/ytdl_sub/prebuilt_presets/helpers/filter_duration.yaml rename src/ytdl_sub/prebuilt_presets/helpers/{filtering.yaml => filter_keywords.yaml} (100%) create mode 100644 tests/integration/prebuilt_presets/test_filter_duration.py create mode 100644 tests/resources/transaction_log_summaries/integration/prebuilt_presets/filter_duration.txt diff --git a/docs/source/config_reference/prebuilt_presets/common.rst b/docs/source/config_reference/prebuilt_presets/common.rst index ab1fd545..9861e813 100644 --- a/docs/source/config_reference/prebuilt_presets/common.rst +++ b/docs/source/config_reference/prebuilt_presets/common.rst @@ -4,10 +4,15 @@ Common .. highlight:: yaml -Filtering -------------- +Filter Keywords +--------------- -.. literalinclude:: /../../src/ytdl_sub/prebuilt_presets/helpers/filtering.yaml +.. literalinclude:: /../../src/ytdl_sub/prebuilt_presets/helpers/filter_keywords.yaml + +Filter Duration +--------------- + +.. literalinclude:: /../../src/ytdl_sub/prebuilt_presets/helpers/filter_duration.yaml Media Quality ------------- diff --git a/docs/source/prebuilt_presets/helpers.rst b/docs/source/prebuilt_presets/helpers.rst index e8e37c31..e545d9ee 100644 --- a/docs/source/prebuilt_presets/helpers.rst +++ b/docs/source/prebuilt_presets/helpers.rst @@ -71,6 +71,35 @@ Supports the following override variables: - "maple leafs" - "highlights" +Filter Duration +--------------- + +``Filter Duration`` can include or exclude media based on its duration. + +Supports the following override variables: + +* ``filter_duration_min_s`` +* ``filter_duration_max_s`` + +.. tip:: + + Use the `~` tilda subscription mode to set a subscription's list override variables. + Tilda mode allows override variables to be set directly underneath it. + + .. code-block:: yaml + + Plex TV Show by Date | Filter Duration: + + = Documentaries: + "~NOVA PBS": + url: "https://www.youtube.com/@novapbs" + filter_duration_min_s: 120 # Only download videos at least 2m long + + = Sports: + "~Maple Leafs Highlights": + url: "https://www.youtube.com/@NHL" + filter_duration_max_s: 180 # Only get highlight videos less than 3m long + Chunk Downloads --------------- diff --git a/src/ytdl_sub/prebuilt_presets/helpers/filter_duration.yaml b/src/ytdl_sub/prebuilt_presets/helpers/filter_duration.yaml new file mode 100644 index 00000000..e763fff5 --- /dev/null +++ b/src/ytdl_sub/prebuilt_presets/helpers/filter_duration.yaml @@ -0,0 +1,35 @@ +presets: + + ############################################################################# + # Include Keywords + # Include or exclude media with any of the listed keywords in their titles + # Keywords will check a lower-cased title or description + Filter Duration: + overrides: + filter_duration_min_s: 0 + filter_duration_max_s: 4294967296 + + "%filter_duration_ensure_numeric": >- + { + %assert_then( + %is_numeric($0), + $0, + "filter_duration args must be numeric" + ) + } + + filter_duration_zero_msg: "Duration metadata for {title} is missing, cannot perform filter." + + "%filter_duration_eval": >- + { + %if( + %eq(duration, 0), + %print(filter_duration_zero_msg, False) + $0 + ) + } + + + filter_exclude: + - "{ %filter_duration_eval( %lt(duration, %filter_duration_ensure_numeric(filter_duration_min_s)) ) }" + - "{ %filter_duration_eval( %gt(duration, %filter_duration_ensure_numeric(filter_duration_max_s)) ) }" diff --git a/src/ytdl_sub/prebuilt_presets/helpers/filtering.yaml b/src/ytdl_sub/prebuilt_presets/helpers/filter_keywords.yaml similarity index 100% rename from src/ytdl_sub/prebuilt_presets/helpers/filtering.yaml rename to src/ytdl_sub/prebuilt_presets/helpers/filter_keywords.yaml diff --git a/tests/integration/prebuilt_presets/test_filter_duration.py b/tests/integration/prebuilt_presets/test_filter_duration.py new file mode 100644 index 00000000..7faf123f --- /dev/null +++ b/tests/integration/prebuilt_presets/test_filter_duration.py @@ -0,0 +1,111 @@ +import pytest +from expected_transaction_log import assert_transaction_log_matches + +from ytdl_sub.script.utils.exceptions import UserThrownRuntimeError +from ytdl_sub.subscriptions.subscription import Subscription + + +@pytest.fixture +def filter_subscription_dict(output_directory): + return { + "preset": [ + "Plex TV Show by Date", + "Filter Duration", + ], + "overrides": {"url": "https://your.name.here", "tv_show_directory": output_directory}, + } + + +class TestFilterKeywords: + + def test_no_overrides( + self, + config, + filter_subscription_dict, + output_directory, + subscription_name, + mock_download_collection_entries, + ): + subscription = Subscription.from_dict( + config=config, + preset_name=subscription_name, + preset_dict=filter_subscription_dict, + ) + + with mock_download_collection_entries( + is_youtube_channel=False, num_urls=1, is_dry_run=True + ): + transaction_log = subscription.download(dry_run=True) + + assert_transaction_log_matches( + output_directory=output_directory, + transaction_log=transaction_log, + transaction_log_summary_file_name=f"integration/prebuilt_presets/filter_keywords_empty.txt", + ) + + @pytest.mark.parametrize( + "filter_eval, filter_value, filters_all", + [ + ("min", 10, False), + ("max", 100, False), + ("min", 100, True), + ("max", 10, True), + ], + ) + def test_filter_duration( + self, + config, + filter_subscription_dict, + output_directory, + subscription_name, + mock_download_collection_entries, + filter_eval: str, + filter_value: int, + filters_all: bool, + ): + filter_subscription_dict["overrides"][f"filter_duration_{filter_eval}_s"] = filter_value + subscription = Subscription.from_dict( + config=config, + preset_name=subscription_name, + preset_dict=filter_subscription_dict, + ) + + with mock_download_collection_entries( + is_youtube_channel=False, num_urls=1, is_dry_run=True + ): + transaction_log = subscription.download(dry_run=True) + + if filters_all: + assert transaction_log.is_empty + else: + assert_transaction_log_matches( + output_directory=output_directory, + transaction_log=transaction_log, + transaction_log_summary_file_name=f"integration/prebuilt_presets/filter_duration.txt", + ) + + @pytest.mark.parametrize( + "filter_eval", + ["min", "max"], + ) + def test_error_not_numeric( + self, + config, + filter_subscription_dict, + output_directory, + subscription_name, + mock_download_collection_entries, + filter_eval, + ): + filter_subscription_dict["overrides"][f"filter_duration_{filter_eval}_s"] = "egg" + subscription = Subscription.from_dict( + config=config, + preset_name=subscription_name, + preset_dict=filter_subscription_dict, + ) + + with ( + mock_download_collection_entries(is_youtube_channel=False, num_urls=1, is_dry_run=True), + pytest.raises(UserThrownRuntimeError, match=f"filter_duration args must be numeric"), + ): + _ = subscription.download(dry_run=True) diff --git a/tests/resources/transaction_log_summaries/integration/prebuilt_presets/filter_duration.txt b/tests/resources/transaction_log_summaries/integration/prebuilt_presets/filter_duration.txt new file mode 100644 index 00000000..8c3ebf9a --- /dev/null +++ b/tests/resources/transaction_log_summaries/integration/prebuilt_presets/filter_duration.txt @@ -0,0 +1,66 @@ +Files created: +---------------------------------------- +{output_directory} + .ytdl-sub-subscription_test-download-archive.json +{output_directory}/Season 2020 + s2020.e080701 - Mock Entry 20-3-thumb.jpg + s2020.e080701 - Mock Entry 20-3.info.json + s2020.e080701 - Mock Entry 20-3.mp4 + Video Tags: + contentRating: TV-14 + date: 2020-08-07 + episode_id: 80701 + genre: ytdl-sub + show: subscription_test + synopsis: + https://20-3.com + + The Description + title: 2020-08-07 - Mock Entry 20-3 + year: 2020 + s2020.e080801 - Mock Entry 20-2-thumb.jpg + s2020.e080801 - Mock Entry 20-2.info.json + s2020.e080801 - Mock Entry 20-2.mp4 + Video Tags: + contentRating: TV-14 + date: 2020-08-08 + episode_id: 80801 + genre: ytdl-sub + show: subscription_test + synopsis: + https://20-2.com + + The Description + title: 2020-08-08 - Mock Entry 20-2 + year: 2020 + s2020.e080802 - Mock Entry 20-1-thumb.jpg + s2020.e080802 - Mock Entry 20-1.info.json + s2020.e080802 - Mock Entry 20-1.mp4 + Video Tags: + contentRating: TV-14 + date: 2020-08-08 + episode_id: 80802 + genre: ytdl-sub + show: subscription_test + synopsis: + https://20-1.com + + The Description + title: 2020-08-08 - Mock Entry 20-1 + year: 2020 +{output_directory}/Season 2021 + s2021.e080801 - Mock Entry 21-1-thumb.jpg + s2021.e080801 - Mock Entry 21-1.info.json + s2021.e080801 - Mock Entry 21-1.mp4 + Video Tags: + contentRating: TV-14 + date: 2021-08-08 + episode_id: 80801 + genre: ytdl-sub + show: subscription_test + synopsis: + https://21-1.com + + The Description + title: 2021-08-08 - Mock Entry 21-1 + year: 2021 \ No newline at end of file