From 29398862da31198fd37062ead0a5dc6519869b7b Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Thu, 18 Jan 2024 19:00:19 -0800 Subject: [PATCH 01/39] [BUGFIX] Fix YouTube channels iterating 2x entries (#903) A recent feature to grab channel artwork for playlists caused downloading channels to misreport the number of entries it was downloading. This change fixes that --- src/ytdl_sub/downloaders/ytdlp.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/ytdl_sub/downloaders/ytdlp.py b/src/ytdl_sub/downloaders/ytdlp.py index c0ac01e0..42f3d865 100644 --- a/src/ytdl_sub/downloaders/ytdlp.py +++ b/src/ytdl_sub/downloaders/ytdlp.py @@ -248,20 +248,22 @@ class YTDLP: continue cls.logger.debug("Attempting to get parent metadata from URL %s", uploader_url) + parent_dict: Optional[Dict] = None try: parent_dict = cls.extract_info( ytdl_options_overrides=ytdl_options_overrides | {"playlist_items": "0:0"}, url=uploader_url, ) except Exception: # pylint: disable=broad-except - # Do not try this uploader_id again - entry_ids.add(uploader_id) - break + pass - if isinstance(parent_dict, dict): - parent_id = parent_dict.get("id") + parent_id = parent_dict.get("id") if isinstance(parent_dict, dict) else None + if parent_id and parent_id not in entry_ids: parent_dicts.append(parent_dict) - entry_ids |= {uploader_id, parent_id} + entry_ids.add(parent_id) cls.logger.debug("Adding parent metadata with ids [%s, %s]", uploader_id, parent_id) + # Always add the uploader_id since it has been tried + entry_ids.add(uploader_id) + return entry_dicts + parent_dicts From 213580ee8441a24a4f9cbf4e1ff6683a710c9819 Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Thu, 18 Jan 2024 23:24:10 -0800 Subject: [PATCH 02/39] [DEV] Add type-check functions (#905) --- .../scripting/scripting_functions.rst | 49 +++++++++++++++ .../script/functions/boolean_functions.py | 60 +++++++++++++++++++ .../functions/test_boolean_functions.py | 31 +++++++++- 3 files changed, 138 insertions(+), 2 deletions(-) diff --git a/docs/source/config_reference/scripting/scripting_functions.rst b/docs/source/config_reference/scripting/scripting_functions.rst index 1e62d137..9302e10b 100644 --- a/docs/source/config_reference/scripting/scripting_functions.rst +++ b/docs/source/config_reference/scripting/scripting_functions.rst @@ -172,6 +172,41 @@ gte :description: ``>=`` operator. Returns True if left >= right. False otherwise. +is_array +~~~~~~~~ +:spec: ``is_array(value: AnyArgument) -> Boolean`` + +:description: + Returns True if a value is a Map. False otherwise. + +is_bool +~~~~~~~ +:spec: ``is_bool(value: AnyArgument) -> Boolean`` + +:description: + Returns True if a value is a Float. False otherwise. + +is_float +~~~~~~~~ +:spec: ``is_float(value: AnyArgument) -> Boolean`` + +:description: + Returns True if a value is a Float. False otherwise. + +is_int +~~~~~~ +:spec: ``is_int(value: AnyArgument) -> Boolean`` + +:description: + Returns True if a value is an Integer. False otherwise. + +is_map +~~~~~~ +:spec: ``is_map(value: AnyArgument) -> Boolean`` + +:description: + Returns True if a value is a Map. False otherwise. + is_null ~~~~~~~ :spec: ``is_null(value: AnyArgument) -> Boolean`` @@ -179,6 +214,20 @@ is_null :description: Returns True if a value is null (i.e. an empty string). False otherwise. +is_numeric +~~~~~~~~~~ +:spec: ``is_numeric(value: AnyArgument) -> Boolean`` + +:description: + Returns True if a value is either an Integer or Float. False otherwise. + +is_string +~~~~~~~~~ +:spec: ``is_string(value: AnyArgument) -> Boolean`` + +:description: + Returns True if a value is a String. False otherwise. + lt ~~ :spec: ``lt(left: AnyArgument, right: AnyArgument) -> Boolean`` diff --git a/src/ytdl_sub/script/functions/boolean_functions.py b/src/ytdl_sub/script/functions/boolean_functions.py index bfb7416f..f2c89e28 100644 --- a/src/ytdl_sub/script/functions/boolean_functions.py +++ b/src/ytdl_sub/script/functions/boolean_functions.py @@ -1,5 +1,9 @@ +from ytdl_sub.script.types.array import Array +from ytdl_sub.script.types.map import Map from ytdl_sub.script.types.resolvable import AnyArgument from ytdl_sub.script.types.resolvable import Boolean +from ytdl_sub.script.types.resolvable import Float +from ytdl_sub.script.types.resolvable import Integer from ytdl_sub.script.types.resolvable import String # pylint: disable=invalid-name @@ -107,3 +111,59 @@ class BooleanFunctions: Returns True if a value is null (i.e. an empty string). False otherwise. """ return Boolean(isinstance(value, String) and value.value == "") + + @staticmethod + def is_map(value: AnyArgument) -> Boolean: + """ + :description: + Returns True if a value is a Map. False otherwise. + """ + return Boolean(isinstance(value, Map)) + + @staticmethod + def is_array(value: AnyArgument) -> Boolean: + """ + :description: + Returns True if a value is a Map. False otherwise. + """ + return Boolean(isinstance(value, Array)) + + @staticmethod + def is_string(value: AnyArgument) -> Boolean: + """ + :description: + Returns True if a value is a String. False otherwise. + """ + return Boolean(isinstance(value, String)) + + @staticmethod + def is_numeric(value: AnyArgument) -> Boolean: + """ + :description: + Returns True if a value is either an Integer or Float. False otherwise. + """ + return Boolean(isinstance(value, (Integer, Float))) + + @staticmethod + def is_int(value: AnyArgument) -> Boolean: + """ + :description: + Returns True if a value is an Integer. False otherwise. + """ + return Boolean(isinstance(value, Integer)) + + @staticmethod + def is_float(value: AnyArgument) -> Boolean: + """ + :description: + Returns True if a value is a Float. False otherwise. + """ + return Boolean(isinstance(value, Float)) + + @staticmethod + def is_bool(value: AnyArgument) -> Boolean: + """ + :description: + Returns True if a value is a Float. False otherwise. + """ + return Boolean(isinstance(value, Boolean)) diff --git a/tests/unit/script/functions/test_boolean_functions.py b/tests/unit/script/functions/test_boolean_functions.py index dfdd79c6..a141a258 100644 --- a/tests/unit/script/functions/test_boolean_functions.py +++ b/tests/unit/script/functions/test_boolean_functions.py @@ -1,8 +1,6 @@ import pytest from unit.script.conftest import single_variable_output -from ytdl_sub.script.script import Script - class TestBooleanFunctions: @pytest.mark.parametrize( @@ -140,3 +138,32 @@ class TestBooleanFunctions: def test_is_null(self, value: str, expected_output: bool): output = single_variable_output(f"{{%is_null({value})}}") assert output == expected_output + + def test_is_array(self): + assert single_variable_output("{ %is_array( [] ) }") is True + assert single_variable_output("{ %is_array( {} ) }") is False + + def test_is_map(self): + assert single_variable_output("{ %is_map( {} ) }") is True + assert single_variable_output("{ %is_map( [] ) }") is False + + def test_is_string(self): + assert single_variable_output("{ %is_string( 'hi' ) }") is True + assert single_variable_output("{ %is_string( False ) }") is False + + def test_is_bool(self): + assert single_variable_output("{ %is_bool( True ) }") is True + assert single_variable_output("{ %is_bool( 0 ) }") is False + + def test_is_int(self): + assert single_variable_output("{ %is_int( 2 ) }") is True + assert single_variable_output("{ %is_int( 2.3 ) }") is False + + def test_is_float(self): + assert single_variable_output("{ %is_float( 3.14 ) }") is True + assert single_variable_output("{ %is_float( 4 ) }") is False + + def test_is_numeric(self): + assert single_variable_output("{ %is_numeric( 4 ) }") is True + assert single_variable_output("{ %is_numeric( 2.34 ) }") is True + assert single_variable_output("{ %is_numeric( '3.12' ) }") is False From b3374cb4d5e18ebcc564761c0a88cc9ef14f1b79 Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Fri, 19 Jan 2024 00:51:44 -0800 Subject: [PATCH 03/39] [DEV] `%map_extend` scripting function (#906) * [DEV] `%map_extend` scripting function * lint --- .../scripting/scripting_functions.rst | 8 ++++++++ src/ytdl_sub/script/functions/map_functions.py | 14 ++++++++++++++ tests/unit/script/functions/test_map_functions.py | 12 ++++++++++++ 3 files changed, 34 insertions(+) diff --git a/docs/source/config_reference/scripting/scripting_functions.rst b/docs/source/config_reference/scripting/scripting_functions.rst index 9302e10b..db773b17 100644 --- a/docs/source/config_reference/scripting/scripting_functions.rst +++ b/docs/source/config_reference/scripting/scripting_functions.rst @@ -415,6 +415,14 @@ map_enumerate Apply a lambda function on the Map, where each arg passed to the lambda function is ``idx, key, value`` as three separate args. +map_extend +~~~~~~~~~~ +:spec: ``map_extend(maps: Map, ...) -> Map`` + +:description: + Return maps combined in the order from left-to-right. Duplicate keys will use the + right-most map's value. + map_get ~~~~~~~ :spec: ``map_get(mapping: Map, key: AnyArgument, default: Optional[AnyArgument]) -> AnyArgument`` diff --git a/src/ytdl_sub/script/functions/map_functions.py b/src/ytdl_sub/script/functions/map_functions.py index b34fa611..6d93d31b 100644 --- a/src/ytdl_sub/script/functions/map_functions.py +++ b/src/ytdl_sub/script/functions/map_functions.py @@ -1,3 +1,4 @@ +from typing import Dict from typing import Optional from ytdl_sub.script.types.array import Array @@ -66,6 +67,19 @@ class MapFunctions: ) return mapping.value[key] + @staticmethod + def map_extend(*maps: Map) -> Map: + """ + :description: + Return maps combined in the order from left-to-right. Duplicate keys will use the + right-most map's value. + """ + output_dict: Dict = {} + for map_i in maps: + output_dict |= map_i.value + + return Map(output_dict) + @staticmethod def map_get_non_empty(mapping: Map, key: AnyArgument, default: AnyArgument) -> AnyArgument: """ diff --git a/tests/unit/script/functions/test_map_functions.py b/tests/unit/script/functions/test_map_functions.py index 709445e6..befc611e 100644 --- a/tests/unit/script/functions/test_map_functions.py +++ b/tests/unit/script/functions/test_map_functions.py @@ -138,3 +138,15 @@ class TestMapFunctions: FunctionRuntimeException, match="Tried and failed to cast Integer as a Map" ): single_variable_output("{%map(1)}") + + def test_map_extend(self): + output = single_variable_output( + """{ + %map_extend( + {'key': 'value', 1: 3}, + {'key': 'override'} + {'new': [1, 2]} + ) + }""" + ) + assert output == {"key": "override", "new": [1, 2], 1: 3} From 29616144b4223dc5fb12892610ed53d1e0aec56c Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Sat, 20 Jan 2024 11:16:17 -0800 Subject: [PATCH 04/39] [BUGFIX] Fix multiple top-level parents for linked VEVO YouTube channels (#909) Fixes https://github.com/jmbannon/ytdl-sub/issues/908 - when channel URLs return videos from other channels --- src/ytdl_sub/entries/entry_parent.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/ytdl_sub/entries/entry_parent.py b/src/ytdl_sub/entries/entry_parent.py index 232b2ab4..797c4d62 100644 --- a/src/ytdl_sub/entries/entry_parent.py +++ b/src/ytdl_sub/entries/entry_parent.py @@ -4,6 +4,7 @@ from typing import Dict from typing import List from typing import Optional from typing import Set +from urllib.parse import urlparse from ytdl_sub.entries.base_entry import BaseEntry from ytdl_sub.entries.base_entry import TBaseEntry @@ -152,7 +153,12 @@ class EntryParent(BaseEntry): """ def _url_matches(parent: "EntryParent"): - return parent.webpage_url in url or url in parent.webpage_url + url_parsed = urlparse(url) + parent_parsed = urlparse(parent.webpage_url) + return ( + url_parsed.hostname == parent_parsed.hostname + and url_parsed.path == parent_parsed.path + ) def _uid_is_uploader_id(parent: "EntryParent"): return parent.uid == parent.uploader_id @@ -168,18 +174,10 @@ class EntryParent(BaseEntry): if len(top_level_parents) > 1: top_level_parents = [parent for parent in top_level_parents if _url_matches(parent)] - match len(top_level_parents): - case 0: - return None - case 1: - return top_level_parents[0] - case 2: - # Channels can have two of the same .info.json. Handle it here - top0 = top_level_parents[0] - top1 = top_level_parents[1] - if top0.uploader_id == top1.uploader_id: - return top0 if not top0.webpage_url.endswith("/videos") else top1 - + if len(top_level_parents) == 0: + return None + if len(top_level_parents) == 1: + return top_level_parents[0] raise ValueError( "Detected multiple top-level parents. " "Please file an issue on GitHub with the URLs used to produce this error" From cea21ca47f190cbc82cdf51c4fa0ec4cddebbcde Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Fri, 2 Feb 2024 22:52:17 -0800 Subject: [PATCH 05/39] [DOCS] Add more configuration docs (#916) --- .../prebuilt_presets/common.rst | 15 +++++++ .../prebuilt_presets/helpers_common.rst | 7 ---- .../prebuilt_presets/helpers_players.rst | 7 ---- .../prebuilt_presets/helpers_url.rst | 7 ---- .../prebuilt_presets/index.rst | 8 ++-- .../prebuilt_presets/music.rst | 9 +++++ .../prebuilt_presets/tv_show.rst | 9 +++++ .../advanced_configuration.rst | 35 ++++++++++++++++ .../guides/getting_started/first_config.rst | 8 ++++ .../helpers/download_deletion_options.yaml | 40 ++++++++++++------- .../helpers/media_quality.yaml | 22 +++++++++- 11 files changed, 125 insertions(+), 42 deletions(-) create mode 100644 docs/source/config_reference/prebuilt_presets/common.rst delete mode 100644 docs/source/config_reference/prebuilt_presets/helpers_common.rst delete mode 100644 docs/source/config_reference/prebuilt_presets/helpers_players.rst delete mode 100644 docs/source/config_reference/prebuilt_presets/helpers_url.rst create mode 100644 docs/source/config_reference/prebuilt_presets/music.rst create mode 100644 docs/source/config_reference/prebuilt_presets/tv_show.rst diff --git a/docs/source/config_reference/prebuilt_presets/common.rst b/docs/source/config_reference/prebuilt_presets/common.rst new file mode 100644 index 00000000..0a6c8e8a --- /dev/null +++ b/docs/source/config_reference/prebuilt_presets/common.rst @@ -0,0 +1,15 @@ +======================= +Common +======================= + +.. highlight:: yaml + +Media Quality +------------- + +.. literalinclude:: /../../src/ytdl_sub/prebuilt_presets/helpers/media_quality.yaml + +Only Recent Videos +------------------ + +.. literalinclude:: /../../src/ytdl_sub/prebuilt_presets/helpers/download_deletion_options.yaml \ No newline at end of file diff --git a/docs/source/config_reference/prebuilt_presets/helpers_common.rst b/docs/source/config_reference/prebuilt_presets/helpers_common.rst deleted file mode 100644 index 3dc5e105..00000000 --- a/docs/source/config_reference/prebuilt_presets/helpers_common.rst +++ /dev/null @@ -1,7 +0,0 @@ -======================= -Common Preset Reference -======================= - -.. highlight:: yaml - -.. literalinclude:: /../../src/ytdl_sub/prebuilt_presets/helpers/common.yaml diff --git a/docs/source/config_reference/prebuilt_presets/helpers_players.rst b/docs/source/config_reference/prebuilt_presets/helpers_players.rst deleted file mode 100644 index 404c38ab..00000000 --- a/docs/source/config_reference/prebuilt_presets/helpers_players.rst +++ /dev/null @@ -1,7 +0,0 @@ -======================== -Players Preset Reference -======================== - -.. highlight:: yaml - -.. literalinclude:: /../../src/ytdl_sub/prebuilt_presets/helpers/players.yaml diff --git a/docs/source/config_reference/prebuilt_presets/helpers_url.rst b/docs/source/config_reference/prebuilt_presets/helpers_url.rst deleted file mode 100644 index b6928288..00000000 --- a/docs/source/config_reference/prebuilt_presets/helpers_url.rst +++ /dev/null @@ -1,7 +0,0 @@ -==================== -URL Preset Reference -==================== - -.. highlight:: yaml - -.. literalinclude:: /../../src/ytdl_sub/prebuilt_presets/helpers/url.yaml diff --git a/docs/source/config_reference/prebuilt_presets/index.rst b/docs/source/config_reference/prebuilt_presets/index.rst index 9af49b74..d50cc51d 100644 --- a/docs/source/config_reference/prebuilt_presets/index.rst +++ b/docs/source/config_reference/prebuilt_presets/index.rst @@ -6,7 +6,7 @@ This section contains the code for the prebuilt presets. If you just want to und -.. toctree:: - helpers_common - helpers_players - helpers_url \ No newline at end of file +.. toctree:: + common + tv_show + music \ No newline at end of file diff --git a/docs/source/config_reference/prebuilt_presets/music.rst b/docs/source/config_reference/prebuilt_presets/music.rst new file mode 100644 index 00000000..dde4c28e --- /dev/null +++ b/docs/source/config_reference/prebuilt_presets/music.rst @@ -0,0 +1,9 @@ +===== +Music +===== + +All audio music based presets inherit from ``_music_base``. + +.. highlight:: yaml + +.. literalinclude:: /../../src/ytdl_sub/prebuilt_presets/music/singles.yaml \ No newline at end of file diff --git a/docs/source/config_reference/prebuilt_presets/tv_show.rst b/docs/source/config_reference/prebuilt_presets/tv_show.rst new file mode 100644 index 00000000..4cb993a3 --- /dev/null +++ b/docs/source/config_reference/prebuilt_presets/tv_show.rst @@ -0,0 +1,9 @@ +======================== +TV Show +======================== + +All TV show based presets inherit from ``_episode_base``. + +.. highlight:: yaml + +.. literalinclude:: /../../src/ytdl_sub/prebuilt_presets/tv_show/episode.yaml diff --git a/docs/source/guides/getting_started/advanced_configuration.rst b/docs/source/guides/getting_started/advanced_configuration.rst index 385ed3b7..e234b77a 100644 --- a/docs/source/guides/getting_started/advanced_configuration.rst +++ b/docs/source/guides/getting_started/advanced_configuration.rst @@ -16,6 +16,41 @@ The layout of the ``config.yaml`` file is relatively straightforward: plugin1: plugin1_option1: value1 +This creates a preset named ``preset_name``, which contains the made-up +:doc:`plugin ` ``plugin1``. Under each plugin are its settings. +A preset can contain multiple plugins. Preset Inheritance ------------------ + +You can modularize your presets via preset inheritance. For example, + +.. code-block:: yaml + + presets: + + TV Show: + presets: + - "Jellyfin TV Show by Date" + + overrides: + tv_show_directory: "/ytdl_sub_tv_shows" + + TV Show Only Recent: + presets: + - "TV Show" + - "Only Recent" + + overrides: + only_recent_date_range: "3weeks" + +This creates two presets: + +* ``TV Show`` + * Inherits the :doc:`prebuilt ` ``Jellyfin TV Show by Date`` preset + * Sets the output tv show directory +* ``TV Show Only Recent`` + * Inherits the ``TV Show`` preset made above it and the ``Only Recent`` prebuilt preset + * Sets only_recent preset to only keep the last 3 weeks worth of videos + +Inheritance makes it easy to extend existing presets to include logic for your specific needs. diff --git a/docs/source/guides/getting_started/first_config.rst b/docs/source/guides/getting_started/first_config.rst index 3deac497..a1bfbef8 100644 --- a/docs/source/guides/getting_started/first_config.rst +++ b/docs/source/guides/getting_started/first_config.rst @@ -9,6 +9,14 @@ Your first configuration will look pretty simple: configuration: working_directory: '.ytdl-sub-downloads' + presets: + TV Show: + preset: + - "Jellyfin TV Show by Date" + - "Only Recent" + + overrides: + tv_show_directory: "/ytdl_sub_tv_shows" The first two lines in this ``config.yaml`` file are the ``configuration``, and define the ``working_directory``, which is described near the bottom of :ref:`this section ` diff --git a/src/ytdl_sub/prebuilt_presets/helpers/download_deletion_options.yaml b/src/ytdl_sub/prebuilt_presets/helpers/download_deletion_options.yaml index ffb0f596..b62f73cd 100644 --- a/src/ytdl_sub/prebuilt_presets/helpers/download_deletion_options.yaml +++ b/src/ytdl_sub/prebuilt_presets/helpers/download_deletion_options.yaml @@ -1,18 +1,8 @@ presets: - "Only Recent": - preset: - - "Only Recent Archive" - # Only fetch videos after today minus date_range - - # Only keep files uploaded after date_range - output_options: - keep_files_after: "today-{only_recent_date_range}" - keep_max_files: "{only_recent_max_files}" - - overrides: - only_recent_max_files: 0 - + ############################################################################# + # Only Recent Archive + # Downloads only `date_range` amount of videos (no deletion) "Only Recent Archive": # Only fetch videos after today minus date_range @@ -23,9 +13,29 @@ presets: overrides: date_range: "2months" # keep for legacy-reasons only_recent_date_range: "{date_range}" - - chunk_initial_download: + ############################################################################# + # Only Recent + # Downloads only `date_range` amount of videos and deletes older videos + # that fall out of that range + + "Only Recent": + preset: + - "Only Recent Archive" + + output_options: + keep_files_after: "today-{only_recent_date_range}" + keep_max_files: "{only_recent_max_files}" + + overrides: + only_recent_max_files: 0 + + ############################################################################# + # Download in Chunks + # Will only download 20 videos per invocation of ytdl-sub, starting + # at the very beginning of the channel + + chunk_initial_download: # legacy preset name ytdl_options: max_downloads: 20 playlistreverse: True diff --git a/src/ytdl_sub/prebuilt_presets/helpers/media_quality.yaml b/src/ytdl_sub/prebuilt_presets/helpers/media_quality.yaml index 7bb5e53d..a4c35dc9 100644 --- a/src/ytdl_sub/prebuilt_presets/helpers/media_quality.yaml +++ b/src/ytdl_sub/prebuilt_presets/helpers/media_quality.yaml @@ -1,5 +1,9 @@ presets: + ############################################################################# + # Best Video Quality + # Gets the best available quality + best_video_quality: format: "bestvideo+bestaudio/best" ytdl_options: @@ -9,18 +13,26 @@ presets: preset: - best_video_quality + ############################################################################# + # Max 2160p "Max 2160p": format: "(bv*[height<=2160]+bestaudio/best[height<=2160])" ytdl_options: merge_output_format: "mp4" + ############################################################################# + # Max 1440p + "Max 1440p": format: "(bv*[height<=1440]+bestaudio/best[height<=1440])" ytdl_options: merge_output_format: "mp4" - max_1080p: + ############################################################################# + # Max 1080p + + max_1080p: # legacy name format: "(bv*[height<=1080]+bestaudio/best[height<=1080])" ytdl_options: merge_output_format: "mp4" @@ -28,12 +40,18 @@ presets: "Max 1080p": preset: - max_1080p - + + ############################################################################# + # Max 720p + "Max 720p": format: "(bv*[height<=720]+bestaudio/best[height<=720])" ytdl_options: merge_output_format: "mp4" + ############################################################################# + # Max 480p + "Max 480p": format: "(bv*[height<=480]+bestaudio/best[height<=480])" ytdl_options: From 7c217db843fc2633217baa46bb7c10983425cf06 Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Mon, 12 Feb 2024 16:05:50 -0800 Subject: [PATCH 06/39] [BUGFIX] Fix usage of `chapter_title_sanitized` (#924) Usage of `chapter_title_sanitized` would sometimes result in an error. This should hopefully fix it --- src/ytdl_sub/plugins/split_by_chapters.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ytdl_sub/plugins/split_by_chapters.py b/src/ytdl_sub/plugins/split_by_chapters.py index b812c0b9..9233babf 100644 --- a/src/ytdl_sub/plugins/split_by_chapters.py +++ b/src/ytdl_sub/plugins/split_by_chapters.py @@ -89,7 +89,6 @@ class SplitByChaptersOptions(OptionsDictValidator): return { PluginOperation.MODIFY_ENTRY: { "chapter_title", - "chapter_title_sanitized", "chapter_index", "chapter_index_padded", "chapter_count", From b95ba862797327104a0d69deb01d01b0f29e2044 Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Sat, 17 Feb 2024 08:19:21 -0800 Subject: [PATCH 07/39] [BUGFIX] Use epoch_date if upload_date is missing (#927) Fixes https://github.com/jmbannon/ytdl-sub/issues/912 If a required variable (like uid) is missing, ytdl-sub will error. This bugfix prevents sites that do not provide an upload date from erroring. --- src/ytdl_sub/entries/script/variable_definitions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ytdl_sub/entries/script/variable_definitions.py b/src/ytdl_sub/entries/script/variable_definitions.py index d06b015f..a24f0aa7 100644 --- a/src/ytdl_sub/entries/script/variable_definitions.py +++ b/src/ytdl_sub/entries/script/variable_definitions.py @@ -394,7 +394,9 @@ class UploadDateVariableDefinitions(ABC): :description: The entry’s uploaded date, in YYYYMMDD format. If not present, return today’s date. """ - return StringDateMetadataVariable.from_entry(metadata_key="upload_date").as_date_variable() + return StringDateMetadataVariable.from_entry( + metadata_key="upload_date", default=self.epoch_date + ).as_date_variable() @cached_property def upload_year(self: "VariableDefinitions") -> IntegerVariable: From 248e9a15a68aa1d12f9745b34dcacf9ac1a2bd76 Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Sat, 17 Feb 2024 08:53:06 -0800 Subject: [PATCH 08/39] [DOCS] Various doc cleanups (#928) --- README.md | 2 +- docker/root/defaults/config.yaml | 4 +-- docs/source/conf.py | 6 ++-- docs/source/config_reference/plugins.rst | 2 +- docs/source/faq/index.rst | 12 +++++++ docs/source/guides/getting_started/index.rst | 36 ++++++++++++++------ docs/source/prebuilt_presets/tv_shows.rst | 6 ++-- src/ytdl_sub/plugins/date_range.py | 2 +- 8 files changed, 49 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index f2ba4fd5..15036dff 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ music_videos/ ## Custom Configs Any part of this process is modifiable by using custom configs. See our -[walk-through guide](https://github.com/jmbannon/ytdl-sub/wiki) +[walk-through guide](https://ytdl-sub.readthedocs.io/en/latest/guides/index.html) on how to build your first config from scratch. Ready-to-use [example configurations](https://github.com/jmbannon/ytdl-sub/tree/master/examples) can be found here alongside our diff --git a/docker/root/defaults/config.yaml b/docker/root/defaults/config.yaml index 6906f357..9a55063e 100644 --- a/docker/root/defaults/config.yaml +++ b/docker/root/defaults/config.yaml @@ -1,8 +1,8 @@ # Bare-bones config. Here are some useful links to get started: -# Walk-through Guide: https://github.com/jmbannon/ytdl-sub/wiki/1.-Introduction +# Walk-through Guide: https://ytdl-sub.readthedocs.io/en/latest/guides/index.html # Config Examples: https://github.com/jmbannon/ytdl-sub/tree/master/examples # Prebuilt Presets: https://ytdl-sub.readthedocs.io/en/latest/presets.html -# Config Docs: https://ytdl-sub.readthedocs.io/en/latest/config.html +# Config Reference: https://ytdl-sub.readthedocs.io/en/latest/config_reference/index.html # # The subscriptions in `subscriptions.yaml` uses prebuilt presets which do not require # any additions to this config. They can be downloaded using the command: diff --git a/docs/source/conf.py b/docs/source/conf.py index 7e551b67..f754815a 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -7,9 +7,9 @@ # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information project = "ytdl-sub" -copyright = "2023, Jesse Bannon" +copyright = "2024, Jesse Bannon" author = "Jesse Bannon" -release = "2023.12.15" +release = "" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration @@ -47,7 +47,7 @@ html_theme_options = { "type": "url", }, ], - "announcement": ("Please excuse our mess as we update these documents"), + "announcement": "", "navigation_depth": 10, "show_toc_level": 10, } diff --git a/docs/source/config_reference/plugins.rst b/docs/source/config_reference/plugins.rst index 6aa05162..7e9d1688 100644 --- a/docs/source/config_reference/plugins.rst +++ b/docs/source/config_reference/plugins.rst @@ -156,7 +156,7 @@ granularity possible. :expected type: Optional[OverridesFormatter] :description: - Only download videos before this datetime. + Only download videos after this datetime. ``before`` diff --git a/docs/source/faq/index.rst b/docs/source/faq/index.rst index 489240de..b55f155c 100644 --- a/docs/source/faq/index.rst +++ b/docs/source/faq/index.rst @@ -10,6 +10,18 @@ Since ytdl-sub is relatively new to the public, there has not been many question How do I... ----------- +...remove the date in the video title? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The :ref:`config_reference/prebuilt_presets/tv_show:TV Show` presets by default include the upload date in the ``episode_title`` +override variable. This variable is used to set the title in things like the video metadata, NFO file, etc, which is +subsequently read by media players. This can be overwritten as you see fit by redefining it: + +.. code-block:: yaml + + overrides: + episode_title: "{title}" # Only sets the video title + ...get support or reach out to contribute? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/source/guides/getting_started/index.rst b/docs/source/guides/getting_started/index.rst index c503a5ad..3aa856bc 100644 --- a/docs/source/guides/getting_started/index.rst +++ b/docs/source/guides/getting_started/index.rst @@ -29,28 +29,44 @@ If you plan on using the headless image of ``ytdl-sub``, you: Additional useful (but not required) knowledge: ☑ Understanding how :yt-dlp:`\ ` works - -Quick Overview of ``ytdl-sub`` ------------------------------- - +Overview +-------- ``ytdl-sub`` uses two types of YAML files: -- ``config.yaml`` defines ``presets``, which are the "definitions" of your media. ``presets`` "define" how you want your media downloaded, which formats, naming conventions to follow when saving them, etc. These ``presets`` can also inherit other ``presets``, so that you can easily modify an existing ``preset``. -- ``subscriptions.yaml`` defines ``subscriptions``, which specify the media we want to recurrently download, like YouTube channels and playlists, SoundCloud artists, or any :yt-dlp:`yt-dlp supported site `. ``subscriptions`` use ``presets`` to define how ``ytdl-sub`` should handle downloading, processing, and saving them. +subscriptions.yaml +~~~~~~~~~~~~~~~~~~ +Defines ``subscriptions``, which specify the media we want to recurrently download, like YouTube +channels and playlists, SoundCloud artists, or any +:yt-dlp:`yt-dlp supported site `. ``subscriptions`` use ``presets`` +to define how ``ytdl-sub`` should handle downloading, processing, and saving them. -When ``ytdl-sub`` is run, in its most basic form: +``ytdl-sub`` comes packaged with many +:ref:`prebuilt presets ` +that will play nicely with well-known media players. + +config.yaml +~~~~~~~~~~~ +To customize ``ytdl-sub`` to beyond the prebuilt presets, you will need a ``config.yaml`` file. This +file is where custom ``presets`` can be defined to orchestrate ``ytdl-sub`` to your very specific needs. + +Running ytdl-sub +~~~~~~~~~~~~~~~~ +To invoke ``ytdl-sub`` to download subscriptions, use the following command: .. tab-set-code:: .. code-block:: shell - ytdl-sub sub + ytdl-sub sub subscriptions.yaml .. code-block:: powershell - ytdl-sub.exe sub + ytdl-sub.exe sub subscriptions.yaml -``ytdl-sub`` initially downloads all files to a defined ``working_directory``. This is a temporary storage spot for metadata and media files so that errors during processing- if they occur- don't affect your existing media library. Once all file processing is complete, your media files are moved to the ``output_directory``. +``ytdl-sub`` initially downloads all files to a defined ``working_directory``. This is a temporary +storage spot for metadata and media files so that errors during processing- if they occur- don't +affect your existing media library. Once all file processing is complete, your media files are +moved to the ``output_directory``. Ready to Start? --------------- diff --git a/docs/source/prebuilt_presets/tv_shows.rst b/docs/source/prebuilt_presets/tv_shows.rst index f2b1da1d..a7770d0f 100644 --- a/docs/source/prebuilt_presets/tv_shows.rst +++ b/docs/source/prebuilt_presets/tv_shows.rst @@ -11,17 +11,17 @@ The following actions are taken based on the indicated player: Jellyfin -~~~~~~~~ +-------- * Places any season-specific poster art in the main show folder * Generates NFO tags Kodi -~~~~ +-------- * Everything that the Jellyfin version does * Enables ``kodi_safe`` NFOs, replacing 4-byte unicode characters that break kodi with ``□`` Plex -~~~~ +-------- * :ref:`Special sanitization ` of numbers so Plex doesn't recognize numbers that are part of the title as the episode number * Converts all downloaded videos to the mp4 format * Places any season-specific poster art into the season folder diff --git a/src/ytdl_sub/plugins/date_range.py b/src/ytdl_sub/plugins/date_range.py index e01ce1ff..27b9c808 100644 --- a/src/ytdl_sub/plugins/date_range.py +++ b/src/ytdl_sub/plugins/date_range.py @@ -57,7 +57,7 @@ class DateRangeOptions(ToggleableOptionsDictValidator): """ :expected type: Optional[OverridesFormatter] :description: - Only download videos before this datetime. + Only download videos after this datetime. """ return self._after From f346b0ef52e4a9fbd83774003b48f6e0fac801be Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Sat, 17 Feb 2024 09:51:21 -0800 Subject: [PATCH 09/39] [BUGFIX] Handle case when yt-dlp returns LazyList (#929) Fixes https://github.com/jmbannon/ytdl-sub/issues/910 , when yt-dlp sometimes returns a non-serializable LazyList --- src/ytdl_sub/entries/base_entry.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ytdl_sub/entries/base_entry.py b/src/ytdl_sub/entries/base_entry.py index 1d8acd3e..d863bc42 100644 --- a/src/ytdl_sub/entries/base_entry.py +++ b/src/ytdl_sub/entries/base_entry.py @@ -8,6 +8,7 @@ from typing import Type from typing import TypeVar from typing import final +from yt_dlp.utils import LazyList from yt_dlp.utils import sanitize_filename from ytdl_sub.entries.script.variable_definitions import VARIABLES @@ -37,6 +38,12 @@ class BaseEntry(ABC): self._working_directory = working_directory self._kwargs = entry_dict + # Sometimes yt-dlp can return a LazyList which is not JSON serializable. + # Cast it to a native list here. (https://github.com/jmbannon/ytdl-sub/issues/910) + for key in self._kwargs.keys(): + if isinstance(self._kwargs[key], LazyList): + self._kwargs[key] = list(self._kwargs[key]) + @property def uid(self) -> str: """ From 8e6a2cb98e9bcdaaf6312c9276f1a9b3e26c4c7e Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Tue, 20 Feb 2024 21:04:13 -0800 Subject: [PATCH 10/39] [DOCS] Rewrite first subscription documentation (#932) --- .../guides/getting_started/first_download.rst | 6 +- .../guides/getting_started/first_sub.rst | 123 +++++++++++++++++- 2 files changed, 121 insertions(+), 8 deletions(-) diff --git a/docs/source/guides/getting_started/first_download.rst b/docs/source/guides/getting_started/first_download.rst index 8babc19e..805b4cd1 100644 --- a/docs/source/guides/getting_started/first_download.rst +++ b/docs/source/guides/getting_started/first_download.rst @@ -1,7 +1,9 @@ Initial Download ================ -Once you have the ``config.yaml`` and ``subscriptions.yaml`` files created and filled out, you can perform your first download. Access ``ytdl-sub``, navigate to the directory containing your ``config.yaml`` and ``subscriptions.yaml`` files, then run the below command: +Once you have a ``subscriptions.yaml`` file created and filled out, you can perform your first +download. Access ``ytdl-sub``, navigate to the directory containing your ``subscriptions.yaml`` +file, then run the below command: .. tab-set:: @@ -27,4 +29,4 @@ Once you have the ``config.yaml`` and ``subscriptions.yaml`` files created and f .. code-block:: shell - ytdl-sub dl --preset "My Favorite YouTube Channels" --overrides.subscription_name "Rick Astley" --overrides.subscription_value "https://www.youtube.com/@RickAstleyYT/videos" \ No newline at end of file + ytdl-sub dl --preset "Jellyfin TV Show by Date" --overrides.subscription_name "NOVA PBS" --overrides.subscription_value "https://www.youtube.com/@novapbs" --overrides.tv_show_genre "Documentaries" \ No newline at end of file diff --git a/docs/source/guides/getting_started/first_sub.rst b/docs/source/guides/getting_started/first_sub.rst index b2a83a88..1043960e 100644 --- a/docs/source/guides/getting_started/first_sub.rst +++ b/docs/source/guides/getting_started/first_sub.rst @@ -1,7 +1,7 @@ Initial Subscription ==================== -Your first subscription should look similar to the below: +Your first subscription should look something like this: .. code-block:: yaml :linenos: @@ -9,13 +9,124 @@ Your first subscription should look similar to the below: __preset__: overrides: tv_show_directory: "/tv_shows" + music_directory: "/music" - "My Favorite YouTube Channels": - "Rick Astley": "https://www.youtube.com/@RickAstleyYT/videos" + # Can choose between: + # - Plex TV Show by Date: + # - Jellyfin TV Show by Date: + # - Kodi TV Show by Date: + # + Jellyfin TV Show by Date: + = Documentaries: + "NOVA PBS": "https://www.youtube.com/@novapbs" + + = Kids | = TV-Y: + "Jake Trains": "https://www.youtube.com/@JakeTrains" + + YouTube Releases: + = Jazz: # Sets genre tag to "Jazz" + "Thelonious Monk": "https://www.youtube.com/@theloniousmonk3870/releases" + + YouTube Full Albums: + = Lofi: + "Game Chops": "https://www.youtube.com/playlist?list=PLBsm_SagFMmdWnCnrNtLjA9kzfrRkto4i" + +Lets break this down: + +.. code-block:: yaml + :lineno-start: 1 + + __preset__: + overrides: + tv_show_directory: "/tv_shows" + music_directory: "/music" -The first three lines in this subscription file define where to save the downloaded files associated with all subscriptions in this file. +The first :ref:`__preset__ ` section is where we +can set modifications that apply to every subscription in this file. -The fifth line in this subscription file is the ``preset``, which provides the "definitions" for the subscription as listed in :doc:`/guides/getting_started/first_config`. +------------------------------------- -The sixth line is the actual ``subscription``, named ``Rick Astley``, with a link to a :yt-dlp:`yt-dlp supported site `, in this case a YouTube channel. \ No newline at end of file +.. code-block:: yaml + :lineno-start: 6 + + # Can choose between: + # - Plex TV Show by Date: + # - Jellyfin TV Show by Date: + # - Kodi TV Show by Date: + # + +Lines 6-10 are comments that get ignored when parsing YAML since they are prefixed with ``#``. +It is good practice to leave informative comments in your config or subscription files to remind +yourself of various things. + +------------------------------------- + +.. code-block:: yaml + :lineno-start: 11 + + Jellyfin TV Show by Date: + +On line 11, we set the key to ``Jellyfin TV Show by Date``. This is a +:ref:`prebuilt preset ` that configures +subscriptions to look like TV shows in the Jellyfin media player (can be changed to +one of the presets outlined in the comment above). Setting it as a YAML key implies that all +subscriptions underneath it will *inherit* this preset. + +------------------------------------- + +.. code-block:: yaml + :lineno-start: 11 + + Jellyfin TV Show by Date: + = Documentaries: + +Line 12 sets the key to ``= Documentaries``. When keys are prefixed with ``=``, it means we are +setting the +:ref:`subscription indent variable `. +For TV Show presets, the first subscription indent variable maps to the TV show's genre. +Setting subscription indent variables as a key implies all subscriptions underneath it will +have this variable set. + +To better understand what variables are used in prebuilt presets, refer to the +:ref:`prebuilt preset reference `. +Here you will see the underlying variables used in prebuilt presets that can be overwritten. +We already overwrote a few of the variables in the ``__preset__`` section above to define our +output directory. + +------------------------------------- + +.. code-block:: yaml + :lineno-start: 11 + + Jellyfin TV Show by Date: + = Documentaries: + "NOVA PBS": "https://www.youtube.com/@novapbs" + +Line 13 is where we define our first subscription. We set the subscription name to ``NOVA PBS``, +and the subscription value to ``https://www.youtube.com/@novapbs``. Referring to the +:ref:`TV show preset reference `, +we can see that ``{subscription_name}`` is used to set the ``tv_show_name`` variable. + +------------------------------------- + +.. code-block:: yaml + :lineno-start: 11 + + Jellyfin TV Show by Date: + = Documentaries: + "NOVA PBS": "https://www.youtube.com/@novapbs" + + = Kids | = TV-Y: + "Jake Trains": "https://www.youtube.com/@JakeTrains" + +Line 15 underneath ``Jellyfin TV Show by Date``, but at the same level as ``= Documentaries``. +This means we'll inherit the TV show preset, but not the documentaries indent variable. We instead +set the indent variables to ``= Kids | = TV-Y``. This sets two indent variables. We can set +multiple presets and/or indent variables on the same key by using ``|`` as a separator. + +Referring to the +:ref:`TV show preset reference `, the first +two indent variables map to the TV show genre and TV show content rating. + +The above info should be enough to understand the rest of the subscription file. \ No newline at end of file From 0beeeb464d241a323251d49f18df513a9eea3ca5 Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Wed, 28 Feb 2024 13:48:42 -0800 Subject: [PATCH 11/39] [FEATURE] Music tag dates (#938) Adds proper support for setting `date` and `original_date` fields via `music_tag` plugin using a standardized date format. --- docs/source/config_reference/plugins.rst | 4 +++ src/ytdl_sub/plugins/music_tags.py | 34 ++++++++++++++++++- .../prebuilt_presets/music/singles.yaml | 2 ++ .../bandcamp/test_artist_url.json | 30 ++++++++-------- .../plugins/split_by_chapters_video.json | 22 ++++++------ ...ters_with_regex_no_chapters_video_pass.txt | 2 +- .../split_by_chapters_with_regex_video.json | 22 ++++++------ .../plugins/test_audio_extract_playlist.json | 6 ++-- .../plugins/test_audio_extract_single.json | 2 +- .../test_audio_extract_single_best.json | 2 +- .../test_chapters_sb_and_embedded_subs.json | 2 +- .../plugins/test_subtitles_embedded.json | 2 +- .../test_subtitles_embedded_and_file.json | 2 +- .../test_soundcloud_discography.json | 26 +++++++------- .../unit/music/Bandcamp.json | 8 ++--- .../unit/music/Single.json | 8 ++--- .../unit/music/SoundCloud Discography.json | 16 ++++----- .../unit/music/YouTube Full Albums.json | 8 ++--- .../unit/music/YouTube Releases.json | 8 ++--- .../bandcamp/test_artist_url.txt | 30 ++++++++++++++++ .../split_by_chapters_video-dry-run.txt | 22 ++++++++++++ .../plugins/split_by_chapters_video.txt | 22 ++++++++++++ ...ters_with_regex_no_chapters_video_pass.txt | 2 ++ ...t_by_chapters_with_regex_video-dry-run.txt | 22 ++++++++++++ .../split_by_chapters_with_regex_video.txt | 22 ++++++++++++ .../plugins/test_audio_extract_playlist.txt | 6 ++++ .../plugins/test_audio_extract_single.txt | 2 ++ .../test_audio_extract_single_best.txt | 2 ++ ...test_audio_extract_single_best_dry_run.txt | 2 ++ .../test_soundcloud_discography.txt | 26 ++++++++++++++ .../unit/music/Bandcamp.txt | 8 +++++ .../unit/music/Single.txt | 8 +++++ .../unit/music/SoundCloud Discography.txt | 16 +++++++++ .../unit/music/YouTube Full Albums.txt | 8 +++++ .../unit/music/YouTube Releases.txt | 8 +++++ 35 files changed, 328 insertions(+), 84 deletions(-) diff --git a/docs/source/config_reference/plugins.rst b/docs/source/config_reference/plugins.rst index 7e9d1688..52010d57 100644 --- a/docs/source/config_reference/plugins.rst +++ b/docs/source/config_reference/plugins.rst @@ -402,6 +402,9 @@ It supports basic tags like ``title``, ``album``, ``artist`` and ``albumartist`` a full list of tags for various file types in MediaFile's `source code `_. +Note that the date fields ``date`` and ``original_date`` expected a standardized date in the +form of YYYY-MM-DD. The variable ``upload_date_standardized`` returns a compatible format. + :Usage: .. code-block:: yaml @@ -418,6 +421,7 @@ a full list of tags for various file types in MediaFile's albumartists: - "{artist}" - "ytdl-sub" + date: "{upload_date_standardized}" ---------------------------------------------------------------------------------------------------- diff --git a/src/ytdl_sub/plugins/music_tags.py b/src/ytdl_sub/plugins/music_tags.py index b9542810..d7804b67 100644 --- a/src/ytdl_sub/plugins/music_tags.py +++ b/src/ytdl_sub/plugins/music_tags.py @@ -1,4 +1,6 @@ from collections import defaultdict +from datetime import datetime +from typing import Any from typing import Dict from typing import List @@ -9,6 +11,7 @@ from ytdl_sub.config.validators.options import OptionsDictValidator from ytdl_sub.entries.entry import Entry from ytdl_sub.entries.script.variable_definitions import VARIABLES from ytdl_sub.entries.script.variable_definitions import VariableDefinitions +from ytdl_sub.utils.exceptions import ValidationException from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.utils.logger import Logger from ytdl_sub.validators.audo_codec_validator import AUDIO_CODEC_EXTS @@ -36,6 +39,17 @@ def _is_multi_field(tag_name: str) -> bool: } +def _is_date_field(tag_name: str) -> bool: + return tag_name in { + "date", + "original_date", + } + + +def _to_datetime(tag_value: str) -> Any: + return datetime.strptime(tag_value, "%Y-%m-%d") + + class MusicTagsOptions(OptionsDictValidator): """ Adds tags to every download audio file using @@ -46,6 +60,9 @@ class MusicTagsOptions(OptionsDictValidator): a full list of tags for various file types in MediaFile's `source code `_. + Note that the date fields ``date`` and ``original_date`` expected a standardized date in the + form of YYYY-MM-DD. The variable ``upload_date_standardized`` returns a compatible format. + :Usage: .. code-block:: yaml @@ -62,6 +79,7 @@ class MusicTagsOptions(OptionsDictValidator): albumartists: - "{artist}" - "ytdl-sub" + date: "{upload_date_standardized}" """ _optional_keys = set(list(mediafile.MediaFile.sorted_fields())) @@ -104,12 +122,26 @@ class MusicTagsPlugin(Plugin[MusicTagsOptions]): tag_value = self.overrides.apply_formatter(formatter=tag_formatter, entry=entry) tags_to_write[tag_name].append(tag_value) + if _is_date_field(tag_name): + try: + if len(tags_to_write[tag_name]) != 1: + raise ValueError("caught below") + + _ = _to_datetime(tags_to_write[tag_name][0]) + except Exception as exc: + raise ValidationException( + "Date-based music tags must be a single tag in the form of YYYY-MM-DD" + ) from exc + # write the actual tags if its not a dry run if not self.is_dry_run: audio_file = mediafile.MediaFile(entry.get_download_file_path()) for tag_name, tag_value in tags_to_write.items(): + # If the attribute is a date-type, set it as a datetime type + if _is_date_field(tag_name): + setattr(audio_file, tag_name, _to_datetime(tag_value[0])) # If the attribute is a multi-type, set it as the list type - if _is_multi_field(tag_name): + elif _is_multi_field(tag_name): setattr(audio_file, tag_name, tag_value) # Otherwise, set as single value else: diff --git a/src/ytdl_sub/prebuilt_presets/music/singles.yaml b/src/ytdl_sub/prebuilt_presets/music/singles.yaml index 634fd43f..1a6acb96 100644 --- a/src/ytdl_sub/prebuilt_presets/music/singles.yaml +++ b/src/ytdl_sub/prebuilt_presets/music/singles.yaml @@ -24,6 +24,8 @@ presets: track: "{track_number}" tracktotal: "{track_total}" year: "{track_year}" + date: "{upload_date_standardized}" + original_date: "{upload_date_standardized}" # multi-tags artists: - "{track_artist}" diff --git a/tests/resources/expected_downloads_summaries/bandcamp/test_artist_url.json b/tests/resources/expected_downloads_summaries/bandcamp/test_artist_url.json index 9c2ea6ef..4acda2de 100644 --- a/tests/resources/expected_downloads_summaries/bandcamp/test_artist_url.json +++ b/tests/resources/expected_downloads_summaries/bandcamp/test_artist_url.json @@ -1,20 +1,20 @@ { ".ytdl-sub-Sithu Aye-download-archive.json": "ca37a404a860a2a6b6b0f38b659c9f17", - "Sithu Aye/[2021] 10 Years: Remixes and Reimaginings/01 - Double Helix Reimagined.mp3": "56f7ee579031f4795230e68b63b15f6b", - "Sithu Aye/[2021] 10 Years: Remixes and Reimaginings/02 - Skye Reimagined.mp3": "dfb24e0ef03e203d471bb81f854f5cd3", - "Sithu Aye/[2021] 10 Years: Remixes and Reimaginings/03 - Baryofusion.mp3": "95bd9ab2238e5372f445c59daafd0138", - "Sithu Aye/[2021] 10 Years: Remixes and Reimaginings/04 - Mandalay Reimagined.mp3": "7d2b38559b4c66a2e4841a6976f726af", - "Sithu Aye/[2021] 10 Years: Remixes and Reimaginings/05 - Messenger EDM Remix.mp3": "971ed99fa1d80ad53dd15069459576ba", + "Sithu Aye/[2021] 10 Years: Remixes and Reimaginings/01 - Double Helix Reimagined.mp3": "ac0e6a2936c309765c69a4c98c42ad10", + "Sithu Aye/[2021] 10 Years: Remixes and Reimaginings/02 - Skye Reimagined.mp3": "38387bd0ec5fc229e30b1a8ce5b9cddb", + "Sithu Aye/[2021] 10 Years: Remixes and Reimaginings/03 - Baryofusion.mp3": "344dbb939b09713dcc33894a8b1b8459", + "Sithu Aye/[2021] 10 Years: Remixes and Reimaginings/04 - Mandalay Reimagined.mp3": "9bacbd5166a740c06d3329dcfae0d9aa", + "Sithu Aye/[2021] 10 Years: Remixes and Reimaginings/05 - Messenger EDM Remix.mp3": "ee20efe93c637ba4347431dc11b66691", "Sithu Aye/[2021] 10 Years: Remixes and Reimaginings/folder.jpg": "bf6f70d51557a71b69fed85b2cb476f0", - "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/01 - Invent the Universe.mp3": "14fe5186fe68eacef265b77c4653ff73", - "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/02 - Grand Unification (feat. David Maxim Micic).mp3": "839b79a68464246f19b4701c4daec3cb", - "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/03 - Expansion.mp3": "f44bd1b6b2db8ab29987f47045f1acca", - "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/04 - Baryogenesis.mp3": "a6d5a0f1026ca759c30f69a8d07d64b8", - "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/05 - Particles Collide (feat. Plini).mp3": "9f41e0823c252b9c238e4ce565c78c6e", - "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/06 - Nucleosynthesis.mp3": "26e7c844ea45e60ff906f3f4280c991c", - "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/07 - Recombination.mp3": "8544b560ea220bed0ede54fe73e80bec", - "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/08 - Dark Ages.mp3": "8d209b11a038fb870f5a275c6304159c", - "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/09 - Formation.mp3": "48b148d54eed289fd5aef6898f8b72df", - "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/10 - Pale Blue Dot.mp3": "f506f2d69d03430a2ab0a6890184c162", + "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/01 - Invent the Universe.mp3": "482e97a4f9a30aa4413f45f5f3f5dbba", + "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/02 - Grand Unification (feat. David Maxim Micic).mp3": "34e43fd80b4c61295abbb8b794e523a7", + "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/03 - Expansion.mp3": "0ba4b8ef65e274cbf065b6f0a5f444e5", + "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/04 - Baryogenesis.mp3": "ebde7ae4e211f10f2b25a46f2b9483c7", + "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/05 - Particles Collide (feat. Plini).mp3": "7df698f89a55fb21a2f07ffd8d2bfb75", + "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/06 - Nucleosynthesis.mp3": "9a861212acf25a8ff801833f82ade55e", + "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/07 - Recombination.mp3": "3abbfe126fbdd830195dc2cac223ea10", + "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/08 - Dark Ages.mp3": "28e6a73bea1cd881e5ad38d57d1426c5", + "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/09 - Formation.mp3": "d588ed8edc324ea657abb2be96d557a1", + "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/10 - Pale Blue Dot.mp3": "8245d285ba2403bf512f83c4a585eb81", "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/folder.jpg": "d8cffeca026afaa619f641a95143f803" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/plugins/split_by_chapters_video.json b/tests/resources/expected_downloads_summaries/plugins/split_by_chapters_video.json index 07adaebb..d6f1070d 100644 --- a/tests/resources/expected_downloads_summaries/plugins/split_by_chapters_video.json +++ b/tests/resources/expected_downloads_summaries/plugins/split_by_chapters_video.json @@ -1,15 +1,15 @@ { ".ytdl-sub-Proved Records-download-archive.json": "c3fb0b4f31caaa10ac7954ea93da33c4", - "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/01 - 01. Intro (Feat. Racheal Ofori & Barney Artist).mp3": "1e3583c9c1dc166b7baf98b81b4ca106", - "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/02 - 02. Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3": "bb4c9085a4345515e6dffcf2c09f0f0f", - "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/03 - 03. Blaze (Feat. Kaya Thomas - Dyke).mp3": "c6766f80d0f9993ec6051555cd37dc32", - "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/04 - 04. What If (Interlude).mp3": "2139e6eedc4d1bc2025f8ef78b0bd2af", - "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/05 - 05. No Peace (Feat. Tom Misch).mp3": "8194d6a4018121e40b1733a0fa7a382e", - "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/06 - 06. Closer (Feat. Lester Duval).mp3": "e244183723e23c65156076b5f7ffcf56", - "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/07 - 07. Delusions: Rumination (Interlude) (Feat. Racheal Ofori).mp3": "692224c169be4735c0892e05726cb335", - "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/08 - 08. Dreams (Feat. Carmody).mp3": "561c339d4d1232c15ad38a5afe8d61c3", - "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/09 - 09. Dreaming (Interlude) (Feat. Racheal Ofori).mp3": "bdef7ad26848b45552891b9ea722659e", - "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/10 - 10. Hopeful (Feat. Jordan Rakei).mp3": "ffff4506702e708288eb54eff8fbaca4", - "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/11 - 11. Sunrise (Pillows) (Feat. Emmavie).mp3": "41a88d6c07047bb4215fed0e71443f17", + "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/01 - 01. Intro (Feat. Racheal Ofori & Barney Artist).mp3": "20fec748a00e37dc3b3868797e37fbbf", + "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/02 - 02. Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3": "edfbc8621ddfb163d30fee36d7ad902e", + "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/03 - 03. Blaze (Feat. Kaya Thomas - Dyke).mp3": "45a81d4ad17a4b80e916919656a7772e", + "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/04 - 04. What If (Interlude).mp3": "b536cdb69653303b8eff9b2ae7352b7f", + "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/05 - 05. No Peace (Feat. Tom Misch).mp3": "f8f93a483b7945feccbb48b89c608e46", + "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/06 - 06. Closer (Feat. Lester Duval).mp3": "4405d980fdb78a453d681054f8a5c603", + "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/07 - 07. Delusions: Rumination (Interlude) (Feat. Racheal Ofori).mp3": "804f26a8ae8dcaa25b5882dd0051553f", + "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/08 - 08. Dreams (Feat. Carmody).mp3": "b0f96e5ed99e04064c3537966d332326", + "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/09 - 09. Dreaming (Interlude) (Feat. Racheal Ofori).mp3": "83d1e1acbe08b97ca30ab0c29b6473e2", + "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/10 - 10. Hopeful (Feat. Jordan Rakei).mp3": "bbc6ddf2c4f0dbce33e5173d04976e6a", + "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/11 - 11. Sunrise (Pillows) (Feat. Emmavie).mp3": "e2bdef2556deeec7a7149948c4ffcaec", "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/folder.jpg": "bd3685acc53072e591bae2505ecb0648" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_pass.txt b/tests/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_pass.txt index 97f6d053..f9a235a4 100644 --- a/tests/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_pass.txt +++ b/tests/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_pass.txt @@ -1,5 +1,5 @@ { ".ytdl-sub-split_by_chapters_with_regex_video_no_chapters-download-archive.json": "4008e43668447f1a3a6a55520a6ff475", - "Project Zombie/[2010] Oblivion Mod "Falcor" p.1/01 - Oblivion Mod "Falcor" p.1.mp3": "b886f268a2a9b3b62f528fcf46699082", + "Project Zombie/[2010] Oblivion Mod "Falcor" p.1/01 - Oblivion Mod "Falcor" p.1.mp3": "7813b727a1d3df89effe45c42e7c7e63", "Project Zombie/[2010] Oblivion Mod "Falcor" p.1/folder.jpg": "fb95b510681676e81c321171fc23143e" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_video.json b/tests/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_video.json index c57034be..93c3daab 100644 --- a/tests/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_video.json +++ b/tests/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_video.json @@ -1,15 +1,15 @@ { ".ytdl-sub-split_by_chapters_with_regex_video_preset-download-archive.json": "9798e8289742586d0efd295a97c6c906", - "Alfa Mist/[2017] Nocturne/01 - Intro (Feat. Racheal Ofori & Barney Artist).mp3": "d07f90d214416a11736e3f73c0955208", - "Alfa Mist/[2017] Nocturne/02 - Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3": "9057d3d7fcd98701e06d399a507c0e5b", - "Alfa Mist/[2017] Nocturne/03 - Blaze (Feat. Kaya Thomas - Dyke).mp3": "4cc4e7066112388e83bdd9e803e8051a", - "Alfa Mist/[2017] Nocturne/04 - What If (Interlude).mp3": "8d0f04c04f8c2b0565954dac66ee29a3", - "Alfa Mist/[2017] Nocturne/05 - No Peace (Feat. Tom Misch).mp3": "e8166110c85e6b9af8d1067b8ffbb428", - "Alfa Mist/[2017] Nocturne/06 - Closer (Feat. Lester Duval).mp3": "64e39199ddcd05b96c15c82a3c695d25", - "Alfa Mist/[2017] Nocturne/07 - Delusions: Rumination (Interlude) (Feat. Racheal Ofori).mp3": "9a53bee41d1985e48e771a02b052986b", - "Alfa Mist/[2017] Nocturne/08 - Dreams (Feat. Carmody).mp3": "19c50e84c08eacdd1095e461306d9077", - "Alfa Mist/[2017] Nocturne/09 - Dreaming (Interlude) (Feat. Racheal Ofori).mp3": "f28df20fdbadf0ec4985b5f1f30c4e5d", - "Alfa Mist/[2017] Nocturne/10 - Hopeful (Feat. Jordan Rakei).mp3": "d7261e48713426ad4905919915a9b103", - "Alfa Mist/[2017] Nocturne/11 - Sunrise (Pillows) (Feat. Emmavie).mp3": "5d51491d0999e1f29918d4db4ed796d3", + "Alfa Mist/[2017] Nocturne/01 - Intro (Feat. Racheal Ofori & Barney Artist).mp3": "2311726ea7c5e6c4858810544635d8c8", + "Alfa Mist/[2017] Nocturne/02 - Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3": "679c3893251b934830855f5f09c35d76", + "Alfa Mist/[2017] Nocturne/03 - Blaze (Feat. Kaya Thomas - Dyke).mp3": "c7438762ed13c7768e5177aca7feced2", + "Alfa Mist/[2017] Nocturne/04 - What If (Interlude).mp3": "b69cbec7d90a7a28e4684d9f37c2dd01", + "Alfa Mist/[2017] Nocturne/05 - No Peace (Feat. Tom Misch).mp3": "18772abf8868c43b4ffffaaa904ba277", + "Alfa Mist/[2017] Nocturne/06 - Closer (Feat. Lester Duval).mp3": "e9d2cd658fd16deaca41fb491fcb28f6", + "Alfa Mist/[2017] Nocturne/07 - Delusions: Rumination (Interlude) (Feat. Racheal Ofori).mp3": "4db7f44cc632fd6e572f844ef7075d2d", + "Alfa Mist/[2017] Nocturne/08 - Dreams (Feat. Carmody).mp3": "9687d7dd7543a9f99c5837aea86daf25", + "Alfa Mist/[2017] Nocturne/09 - Dreaming (Interlude) (Feat. Racheal Ofori).mp3": "41e4b7db91f7148b86df33f0968ce6a2", + "Alfa Mist/[2017] Nocturne/10 - Hopeful (Feat. Jordan Rakei).mp3": "024fd4477a38f915721ebfcf81f9b1c3", + "Alfa Mist/[2017] Nocturne/11 - Sunrise (Pillows) (Feat. Emmavie).mp3": "d5459648255b4aa22d88e1b2d2411ffb", "Alfa Mist/[2017] Nocturne/folder.jpg": "bd3685acc53072e591bae2505ecb0648" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/plugins/test_audio_extract_playlist.json b/tests/resources/expected_downloads_summaries/plugins/test_audio_extract_playlist.json index 662e2f86..83ed5f8b 100644 --- a/tests/resources/expected_downloads_summaries/plugins/test_audio_extract_playlist.json +++ b/tests/resources/expected_downloads_summaries/plugins/test_audio_extract_playlist.json @@ -1,7 +1,7 @@ { ".ytdl-sub-multiple_songs_test-download-archive.json": "54237df5e00d1598dfd39f341ee03d75", - "Project Zombie/[2011] Jesse's Minecraft Server/01 - Jesse's Minecraft Server [Trailer - Mar.21].ogg": "e7a8a94ebe9f02f086f4bbf3df3946f6", - "Project Zombie/[2011] Jesse's Minecraft Server/02 - Jesse's Minecraft Server [Trailer - Feb.27].ogg": "aeea4b086507fd7fde3c09c0bd868950", - "Project Zombie/[2011] Jesse's Minecraft Server/03 - Jesse's Minecraft Server [Trailer - Feb.1].ogg": "80200d21a46c7f521bfb23aef2f87e34", + "Project Zombie/[2011] Jesse's Minecraft Server/01 - Jesse's Minecraft Server [Trailer - Mar.21].ogg": "9e53a68a39f290899a3dce03fdca6490", + "Project Zombie/[2011] Jesse's Minecraft Server/02 - Jesse's Minecraft Server [Trailer - Feb.27].ogg": "02dc8e368de9555d062bde31dcc82852", + "Project Zombie/[2011] Jesse's Minecraft Server/03 - Jesse's Minecraft Server [Trailer - Feb.1].ogg": "c808e1da2bccd419201eeeffc32c3729", "Project Zombie/[2011] Jesse's Minecraft Server/folder.jpg": "e7830aa8a64b0cde65ba3f7e5fc56530" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/plugins/test_audio_extract_single.json b/tests/resources/expected_downloads_summaries/plugins/test_audio_extract_single.json index 180cc879..1c6ddbc6 100644 --- a/tests/resources/expected_downloads_summaries/plugins/test_audio_extract_single.json +++ b/tests/resources/expected_downloads_summaries/plugins/test_audio_extract_single.json @@ -1,5 +1,5 @@ { ".ytdl-sub-single_song_test-download-archive.json": "c8ff22ec3304c9f8dab18cedaed4e8b4", - "YouTube/[2019] YouTube Rewind 2019: For the Record | #YouTubeRewind/01 - YouTube Rewind 2019: For the Record | #YouTubeRewind.mp3": "829eb7dcc5dcae41240701dec4e1708d", + "YouTube/[2019] YouTube Rewind 2019: For the Record | #YouTubeRewind/01 - YouTube Rewind 2019: For the Record | #YouTubeRewind.mp3": "b85c812fc761379122a96243d98a5ccb", "YouTube/[2019] YouTube Rewind 2019: For the Record | #YouTubeRewind/folder.jpg": "50ee47c80f679029f5d3503bb91b045a" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/plugins/test_audio_extract_single_best.json b/tests/resources/expected_downloads_summaries/plugins/test_audio_extract_single_best.json index 25c1b7e9..95f2b6e8 100644 --- a/tests/resources/expected_downloads_summaries/plugins/test_audio_extract_single_best.json +++ b/tests/resources/expected_downloads_summaries/plugins/test_audio_extract_single_best.json @@ -1,5 +1,5 @@ { ".ytdl-sub-single_song_best_test-download-archive.json": "0f9484ed868dcbeef82810a3cf7b0eea", - "YouTube/[2019] YouTube Rewind 2019: For the Record | #YouTubeRewind/01 - YouTube Rewind 2019: For the Record | #YouTubeRewind.opus": "73e4afdda9bc792807c8b07a63128e5a", + "YouTube/[2019] YouTube Rewind 2019: For the Record | #YouTubeRewind/01 - YouTube Rewind 2019: For the Record | #YouTubeRewind.opus": "eb32b0ef0568582cafd7fa528a5260e8", "YouTube/[2019] YouTube Rewind 2019: For the Record | #YouTubeRewind/folder.jpg": "50ee47c80f679029f5d3503bb91b045a" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/plugins/test_chapters_sb_and_embedded_subs.json b/tests/resources/expected_downloads_summaries/plugins/test_chapters_sb_and_embedded_subs.json index 03a1f924..ed7ead7a 100644 --- a/tests/resources/expected_downloads_summaries/plugins/test_chapters_sb_and_embedded_subs.json +++ b/tests/resources/expected_downloads_summaries/plugins/test_chapters_sb_and_embedded_subs.json @@ -1,6 +1,6 @@ { ".ytdl-sub-sponsorblock_with_embedded_subs_test-download-archive.json": "2cb4b9586fd5bb7f1fed76ed9195e6e4", "JMC/This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.jpg": "b5353a824a4800cc26f884e3025ed969", - "JMC/This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.mp4": "526b6df52a8aaf11dfe56f25ac35a567", + "JMC/This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.mp4": "53590935ec5f801fd7e1b5aacf28fa5d", "JMC/This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.nfo": "ae73ec18a9f0e5a54c90061ccd32e7f4" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/plugins/test_subtitles_embedded.json b/tests/resources/expected_downloads_summaries/plugins/test_subtitles_embedded.json index 3847451d..74a88813 100644 --- a/tests/resources/expected_downloads_summaries/plugins/test_subtitles_embedded.json +++ b/tests/resources/expected_downloads_summaries/plugins/test_subtitles_embedded.json @@ -1,6 +1,6 @@ { ".ytdl-sub-subtitles_embedded_test-download-archive.json": "a74ecea9f7844be23f470bbe702788f3", "JMC/YouTube Rewind 2019: For the Record | #YouTubeRewind.jpg": "50ee47c80f679029f5d3503bb91b045a", - "JMC/YouTube Rewind 2019: For the Record | #YouTubeRewind.mp4": "d5df2fa121748a54d6954c58e3d5884f", + "JMC/YouTube Rewind 2019: For the Record | #YouTubeRewind.mp4": "f90f3bb948014420931c337b09007a18", "JMC/YouTube Rewind 2019: For the Record | #YouTubeRewind.nfo": "e6ac56ce52c747e2e271f12208f9a538" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/plugins/test_subtitles_embedded_and_file.json b/tests/resources/expected_downloads_summaries/plugins/test_subtitles_embedded_and_file.json index 56c983a1..7c4baeb6 100644 --- a/tests/resources/expected_downloads_summaries/plugins/test_subtitles_embedded_and_file.json +++ b/tests/resources/expected_downloads_summaries/plugins/test_subtitles_embedded_and_file.json @@ -3,6 +3,6 @@ "JMC/YouTube Rewind 2019: For the Record | #YouTubeRewind.de.srt": "b343c3bb9257b7ee7ba38f570a115b37", "JMC/YouTube Rewind 2019: For the Record | #YouTubeRewind.en.srt": "fe8c6ee92cae6e059fd80fd61691adbe", "JMC/YouTube Rewind 2019: For the Record | #YouTubeRewind.jpg": "50ee47c80f679029f5d3503bb91b045a", - "JMC/YouTube Rewind 2019: For the Record | #YouTubeRewind.mp4": "d5df2fa121748a54d6954c58e3d5884f", + "JMC/YouTube Rewind 2019: For the Record | #YouTubeRewind.mp4": "f90f3bb948014420931c337b09007a18", "JMC/YouTube Rewind 2019: For the Record | #YouTubeRewind.nfo": "e6ac56ce52c747e2e271f12208f9a538" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/soundcloud/test_soundcloud_discography.json b/tests/resources/expected_downloads_summaries/soundcloud/test_soundcloud_discography.json index 74cca448..11a84c2d 100644 --- a/tests/resources/expected_downloads_summaries/soundcloud/test_soundcloud_discography.json +++ b/tests/resources/expected_downloads_summaries/soundcloud/test_soundcloud_discography.json @@ -1,19 +1,19 @@ { ".ytdl-sub-j_b-download-archive.json": "1a99156e9ece62539fb2608416a07200", - "j_b/[2021] Baby Santana's Dorian Groove/01 - Baby Santana's Dorian Groove.mp3": "18e3db9e6df053b3093b8127eea7db72", + "j_b/[2021] Baby Santana's Dorian Groove/01 - Baby Santana's Dorian Groove.mp3": "3b686f82ebb2ffe4bb7a491b00ea8137", "j_b/[2021] Baby Santana's Dorian Groove/folder.jpg": "967892be44b8c47e1be73f055a7c6f08", - "j_b/[2021] Purple Clouds/01 - Purple Clouds.mp3": "e27b93fd1747754a7c835ee3fb09abcf", + "j_b/[2021] Purple Clouds/01 - Purple Clouds.mp3": "64ded79214390867c7ddc08d290183f4", "j_b/[2021] Purple Clouds/folder.jpg": "967892be44b8c47e1be73f055a7c6f08", - "j_b/[2022] Acoustic Treats/01 - 20160426 184214.mp3": "7218851e2d3df87ebe70a1f6756936a1", - "j_b/[2022] Acoustic Treats/02 - 20160502 123150.mp3": "f47a38223c9393060604d9a70374c8aa", - "j_b/[2022] Acoustic Treats/03 - 20160504 143832.mp3": "58ccf22fd9d3396ac4b58951653e80e5", - "j_b/[2022] Acoustic Treats/04 - 20160601 221234.mp3": "ddeb68d9d3f4d85c495266b2b4e6bc68", - "j_b/[2022] Acoustic Treats/05 - 20160601 222440.mp3": "57ce5558ed17e7d4c34111ae767e2225", - "j_b/[2022] Acoustic Treats/06 - 20170604 190236.mp3": "6104b68541f967893592fb73acae87ba", - "j_b/[2022] Acoustic Treats/07 - 20170612 193646.mp3": "30a14282bed225a413d8a41802a59f56", - "j_b/[2022] Acoustic Treats/08 - 20170628 215206.mp3": "04c42fc4b9331900367e465a7e8aa093", - "j_b/[2022] Acoustic Treats/09 - Finding Home.mp3": "fa649d6c877fe6c0f401536def5a495d", - "j_b/[2022] Acoustic Treats/10 - Shallow Water WIP.mp3": "e11057538a11a9c7627e34a2bfa55524", - "j_b/[2022] Acoustic Treats/11 - Untold History.mp3": "6c47b572f27b3a21ac715f1ba48adf53", + "j_b/[2022] Acoustic Treats/01 - 20160426 184214.mp3": "ef254985af511b8917fbe32feb2bf1a6", + "j_b/[2022] Acoustic Treats/02 - 20160502 123150.mp3": "b668004a76e06871ae8aa5a757f02928", + "j_b/[2022] Acoustic Treats/03 - 20160504 143832.mp3": "0d40f6261cb638d65e473d5c3172d1fa", + "j_b/[2022] Acoustic Treats/04 - 20160601 221234.mp3": "670f2f35e83f588023cabdaade2f5537", + "j_b/[2022] Acoustic Treats/05 - 20160601 222440.mp3": "032f59d0f2c7c3ce352a677ce5d30ee4", + "j_b/[2022] Acoustic Treats/06 - 20170604 190236.mp3": "faffcfb3d1b87b18be77ab4f86dd298f", + "j_b/[2022] Acoustic Treats/07 - 20170612 193646.mp3": "a7976b0380ec7b0c32193a58dc15cfa2", + "j_b/[2022] Acoustic Treats/08 - 20170628 215206.mp3": "b8de5803604102592564c0ebc46000a3", + "j_b/[2022] Acoustic Treats/09 - Finding Home.mp3": "29b40c0c66a3bce2da6fb86d9bad1b42", + "j_b/[2022] Acoustic Treats/10 - Shallow Water WIP.mp3": "8de2a91d10ce54deaa81980c03457963", + "j_b/[2022] Acoustic Treats/11 - Untold History.mp3": "966c57fcf80ab88e2f083625baf0b8bc", "j_b/[2022] Acoustic Treats/folder.jpg": "967892be44b8c47e1be73f055a7c6f08" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/unit/music/Bandcamp.json b/tests/resources/expected_downloads_summaries/unit/music/Bandcamp.json index 4cef1f71..a43434b3 100644 --- a/tests/resources/expected_downloads_summaries/unit/music/Bandcamp.json +++ b/tests/resources/expected_downloads_summaries/unit/music/Bandcamp.json @@ -1,9 +1,9 @@ { ".ytdl-sub-subscription_test-download-archive.json": "36b3c7143ac4257489791309d802af82", - "subscription_test/[2020] Download First/02 - Mock Entry 20-1.mp3": "9f60dbd2cb17b5f749cfdfa3f88526c6", - "subscription_test/[2020] Download First/03 - Mock Entry 20-2.mp3": "18098c59793c73f1da94eeefe33c078e", - "subscription_test/[2020] Download First/04 - Mock Entry 20-3.mp3": "51d9b693b0e8b80ee51b53e544e25bc6", + "subscription_test/[2020] Download First/02 - Mock Entry 20-1.mp3": "f7169b5892ebb4285c33ee153abdbfdc", + "subscription_test/[2020] Download First/03 - Mock Entry 20-2.mp3": "7ffd6a9e71c7f327e8f9765ad4645a0e", + "subscription_test/[2020] Download First/04 - Mock Entry 20-3.mp3": "d3718a85ccd6cd03634cba94927496a6", "subscription_test/[2020] Download First/folder.jpg": "e80c508c4818454300133fe1dc1a9cd7", - "subscription_test/[2021] Download First/01 - Mock Entry 21-1.mp3": "9d3561bc8e348273aae58fe97d7ce5ba", + "subscription_test/[2021] Download First/01 - Mock Entry 21-1.mp3": "6b9c7da5acce22b180fd42a463806a68", "subscription_test/[2021] Download First/folder.jpg": "e80c508c4818454300133fe1dc1a9cd7" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/unit/music/Single.json b/tests/resources/expected_downloads_summaries/unit/music/Single.json index a5083761..fe7acd68 100644 --- a/tests/resources/expected_downloads_summaries/unit/music/Single.json +++ b/tests/resources/expected_downloads_summaries/unit/music/Single.json @@ -1,11 +1,11 @@ { ".ytdl-sub-subscription_test-download-archive.json": "9f6f4458d42da4561db236473896fe71", - "subscription_test/[2020] Mock Entry 20-1/01 - Mock Entry 20-1.mp3": "768c5ef83f17030803de16f8c3a698e5", + "subscription_test/[2020] Mock Entry 20-1/01 - Mock Entry 20-1.mp3": "c967fcb589fca43fb3ae9038290e3a49", "subscription_test/[2020] Mock Entry 20-1/folder.jpg": "e80c508c4818454300133fe1dc1a9cd7", - "subscription_test/[2020] Mock Entry 20-2/01 - Mock Entry 20-2.mp3": "def09340d707bb079f8fd852a6af12e7", + "subscription_test/[2020] Mock Entry 20-2/01 - Mock Entry 20-2.mp3": "5f22ce0cdaa93518786f2d68ff088a83", "subscription_test/[2020] Mock Entry 20-2/folder.jpg": "e80c508c4818454300133fe1dc1a9cd7", - "subscription_test/[2020] Mock Entry 20-3/01 - Mock Entry 20-3.mp3": "29f180ed448ae5ab6e174880cd4c6da5", + "subscription_test/[2020] Mock Entry 20-3/01 - Mock Entry 20-3.mp3": "645dedee58a794da96e0e29129e90744", "subscription_test/[2020] Mock Entry 20-3/folder.jpg": "e80c508c4818454300133fe1dc1a9cd7", - "subscription_test/[2021] Mock Entry 21-1/01 - Mock Entry 21-1.mp3": "530ff53239278e5f400b49e33e13de19", + "subscription_test/[2021] Mock Entry 21-1/01 - Mock Entry 21-1.mp3": "ad3e8f379cae386dd6d76d5af4552d59", "subscription_test/[2021] Mock Entry 21-1/folder.jpg": "e80c508c4818454300133fe1dc1a9cd7" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/unit/music/SoundCloud Discography.json b/tests/resources/expected_downloads_summaries/unit/music/SoundCloud Discography.json index c850de5c..20694c1f 100644 --- a/tests/resources/expected_downloads_summaries/unit/music/SoundCloud Discography.json +++ b/tests/resources/expected_downloads_summaries/unit/music/SoundCloud Discography.json @@ -1,17 +1,17 @@ { ".ytdl-sub-subscription_test-download-archive.json": "71c01e840f508dad1f7cf2b533bdb9d3", - "subscription_test/[2020] Download First/02 - Mock Entry 20-1.mp3": "9f60dbd2cb17b5f749cfdfa3f88526c6", - "subscription_test/[2020] Download First/03 - Mock Entry 20-2.mp3": "18098c59793c73f1da94eeefe33c078e", - "subscription_test/[2020] Download First/04 - Mock Entry 20-3.mp3": "51d9b693b0e8b80ee51b53e544e25bc6", + "subscription_test/[2020] Download First/02 - Mock Entry 20-1.mp3": "f7169b5892ebb4285c33ee153abdbfdc", + "subscription_test/[2020] Download First/03 - Mock Entry 20-2.mp3": "7ffd6a9e71c7f327e8f9765ad4645a0e", + "subscription_test/[2020] Download First/04 - Mock Entry 20-3.mp3": "d3718a85ccd6cd03634cba94927496a6", "subscription_test/[2020] Download First/folder.jpg": "e80c508c4818454300133fe1dc1a9cd7", - "subscription_test/[2020] Mock Entry 20-4/01 - Mock Entry 20-4.mp3": "05d949f0667de8ffdd4c34f077c4f0d0", + "subscription_test/[2020] Mock Entry 20-4/01 - Mock Entry 20-4.mp3": "98161c8844f491a30b76a8ad436ea062", "subscription_test/[2020] Mock Entry 20-4/folder.jpg": "e80c508c4818454300133fe1dc1a9cd7", - "subscription_test/[2020] Mock Entry 20-5/01 - Mock Entry 20-5.mp3": "e871867e586d0cf9f5173419d609e9f0", + "subscription_test/[2020] Mock Entry 20-5/01 - Mock Entry 20-5.mp3": "7c18a92f2f06a0409f5e4289a014ddf1", "subscription_test/[2020] Mock Entry 20-5/folder.jpg": "e80c508c4818454300133fe1dc1a9cd7", - "subscription_test/[2020] Mock Entry 20-6/01 - Mock Entry 20-6.mp3": "c66c49f38a8327e7148244f0af820c49", + "subscription_test/[2020] Mock Entry 20-6/01 - Mock Entry 20-6.mp3": "0869ae0c4d537c5d50769eb73d5a65c9", "subscription_test/[2020] Mock Entry 20-6/folder.jpg": "e80c508c4818454300133fe1dc1a9cd7", - "subscription_test/[2020] Mock Entry 20-7/01 - Mock Entry 20-7.mp3": "2c339b41c55019999990a66740f4d1c3", + "subscription_test/[2020] Mock Entry 20-7/01 - Mock Entry 20-7.mp3": "65d8c2d2f7414b0828dff0e60d23e66f", "subscription_test/[2020] Mock Entry 20-7/folder.jpg": "e80c508c4818454300133fe1dc1a9cd7", - "subscription_test/[2021] Download First/01 - Mock Entry 21-1.mp3": "9d3561bc8e348273aae58fe97d7ce5ba", + "subscription_test/[2021] Download First/01 - Mock Entry 21-1.mp3": "6b9c7da5acce22b180fd42a463806a68", "subscription_test/[2021] Download First/folder.jpg": "e80c508c4818454300133fe1dc1a9cd7" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/unit/music/YouTube Full Albums.json b/tests/resources/expected_downloads_summaries/unit/music/YouTube Full Albums.json index a5083761..fe7acd68 100644 --- a/tests/resources/expected_downloads_summaries/unit/music/YouTube Full Albums.json +++ b/tests/resources/expected_downloads_summaries/unit/music/YouTube Full Albums.json @@ -1,11 +1,11 @@ { ".ytdl-sub-subscription_test-download-archive.json": "9f6f4458d42da4561db236473896fe71", - "subscription_test/[2020] Mock Entry 20-1/01 - Mock Entry 20-1.mp3": "768c5ef83f17030803de16f8c3a698e5", + "subscription_test/[2020] Mock Entry 20-1/01 - Mock Entry 20-1.mp3": "c967fcb589fca43fb3ae9038290e3a49", "subscription_test/[2020] Mock Entry 20-1/folder.jpg": "e80c508c4818454300133fe1dc1a9cd7", - "subscription_test/[2020] Mock Entry 20-2/01 - Mock Entry 20-2.mp3": "def09340d707bb079f8fd852a6af12e7", + "subscription_test/[2020] Mock Entry 20-2/01 - Mock Entry 20-2.mp3": "5f22ce0cdaa93518786f2d68ff088a83", "subscription_test/[2020] Mock Entry 20-2/folder.jpg": "e80c508c4818454300133fe1dc1a9cd7", - "subscription_test/[2020] Mock Entry 20-3/01 - Mock Entry 20-3.mp3": "29f180ed448ae5ab6e174880cd4c6da5", + "subscription_test/[2020] Mock Entry 20-3/01 - Mock Entry 20-3.mp3": "645dedee58a794da96e0e29129e90744", "subscription_test/[2020] Mock Entry 20-3/folder.jpg": "e80c508c4818454300133fe1dc1a9cd7", - "subscription_test/[2021] Mock Entry 21-1/01 - Mock Entry 21-1.mp3": "530ff53239278e5f400b49e33e13de19", + "subscription_test/[2021] Mock Entry 21-1/01 - Mock Entry 21-1.mp3": "ad3e8f379cae386dd6d76d5af4552d59", "subscription_test/[2021] Mock Entry 21-1/folder.jpg": "e80c508c4818454300133fe1dc1a9cd7" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/unit/music/YouTube Releases.json b/tests/resources/expected_downloads_summaries/unit/music/YouTube Releases.json index 4cef1f71..a43434b3 100644 --- a/tests/resources/expected_downloads_summaries/unit/music/YouTube Releases.json +++ b/tests/resources/expected_downloads_summaries/unit/music/YouTube Releases.json @@ -1,9 +1,9 @@ { ".ytdl-sub-subscription_test-download-archive.json": "36b3c7143ac4257489791309d802af82", - "subscription_test/[2020] Download First/02 - Mock Entry 20-1.mp3": "9f60dbd2cb17b5f749cfdfa3f88526c6", - "subscription_test/[2020] Download First/03 - Mock Entry 20-2.mp3": "18098c59793c73f1da94eeefe33c078e", - "subscription_test/[2020] Download First/04 - Mock Entry 20-3.mp3": "51d9b693b0e8b80ee51b53e544e25bc6", + "subscription_test/[2020] Download First/02 - Mock Entry 20-1.mp3": "f7169b5892ebb4285c33ee153abdbfdc", + "subscription_test/[2020] Download First/03 - Mock Entry 20-2.mp3": "7ffd6a9e71c7f327e8f9765ad4645a0e", + "subscription_test/[2020] Download First/04 - Mock Entry 20-3.mp3": "d3718a85ccd6cd03634cba94927496a6", "subscription_test/[2020] Download First/folder.jpg": "e80c508c4818454300133fe1dc1a9cd7", - "subscription_test/[2021] Download First/01 - Mock Entry 21-1.mp3": "9d3561bc8e348273aae58fe97d7ce5ba", + "subscription_test/[2021] Download First/01 - Mock Entry 21-1.mp3": "6b9c7da5acce22b180fd42a463806a68", "subscription_test/[2021] Download First/folder.jpg": "e80c508c4818454300133fe1dc1a9cd7" } \ No newline at end of file diff --git a/tests/resources/transaction_log_summaries/bandcamp/test_artist_url.txt b/tests/resources/transaction_log_summaries/bandcamp/test_artist_url.txt index f4b6d977..eb39eee4 100644 --- a/tests/resources/transaction_log_summaries/bandcamp/test_artist_url.txt +++ b/tests/resources/transaction_log_summaries/bandcamp/test_artist_url.txt @@ -10,7 +10,9 @@ Files created: albumartists: Sithu Aye artist: Sithu Aye artists: Sithu Aye + date: 2021-11-26 genres: Progressive Metal + original_date: 2021-11-26 title: Double Helix Reimagined track: 1 tracktotal: 10 @@ -22,7 +24,9 @@ Files created: albumartists: Sithu Aye artist: Sithu Aye artists: Sithu Aye + date: 2021-11-26 genres: Progressive Metal + original_date: 2021-11-26 title: Skye Reimagined track: 2 tracktotal: 10 @@ -34,7 +38,9 @@ Files created: albumartists: Sithu Aye artist: Sithu Aye artists: Sithu Aye + date: 2021-11-26 genres: Progressive Metal + original_date: 2021-11-26 title: Baryofusion track: 3 tracktotal: 10 @@ -46,7 +52,9 @@ Files created: albumartists: Sithu Aye artist: Sithu Aye artists: Sithu Aye + date: 2021-11-26 genres: Progressive Metal + original_date: 2021-11-26 title: Mandalay Reimagined track: 4 tracktotal: 10 @@ -58,7 +66,9 @@ Files created: albumartists: Sithu Aye artist: Sithu Aye artists: Sithu Aye + date: 2021-11-26 genres: Progressive Metal + original_date: 2021-11-26 title: Messenger EDM Remix track: 5 tracktotal: 10 @@ -72,7 +82,9 @@ Files created: albumartists: Sithu Aye artist: Sithu Aye artists: Sithu Aye + date: 2022-10-04 genres: Progressive Metal + original_date: 2022-10-04 title: Invent the Universe track: 1 tracktotal: 10 @@ -84,7 +96,9 @@ Files created: albumartists: Sithu Aye artist: Sithu Aye artists: Sithu Aye + date: 2022-10-04 genres: Progressive Metal + original_date: 2022-10-04 title: Grand Unification (feat. David Maxim Micic) track: 2 tracktotal: 10 @@ -96,7 +110,9 @@ Files created: albumartists: Sithu Aye artist: Sithu Aye artists: Sithu Aye + date: 2022-10-04 genres: Progressive Metal + original_date: 2022-10-04 title: Expansion track: 3 tracktotal: 10 @@ -108,7 +124,9 @@ Files created: albumartists: Sithu Aye artist: Sithu Aye artists: Sithu Aye + date: 2022-10-04 genres: Progressive Metal + original_date: 2022-10-04 title: Baryogenesis track: 4 tracktotal: 10 @@ -120,7 +138,9 @@ Files created: albumartists: Sithu Aye artist: Sithu Aye artists: Sithu Aye + date: 2022-10-04 genres: Progressive Metal + original_date: 2022-10-04 title: Particles Collide (feat. Plini) track: 5 tracktotal: 10 @@ -132,7 +152,9 @@ Files created: albumartists: Sithu Aye artist: Sithu Aye artists: Sithu Aye + date: 2022-10-04 genres: Progressive Metal + original_date: 2022-10-04 title: Nucleosynthesis track: 6 tracktotal: 10 @@ -144,7 +166,9 @@ Files created: albumartists: Sithu Aye artist: Sithu Aye artists: Sithu Aye + date: 2022-10-04 genres: Progressive Metal + original_date: 2022-10-04 title: Recombination track: 7 tracktotal: 10 @@ -156,7 +180,9 @@ Files created: albumartists: Sithu Aye artist: Sithu Aye artists: Sithu Aye + date: 2022-10-04 genres: Progressive Metal + original_date: 2022-10-04 title: Dark Ages track: 8 tracktotal: 10 @@ -168,7 +194,9 @@ Files created: albumartists: Sithu Aye artist: Sithu Aye artists: Sithu Aye + date: 2022-10-04 genres: Progressive Metal + original_date: 2022-10-04 title: Formation track: 9 tracktotal: 10 @@ -180,7 +208,9 @@ Files created: albumartists: Sithu Aye artist: Sithu Aye artists: Sithu Aye + date: 2022-10-04 genres: Progressive Metal + original_date: 2022-10-04 title: Pale Blue Dot track: 10 tracktotal: 10 diff --git a/tests/resources/transaction_log_summaries/plugins/split_by_chapters_video-dry-run.txt b/tests/resources/transaction_log_summaries/plugins/split_by_chapters_video-dry-run.txt index da1da8c1..9e5c6dc7 100644 --- a/tests/resources/transaction_log_summaries/plugins/split_by_chapters_video-dry-run.txt +++ b/tests/resources/transaction_log_summaries/plugins/split_by_chapters_video-dry-run.txt @@ -14,7 +14,9 @@ Files created: albumartists: Proved Records artist: Proved Records artists: Proved Records + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: 01. Intro (Feat. Racheal Ofori & Barney Artist) track: 1 tracktotal: 11 @@ -30,7 +32,9 @@ Files created: albumartists: Proved Records artist: Proved Records artists: Proved Records + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: 02. Answers (Feat. Rick David & Kaya Thomas - Dyke) track: 2 tracktotal: 11 @@ -46,7 +50,9 @@ Files created: albumartists: Proved Records artist: Proved Records artists: Proved Records + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: 03. Blaze (Feat. Kaya Thomas - Dyke) track: 3 tracktotal: 11 @@ -62,7 +68,9 @@ Files created: albumartists: Proved Records artist: Proved Records artists: Proved Records + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: 04. What If (Interlude) track: 4 tracktotal: 11 @@ -78,7 +86,9 @@ Files created: albumartists: Proved Records artist: Proved Records artists: Proved Records + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: 05. No Peace (Feat. Tom Misch) track: 5 tracktotal: 11 @@ -94,7 +104,9 @@ Files created: albumartists: Proved Records artist: Proved Records artists: Proved Records + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: 06. Closer (Feat. Lester Duval) track: 6 tracktotal: 11 @@ -110,7 +122,9 @@ Files created: albumartists: Proved Records artist: Proved Records artists: Proved Records + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: 07. Delusions: Rumination (Interlude) (Feat. Racheal Ofori) track: 7 tracktotal: 11 @@ -126,7 +140,9 @@ Files created: albumartists: Proved Records artist: Proved Records artists: Proved Records + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: 08. Dreams (Feat. Carmody) track: 8 tracktotal: 11 @@ -142,7 +158,9 @@ Files created: albumartists: Proved Records artist: Proved Records artists: Proved Records + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: 09. Dreaming (Interlude) (Feat. Racheal Ofori) track: 9 tracktotal: 11 @@ -158,7 +176,9 @@ Files created: albumartists: Proved Records artist: Proved Records artists: Proved Records + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: 10. Hopeful (Feat. Jordan Rakei) track: 10 tracktotal: 11 @@ -174,7 +194,9 @@ Files created: albumartists: Proved Records artist: Proved Records artists: Proved Records + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: 11. Sunrise (Pillows) (Feat. Emmavie) track: 11 tracktotal: 11 diff --git a/tests/resources/transaction_log_summaries/plugins/split_by_chapters_video.txt b/tests/resources/transaction_log_summaries/plugins/split_by_chapters_video.txt index 128f8c6b..ffaae7a1 100644 --- a/tests/resources/transaction_log_summaries/plugins/split_by_chapters_video.txt +++ b/tests/resources/transaction_log_summaries/plugins/split_by_chapters_video.txt @@ -13,7 +13,9 @@ Files created: albumartists: Proved Records artist: Proved Records artists: Proved Records + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: 01. Intro (Feat. Racheal Ofori & Barney Artist) track: 1 tracktotal: 11 @@ -28,7 +30,9 @@ Files created: albumartists: Proved Records artist: Proved Records artists: Proved Records + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: 02. Answers (Feat. Rick David & Kaya Thomas - Dyke) track: 2 tracktotal: 11 @@ -43,7 +47,9 @@ Files created: albumartists: Proved Records artist: Proved Records artists: Proved Records + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: 03. Blaze (Feat. Kaya Thomas - Dyke) track: 3 tracktotal: 11 @@ -58,7 +64,9 @@ Files created: albumartists: Proved Records artist: Proved Records artists: Proved Records + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: 04. What If (Interlude) track: 4 tracktotal: 11 @@ -73,7 +81,9 @@ Files created: albumartists: Proved Records artist: Proved Records artists: Proved Records + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: 05. No Peace (Feat. Tom Misch) track: 5 tracktotal: 11 @@ -88,7 +98,9 @@ Files created: albumartists: Proved Records artist: Proved Records artists: Proved Records + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: 06. Closer (Feat. Lester Duval) track: 6 tracktotal: 11 @@ -103,7 +115,9 @@ Files created: albumartists: Proved Records artist: Proved Records artists: Proved Records + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: 07. Delusions: Rumination (Interlude) (Feat. Racheal Ofori) track: 7 tracktotal: 11 @@ -118,7 +132,9 @@ Files created: albumartists: Proved Records artist: Proved Records artists: Proved Records + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: 08. Dreams (Feat. Carmody) track: 8 tracktotal: 11 @@ -133,7 +149,9 @@ Files created: albumartists: Proved Records artist: Proved Records artists: Proved Records + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: 09. Dreaming (Interlude) (Feat. Racheal Ofori) track: 9 tracktotal: 11 @@ -148,7 +166,9 @@ Files created: albumartists: Proved Records artist: Proved Records artists: Proved Records + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: 10. Hopeful (Feat. Jordan Rakei) track: 10 tracktotal: 11 @@ -163,7 +183,9 @@ Files created: albumartists: Proved Records artist: Proved Records artists: Proved Records + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: 11. Sunrise (Pillows) (Feat. Emmavie) track: 11 tracktotal: 11 diff --git a/tests/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_pass.txt b/tests/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_pass.txt index 717cfb93..9cccab33 100644 --- a/tests/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_pass.txt +++ b/tests/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_pass.txt @@ -10,7 +10,9 @@ Files created: albumartists: Project Zombie artist: Project Zombie artists: Project Zombie + date: 2010-08-13 genres: Unset + original_date: 2010-08-13 title: Oblivion Mod "Falcor" p.1 track: 1 tracktotal: 1 diff --git a/tests/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_video-dry-run.txt b/tests/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_video-dry-run.txt index 04afc59c..d0364f76 100644 --- a/tests/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_video-dry-run.txt +++ b/tests/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_video-dry-run.txt @@ -14,7 +14,9 @@ Files created: albumartists: Alfa Mist artist: Alfa Mist artists: Alfa Mist + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: Intro (Feat. Racheal Ofori & Barney Artist) track: 1 tracktotal: 11 @@ -30,7 +32,9 @@ Files created: albumartists: Alfa Mist artist: Alfa Mist artists: Alfa Mist + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: Answers (Feat. Rick David & Kaya Thomas - Dyke) track: 2 tracktotal: 11 @@ -46,7 +50,9 @@ Files created: albumartists: Alfa Mist artist: Alfa Mist artists: Alfa Mist + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: Blaze (Feat. Kaya Thomas - Dyke) track: 3 tracktotal: 11 @@ -62,7 +68,9 @@ Files created: albumartists: Alfa Mist artist: Alfa Mist artists: Alfa Mist + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: What If (Interlude) track: 4 tracktotal: 11 @@ -78,7 +86,9 @@ Files created: albumartists: Alfa Mist artist: Alfa Mist artists: Alfa Mist + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: No Peace (Feat. Tom Misch) track: 5 tracktotal: 11 @@ -94,7 +104,9 @@ Files created: albumartists: Alfa Mist artist: Alfa Mist artists: Alfa Mist + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: Closer (Feat. Lester Duval) track: 6 tracktotal: 11 @@ -110,7 +122,9 @@ Files created: albumartists: Alfa Mist artist: Alfa Mist artists: Alfa Mist + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: Delusions: Rumination (Interlude) (Feat. Racheal Ofori) track: 7 tracktotal: 11 @@ -126,7 +140,9 @@ Files created: albumartists: Alfa Mist artist: Alfa Mist artists: Alfa Mist + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: Dreams (Feat. Carmody) track: 8 tracktotal: 11 @@ -142,7 +158,9 @@ Files created: albumartists: Alfa Mist artist: Alfa Mist artists: Alfa Mist + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: Dreaming (Interlude) (Feat. Racheal Ofori) track: 9 tracktotal: 11 @@ -158,7 +176,9 @@ Files created: albumartists: Alfa Mist artist: Alfa Mist artists: Alfa Mist + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: Hopeful (Feat. Jordan Rakei) track: 10 tracktotal: 11 @@ -174,7 +194,9 @@ Files created: albumartists: Alfa Mist artist: Alfa Mist artists: Alfa Mist + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: Sunrise (Pillows) (Feat. Emmavie) track: 11 tracktotal: 11 diff --git a/tests/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_video.txt b/tests/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_video.txt index 619ff263..3f07edd1 100644 --- a/tests/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_video.txt +++ b/tests/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_video.txt @@ -13,7 +13,9 @@ Files created: albumartists: Alfa Mist artist: Alfa Mist artists: Alfa Mist + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: Intro (Feat. Racheal Ofori & Barney Artist) track: 1 tracktotal: 11 @@ -28,7 +30,9 @@ Files created: albumartists: Alfa Mist artist: Alfa Mist artists: Alfa Mist + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: Answers (Feat. Rick David & Kaya Thomas - Dyke) track: 2 tracktotal: 11 @@ -43,7 +47,9 @@ Files created: albumartists: Alfa Mist artist: Alfa Mist artists: Alfa Mist + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: Blaze (Feat. Kaya Thomas - Dyke) track: 3 tracktotal: 11 @@ -58,7 +64,9 @@ Files created: albumartists: Alfa Mist artist: Alfa Mist artists: Alfa Mist + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: What If (Interlude) track: 4 tracktotal: 11 @@ -73,7 +81,9 @@ Files created: albumartists: Alfa Mist artist: Alfa Mist artists: Alfa Mist + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: No Peace (Feat. Tom Misch) track: 5 tracktotal: 11 @@ -88,7 +98,9 @@ Files created: albumartists: Alfa Mist artist: Alfa Mist artists: Alfa Mist + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: Closer (Feat. Lester Duval) track: 6 tracktotal: 11 @@ -103,7 +115,9 @@ Files created: albumartists: Alfa Mist artist: Alfa Mist artists: Alfa Mist + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: Delusions: Rumination (Interlude) (Feat. Racheal Ofori) track: 7 tracktotal: 11 @@ -118,7 +132,9 @@ Files created: albumartists: Alfa Mist artist: Alfa Mist artists: Alfa Mist + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: Dreams (Feat. Carmody) track: 8 tracktotal: 11 @@ -133,7 +149,9 @@ Files created: albumartists: Alfa Mist artist: Alfa Mist artists: Alfa Mist + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: Dreaming (Interlude) (Feat. Racheal Ofori) track: 9 tracktotal: 11 @@ -148,7 +166,9 @@ Files created: albumartists: Alfa Mist artist: Alfa Mist artists: Alfa Mist + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: Hopeful (Feat. Jordan Rakei) track: 10 tracktotal: 11 @@ -163,7 +183,9 @@ Files created: albumartists: Alfa Mist artist: Alfa Mist artists: Alfa Mist + date: 2017-04-02 genres: Unset + original_date: 2017-04-02 title: Sunrise (Pillows) (Feat. Emmavie) track: 11 tracktotal: 11 diff --git a/tests/resources/transaction_log_summaries/plugins/test_audio_extract_playlist.txt b/tests/resources/transaction_log_summaries/plugins/test_audio_extract_playlist.txt index 385763f2..1430099c 100644 --- a/tests/resources/transaction_log_summaries/plugins/test_audio_extract_playlist.txt +++ b/tests/resources/transaction_log_summaries/plugins/test_audio_extract_playlist.txt @@ -10,7 +10,9 @@ Files created: albumartists: Project Zombie artist: Project Zombie artists: Project Zombie + date: 2011-03-21 genres: Unset + original_date: 2011-03-21 title: Jesse's Minecraft Server [Trailer - Mar.21] track: 1 tracktotal: 3 @@ -22,7 +24,9 @@ Files created: albumartists: Project Zombie artist: Project Zombie artists: Project Zombie + date: 2011-02-27 genres: Unset + original_date: 2011-02-27 title: Jesse's Minecraft Server [Trailer - Feb.27] track: 2 tracktotal: 3 @@ -34,7 +38,9 @@ Files created: albumartists: Project Zombie artist: Project Zombie artists: Project Zombie + date: 2011-02-01 genres: Unset + original_date: 2011-02-01 title: Jesse's Minecraft Server [Trailer - Feb.1] track: 3 tracktotal: 3 diff --git a/tests/resources/transaction_log_summaries/plugins/test_audio_extract_single.txt b/tests/resources/transaction_log_summaries/plugins/test_audio_extract_single.txt index 914485d4..9df078ac 100644 --- a/tests/resources/transaction_log_summaries/plugins/test_audio_extract_single.txt +++ b/tests/resources/transaction_log_summaries/plugins/test_audio_extract_single.txt @@ -10,10 +10,12 @@ Files created: albumartists: YouTube artist: YouTube artists: YouTube + date: 2019-12-05 genres: - Unset - multi_tag_1 - multi_tag_2 + original_date: 2019-12-05 title: YouTube Rewind 2019: For the Record | #YouTubeRewind track: 1 tracktotal: 1 diff --git a/tests/resources/transaction_log_summaries/plugins/test_audio_extract_single_best.txt b/tests/resources/transaction_log_summaries/plugins/test_audio_extract_single_best.txt index 07cb8132..557579ec 100644 --- a/tests/resources/transaction_log_summaries/plugins/test_audio_extract_single_best.txt +++ b/tests/resources/transaction_log_summaries/plugins/test_audio_extract_single_best.txt @@ -10,10 +10,12 @@ Files created: albumartists: YouTube artist: YouTube artists: YouTube + date: 2019-12-05 genres: - Unset - multi_tag_1 - multi_tag_2 + original_date: 2019-12-05 title: YouTube Rewind 2019: For the Record | #YouTubeRewind track: 1 tracktotal: 1 diff --git a/tests/resources/transaction_log_summaries/plugins/test_audio_extract_single_best_dry_run.txt b/tests/resources/transaction_log_summaries/plugins/test_audio_extract_single_best_dry_run.txt index 23662d3e..dd88924a 100644 --- a/tests/resources/transaction_log_summaries/plugins/test_audio_extract_single_best_dry_run.txt +++ b/tests/resources/transaction_log_summaries/plugins/test_audio_extract_single_best_dry_run.txt @@ -11,10 +11,12 @@ Files created: albumartists: YouTube artist: YouTube artists: YouTube + date: 2019-12-05 genres: - Unset - multi_tag_1 - multi_tag_2 + original_date: 2019-12-05 title: YouTube Rewind 2019: For the Record | #YouTubeRewind track: 1 tracktotal: 1 diff --git a/tests/resources/transaction_log_summaries/soundcloud/test_soundcloud_discography.txt b/tests/resources/transaction_log_summaries/soundcloud/test_soundcloud_discography.txt index 1a2ab30c..8a713e40 100644 --- a/tests/resources/transaction_log_summaries/soundcloud/test_soundcloud_discography.txt +++ b/tests/resources/transaction_log_summaries/soundcloud/test_soundcloud_discography.txt @@ -10,7 +10,9 @@ Files created: albumartists: j_b artist: j_b artists: j_b + date: 2021-10-29 genres: Acoustic + original_date: 2021-10-29 title: Baby Santana's Dorian Groove track: 1 tracktotal: 1 @@ -24,7 +26,9 @@ Files created: albumartists: j_b artist: j_b artists: j_b + date: 2021-01-18 genres: Acoustic + original_date: 2021-01-18 title: Purple Clouds track: 1 tracktotal: 1 @@ -38,7 +42,9 @@ Files created: albumartists: j_b artist: j_b artists: j_b + date: 2022-05-29 genres: Acoustic + original_date: 2022-05-29 title: 20160426 184214 track: 1 tracktotal: 11 @@ -50,7 +56,9 @@ Files created: albumartists: j_b artist: j_b artists: j_b + date: 2022-05-29 genres: Acoustic + original_date: 2022-05-29 title: 20160502 123150 track: 2 tracktotal: 11 @@ -62,7 +70,9 @@ Files created: albumartists: j_b artist: j_b artists: j_b + date: 2022-05-29 genres: Acoustic + original_date: 2022-05-29 title: 20160504 143832 track: 3 tracktotal: 11 @@ -74,7 +84,9 @@ Files created: albumartists: j_b artist: j_b artists: j_b + date: 2022-05-29 genres: Acoustic + original_date: 2022-05-29 title: 20160601 221234 track: 4 tracktotal: 11 @@ -86,7 +98,9 @@ Files created: albumartists: j_b artist: j_b artists: j_b + date: 2022-05-29 genres: Acoustic + original_date: 2022-05-29 title: 20160601 222440 track: 5 tracktotal: 11 @@ -98,7 +112,9 @@ Files created: albumartists: j_b artist: j_b artists: j_b + date: 2022-05-29 genres: Acoustic + original_date: 2022-05-29 title: 20170604 190236 track: 6 tracktotal: 11 @@ -110,7 +126,9 @@ Files created: albumartists: j_b artist: j_b artists: j_b + date: 2022-05-29 genres: Acoustic + original_date: 2022-05-29 title: 20170612 193646 track: 7 tracktotal: 11 @@ -122,7 +140,9 @@ Files created: albumartists: j_b artist: j_b artists: j_b + date: 2022-05-29 genres: Acoustic + original_date: 2022-05-29 title: 20170628 215206 track: 8 tracktotal: 11 @@ -134,7 +154,9 @@ Files created: albumartists: j_b artist: j_b artists: j_b + date: 2022-05-29 genres: Acoustic + original_date: 2022-05-29 title: Finding Home track: 9 tracktotal: 11 @@ -146,7 +168,9 @@ Files created: albumartists: j_b artist: j_b artists: j_b + date: 2022-05-29 genres: Acoustic + original_date: 2022-05-29 title: Shallow Water WIP track: 10 tracktotal: 11 @@ -158,7 +182,9 @@ Files created: albumartists: j_b artist: j_b artists: j_b + date: 2022-05-29 genres: Acoustic + original_date: 2022-05-29 title: Untold History track: 11 tracktotal: 11 diff --git a/tests/resources/transaction_log_summaries/unit/music/Bandcamp.txt b/tests/resources/transaction_log_summaries/unit/music/Bandcamp.txt index 669e1bda..aad6ba11 100644 --- a/tests/resources/transaction_log_summaries/unit/music/Bandcamp.txt +++ b/tests/resources/transaction_log_summaries/unit/music/Bandcamp.txt @@ -10,7 +10,9 @@ Files created: albumartists: subscription_test artist: subscription_test artists: subscription_test + date: 2020-08-08 genres: Unset + original_date: 2020-08-08 title: Mock Entry 20-1 track: 2 tracktotal: 4 @@ -22,7 +24,9 @@ Files created: albumartists: subscription_test artist: subscription_test artists: subscription_test + date: 2020-08-08 genres: Unset + original_date: 2020-08-08 title: Mock Entry 20-2 track: 3 tracktotal: 4 @@ -34,7 +38,9 @@ Files created: albumartists: subscription_test artist: subscription_test artists: subscription_test + date: 2020-08-07 genres: Unset + original_date: 2020-08-07 title: Mock Entry 20-3 track: 4 tracktotal: 4 @@ -48,7 +54,9 @@ Files created: albumartists: subscription_test artist: subscription_test artists: subscription_test + date: 2021-08-08 genres: Unset + original_date: 2021-08-08 title: Mock Entry 21-1 track: 1 tracktotal: 4 diff --git a/tests/resources/transaction_log_summaries/unit/music/Single.txt b/tests/resources/transaction_log_summaries/unit/music/Single.txt index b0dd752b..5137c6b2 100644 --- a/tests/resources/transaction_log_summaries/unit/music/Single.txt +++ b/tests/resources/transaction_log_summaries/unit/music/Single.txt @@ -10,7 +10,9 @@ Files created: albumartists: subscription_test artist: subscription_test artists: subscription_test + date: 2020-08-08 genres: Unset + original_date: 2020-08-08 title: Mock Entry 20-1 track: 1 tracktotal: 1 @@ -24,7 +26,9 @@ Files created: albumartists: subscription_test artist: subscription_test artists: subscription_test + date: 2020-08-08 genres: Unset + original_date: 2020-08-08 title: Mock Entry 20-2 track: 1 tracktotal: 1 @@ -38,7 +42,9 @@ Files created: albumartists: subscription_test artist: subscription_test artists: subscription_test + date: 2020-08-07 genres: Unset + original_date: 2020-08-07 title: Mock Entry 20-3 track: 1 tracktotal: 1 @@ -52,7 +58,9 @@ Files created: albumartists: subscription_test artist: subscription_test artists: subscription_test + date: 2021-08-08 genres: Unset + original_date: 2021-08-08 title: Mock Entry 21-1 track: 1 tracktotal: 1 diff --git a/tests/resources/transaction_log_summaries/unit/music/SoundCloud Discography.txt b/tests/resources/transaction_log_summaries/unit/music/SoundCloud Discography.txt index 1e762076..ab148911 100644 --- a/tests/resources/transaction_log_summaries/unit/music/SoundCloud Discography.txt +++ b/tests/resources/transaction_log_summaries/unit/music/SoundCloud Discography.txt @@ -10,7 +10,9 @@ Files created: albumartists: subscription_test artist: subscription_test artists: subscription_test + date: 2020-08-08 genres: Unset + original_date: 2020-08-08 title: Mock Entry 20-1 track: 2 tracktotal: 4 @@ -22,7 +24,9 @@ Files created: albumartists: subscription_test artist: subscription_test artists: subscription_test + date: 2020-08-08 genres: Unset + original_date: 2020-08-08 title: Mock Entry 20-2 track: 3 tracktotal: 4 @@ -34,7 +38,9 @@ Files created: albumartists: subscription_test artist: subscription_test artists: subscription_test + date: 2020-08-07 genres: Unset + original_date: 2020-08-07 title: Mock Entry 20-3 track: 4 tracktotal: 4 @@ -48,7 +54,9 @@ Files created: albumartists: subscription_test artist: subscription_test artists: subscription_test + date: 2020-08-06 genres: Unset + original_date: 2020-08-06 title: Mock Entry 20-4 track: 1 tracktotal: 1 @@ -62,7 +70,9 @@ Files created: albumartists: subscription_test artist: subscription_test artists: subscription_test + date: 2020-07-06 genres: Unset + original_date: 2020-07-06 title: Mock Entry 20-5 track: 1 tracktotal: 1 @@ -76,7 +86,9 @@ Files created: albumartists: subscription_test artist: subscription_test artists: subscription_test + date: 2020-07-06 genres: Unset + original_date: 2020-07-06 title: Mock Entry 20-6 track: 1 tracktotal: 1 @@ -90,7 +102,9 @@ Files created: albumartists: subscription_test artist: subscription_test artists: subscription_test + date: 2020-06-06 genres: Unset + original_date: 2020-06-06 title: Mock Entry 20-7 track: 1 tracktotal: 1 @@ -104,7 +118,9 @@ Files created: albumartists: subscription_test artist: subscription_test artists: subscription_test + date: 2021-08-08 genres: Unset + original_date: 2021-08-08 title: Mock Entry 21-1 track: 1 tracktotal: 4 diff --git a/tests/resources/transaction_log_summaries/unit/music/YouTube Full Albums.txt b/tests/resources/transaction_log_summaries/unit/music/YouTube Full Albums.txt index b0dd752b..5137c6b2 100644 --- a/tests/resources/transaction_log_summaries/unit/music/YouTube Full Albums.txt +++ b/tests/resources/transaction_log_summaries/unit/music/YouTube Full Albums.txt @@ -10,7 +10,9 @@ Files created: albumartists: subscription_test artist: subscription_test artists: subscription_test + date: 2020-08-08 genres: Unset + original_date: 2020-08-08 title: Mock Entry 20-1 track: 1 tracktotal: 1 @@ -24,7 +26,9 @@ Files created: albumartists: subscription_test artist: subscription_test artists: subscription_test + date: 2020-08-08 genres: Unset + original_date: 2020-08-08 title: Mock Entry 20-2 track: 1 tracktotal: 1 @@ -38,7 +42,9 @@ Files created: albumartists: subscription_test artist: subscription_test artists: subscription_test + date: 2020-08-07 genres: Unset + original_date: 2020-08-07 title: Mock Entry 20-3 track: 1 tracktotal: 1 @@ -52,7 +58,9 @@ Files created: albumartists: subscription_test artist: subscription_test artists: subscription_test + date: 2021-08-08 genres: Unset + original_date: 2021-08-08 title: Mock Entry 21-1 track: 1 tracktotal: 1 diff --git a/tests/resources/transaction_log_summaries/unit/music/YouTube Releases.txt b/tests/resources/transaction_log_summaries/unit/music/YouTube Releases.txt index 669e1bda..aad6ba11 100644 --- a/tests/resources/transaction_log_summaries/unit/music/YouTube Releases.txt +++ b/tests/resources/transaction_log_summaries/unit/music/YouTube Releases.txt @@ -10,7 +10,9 @@ Files created: albumartists: subscription_test artist: subscription_test artists: subscription_test + date: 2020-08-08 genres: Unset + original_date: 2020-08-08 title: Mock Entry 20-1 track: 2 tracktotal: 4 @@ -22,7 +24,9 @@ Files created: albumartists: subscription_test artist: subscription_test artists: subscription_test + date: 2020-08-08 genres: Unset + original_date: 2020-08-08 title: Mock Entry 20-2 track: 3 tracktotal: 4 @@ -34,7 +38,9 @@ Files created: albumartists: subscription_test artist: subscription_test artists: subscription_test + date: 2020-08-07 genres: Unset + original_date: 2020-08-07 title: Mock Entry 20-3 track: 4 tracktotal: 4 @@ -48,7 +54,9 @@ Files created: albumartists: subscription_test artist: subscription_test artists: subscription_test + date: 2021-08-08 genres: Unset + original_date: 2021-08-08 title: Mock Entry 21-1 track: 1 tracktotal: 4 From a02e44f09d38e8a3e990772e78b4ab582bb2a16a Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Wed, 28 Feb 2024 15:05:39 -0800 Subject: [PATCH 12/39] [FEATURE] Add dedicated `track_date` and `track_original_date` variables (#939) As title, to make it easier to override --- src/ytdl_sub/prebuilt_presets/music/singles.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ytdl_sub/prebuilt_presets/music/singles.yaml b/src/ytdl_sub/prebuilt_presets/music/singles.yaml index 1a6acb96..dc7b5a25 100644 --- a/src/ytdl_sub/prebuilt_presets/music/singles.yaml +++ b/src/ytdl_sub/prebuilt_presets/music/singles.yaml @@ -24,8 +24,8 @@ presets: track: "{track_number}" tracktotal: "{track_total}" year: "{track_year}" - date: "{upload_date_standardized}" - original_date: "{upload_date_standardized}" + date: "{track_date}" + original_date: "{track_original_date}" # multi-tags artists: - "{track_artist}" @@ -55,6 +55,8 @@ presets: track_number_padded: "01" track_total: "1" track_year: "{upload_year}" + track_date: "{upload_date_standardized}" + track_original_date: "{track_date}" track_genre: "{subscription_indent_1}" # Directory Overrides From f940d3ec3efa013396ff24d1b950927cbecab44e Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Mon, 18 Mar 2024 22:29:35 -0700 Subject: [PATCH 13/39] [BACKEND] yt-dlp 2024.3.10 (#943) --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 2aa2a99e..011990fe 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,7 +27,7 @@ package_dir = packages=find: install_requires = - yt-dlp==2023.12.30 + yt-dlp==2024.3.10 argparse==1.4.0 colorama==0.4.6 mergedeep==1.3.4 From 82c503c5154d9126a7c9f375619eec366060e4dd Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Mon, 25 Mar 2024 00:25:28 -0700 Subject: [PATCH 14/39] [DEV] Update fixtures (#947) * [DEV] Update fixtures * bandcamp --- .../bandcamp/test_artist_url.json | 7 +++-- .../youtube/test_channel_full.json | 4 +-- .../youtube/test_playlist.json | 2 +- .../test_playlist_archive_migrated.json | 2 +- .../bandcamp/test_artist_url.txt | 30 ++++++++++--------- 5 files changed, 24 insertions(+), 21 deletions(-) diff --git a/tests/resources/expected_downloads_summaries/bandcamp/test_artist_url.json b/tests/resources/expected_downloads_summaries/bandcamp/test_artist_url.json index 4acda2de..f56b6371 100644 --- a/tests/resources/expected_downloads_summaries/bandcamp/test_artist_url.json +++ b/tests/resources/expected_downloads_summaries/bandcamp/test_artist_url.json @@ -1,10 +1,9 @@ { - ".ytdl-sub-Sithu Aye-download-archive.json": "ca37a404a860a2a6b6b0f38b659c9f17", + ".ytdl-sub-Sithu Aye-download-archive.json": "1c4bcf58581eac1851be92ee29a3c4d7", "Sithu Aye/[2021] 10 Years: Remixes and Reimaginings/01 - Double Helix Reimagined.mp3": "ac0e6a2936c309765c69a4c98c42ad10", "Sithu Aye/[2021] 10 Years: Remixes and Reimaginings/02 - Skye Reimagined.mp3": "38387bd0ec5fc229e30b1a8ce5b9cddb", "Sithu Aye/[2021] 10 Years: Remixes and Reimaginings/03 - Baryofusion.mp3": "344dbb939b09713dcc33894a8b1b8459", "Sithu Aye/[2021] 10 Years: Remixes and Reimaginings/04 - Mandalay Reimagined.mp3": "9bacbd5166a740c06d3329dcfae0d9aa", - "Sithu Aye/[2021] 10 Years: Remixes and Reimaginings/05 - Messenger EDM Remix.mp3": "ee20efe93c637ba4347431dc11b66691", "Sithu Aye/[2021] 10 Years: Remixes and Reimaginings/folder.jpg": "bf6f70d51557a71b69fed85b2cb476f0", "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/01 - Invent the Universe.mp3": "482e97a4f9a30aa4413f45f5f3f5dbba", "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/02 - Grand Unification (feat. David Maxim Micic).mp3": "34e43fd80b4c61295abbb8b794e523a7", @@ -16,5 +15,7 @@ "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/08 - Dark Ages.mp3": "28e6a73bea1cd881e5ad38d57d1426c5", "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/09 - Formation.mp3": "d588ed8edc324ea657abb2be96d557a1", "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/10 - Pale Blue Dot.mp3": "8245d285ba2403bf512f83c4a585eb81", - "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/folder.jpg": "d8cffeca026afaa619f641a95143f803" + "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/folder.jpg": "d8cffeca026afaa619f641a95143f803", + "Sithu Aye/[2024] Kindness/01 - Run it Down.mp3": "9570867fd53419c1297411c9bf877a62", + "Sithu Aye/[2024] Kindness/folder.jpg": "8f72c9acb1a2e49fcbdc2624a46b229c" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/youtube/test_channel_full.json b/tests/resources/expected_downloads_summaries/youtube/test_channel_full.json index 386defa9..15d3fd4d 100644 --- a/tests/resources/expected_downloads_summaries/youtube/test_channel_full.json +++ b/tests/resources/expected_downloads_summaries/youtube/test_channel_full.json @@ -10,7 +10,7 @@ "Project ⧸ Zombie/Season 2010/s2010.e120201 - Oblivion Mod "Falcor" p.2.nfo": "4ad498ce223454a4baa7d64bb4a837d6", "Project ⧸ Zombie/Season 2011/s2011.e020101 - Jesse's Minecraft Server [Trailer - Feb.1]-thumb.jpg": "b232d253df621aa770b780c1301d364d", "Project ⧸ Zombie/Season 2011/s2011.e020101 - Jesse's Minecraft Server [Trailer - Feb.1].info.json": "INFO_JSON", - "Project ⧸ Zombie/Season 2011/s2011.e020101 - Jesse's Minecraft Server [Trailer - Feb.1].mp4": "ae1d5e2e3979cea3c96e6a4cfcae8073", + "Project ⧸ Zombie/Season 2011/s2011.e020101 - Jesse's Minecraft Server [Trailer - Feb.1].mp4": "0c4610497723e58fd72ea7bc68ac19ad", "Project ⧸ Zombie/Season 2011/s2011.e020101 - Jesse's Minecraft Server [Trailer - Feb.1].nfo": "073eefa6e5c6d76edde80258ddf452ee", "Project ⧸ Zombie/Season 2011/s2011.e022701 - Jesse's Minecraft Server [Trailer - Feb.27]-thumb.jpg": "d17c379ea8b362f5b97c6b213b0342cb", "Project ⧸ Zombie/Season 2011/s2011.e022701 - Jesse's Minecraft Server [Trailer - Feb.27].info.json": "INFO_JSON", @@ -26,7 +26,7 @@ "Project ⧸ Zombie/Season 2011/s2011.e052901 - Project Zombie |Official Trailer| (IP: mc.projectzombie.beastnode.net).nfo": "cb0184784a8eda842cfaf851f6e6af7d", "Project ⧸ Zombie/Season 2011/s2011.e063001 - Project Zombie |Fin|-thumb.jpg": "00ed383591779ffe98291de60f198fe9", "Project ⧸ Zombie/Season 2011/s2011.e063001 - Project Zombie |Fin|.info.json": "INFO_JSON", - "Project ⧸ Zombie/Season 2011/s2011.e063001 - Project Zombie |Fin|.mp4": "33b5f10d819e5a8f0a87c28116b90bdc", + "Project ⧸ Zombie/Season 2011/s2011.e063001 - Project Zombie |Fin|.mp4": "5f9f8841aed3f5d95efbd1839045741e", "Project ⧸ Zombie/Season 2011/s2011.e063001 - Project Zombie |Fin|.nfo": "54ea4a48116aa98480a79495036c25e9", "Project ⧸ Zombie/Season 2011/s2011.e112101 - Skyrim 'Ultra HD w⧸Mods' [PC]-thumb.jpg": "1718599d5189c65f7d8cf6acfa5ea851", "Project ⧸ Zombie/Season 2011/s2011.e112101 - Skyrim 'Ultra HD w⧸Mods' [PC].info.json": "INFO_JSON", diff --git a/tests/resources/expected_downloads_summaries/youtube/test_playlist.json b/tests/resources/expected_downloads_summaries/youtube/test_playlist.json index 1aa4510f..d7ed7312 100644 --- a/tests/resources/expected_downloads_summaries/youtube/test_playlist.json +++ b/tests/resources/expected_downloads_summaries/youtube/test_playlist.json @@ -2,7 +2,7 @@ "JMC/.ytdl-sub-music_video_playlist_test-download-archive.json": "3fdab8d103e51aa70430b6da0ceb07e2", "JMC/Season 01/s01.e11020101 - Jesse's Minecraft Server [Trailer - Feb.1]-thumb.jpg": "b232d253df621aa770b780c1301d364d", "JMC/Season 01/s01.e11020101 - Jesse's Minecraft Server [Trailer - Feb.1].info.json": "INFO_JSON", - "JMC/Season 01/s01.e11020101 - Jesse's Minecraft Server [Trailer - Feb.1].mp4": "82bcd97a13f2ba361e66ad631aeac32f", + "JMC/Season 01/s01.e11020101 - Jesse's Minecraft Server [Trailer - Feb.1].mp4": "7fa67dd3a895da12c17669b0fa2f3763", "JMC/Season 01/s01.e11020101 - Jesse's Minecraft Server [Trailer - Feb.1].nfo": "2a2997cbf16fb6b943d9933ad267331e", "JMC/Season 01/s01.e11022701 - Jesse's Minecraft Server [Trailer - Feb.27]-thumb.jpg": "d17c379ea8b362f5b97c6b213b0342cb", "JMC/Season 01/s01.e11022701 - Jesse's Minecraft Server [Trailer - Feb.27].info.json": "INFO_JSON", diff --git a/tests/resources/expected_downloads_summaries/youtube/test_playlist_archive_migrated.json b/tests/resources/expected_downloads_summaries/youtube/test_playlist_archive_migrated.json index 49d30e58..a35908dc 100644 --- a/tests/resources/expected_downloads_summaries/youtube/test_playlist_archive_migrated.json +++ b/tests/resources/expected_downloads_summaries/youtube/test_playlist_archive_migrated.json @@ -2,7 +2,7 @@ "JMC/.ytdl-sub-JMC-download-archive.json": "3fdab8d103e51aa70430b6da0ceb07e2", "JMC/Season 01/s01.e11020101 - Jesse's Minecraft Server [Trailer - Feb.1]-thumb.jpg": "b232d253df621aa770b780c1301d364d", "JMC/Season 01/s01.e11020101 - Jesse's Minecraft Server [Trailer - Feb.1].info.json": "INFO_JSON", - "JMC/Season 01/s01.e11020101 - Jesse's Minecraft Server [Trailer - Feb.1].mp4": "82bcd97a13f2ba361e66ad631aeac32f", + "JMC/Season 01/s01.e11020101 - Jesse's Minecraft Server [Trailer - Feb.1].mp4": "7fa67dd3a895da12c17669b0fa2f3763", "JMC/Season 01/s01.e11020101 - Jesse's Minecraft Server [Trailer - Feb.1].nfo": "2a2997cbf16fb6b943d9933ad267331e", "JMC/Season 01/s01.e11022701 - Jesse's Minecraft Server [Trailer - Feb.27]-thumb.jpg": "d17c379ea8b362f5b97c6b213b0342cb", "JMC/Season 01/s01.e11022701 - Jesse's Minecraft Server [Trailer - Feb.27].info.json": "INFO_JSON", diff --git a/tests/resources/transaction_log_summaries/bandcamp/test_artist_url.txt b/tests/resources/transaction_log_summaries/bandcamp/test_artist_url.txt index eb39eee4..60d1e689 100644 --- a/tests/resources/transaction_log_summaries/bandcamp/test_artist_url.txt +++ b/tests/resources/transaction_log_summaries/bandcamp/test_artist_url.txt @@ -59,20 +59,6 @@ Files created: track: 4 tracktotal: 10 year: 2021 - 05 - Messenger EDM Remix.mp3 - Music Tags: - album: 10 Years: Remixes and Reimaginings - albumartist: Sithu Aye - albumartists: Sithu Aye - artist: Sithu Aye - artists: Sithu Aye - date: 2021-11-26 - genres: Progressive Metal - original_date: 2021-11-26 - title: Messenger EDM Remix - track: 5 - tracktotal: 10 - year: 2021 folder.jpg {output_directory}/Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster) 01 - Invent the Universe.mp3 @@ -215,4 +201,20 @@ Files created: track: 10 tracktotal: 10 year: 2022 + folder.jpg +{output_directory}/Sithu Aye/[2024] Kindness + 01 - Run it Down.mp3 + Music Tags: + album: Kindness + albumartist: Sithu Aye + albumartists: Sithu Aye + artist: Sithu Aye + artists: Sithu Aye + date: 2024-03-23 + genres: Progressive Metal + original_date: 2024-03-23 + title: Run it Down + track: 1 + tracktotal: 1 + year: 2024 folder.jpg \ No newline at end of file From d5e647554e4f617786a14b75b750a56a9875591a Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Sat, 30 Mar 2024 00:53:05 -0700 Subject: [PATCH 15/39] [DEV] Fix resolve_once script bug with custom functions (#952) * [DEV] Fix resolve_once script bug with custom functions * function name * fix adding custom functions --- .../config/validators/variable_validation.py | 4 ++ src/ytdl_sub/script/script.py | 50 +++++++++++++++---- src/ytdl_sub/utils/script.py | 6 ++- tests/unit/script/test_script.py | 24 +++++++++ 4 files changed, 72 insertions(+), 12 deletions(-) diff --git a/src/ytdl_sub/config/validators/variable_validation.py b/src/ytdl_sub/config/validators/variable_validation.py index f5de3ed5..bdeb9fac 100644 --- a/src/ytdl_sub/config/validators/variable_validation.py +++ b/src/ytdl_sub/config/validators/variable_validation.py @@ -15,6 +15,7 @@ from ytdl_sub.config.validators.options import OptionsValidator from ytdl_sub.downloaders.url.validators import MultiUrlValidator from ytdl_sub.entries.variables.override_variables import SubscriptionVariables from ytdl_sub.script.script import Script +from ytdl_sub.script.script import _is_function from ytdl_sub.utils.scriptable import BASE_SCRIPT from ytdl_sub.validators.string_formatter_validators import to_variable_dependency_format_string from ytdl_sub.validators.string_formatter_validators import validate_formatters @@ -33,6 +34,9 @@ def _add_dummy_overrides(overrides: Overrides) -> Dict[str, str]: # Have the dummy override variable contain all variable deps that it uses in the string dummy_overrides: Dict[str, str] = {} for override_name in _override_variables(overrides): + if _is_function(override_name): + continue + # pylint: disable=protected-access dummy_overrides[override_name] = to_variable_dependency_format_string( script=overrides.script, parsed_format_string=overrides.script._variables[override_name] diff --git a/src/ytdl_sub/script/script.py b/src/ytdl_sub/script/script.py index 3f3b42ff..f285eae6 100644 --- a/src/ytdl_sub/script/script.py +++ b/src/ytdl_sub/script/script.py @@ -10,6 +10,7 @@ from ytdl_sub.script.script_output import ScriptOutput from ytdl_sub.script.types.resolvable import Lambda from ytdl_sub.script.types.resolvable import Resolvable from ytdl_sub.script.types.syntax_tree import SyntaxTree +from ytdl_sub.script.types.variable import FunctionArgument from ytdl_sub.script.types.variable import Variable from ytdl_sub.script.utils.exceptions import UNREACHABLE from ytdl_sub.script.utils.exceptions import CycleDetected @@ -257,12 +258,23 @@ class Script: f"Output filter variable contains the variable {var_dep} " f"which is set as unresolvable" ) + + # Do not recurse custom function arguments since they have no deps + if isinstance(var_dep, FunctionArgument): + continue + subset_to_resolve.add(var_dep.name) subset_to_resolve |= self._recursive_get_unresolved_output_filter_variables( current_var=self._variables[var_dep.name], subset_to_resolve=subset_to_resolve, unresolvable=unresolvable, ) + for custom_func_dep in current_var.custom_functions: + subset_to_resolve |= self._recursive_get_unresolved_output_filter_variables( + current_var=self._functions[custom_func_dep.name], + subset_to_resolve=subset_to_resolve, + unresolvable=unresolvable, + ) return subset_to_resolve @@ -440,18 +452,34 @@ class Script: self """ added_variables_to_validate: Set[str] = set() - for variable_name, variable_definition in variables.items(): - self._variables[variable_name] = parse( - text=variable_definition, - name=variable_name, - custom_function_names=set(self._functions.keys()), - variable_names=set(self._variables.keys()) - .union(variables.keys()) - .union(unresolvable or set()), - ) - if self._variables[variable_name].maybe_resolvable is None: - added_variables_to_validate.add(variable_name) + functions_to_add = { + _function_name(name): definition + for name, definition in variables.items() + if _is_function(name) + } + variables_to_add = { + name: definition for name, definition in variables.items() if not _is_function(name) + } + + for definitions in [functions_to_add, variables_to_add]: + for name, definition in definitions.items(): + parsed = parse( + text=definition, + name=name, + custom_function_names=set(self._functions.keys()), + variable_names=set(self._variables.keys()) + .union(variables.keys()) + .union(unresolvable or set()), + ) + + if parsed.maybe_resolvable is None: + added_variables_to_validate.add(name) + + if name in functions_to_add: + self._functions[name] = parsed + else: + self._variables[name] = parsed if added_variables_to_validate: self._validate(added_variables=added_variables_to_validate) diff --git a/src/ytdl_sub/utils/script.py b/src/ytdl_sub/utils/script.py index 5653bc74..e2fc6d4c 100644 --- a/src/ytdl_sub/utils/script.py +++ b/src/ytdl_sub/utils/script.py @@ -3,6 +3,8 @@ import re from typing import Any from typing import Dict +from ytdl_sub.script.script import _is_function + class ScriptUtils: @classmethod @@ -11,7 +13,9 @@ class ScriptUtils: Helper to add sanitized variables to a Script """ sanitized_variables = { - f"{name}_sanitized": f"{{%sanitize({name})}}" for name in variables.keys() + f"{name}_sanitized": f"{{%sanitize({name})}}" + for name in variables.keys() + if not _is_function(name) } return dict(variables, **sanitized_variables) diff --git a/tests/unit/script/test_script.py b/tests/unit/script/test_script.py index 0b86e2be..dfa7994e 100644 --- a/tests/unit/script/test_script.py +++ b/tests/unit/script/test_script.py @@ -61,3 +61,27 @@ class TestScript: assert script.get("new_variable_upper") == String("HI MOM THE TITLE") assert script.get("new_variable_titlecase") == String("Hi Mom The Title") assert script.get("entry") == entry_map + + def test_resolve_once_with_custom_functions(self): + script = Script( + { + "%is_bilateral_url": "{ %not(%contains( $0, 'youtube.com/playlist' )) }", + "%bilateral_url": """{ + %if( + %and( + enable_bilateral_scraping, + %is_bilateral_url($0) + ), + $0, + "" + ) + }""", + "enable_bilateral_scraping": "True", + } + ) + + script.add({"%bilateral_url_wrap": "{ %bilateral_url($0) }"}) + + assert ( + script.resolve_once({"url": "{ %bilateral_url_wrap('nope') }"})["url"].native == "nope" + ) From 083db0d9dc676414a997ba5e29d11ebf46d00d07 Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Sat, 30 Mar 2024 22:04:45 -0700 Subject: [PATCH 16/39] [FEATURE] Simplify TV Show Collection Preset (#953) Simplifies TV Show Collection presets, drastically. Old version: ``` rick_a_tv_show_collection: preset: - "jellyfin_tv_show_collection" - "season_by_collection__episode_by_year_month_day_reversed" - "collection_season_1" - "collection_season_2" overrides: tv_show_name: "Rick A" collection_season_1_url: "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw" collection_season_1_name: "All Videos" collection_season_2_url: "https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc" collection_season_2_name: "Official Music Videos" ``` New version: ``` Jellyfin TV Show Collection: "~Rick A": s01_name: "All Videos" s01_url: "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw" s02_name: "Official Music Videos" s02_url: "https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc" ``` --- README.md | 27 +- .../config_reference/subscriptions_yaml.rst | 4 +- docs/source/prebuilt_presets/tv_shows.rst | 139 +- examples/tv_show_subscriptions.yaml | 19 +- .../music/other_websites.yaml | 1 - .../prebuilt_presets/tv_show/__init__.py | 2 + .../prebuilt_presets/tv_show/tv_show.yaml | 8 - .../tv_show/tv_show_collection.yaml | 1354 ++++++++--------- .../youtube/test_channel_full.json | 2 +- tests/unit/config/test_subscription.py | 2 +- 10 files changed, 754 insertions(+), 804 deletions(-) diff --git a/README.md b/README.md index 15036dff..01fd005c 100644 --- a/README.md +++ b/README.md @@ -71,13 +71,8 @@ __preset__: cookiefile: "/config/cookie.txt" ################################################################### -# Subscriptions nested under this will use the -# `Plex TV Show by Date` preset. -# -# Can choose between: -# - Plex TV Show by Date: -# - Jellyfin TV Show by Date: -# - Kodi TV Show by Date: +# TV Show Presets. Can replace Plex with Plex/Jellyfin/Kodi + Plex TV Show by Date: # Sets genre tag to "Documentaries" @@ -101,9 +96,18 @@ Plex TV Show by Date: = News | Only Recent: "BBC News": "https://www.youtube.com/@BBCNews" +Plex TV Show Collection: + = Music: + # Prefix with ~ to set specific override variables + "~Beyond the Guitar": + s01_name: "Videos" + s01_url: "https://www.youtube.com/c/BeyondTheGuitar" + s02_name: "Covers" + s02_url: "https://www.youtube.com/playlist?list=PLE62gWlWZk5NWVAVuf0Lm9jdv_-_KXs0W" + ################################################################### -# Subscriptions nested under these will use the various prebuilt -# music presets +# Music Presets. Can replace Plex with Plex/Jellyfin/Kodi + YouTube Releases: = Jazz: # Sets genre tag to "Jazz" "Thelonious Monk": "https://www.youtube.com/@theloniousmonk3870/releases" @@ -124,10 +128,7 @@ Bandcamp: "Emily Hopkins": "https://emilyharpist.bandcamp.com/" ################################################################### -# Can choose between: -# - Plex Music Videos: -# - Jellyfin Music Videos: -# - Kodi Music Videos: +# Music Video Presets "Plex Music Videos": = Pop: # Sets genre tag to "Pop" "Rick Astley": "https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc" diff --git a/docs/source/config_reference/subscriptions_yaml.rst b/docs/source/config_reference/subscriptions_yaml.rst index 5c976149..d97b65f9 100644 --- a/docs/source/config_reference/subscriptions_yaml.rst +++ b/docs/source/config_reference/subscriptions_yaml.rst @@ -19,9 +19,7 @@ Below is an example that downloads a YouTube playlist: presets: playlist_preset_ex: - download: - download_strategy: "url" - url: "{url}" + download: "{url}" output_options: output_directory: "{output_directory}/{playlist_name}" file_name: "{playlist_name}.{title}.{ext}" diff --git a/docs/source/prebuilt_presets/tv_shows.rst b/docs/source/prebuilt_presets/tv_shows.rst index a7770d0f..8510350d 100644 --- a/docs/source/prebuilt_presets/tv_shows.rst +++ b/docs/source/prebuilt_presets/tv_shows.rst @@ -38,17 +38,39 @@ TV Show by Date TV Show by Date will organize something like a YouTube channel or playlist into a tv show, where seasons and episodes are organized using upload date. -Plug and Play Presets -~~~~~~~~~~~~~~~~~~~~~ - -You can use any of these presets in your ``subscriptions.yaml`` as a "One size fits all" solution- they should set all appropriate values. These will organize seasons by year and episodes by month, then day. - -Must define ``tv_show_directory`` +Example +~~~~~~~ +Must define ``tv_show_directory``. Available presets: * ``"Kodi TV Show by Date"`` * ``"Jellyfin TV Show by Date"`` * ``"Plex TV Show by Date"`` +.. code-block:: yaml + + __preset__: + overrides: + tv_show_directory: "/tv_shows" + + Plex TV Show by Date: + + # Sets genre tag to "Documentaries" + = Documentaries: + "NOVA PBS": "https://www.youtube.com/@novapbs" + "National Geographic": "https://www.youtube.com/@NatGeo" + "Cosmos - What If": "https://www.youtube.com/playlist?list=PLZdXRHYAVxTJno6oFF9nLGuwXNGYHmE8U" + + # Sets genre tag to "Kids", "TV-Y" for content rating + = Kids | = TV-Y: + "Jake Trains": "https://www.youtube.com/@JakeTrains" + "Kids Toys Play": "https://www.youtube.com/@KidsToysPlayChannel" + + = Music: + # TV show subscriptions can support multiple urls and store in the same TV Show + "Rick Beato": + - "https://www.youtube.com/@RickBeato" + - "https://www.youtube.com/@rickbeato240" + Advanced Usage ~~~~~~~~~~~~~~ @@ -71,43 +93,13 @@ And then add one of these: * Episodes are numbered by the download order. NOTE that this is fetched using the length of the download archive. Do not use if you intend to remove old videos. -An example of a subscription that will be played on Kodi, organized by year with the most recent episode at the top (having a lower episode number), with a genre of "Pop": - -.. code-block:: yaml - :caption: subscriptions.yaml - - __preset__: - overrides: - tv_show_directory: "/tv_shows" - - kodi_tv_show_by_date: - season_by_year_episode_by_month_day_reversed: - = Pop: - "Rick A": "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw" - -You can also choose to combine multiple URLs into one show. This will result in your videos being downloaded to the same folder, and the episode numbers being shared between them (so you won't have two episode 10's, for example). Note that you may :ytdl-sub-gh:`experience issues ` if you use more than 20 URLs at this time. - -.. code-block:: yaml - :caption: subscriptions.yaml - - __preset__: - overrides: - tv_show_directory: "/tv_shows" - - kodi_tv_show_by_date: - season_by_year_episode_by_month_day_reversed: - = Pop: - "~Rick A": - url: "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw" - url2: "https://www.youtube.com/@just.rick_6" - TV Show Collection ------------------ -TV Show Collections are made up of multiple URLs, where each URL is a season. -If a video belongs to multiple URLs (i.e. a channel and a channel's playlist), -it will resolve to the bottom-most season, as defined in the subscription. +TV Show Collections set each URL as its own season. If a video belongs to multiple URLs +(i.e. a channel and a channel's playlist), the video will only download once and reside in +the higher-numbered season. Two main use cases of a collection are: 1. Organize a YouTube channel TV show where Season 1 contains any video @@ -116,15 +108,41 @@ Two main use cases of a collection are: 2. Organize one or more YouTube channels/playlists, where each season represents a separate channel/playlist. -Player Presets +Example +~~~~~~~ +Must define ``tv_show_directory``. Available presets: + +* ``"Kodi TV Show Collection"`` +* ``"Jellyfin TV Show Collection"`` +* ``"Plex TV Show Collection"`` + +.. code-block:: yaml + + __preset__: + overrides: + tv_show_directory: "/tv_shows" + + Plex TV Show Collection: + = Music: + # Prefix with ~ to set specific override variables + "~Beyond the Guitar": + s01_name: "Videos" + s01_url: "https://www.youtube.com/c/BeyondTheGuitar" + s02_name: "Covers" + s02_url: "https://www.youtube.com/playlist?list=PLE62gWlWZk5NWVAVuf0Lm9jdv_-_KXs0W" + +Advanced Usage ~~~~~~~~~~~~~~ +If you prefer a different organization method, you can instead apply multiple presets to your subscriptions. + +You will need a base of one of the below: + * ``kodi_tv_show_collection`` * ``jellyfin_tv_show_collection`` * ``plex_tv_show_collection`` -Episode Formatting Presets -~~~~~~~~~~~~~~~~~~~~~~~~~~ +And then add one of these: * ``season_by_collection__episode_by_year_month_day`` * ``season_by_collection__episode_by_year_month_day_reversed`` @@ -132,40 +150,3 @@ Episode Formatting Presets * Only use playlist_index episode formatting for playlists that will be fully downloaded once and never again. Otherwise, indices can change. * ``season_by_collection__episode_by_playlist_index_reversed`` - -Season Presets -~~~~~~~~~~~~~~ - -* ``collection_season_1`` -* ``collection_season_2`` -* ``collection_season_3`` -* ``collection_season_4`` -* ``...`` -* ``collection_season_40`` - -Example -~~~~~~~ - -A preset/subscription requires specifying a player, episode formatting, and -one or more season presets, with the following override variables: - -.. code-block:: yaml - - rick_a_tv_show_collection: - preset: - - "jellyfin_tv_show_collection" - - "season_by_collection__episode_by_year_month_day_reversed" - - "collection_season_1" - - "collection_season_2" - overrides: - # required - tv_show_name: "Rick A" - tv_show_directory: "/path/to/youtube_shows" - collection_season_1_url: "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw" - collection_season_1_name: "All Videos" - collection_season_2_url: "https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc" - collection_season_2_name: "Official Music Videos" - # can be modified from their default value - # tv_show_genre: "ytdl-sub" - # episode_title: "{upload_date_standardized} - {title}" - # episode_description: "{webpage_url}" \ No newline at end of file diff --git a/examples/tv_show_subscriptions.yaml b/examples/tv_show_subscriptions.yaml index d8b914cb..eb88e4da 100644 --- a/examples/tv_show_subscriptions.yaml +++ b/examples/tv_show_subscriptions.yaml @@ -55,4 +55,21 @@ Plex TV Show by Date: # Set "News" for genre, use `Only Recent` preset to only store videos uploaded recently = News | Only Recent: - "BBC News": "https://www.youtube.com/@BBCNews" \ No newline at end of file + "BBC News": "https://www.youtube.com/@BBCNews" + +# Sets URLs to be explicit seasons. If a video resides in multiple URLs, it will +# only appear once in the higher-numbered season. This is how you can separate a channel's +# videos and playlists you are interested in. +# +# Choose the player you intend to use by setting the top-level key to be either: +# - Plex TV Show Collection: +# - Jellyfin TV Show Collection: +# - Kodi TV Show Collection: +Plex TV Show Collection: + = Music: + "~Beyond the Guitar": + s01_name: "Videos" + s01_url: "https://www.youtube.com/c/BeyondTheGuitar" + + s02_name: "Music Videos" + s02_url: "https://www.youtube.com/playlist?list=PLE62gWlWZk5NWVAVuf0Lm9jdv_-_KXs0W" diff --git a/src/ytdl_sub/prebuilt_presets/music/other_websites.yaml b/src/ytdl_sub/prebuilt_presets/music/other_websites.yaml index 14780bea..32eb964a 100644 --- a/src/ytdl_sub/prebuilt_presets/music/other_websites.yaml +++ b/src/ytdl_sub/prebuilt_presets/music/other_websites.yaml @@ -5,7 +5,6 @@ presets: # Download using the multi_url strategy download: - download_strategy: "multi_url" urls: # The first URL will be all the artist's tracks. # Treat these as singles - an album with a single track diff --git a/src/ytdl_sub/prebuilt_presets/tv_show/__init__.py b/src/ytdl_sub/prebuilt_presets/tv_show/__init__.py index 5e85f0c4..98723bc8 100644 --- a/src/ytdl_sub/prebuilt_presets/tv_show/__init__.py +++ b/src/ytdl_sub/prebuilt_presets/tv_show/__init__.py @@ -52,6 +52,8 @@ class TvShowCollectionEpisodeFormattingPresets(PrebuiltPresets): class TvShowCollectionSeasonPresets(PrebuiltPresets): + """Now Deprecated""" + preset_names = { "collection_season_1", "collection_season_2", diff --git a/src/ytdl_sub/prebuilt_presets/tv_show/tv_show.yaml b/src/ytdl_sub/prebuilt_presets/tv_show/tv_show.yaml index edf64ec5..fb97e0fb 100644 --- a/src/ytdl_sub/prebuilt_presets/tv_show/tv_show.yaml +++ b/src/ytdl_sub/prebuilt_presets/tv_show/tv_show.yaml @@ -40,14 +40,6 @@ presets: #################################################################################################### - # TV show from a collection. Must specify additional `tv_show_collection_season` presets in - # addition. Each season sets its own `collection_season_number/_padded` - _tv_show_collection: - overrides: - collection_season_number_padded: "{ %pad_zero(%int(collection_season_number), 2) }" - season_number: "{collection_season_number}" - season_number_padded: "{collection_season_number_padded}" - _episode_video_tags: video_tags: show: "{tv_show_name}" diff --git a/src/ytdl_sub/prebuilt_presets/tv_show/tv_show_collection.yaml b/src/ytdl_sub/prebuilt_presets/tv_show/tv_show_collection.yaml index 0999161c..8554fda9 100644 --- a/src/ytdl_sub/prebuilt_presets/tv_show/tv_show_collection.yaml +++ b/src/ytdl_sub/prebuilt_presets/tv_show/tv_show_collection.yaml @@ -1,5 +1,25 @@ presets: + ############################### + # All-in-one presets that require no config + + "Kodi TV Show Collection": + preset: + - "kodi_tv_show_collection" + - "season_by_collection__episode_by_year_month_day" + + "Jellyfin TV Show Collection": + preset: + - "jellyfin_tv_show_collection" + - "season_by_collection__episode_by_year_month_day" + + "Plex TV Show Collection": + preset: + - "plex_tv_show_collection" + - "season_by_collection__episode_by_year_month_day" + + ################## + kodi_tv_show_collection: preset: - "_kodi_tv_show" @@ -15,702 +35,7 @@ presets: - "_plex_tv_show" - "_tv_show_collection" -#################################################################################################### -# SEASON PRESETS - - collection_season_1: - download: - - url: "{collection_season_1_url}" - variables: - collection_season_number: "1" - collection_season_name: "{collection_season_1_name}" - playlist_thumbnails: - # Use latest_entry first, then see if YT channel artwork exists - # ONLY FOR SEASON 1! The channel artwork will be the show's artwork. - - name: "{season_poster_file_name}" - uid: "latest_entry" - - name: "{tv_show_poster_file_name}" - uid: "avatar_uncropped" - - name: "{tv_show_fanart_file_name}" - uid: "banner_uncropped" - source_thumbnails: - - name: "{tv_show_poster_file_name}" - uid: "avatar_uncropped" - - name: "{tv_show_fanart_file_name}" - uid: "banner_uncropped" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_1_name}" - attributes: - number: "1" - - collection_season_2: - download: - - url: "{collection_season_2_url}" - variables: - collection_season_number: "2" - collection_season_name: "{collection_season_2_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_2_name}" - attributes: - number: "2" - - collection_season_3: - download: - - url: "{collection_season_3_url}" - variables: - collection_season_number: "3" - collection_season_name: "{collection_season_3_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_3_name}" - attributes: - number: "3" - - collection_season_4: - download: - - url: "{collection_season_4_url}" - variables: - collection_season_number: "4" - collection_season_name: "{collection_season_4_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_4_name}" - attributes: - number: "4" - - collection_season_5: - download: - - url: "{collection_season_5_url}" - variables: - collection_season_number: "5" - collection_season_name: "{collection_season_5_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_5_name}" - attributes: - number: "5" - - collection_season_6: - download: - - url: "{collection_season_6_url}" - variables: - collection_season_number: "6" - collection_season_name: "{collection_season_6_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_6_name}" - attributes: - number: "6" - - collection_season_7: - download: - - url: "{collection_season_7_url}" - variables: - collection_season_number: "7" - collection_season_name: "{collection_season_7_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_7_name}" - attributes: - number: "7" - - collection_season_8: - download: - - url: "{collection_season_8_url}" - variables: - collection_season_number: "8" - collection_season_name: "{collection_season_8_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_8_name}" - attributes: - number: "8" - - collection_season_9: - download: - - url: "{collection_season_9_url}" - variables: - collection_season_number: "9" - collection_season_name: "{collection_season_9_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_9_name}" - attributes: - number: "9" - - collection_season_10: - download: - - url: "{collection_season_10_url}" - variables: - collection_season_number: "10" - collection_season_name: "{collection_season_10_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_10_name}" - attributes: - number: "10" - - collection_season_11: - download: - - url: "{collection_season_11_url}" - variables: - collection_season_number: "11" - collection_season_name: "{collection_season_11_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_11_name}" - attributes: - number: "11" - - collection_season_12: - download: - - url: "{collection_season_12_url}" - variables: - collection_season_number: "12" - collection_season_name: "{collection_season_12_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_12_name}" - attributes: - number: "12" - - collection_season_13: - download: - - url: "{collection_season_13_url}" - variables: - collection_season_number: "13" - collection_season_name: "{collection_season_13_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_13_name}" - attributes: - number: "13" - - collection_season_14: - download: - - url: "{collection_season_14_url}" - variables: - collection_season_number: "14" - collection_season_name: "{collection_season_14_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_14_name}" - attributes: - number: "14" - - collection_season_15: - download: - - url: "{collection_season_15_url}" - variables: - collection_season_number: "15" - collection_season_name: "{collection_season_15_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_15_name}" - attributes: - number: "15" - - collection_season_16: - download: - - url: "{collection_season_16_url}" - variables: - collection_season_number: "16" - collection_season_name: "{collection_season_16_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_16_name}" - attributes: - number: "16" - - collection_season_17: - download: - - url: "{collection_season_17_url}" - variables: - collection_season_number: "17" - collection_season_name: "{collection_season_17_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_17_name}" - attributes: - number: "17" - - collection_season_18: - download: - - url: "{collection_season_18_url}" - variables: - collection_season_number: "18" - collection_season_name: "{collection_season_18_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_18_name}" - attributes: - number: "18" - - collection_season_19: - download: - - url: "{collection_season_19_url}" - variables: - collection_season_number: "19" - collection_season_name: "{collection_season_19_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_19_name}" - attributes: - number: "19" - - collection_season_20: - download: - - url: "{collection_season_20_url}" - variables: - collection_season_number: "20" - collection_season_name: "{collection_season_20_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_20_name}" - attributes: - number: "20" - - collection_season_21: - download: - - url: "{collection_season_21_url}" - variables: - collection_season_number: "21" - collection_season_name: "{collection_season_21_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_21_name}" - attributes: - number: "21" - - collection_season_22: - download: - - url: "{collection_season_22_url}" - variables: - collection_season_number: "22" - collection_season_name: "{collection_season_22_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_22_name}" - attributes: - number: "22" - - collection_season_23: - download: - - url: "{collection_season_23_url}" - variables: - collection_season_number: "23" - collection_season_name: "{collection_season_23_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_23_name}" - attributes: - number: "23" - - collection_season_24: - download: - - url: "{collection_season_24_url}" - variables: - collection_season_number: "24" - collection_season_name: "{collection_season_24_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_24_name}" - attributes: - number: "24" - - collection_season_25: - download: - - url: "{collection_season_25_url}" - variables: - collection_season_number: "25" - collection_season_name: "{collection_season_25_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_25_name}" - attributes: - number: "25" - - collection_season_26: - download: - - url: "{collection_season_26_url}" - variables: - collection_season_number: "26" - collection_season_name: "{collection_season_26_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_26_name}" - attributes: - number: "26" - - collection_season_27: - download: - - url: "{collection_season_27_url}" - variables: - collection_season_number: "27" - collection_season_name: "{collection_season_27_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_27_name}" - attributes: - number: "27" - - collection_season_28: - download: - - url: "{collection_season_28_url}" - variables: - collection_season_number: "28" - collection_season_name: "{collection_season_28_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_28_name}" - attributes: - number: "28" - - collection_season_29: - download: - - url: "{collection_season_29_url}" - variables: - collection_season_number: "29" - collection_season_name: "{collection_season_29_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_29_name}" - attributes: - number: "29" - - collection_season_30: - download: - - url: "{collection_season_30_url}" - variables: - collection_season_number: "30" - collection_season_name: "{collection_season_30_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_30_name}" - attributes: - number: "30" - - collection_season_31: - download: - - url: "{collection_season_31_url}" - variables: - collection_season_number: "31" - collection_season_name: "{collection_season_31_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_31_name}" - attributes: - number: "31" - - collection_season_32: - download: - - url: "{collection_season_32_url}" - variables: - collection_season_number: "32" - collection_season_name: "{collection_season_32_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_32_name}" - attributes: - number: "32" - - collection_season_33: - download: - - url: "{collection_season_33_url}" - variables: - collection_season_number: "33" - collection_season_name: "{collection_season_33_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_33_name}" - attributes: - number: "33" - - collection_season_34: - download: - - url: "{collection_season_34_url}" - variables: - collection_season_number: "34" - collection_season_name: "{collection_season_34_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_34_name}" - attributes: - number: "34" - - collection_season_35: - download: - - url: "{collection_season_35_url}" - variables: - collection_season_number: "35" - collection_season_name: "{collection_season_35_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_35_name}" - attributes: - number: "35" - - collection_season_36: - download: - - url: "{collection_season_36_url}" - variables: - collection_season_number: "36" - collection_season_name: "{collection_season_36_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_36_name}" - attributes: - number: "36" - - collection_season_37: - download: - - url: "{collection_season_37_url}" - variables: - collection_season_number: "37" - collection_season_name: "{collection_season_37_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_37_name}" - attributes: - number: "37" - - collection_season_38: - download: - - url: "{collection_season_38_url}" - variables: - collection_season_number: "38" - collection_season_name: "{collection_season_38_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_38_name}" - attributes: - number: "38" - - collection_season_39: - download: - - url: "{collection_season_39_url}" - variables: - collection_season_number: "39" - collection_season_name: "{collection_season_39_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_39_name}" - attributes: - number: "39" - - collection_season_40: - download: - - url: "{collection_season_40_url}" - variables: - collection_season_number: "40" - collection_season_name: "{collection_season_40_name}" - playlist_thumbnails: - - name: "{season_poster_file_name}" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{collection_season_40_name}" - attributes: - number: "40" - - - ############### + ############################### season_by_collection__episode_by_year_month_day: overrides: @@ -730,4 +55,639 @@ presets: season_by_collection__episode_by_playlist_index_reversed: overrides: episode_number: "{playlist_index_reversed}" - episode_number_padded: "{playlist_index_reversed_padded6}" \ No newline at end of file + episode_number_padded: "{playlist_index_reversed_padded6}" + + ############## + + _tv_show_collection: + download: + - url: "{collection_season_1_url}" + variables: + collection_season_number: "1" + collection_season_name: "{collection_season_1_name}" + playlist_thumbnails: + # Use latest_entry first, then see if YT channel artwork exists + # ONLY FOR SEASON 1! The channel artwork will be the show's artwork. + - name: "{season_poster_file_name}" + uid: "latest_entry" + - name: "{tv_show_poster_file_name}" + uid: "avatar_uncropped" + - name: "{tv_show_fanart_file_name}" + uid: "banner_uncropped" + source_thumbnails: + - name: "{tv_show_poster_file_name}" + uid: "avatar_uncropped" + - name: "{tv_show_fanart_file_name}" + uid: "banner_uncropped" + - url: "{collection_season_2_url}" + variables: + collection_season_number: "2" + collection_season_name: "{collection_season_2_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_3_url}" + variables: + collection_season_number: "3" + collection_season_name: "{collection_season_3_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_4_url}" + variables: + collection_season_number: "4" + collection_season_name: "{collection_season_4_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_5_url}" + variables: + collection_season_number: "5" + collection_season_name: "{collection_season_5_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_6_url}" + variables: + collection_season_number: "6" + collection_season_name: "{collection_season_6_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_7_url}" + variables: + collection_season_number: "7" + collection_season_name: "{collection_season_7_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_8_url}" + variables: + collection_season_number: "8" + collection_season_name: "{collection_season_8_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_9_url}" + variables: + collection_season_number: "9" + collection_season_name: "{collection_season_9_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_10_url}" + variables: + collection_season_number: "10" + collection_season_name: "{collection_season_10_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_11_url}" + variables: + collection_season_number: "11" + collection_season_name: "{collection_season_11_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_12_url}" + variables: + collection_season_number: "12" + collection_season_name: "{collection_season_12_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_13_url}" + variables: + collection_season_number: "13" + collection_season_name: "{collection_season_13_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_14_url}" + variables: + collection_season_number: "14" + collection_season_name: "{collection_season_14_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_15_url}" + variables: + collection_season_number: "15" + collection_season_name: "{collection_season_15_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_16_url}" + variables: + collection_season_number: "16" + collection_season_name: "{collection_season_16_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_17_url}" + variables: + collection_season_number: "17" + collection_season_name: "{collection_season_17_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_18_url}" + variables: + collection_season_number: "18" + collection_season_name: "{collection_season_18_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_19_url}" + variables: + collection_season_number: "19" + collection_season_name: "{collection_season_19_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_20_url}" + variables: + collection_season_number: "20" + collection_season_name: "{collection_season_20_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_21_url}" + variables: + collection_season_number: "21" + collection_season_name: "{collection_season_21_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_22_url}" + variables: + collection_season_number: "22" + collection_season_name: "{collection_season_22_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_23_url}" + variables: + collection_season_number: "23" + collection_season_name: "{collection_season_23_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_24_url}" + variables: + collection_season_number: "24" + collection_season_name: "{collection_season_24_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_25_url}" + variables: + collection_season_number: "25" + collection_season_name: "{collection_season_25_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_26_url}" + variables: + collection_season_number: "26" + collection_season_name: "{collection_season_26_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_27_url}" + variables: + collection_season_number: "27" + collection_season_name: "{collection_season_27_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_28_url}" + variables: + collection_season_number: "28" + collection_season_name: "{collection_season_28_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_29_url}" + variables: + collection_season_number: "29" + collection_season_name: "{collection_season_29_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_30_url}" + variables: + collection_season_number: "30" + collection_season_name: "{collection_season_30_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_31_url}" + variables: + collection_season_number: "31" + collection_season_name: "{collection_season_31_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_32_url}" + variables: + collection_season_number: "32" + collection_season_name: "{collection_season_32_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_33_url}" + variables: + collection_season_number: "33" + collection_season_name: "{collection_season_33_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_34_url}" + variables: + collection_season_number: "34" + collection_season_name: "{collection_season_34_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_35_url}" + variables: + collection_season_number: "35" + collection_season_name: "{collection_season_35_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_36_url}" + variables: + collection_season_number: "36" + collection_season_name: "{collection_season_36_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_37_url}" + variables: + collection_season_number: "37" + collection_season_name: "{collection_season_37_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_38_url}" + variables: + collection_season_number: "38" + collection_season_name: "{collection_season_38_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_39_url}" + variables: + collection_season_number: "39" + collection_season_name: "{collection_season_39_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + - url: "{collection_season_40_url}" + variables: + collection_season_number: "40" + collection_season_name: "{collection_season_40_name}" + playlist_thumbnails: + - name: "{season_poster_file_name}" + uid: "latest_entry" + + output_directory_nfo_tags: + tags: + namedseason: + - tag: "{collection_season_1_name}" + attributes: + number: "1" + - tag: "{collection_season_2_name}" + attributes: + number: "2" + - tag: "{collection_season_3_name}" + attributes: + number: "3" + - tag: "{collection_season_4_name}" + attributes: + number: "4" + - tag: "{collection_season_5_name}" + attributes: + number: "5" + - tag: "{collection_season_6_name}" + attributes: + number: "6" + - tag: "{collection_season_7_name}" + attributes: + number: "7" + - tag: "{collection_season_8_name}" + attributes: + number: "8" + - tag: "{collection_season_9_name}" + attributes: + number: "9" + - tag: "{collection_season_10_name}" + attributes: + number: "10" + - tag: "{collection_season_11_name}" + attributes: + number: "11" + - tag: "{collection_season_12_name}" + attributes: + number: "12" + - tag: "{collection_season_13_name}" + attributes: + number: "13" + - tag: "{collection_season_14_name}" + attributes: + number: "14" + - tag: "{collection_season_15_name}" + attributes: + number: "15" + - tag: "{collection_season_16_name}" + attributes: + number: "16" + - tag: "{collection_season_17_name}" + attributes: + number: "17" + - tag: "{collection_season_18_name}" + attributes: + number: "18" + - tag: "{collection_season_19_name}" + attributes: + number: "19" + - tag: "{collection_season_20_name}" + attributes: + number: "20" + - tag: "{collection_season_21_name}" + attributes: + number: "21" + - tag: "{collection_season_22_name}" + attributes: + number: "22" + - tag: "{collection_season_23_name}" + attributes: + number: "23" + - tag: "{collection_season_24_name}" + attributes: + number: "24" + - tag: "{collection_season_25_name}" + attributes: + number: "25" + - tag: "{collection_season_26_name}" + attributes: + number: "26" + - tag: "{collection_season_27_name}" + attributes: + number: "27" + - tag: "{collection_season_28_name}" + attributes: + number: "28" + - tag: "{collection_season_29_name}" + attributes: + number: "29" + - tag: "{collection_season_30_name}" + attributes: + number: "30" + - tag: "{collection_season_31_name}" + attributes: + number: "31" + - tag: "{collection_season_32_name}" + attributes: + number: "32" + - tag: "{collection_season_33_name}" + attributes: + number: "33" + - tag: "{collection_season_34_name}" + attributes: + number: "34" + - tag: "{collection_season_35_name}" + attributes: + number: "35" + - tag: "{collection_season_36_name}" + attributes: + number: "36" + - tag: "{collection_season_37_name}" + attributes: + number: "37" + - tag: "{collection_season_38_name}" + attributes: + number: "38" + - tag: "{collection_season_39_name}" + attributes: + number: "39" + - tag: "{collection_season_40_name}" + attributes: + number: "40" + + overrides: + collection_season_number_padded: "{ %pad_zero(%int(collection_season_number), 2) }" + season_number: "{collection_season_number}" + season_number_padded: "{collection_season_number_padded}" + + # Legacy name variable + collection_season_1_name: "{s01_name}" + collection_season_2_name: "{s02_name}" + collection_season_3_name: "{s03_name}" + collection_season_4_name: "{s04_name}" + collection_season_5_name: "{s05_name}" + collection_season_6_name: "{s06_name}" + collection_season_7_name: "{s07_name}" + collection_season_8_name: "{s08_name}" + collection_season_9_name: "{s09_name}" + collection_season_10_name: "{s10_name}" + collection_season_11_name: "{s11_name}" + collection_season_12_name: "{s12_name}" + collection_season_13_name: "{s13_name}" + collection_season_14_name: "{s14_name}" + collection_season_15_name: "{s15_name}" + collection_season_16_name: "{s16_name}" + collection_season_17_name: "{s17_name}" + collection_season_18_name: "{s18_name}" + collection_season_19_name: "{s19_name}" + collection_season_20_name: "{s20_name}" + collection_season_21_name: "{s21_name}" + collection_season_22_name: "{s22_name}" + collection_season_23_name: "{s23_name}" + collection_season_24_name: "{s24_name}" + collection_season_25_name: "{s25_name}" + collection_season_26_name: "{s26_name}" + collection_season_27_name: "{s27_name}" + collection_season_28_name: "{s28_name}" + collection_season_29_name: "{s29_name}" + collection_season_30_name: "{s30_name}" + collection_season_31_name: "{s31_name}" + collection_season_32_name: "{s32_name}" + collection_season_33_name: "{s33_name}" + collection_season_34_name: "{s34_name}" + collection_season_35_name: "{s35_name}" + collection_season_36_name: "{s36_name}" + collection_season_37_name: "{s37_name}" + collection_season_38_name: "{s38_name}" + collection_season_39_name: "{s39_name}" + collection_season_40_name: "{s40_name}" + + # Legacy url variable + collection_season_1_url: "{s01_url}" + collection_season_2_url: "{s02_url}" + collection_season_3_url: "{s03_url}" + collection_season_4_url: "{s04_url}" + collection_season_5_url: "{s05_url}" + collection_season_6_url: "{s06_url}" + collection_season_7_url: "{s07_url}" + collection_season_8_url: "{s08_url}" + collection_season_9_url: "{s09_url}" + collection_season_10_url: "{s10_url}" + collection_season_11_url: "{s11_url}" + collection_season_12_url: "{s12_url}" + collection_season_13_url: "{s13_url}" + collection_season_14_url: "{s14_url}" + collection_season_15_url: "{s15_url}" + collection_season_16_url: "{s16_url}" + collection_season_17_url: "{s17_url}" + collection_season_18_url: "{s18_url}" + collection_season_19_url: "{s19_url}" + collection_season_20_url: "{s20_url}" + collection_season_21_url: "{s21_url}" + collection_season_22_url: "{s22_url}" + collection_season_23_url: "{s23_url}" + collection_season_24_url: "{s24_url}" + collection_season_25_url: "{s25_url}" + collection_season_26_url: "{s26_url}" + collection_season_27_url: "{s27_url}" + collection_season_28_url: "{s28_url}" + collection_season_29_url: "{s29_url}" + collection_season_30_url: "{s30_url}" + collection_season_31_url: "{s31_url}" + collection_season_32_url: "{s32_url}" + collection_season_33_url: "{s33_url}" + collection_season_34_url: "{s34_url}" + collection_season_35_url: "{s35_url}" + collection_season_36_url: "{s36_url}" + collection_season_37_url: "{s37_url}" + collection_season_38_url: "{s38_url}" + collection_season_39_url: "{s39_url}" + collection_season_40_url: "{s40_url}" + + s01_name: "" + s02_name: "" + s03_name: "" + s04_name: "" + s05_name: "" + s06_name: "" + s07_name: "" + s08_name: "" + s09_name: "" + s10_name: "" + s11_name: "" + s12_name: "" + s13_name: "" + s14_name: "" + s15_name: "" + s16_name: "" + s17_name: "" + s18_name: "" + s19_name: "" + s20_name: "" + s21_name: "" + s22_name: "" + s23_name: "" + s24_name: "" + s25_name: "" + s26_name: "" + s27_name: "" + s28_name: "" + s29_name: "" + s30_name: "" + s31_name: "" + s32_name: "" + s33_name: "" + s34_name: "" + s35_name: "" + s36_name: "" + s37_name: "" + s38_name: "" + s39_name: "" + s40_name: "" + + s01_url: "" + s02_url: "" + s03_url: "" + s04_url: "" + s05_url: "" + s06_url: "" + s07_url: "" + s08_url: "" + s09_url: "" + s10_url: "" + s11_url: "" + s12_url: "" + s13_url: "" + s14_url: "" + s15_url: "" + s16_url: "" + s17_url: "" + s18_url: "" + s19_url: "" + s20_url: "" + s21_url: "" + s22_url: "" + s23_url: "" + s24_url: "" + s25_url: "" + s26_url: "" + s27_url: "" + s28_url: "" + s29_url: "" + s30_url: "" + s31_url: "" + s32_url: "" + s33_url: "" + s34_url: "" + s35_url: "" + s36_url: "" + s37_url: "" + s38_url: "" + s39_url: "" + s40_url: "" + +#################################################################################################### +# DEPRECATED SEASON PRESETS + + collection_season_1: {} + collection_season_2: {} + collection_season_3: {} + collection_season_4: {} + collection_season_5: {} + collection_season_6: {} + collection_season_7: {} + collection_season_8: {} + collection_season_9: {} + collection_season_10: {} + collection_season_11: {} + collection_season_12: {} + collection_season_13: {} + collection_season_14: {} + collection_season_15: {} + collection_season_16: {} + collection_season_17: {} + collection_season_18: {} + collection_season_19: {} + collection_season_20: {} + collection_season_21: {} + collection_season_22: {} + collection_season_23: {} + collection_season_24: {} + collection_season_25: {} + collection_season_26: {} + collection_season_27: {} + collection_season_28: {} + collection_season_29: {} + collection_season_30: {} + collection_season_31: {} + collection_season_32: {} + collection_season_33: {} + collection_season_34: {} + collection_season_35: {} + collection_season_36: {} + collection_season_37: {} + collection_season_38: {} + collection_season_39: {} + collection_season_40: {} diff --git a/tests/resources/expected_downloads_summaries/youtube/test_channel_full.json b/tests/resources/expected_downloads_summaries/youtube/test_channel_full.json index 15d3fd4d..772a9cfc 100644 --- a/tests/resources/expected_downloads_summaries/youtube/test_channel_full.json +++ b/tests/resources/expected_downloads_summaries/youtube/test_channel_full.json @@ -26,7 +26,7 @@ "Project ⧸ Zombie/Season 2011/s2011.e052901 - Project Zombie |Official Trailer| (IP: mc.projectzombie.beastnode.net).nfo": "cb0184784a8eda842cfaf851f6e6af7d", "Project ⧸ Zombie/Season 2011/s2011.e063001 - Project Zombie |Fin|-thumb.jpg": "00ed383591779ffe98291de60f198fe9", "Project ⧸ Zombie/Season 2011/s2011.e063001 - Project Zombie |Fin|.info.json": "INFO_JSON", - "Project ⧸ Zombie/Season 2011/s2011.e063001 - Project Zombie |Fin|.mp4": "5f9f8841aed3f5d95efbd1839045741e", + "Project ⧸ Zombie/Season 2011/s2011.e063001 - Project Zombie |Fin|.mp4": "b20822d0f7217c9f0a0ccf83e33f8187", "Project ⧸ Zombie/Season 2011/s2011.e063001 - Project Zombie |Fin|.nfo": "54ea4a48116aa98480a79495036c25e9", "Project ⧸ Zombie/Season 2011/s2011.e112101 - Skyrim 'Ultra HD w⧸Mods' [PC]-thumb.jpg": "1718599d5189c65f7d8cf6acfa5ea851", "Project ⧸ Zombie/Season 2011/s2011.e112101 - Skyrim 'Ultra HD w⧸Mods' [PC].info.json": "INFO_JSON", diff --git a/tests/unit/config/test_subscription.py b/tests/unit/config/test_subscription.py index bd7122c5..1415926e 100644 --- a/tests/unit/config/test_subscription.py +++ b/tests/unit/config/test_subscription.py @@ -416,7 +416,7 @@ def test_tv_show_subscriptions(config_file: ConfigFile, tv_show_subscriptions_pa config=config_file, subscription_path=tv_show_subscriptions_path ) - assert len(subs) == 7 + assert len(subs) == 8 assert subs[3].name == "Jake Trains" jake_train_overrides = subs[3].overrides.script From 017db953bf6285ee2f669c5ac87ecabb0df0d20b Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Mon, 1 Apr 2024 04:24:41 -0700 Subject: [PATCH 17/39] [FEATURE] Automatically handle playlists ordered in reverse (#948) Playlists have always been a pain-point with ytdl-sub. If an author adds new videos to the end of a playlist, as opposed to the front, it breaks ytdl-sub's intuition of incremental scraping by breaking on the first (oldest) video. This update now makes it possible to handle this in the prebuilt TV Show presets: - Add each URL variable (`url`, `url2`, ...) into the `download` portion of the subscription twice - First definition is what we all know and use, simply scrapes first-to-last, then downloads last-to-first - Second definition does the following: - Check to see if a URL is a YouTube playlist URL, if so... - Set the field to download, but with modifications to scrape last-to-first, then download first-to-last - Otherwise... - Set the field to an empty string, which means ytdl-sub will skip it --- .../scripting/entry_variables.rst | 12 + .../scripting/static_variables.rst | 5 + src/ytdl_sub/config/overrides.py | 14 +- src/ytdl_sub/config/preset.py | 4 +- .../config/validators/variable_validation.py | 53 +-- .../info_json/info_json_downloader.py | 1 + src/ytdl_sub/downloaders/source_plugin.py | 2 +- src/ytdl_sub/downloaders/url/downloader.py | 156 ++++--- src/ytdl_sub/downloaders/url/validators.py | 20 +- .../entries/script/function_scripts.py | 1 + .../entries/script/variable_definitions.py | 20 + src/ytdl_sub/entries/script/variable_types.py | 8 + .../entries/variables/override_variables.py | 55 ++- .../prebuilt_presets/helpers/url.yaml | 415 ++++++++++++++++++ .../helpers/url_bilateral.yaml | 21 + .../prebuilt_presets/tv_show/tv_show.yaml | 9 - .../tv_show/tv_show_by_date.yaml | 9 + .../tv_show/tv_show_collection.yaml | 368 ++++++++++++++++ .../subscriptions/base_subscription.py | 66 ++- .../subscriptions/subscription_download.py | 36 +- .../subscriptions/subscription_validators.py | 16 +- src/ytdl_sub/utils/scriptable.py | 5 +- tests/e2e/entries/__init__.py | 0 tests/e2e/entries/script/__init__.py | 0 .../entries/script/test_custom_functions.py | 22 + tests/e2e/youtube/test_playlist.py | 111 +++++ ...son_by_year__episode_by_download_index.txt | 13 + ...son_by_year__episode_by_download_index.txt | 9 + ...son_by_year__episode_by_download_index.txt | 13 + ...son_by_year__episode_by_download_index.txt | 9 + ...on__episode_by_playlist_index_reversed.txt | 5 + ...on__episode_by_playlist_index_reversed.txt | 5 + ...on__episode_by_playlist_index_reversed.txt | 8 + ...on__episode_by_playlist_index_reversed.txt | 8 + ...on__episode_by_playlist_index_reversed.txt | 13 + ...on__episode_by_playlist_index_reversed.txt | 13 + ...son_by_year__episode_by_download_index.txt | 13 + ...son_by_year__episode_by_download_index.txt | 9 + ...son_by_year__episode_by_download_index.txt | 13 + ...son_by_year__episode_by_download_index.txt | 9 + ...on__episode_by_playlist_index_reversed.txt | 5 + ...on__episode_by_playlist_index_reversed.txt | 5 + ...on__episode_by_playlist_index_reversed.txt | 8 + ...on__episode_by_playlist_index_reversed.txt | 8 + ...on__episode_by_playlist_index_reversed.txt | 13 + ...on__episode_by_playlist_index_reversed.txt | 13 + ...son_by_year__episode_by_download_index.txt | 13 + ...son_by_year__episode_by_download_index.txt | 9 + ...son_by_year__episode_by_download_index.txt | 13 + ...son_by_year__episode_by_download_index.txt | 9 + ...on__episode_by_playlist_index_reversed.txt | 5 + ...on__episode_by_playlist_index_reversed.txt | 5 + ...on__episode_by_playlist_index_reversed.txt | 8 + ...on__episode_by_playlist_index_reversed.txt | 8 + ...on__episode_by_playlist_index_reversed.txt | 13 + ...on__episode_by_playlist_index_reversed.txt | 13 + .../test_playlist_bilateral_collection_p1.txt | 61 +++ .../test_playlist_bilateral_collection_p2.txt | 170 +++++++ .../youtube/test_playlist_bilateral_p1.txt | 56 +++ .../youtube/test_playlist_bilateral_p2.txt | 170 +++++++ tests/unit/config/test_config_file.py | 2 +- tests/unit/script/test_script.py | 1 + 62 files changed, 2015 insertions(+), 162 deletions(-) create mode 100644 src/ytdl_sub/prebuilt_presets/helpers/url_bilateral.yaml create mode 100644 tests/e2e/entries/__init__.py create mode 100644 tests/e2e/entries/script/__init__.py create mode 100644 tests/e2e/entries/script/test_custom_functions.py create mode 100644 tests/resources/transaction_log_summaries/youtube/test_playlist_bilateral_collection_p1.txt create mode 100644 tests/resources/transaction_log_summaries/youtube/test_playlist_bilateral_collection_p2.txt create mode 100644 tests/resources/transaction_log_summaries/youtube/test_playlist_bilateral_p1.txt create mode 100644 tests/resources/transaction_log_summaries/youtube/test_playlist_bilateral_p2.txt diff --git a/docs/source/config_reference/scripting/entry_variables.rst b/docs/source/config_reference/scripting/entry_variables.rst index 8aa85ca6..84247234 100644 --- a/docs/source/config_reference/scripting/entry_variables.rst +++ b/docs/source/config_reference/scripting/entry_variables.rst @@ -647,3 +647,15 @@ ytdl_sub_input_url :type: ``String`` :description: The input URL used in ytdl-sub to create this entry. + +ytdl_sub_input_url_count +~~~~~~~~~~~~~~~~~~~~~~~~ +:type: ``Integer`` +:description: + The total number of input URLs as defined in the subscription. + +ytdl_sub_input_url_index +~~~~~~~~~~~~~~~~~~~~~~~~ +:type: ``Integer`` +:description: + The index of the input URL as defined in the subscription, top-most being the 0th index. diff --git a/docs/source/config_reference/scripting/static_variables.rst b/docs/source/config_reference/scripting/static_variables.rst index e6e41527..2825edba 100644 --- a/docs/source/config_reference/scripting/static_variables.rst +++ b/docs/source/config_reference/scripting/static_variables.rst @@ -5,6 +5,11 @@ Static Variables Subscription Variables ---------------------- +subscription_has_download_archive +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Returns True if the subscription has any entries recorded in a download archive. False +otherwise. + subscription_indent_i ~~~~~~~~~~~~~~~~~~~~~ For subscriptions in the form of diff --git a/src/ytdl_sub/config/overrides.py b/src/ytdl_sub/config/overrides.py index 3372e936..76092735 100644 --- a/src/ytdl_sub/config/overrides.py +++ b/src/ytdl_sub/config/overrides.py @@ -7,8 +7,8 @@ import mergedeep from ytdl_sub.entries.entry import Entry from ytdl_sub.entries.script.variable_definitions import VARIABLES +from ytdl_sub.entries.variables.override_variables import REQUIRED_OVERRIDE_VARIABLE_NAMES from ytdl_sub.entries.variables.override_variables import OverrideHelpers -from ytdl_sub.entries.variables.override_variables import SubscriptionVariables from ytdl_sub.script.parser import parse from ytdl_sub.script.script import Script from ytdl_sub.script.utils.exceptions import ScriptVariableNotResolved @@ -62,6 +62,7 @@ class Overrides(DictFormatterValidator, Scriptable): self.ensure_variable_name_valid(key) self.unresolvable.add(VARIABLES.entry_metadata.variable_name) + self.unresolvable.update(REQUIRED_OVERRIDE_VARIABLE_NAMES) def ensure_added_plugin_variable_valid(self, added_variable: str) -> bool: """ @@ -127,17 +128,10 @@ class Overrides(DictFormatterValidator, Scriptable): ) return ScriptUtils.add_sanitized_variables(initial_variables) - def initialize_script( - self, subscription_name: str, unresolved_variables: Set[str] - ) -> "Overrides": + def initialize_script(self, unresolved_variables: Set[str]) -> "Overrides": """ - Initialize the override script with override variables + any unresolved variables + Initialize the override script with any unresolved variables """ - self.script.add( - ScriptUtils.add_sanitized_variables( - {SubscriptionVariables.subscription_name(): subscription_name} - ) - ) self.script.add( self.initial_variables( unresolved_variables={ diff --git a/src/ytdl_sub/config/preset.py b/src/ytdl_sub/config/preset.py index 4fec587d..914ebc77 100644 --- a/src/ytdl_sub/config/preset.py +++ b/src/ytdl_sub/config/preset.py @@ -198,9 +198,7 @@ class Preset(_PresetShell): downloader_options=self.downloader_options, output_options=self.output_options, plugins=self.plugins, - ).initialize_overrides( - subscription_name=self.name, overrides=self.overrides - ).ensure_proper_usage() + ).initialize_preset_overrides(overrides=self.overrides).ensure_proper_usage() @property def name(self) -> str: diff --git a/src/ytdl_sub/config/validators/variable_validation.py b/src/ytdl_sub/config/validators/variable_validation.py index bdeb9fac..466762d9 100644 --- a/src/ytdl_sub/config/validators/variable_validation.py +++ b/src/ytdl_sub/config/validators/variable_validation.py @@ -13,13 +13,24 @@ from ytdl_sub.config.plugin.preset_plugins import PresetPlugins from ytdl_sub.config.preset_options import OutputOptions from ytdl_sub.config.validators.options import OptionsValidator from ytdl_sub.downloaders.url.validators import MultiUrlValidator -from ytdl_sub.entries.variables.override_variables import SubscriptionVariables +from ytdl_sub.entries.variables.override_variables import REQUIRED_OVERRIDE_VARIABLE_NAMES from ytdl_sub.script.script import Script from ytdl_sub.script.script import _is_function from ytdl_sub.utils.scriptable import BASE_SCRIPT from ytdl_sub.validators.string_formatter_validators import to_variable_dependency_format_string from ytdl_sub.validators.string_formatter_validators import validate_formatters +# Entry variables to mock during validation +_DUMMY_ENTRY_VARIABLES: Dict[str, str] = { + name: to_variable_dependency_format_string( + # pylint: disable=protected-access + script=BASE_SCRIPT, + parsed_format_string=BASE_SCRIPT._variables[name] + # pylint: enable=protected-access + ) + for name in BASE_SCRIPT.variable_names +} + def _add_dummy_variables(variables: Iterable[str]) -> Dict[str, str]: dummy_variables: Dict[str, str] = {} @@ -72,20 +83,7 @@ def _get_added_and_modified_variables( def _override_variables(overrides: Overrides) -> Set[str]: - return set(list(overrides.initial_variables().keys())) | { - SubscriptionVariables.subscription_name() - } - - -_DUMMY_ENTRY_VARIABLES: Dict[str, str] = { - name: to_variable_dependency_format_string( - # pylint: disable=protected-access - script=BASE_SCRIPT, - parsed_format_string=BASE_SCRIPT._variables[name] - # pylint: enable=protected-access - ) - for name in BASE_SCRIPT.variable_names -} + return set(list(overrides.initial_variables().keys())) class VariableValidation: @@ -103,13 +101,11 @@ class VariableValidation: self.resolved_variables: Set[str] = set() self.unresolved_variables: Set[str] = set() - def initialize_overrides( - self, subscription_name: str, overrides: Overrides - ) -> "VariableValidation": + def initialize_preset_overrides(self, overrides: Overrides) -> "VariableValidation": """ Do some gymnastics to initialize the Overrides script. """ - override_variables = _override_variables(overrides) + override_variables = set(list(overrides.initial_variables().keys())) # Set resolved variables as all entry + override variables # at this point to generate every possible added/modified variable @@ -145,9 +141,7 @@ class VariableValidation: # Initialize overrides with unresolved variables + modified variables to throw an error. # For modified variables, this is to prevent a resolve(update=True) to setting any # dependencies until it has been explicitly added - overrides = overrides.initialize_script( - subscription_name=subscription_name, unresolved_variables=self.unresolved_variables - ) + overrides = overrides.initialize_script(unresolved_variables=self.unresolved_variables) # copy the script and mock entry variables self.script = copy.deepcopy(overrides.script) @@ -162,7 +156,16 @@ class VariableValidation: def _update_script(self) -> None: _ = self.script.resolve(unresolvable=self.unresolved_variables, update=True) - def _add_variables(self, plugin_op: PluginOperation, options: OptionsValidator) -> Set[str]: + def _add_subscription_override_variables(self) -> None: + """ + Add dummy subscription variables for script validation + """ + self.resolved_variables |= REQUIRED_OVERRIDE_VARIABLE_NAMES + + def _add_variables(self, plugin_op: PluginOperation, options: OptionsValidator) -> None: + """ + Add dummy variables for script validation + """ added_variables = options.added_variables( resolved_variables=self.resolved_variables, unresolved_variables=self.unresolved_variables, @@ -175,14 +178,14 @@ class VariableValidation: self.resolved_variables |= resolved_variables self.unresolved_variables -= resolved_variables - return added_variables - def ensure_proper_usage(self) -> None: """ Validate variables resolve as plugins are executed, and return a mock script which contains actualized added variables from the plugins """ + self._add_variables(PluginOperation.DOWNLOADER, options=self.downloader_options) + self._add_subscription_override_variables() # Metadata variables to be added for plugin_options in PluginMapping.order_options_by( diff --git a/src/ytdl_sub/downloaders/info_json/info_json_downloader.py b/src/ytdl_sub/downloaders/info_json/info_json_downloader.py index 71f3c996..582962f5 100644 --- a/src/ytdl_sub/downloaders/info_json/info_json_downloader.py +++ b/src/ytdl_sub/downloaders/info_json/info_json_downloader.py @@ -118,6 +118,7 @@ class InfoJsonDownloader(SourcePlugin[InfoJsonDownloaderOptions]): ) entries.append(entry) + # TODO: MATCH A URL TO A URL_VALIDATOR !!! for entry in sorted(entries, key=lambda ent: ent.get(v.download_index, int)): # Remove each entry from the live download archive since it will get re-added # unless it is filtered diff --git a/src/ytdl_sub/downloaders/source_plugin.py b/src/ytdl_sub/downloaders/source_plugin.py index 167f67fd..f5357fa5 100644 --- a/src/ytdl_sub/downloaders/source_plugin.py +++ b/src/ytdl_sub/downloaders/source_plugin.py @@ -17,7 +17,7 @@ from ytdl_sub.entries.entry import Entry from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive -class SourcePluginExtension(Plugin[TOptionsValidator], ABC): +class SourcePluginExtension(Plugin[TOptionsValidator], Generic[TOptionsValidator], ABC): """ Plugins that get added automatically by using a downloader. Downloader options are the plugin options. diff --git a/src/ytdl_sub/downloaders/url/downloader.py b/src/ytdl_sub/downloaders/url/downloader.py index a0ceb535..8d50f1c3 100644 --- a/src/ytdl_sub/downloaders/url/downloader.py +++ b/src/ytdl_sub/downloaders/url/downloader.py @@ -25,6 +25,7 @@ from ytdl_sub.entries.script.variable_definitions import VARIABLES from ytdl_sub.entries.script.variable_definitions import VariableDefinitions from ytdl_sub.utils.file_handler import FileHandler from ytdl_sub.utils.logger import Logger +from ytdl_sub.utils.script import ScriptUtils from ytdl_sub.utils.thumbnail import ThumbnailTypes from ytdl_sub.utils.thumbnail import download_and_convert_url_thumbnail from ytdl_sub.utils.thumbnail import try_convert_download_thumbnail @@ -41,7 +42,30 @@ class URLDownloadState: self.entries_downloaded = 0 -class UrlDownloaderThumbnailPlugin(SourcePluginExtension): +class UrlDownloaderBasePluginExtension(SourcePluginExtension[MultiUrlValidator]): + def _match_entry_to_url_validator(self, entry: Entry) -> UrlValidator: + """ + Handle matching a URL to its original validator. This is for .info.json updates + when older entries have missing variables + """ + input_url_idx = entry.get(v.ytdl_sub_input_url_index, int) + entry_input_url = entry.get(v.ytdl_sub_input_url, str) + + if 0 <= input_url_idx < len(self.plugin_options.urls.list): + validator = self.plugin_options.urls.list[input_url_idx] + if self.overrides.apply_formatter(validator.url) == entry_input_url: + return validator + + # Match the first validator based on the URL, if one exists + for validator in self.plugin_options.urls.list: + if self.overrides.apply_formatter(validator.url) == entry_input_url: + return validator + + # Return the first validator if none exist + return self.plugin_options.urls.list[0] + + +class UrlDownloaderThumbnailPlugin(UrlDownloaderBasePluginExtension): def __init__( self, options: MultiUrlValidator, @@ -54,10 +78,6 @@ class UrlDownloaderThumbnailPlugin(SourcePluginExtension): enhanced_download_archive=enhanced_download_archive, ) self._thumbnails_downloaded: Set[str] = set() - self._collection_url_mapping: Dict[str, UrlValidator] = { - self.overrides.apply_formatter(collection_url.url): collection_url - for collection_url in options.urls.list - } def _download_parent_thumbnails( self, @@ -136,15 +156,14 @@ class UrlDownloaderThumbnailPlugin(SourcePluginExtension): if not self.is_dry_run: try_convert_download_thumbnail(entry=entry) - if (input_url := entry.get(v.ytdl_sub_input_url, str)) in self._collection_url_mapping: - self._download_url_thumbnails( - collection_url=self._collection_url_mapping[input_url], - entry=entry, - ) + self._download_url_thumbnails( + collection_url=self._match_entry_to_url_validator(entry=entry), + entry=entry, + ) return entry -class UrlDownloaderCollectionVariablePlugin(SourcePluginExtension): +class UrlDownloaderCollectionVariablePlugin(UrlDownloaderBasePluginExtension): def __init__( self, options: MultiUrlValidator, @@ -157,25 +176,12 @@ class UrlDownloaderCollectionVariablePlugin(SourcePluginExtension): enhanced_download_archive=enhanced_download_archive, ) self._thumbnails_downloaded: Set[str] = set() - self._collection_url_mapping: Dict[str, UrlValidator] = { - self.overrides.apply_formatter(collection_url.url): collection_url - for collection_url in options.urls.list - } def modify_entry_metadata(self, entry: Entry) -> Optional[Entry]: """ Add collection variables to the entry """ - # COLLECTION_URL is a recent variable that may not exist for old entries when updating. - # Try to use source_webpage_url if it does not exist - entry_collection_url = entry.get(v.ytdl_sub_input_url, str) - - # If the collection URL cannot find its mapping, use the last URL - collection_url = ( - self._collection_url_mapping.get(entry_collection_url) - or list(self._collection_url_mapping.values())[-1] - ) - + collection_url = self._match_entry_to_url_validator(entry=entry) entry.add(collection_url.variables.dict_with_format_strings) return entry @@ -232,8 +238,7 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]): self._downloaded_entries: Set[str] = set() self._url_state: Optional[URLDownloadState] = None - @property - def download_ytdl_options(self) -> Dict: + def download_ytdl_options(self, url_idx: Optional[int] = None) -> Dict: """ Returns ------- @@ -242,19 +247,26 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]): return ( self._download_ytdl_options_builder.clone() .add(self.ytdl_option_defaults(), before=True) + .add( + self.plugin_options.urls.list[url_idx].ytdl_options.dict + if url_idx is not None + else None, + before=True, + ) .to_dict() ) - @property - def metadata_ytdl_options(self) -> Dict: + def metadata_ytdl_options(self, ytdl_option_overrides: Dict) -> Dict: """ Returns ------- YTDL options dict for fetching metadata """ + return ( self._metadata_ytdl_options_builder.clone() .add(self.ytdl_option_defaults(), before=True) + .add(ytdl_option_overrides, before=True) .to_dict() ) @@ -265,7 +277,7 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]): ------- True if dry-run is enabled. False otherwise. """ - return self.download_ytdl_options.get("skip_download", False) + return self.download_ytdl_options().get("skip_download", False) @property def is_entry_thumbnails_enabled(self) -> bool: @@ -274,7 +286,7 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]): ------- True if entry thumbnails should be downloaded. False otherwise. """ - return self.download_ytdl_options.get("writethumbnail", False) + return self.download_ytdl_options().get("writethumbnail", False) ############################################################################################### # DOWNLOAD FUNCTIONS @@ -301,7 +313,7 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]): clear_info_json_files Whether to delete info.json files after yield """ - archive_path = self.download_ytdl_options.get("download_archive", "") + archive_path = self.download_ytdl_options().get("download_archive", "") backup_archive_path = f"{archive_path}.backup" # If archive path exists, maintain download archive is enable @@ -336,7 +348,9 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]): def _extract_entry_info_with_retry(self, entry: Entry) -> Entry: download_entry_dict = YTDLP.extract_info_with_retry( - ytdl_options_overrides=self.download_ytdl_options, + ytdl_options_overrides=self.download_ytdl_options( + url_idx=entry.get(v.ytdl_sub_input_url_index, int) + ), is_downloaded_fn=None if self.is_dry_run else entry.is_downloaded, is_thumbnail_downloaded_fn=None if (self.is_dry_run or not self.is_entry_thumbnails_enabled) @@ -352,27 +366,29 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]): self, entries: List[Entry], download_reversed: bool ) -> Iterator[Entry]: # Iterate a list of entries, and delete the entries after yielding - indices = list(range(len(entries))) + entries_to_iter: List[Optional[Entry]] = entries + + indices = list(range(len(entries_to_iter))) if download_reversed: indices = reversed(indices) for idx in indices: self._url_state.entries_downloaded += 1 - if self._is_downloaded(entries[idx]): + if self._is_downloaded(entries_to_iter[idx]): download_logger.info( "Already downloaded entry %d/%d: %s", self._url_state.entries_downloaded, self._url_state.entries_total, - entries[idx].title, + entries_to_iter[idx].title, ) - del entries[idx] + entries_to_iter[idx] = None continue - yield entries[idx] - self._mark_downloaded(entries[idx]) + yield entries_to_iter[idx] + self._mark_downloaded(entries_to_iter[idx]) - del entries[idx] + entries_to_iter[idx] = None def _iterate_parent_entry( self, parent: EntryParent, download_reversed: bool @@ -390,7 +406,7 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]): yield entry_child def _download_url_metadata( - self, url: str, include_sibling_metadata: bool + self, url: str, include_sibling_metadata: bool, ytdl_options_overrides: Dict ) -> Tuple[List[EntryParent], List[Entry]]: """ Downloads only info.json files and forms EntryParent trees @@ -398,7 +414,7 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]): with self._separate_download_archives(): entry_dicts = YTDLP.extract_info_via_info_json( working_directory=self.working_directory, - ytdl_options_overrides=self.metadata_ytdl_options, + ytdl_options_overrides=ytdl_options_overrides, log_prefix_on_info_json_dl="Downloading metadata for", url=url, ) @@ -439,34 +455,50 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]): ): yield orphan + def _download_metadata(self, url: str, validator: UrlValidator) -> Iterable[Entry]: + metadata_ytdl_options = self.metadata_ytdl_options( + ytdl_option_overrides=validator.ytdl_options.dict + ) + download_reversed = ScriptUtils.bool_formatter_output( + self.overrides.apply_formatter(validator.download_reverse) + ) + + parents, orphan_entries = self._download_url_metadata( + url=url, + include_sibling_metadata=validator.include_sibling_metadata, + ytdl_options_overrides=metadata_ytdl_options, + ) + + # TODO: Encapsulate this logic into its own class + self._url_state = URLDownloadState( + entries_total=sum(parent.num_children() for parent in parents) + len(orphan_entries) + ) + + download_logger.info("Beginning downloads for %s", url) + for entry in self._iterate_entries( + parents=parents, + orphans=orphan_entries, + download_reversed=download_reversed, + ): + yield entry + def download_metadata(self) -> Iterable[Entry]: """The function to perform the download of all media entries""" # download the bottom-most urls first since they are top-priority - for collection_url in reversed(self.collection.urls.list): + for idx, url_validator in reversed(list(enumerate(self.collection.urls.list))): # URLs can be empty. If they are, then skip - if not (url := self.overrides.apply_formatter(collection_url.url)): + if not (url := self.overrides.apply_formatter(url_validator.url)): continue - parents, orphan_entries = self._download_url_metadata( - url=url, include_sibling_metadata=collection_url.include_sibling_metadata - ) - - # TODO: Encapsulate this logic into its own class - self._url_state = URLDownloadState( - entries_total=sum(parent.num_children() for parent in parents) + len(orphan_entries) - ) - - download_logger.info( - "Beginning downloads for %s", self.overrides.apply_formatter(collection_url.url) - ) - for entry in self._iterate_entries( - parents=parents, - orphans=orphan_entries, - download_reversed=collection_url.download_reverse, - ): + for entry in self._download_metadata(url=url, validator=url_validator): entry.initialize_script(self.overrides).add( - {v.ytdl_sub_input_url: self.overrides.apply_formatter(collection_url.url)} + { + v.ytdl_sub_input_url: url, + v.ytdl_sub_input_url_index: idx, + v.ytdl_sub_input_url_count: len(self.collection.urls.list), + } ) + yield entry def download(self, entry: Entry) -> Optional[Entry]: diff --git a/src/ytdl_sub/downloaders/url/validators.py b/src/ytdl_sub/downloaders/url/validators.py index 53330306..61d794dd 100644 --- a/src/ytdl_sub/downloaders/url/validators.py +++ b/src/ytdl_sub/downloaders/url/validators.py @@ -4,10 +4,12 @@ from typing import Optional from typing import Set from ytdl_sub.config.plugin.plugin_operation import PluginOperation +from ytdl_sub.config.preset_options import YTDLOptions from ytdl_sub.config.validators.options import OptionsValidator from ytdl_sub.script.parser import parse from ytdl_sub.validators.strict_dict_validator import StrictDictValidator from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator +from ytdl_sub.validators.string_formatter_validators import OverridesBooleanFormatterValidator from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator from ytdl_sub.validators.validators import BoolValidator @@ -49,6 +51,7 @@ class UrlValidator(StrictDictValidator): "source_thumbnails", "playlist_thumbnails", "download_reverse", + "ytdl_options", "include_sibling_metadata", } @@ -77,7 +80,10 @@ class UrlValidator(StrictDictValidator): key="playlist_thumbnails", validator=UrlThumbnailListValidator, default=[] ) self._download_reverse = self._validate_key( - key="download_reverse", validator=BoolValidator, default=True + key="download_reverse", validator=OverridesBooleanFormatterValidator, default="True" + ) + self._ytdl_options = self._validate_key( + key="ytdl_options", validator=YTDLOptions, default={} ) self._include_sibling_metadata = self._validate_key( key="include_sibling_metadata", validator=BoolValidator, default=False @@ -148,12 +154,20 @@ class UrlValidator(StrictDictValidator): return self._source_thumbnails @property - def download_reverse(self) -> bool: + def download_reverse(self) -> OverridesBooleanFormatterValidator: """ Optional. Whether to download entries in the reverse order of the metadata downloaded. Defaults to True. """ - return self._download_reverse.value + return self._download_reverse + + @property + def ytdl_options(self) -> YTDLOptions: + """ + Optional. ``ytdl_options`` that only apply to this URL. These take precedence + over the plugin ``ytdl_options``. + """ + return self._ytdl_options @property def include_sibling_metadata(self) -> bool: diff --git a/src/ytdl_sub/entries/script/function_scripts.py b/src/ytdl_sub/entries/script/function_scripts.py index 68019a00..58f950bb 100644 --- a/src/ytdl_sub/entries/script/function_scripts.py +++ b/src/ytdl_sub/entries/script/function_scripts.py @@ -5,6 +5,7 @@ from ytdl_sub.entries.script.variable_definitions import VariableDefinitions v: VariableDefinitions = VARIABLES +# TODO: Make this a proper class with docstrings CUSTOM_FUNCTION_SCRIPTS: Dict[str, str] = { ############################################################################################# # SIBLING GETTER diff --git a/src/ytdl_sub/entries/script/variable_definitions.py b/src/ytdl_sub/entries/script/variable_definitions.py index a24f0aa7..4fc9e29e 100644 --- a/src/ytdl_sub/entries/script/variable_definitions.py +++ b/src/ytdl_sub/entries/script/variable_definitions.py @@ -750,6 +750,24 @@ class YtdlSubVariableDefinitions(ABC): """ return StringVariable(variable_name="ytdl_sub_input_url", definition="{ %string('') }") + @cached_property + def ytdl_sub_input_url_index(self: "VariableDefinitions") -> IntegerVariable: + """ + :description: + The index of the input URL as defined in the subscription, top-most being the 0th index. + """ + # init as -1 so if prior downloaded entries are known when they do not have this value + # in their .info.json + return IntegerVariable(variable_name="ytdl_sub_input_url_index", definition="{ %int(-1) }") + + @cached_property + def ytdl_sub_input_url_count(self: "VariableDefinitions") -> IntegerVariable: + """ + :description: + The total number of input URLs as defined in the subscription. + """ + return IntegerVariable(variable_name="ytdl_sub_input_url_count", definition="{ %int(0) }") + @cached_property def download_index(self: "VariableDefinitions") -> IntegerVariable: """ @@ -1100,6 +1118,8 @@ class VariableDefinitions( self.chapters, self.sponsorblock_chapters, self.ytdl_sub_input_url, + self.ytdl_sub_input_url_index, + self.ytdl_sub_input_url_count, } @cache diff --git a/src/ytdl_sub/entries/script/variable_types.py b/src/ytdl_sub/entries/script/variable_types.py index 7cca58cd..214d8a7c 100644 --- a/src/ytdl_sub/entries/script/variable_types.py +++ b/src/ytdl_sub/entries/script/variable_types.py @@ -9,6 +9,7 @@ from typing import TypeVar from ytdl_sub.script.types.array import Array from ytdl_sub.script.types.map import Map +from ytdl_sub.script.types.resolvable import Boolean from ytdl_sub.script.types.resolvable import Integer from ytdl_sub.script.types.resolvable import String @@ -63,6 +64,13 @@ class Variable(ABC): """ +@dataclass(frozen=True) +class BooleanVariable(Variable): + @classmethod + def human_readable_type(cls) -> str: + return Boolean.__name__ + + @dataclass(frozen=True) class StringVariable(Variable): @classmethod diff --git a/src/ytdl_sub/entries/variables/override_variables.py b/src/ytdl_sub/entries/variables/override_variables.py index 007aaf2b..68ec72d4 100644 --- a/src/ytdl_sub/entries/variables/override_variables.py +++ b/src/ytdl_sub/entries/variables/override_variables.py @@ -1,5 +1,12 @@ +from typing import Dict +from typing import Set + from ytdl_sub.entries.script.function_scripts import CUSTOM_FUNCTION_SCRIPTS from ytdl_sub.entries.script.variable_definitions import VARIABLE_SCRIPTS +from ytdl_sub.entries.script.variable_types import BooleanVariable +from ytdl_sub.entries.script.variable_types import MapVariable +from ytdl_sub.entries.script.variable_types import StringVariable +from ytdl_sub.entries.script.variable_types import Variable from ytdl_sub.script.functions import Functions from ytdl_sub.script.utils.name_validation import is_valid_name @@ -9,15 +16,15 @@ SUBSCRIPTION_ARRAY = "subscription_array" class SubscriptionVariables: @staticmethod - def subscription_name() -> str: + def subscription_name() -> StringVariable: """ Name of the subscription. For subscriptions types that use a prefix (``~``, ``+``), the prefix and all whitespace afterwards is stripped from the subscription name. """ - return "subscription_name" + return StringVariable(variable_name="subscription_name", definition="{ %string('') }") @staticmethod - def subscription_value() -> str: + def subscription_value() -> StringVariable: """ For subscriptions in the form of @@ -27,10 +34,10 @@ class SubscriptionVariables: ``subscription_value`` gets set to ``https://...``. """ - return "subscription_value" + return StringVariable(variable_name="subscription_value", definition="{ %string('') }") @staticmethod - def subscription_indent_i(index: int) -> str: + def subscription_indent_i(index: int) -> StringVariable: """ For subscriptions in the form of @@ -43,10 +50,12 @@ class SubscriptionVariables: ``subscription_indent_1`` and ``subscription_indent_2`` get set to ``Indent Value 1`` and ``Indent Value 2``. """ - return f"subscription_indent_{index + 1}" + return StringVariable( + variable_name=f"subscription_indent_{index + 1}", definition="{ %string('') }" + ) @staticmethod - def subscription_value_i(index: int) -> str: + def subscription_value_i(index: int) -> StringVariable: """ For subscriptions in the form of @@ -60,10 +69,12 @@ class SubscriptionVariables: and ``https://url2.com/...``. Note that ``subscription_value_1`` also gets set to ``subscription_value``. """ - return f"subscription_value_{index + 1}" + return StringVariable( + variable_name=f"subscription_value_{index + 1}", definition="{ %string('') }" + ) @staticmethod - def subscription_map() -> str: + def subscription_map() -> MapVariable: """ For subscriptions in the form of @@ -89,7 +100,17 @@ class SubscriptionVariables: ] } """ - return "subscription_map" + return MapVariable(variable_name="subscription_map", definition="{ {} }") + + @staticmethod + def subscription_has_download_archive() -> BooleanVariable: + """ + Returns True if the subscription has any entries recorded in a download archive. False + otherwise. + """ + return BooleanVariable( + variable_name="subscription_has_download_archive", definition="{ %bool(True) }" + ) class OverrideHelpers: @@ -124,3 +145,17 @@ class OverrideHelpers: return is_valid_name(name=name[1:]) return is_valid_name(name=name) + + +REQUIRED_OVERRIDE_VARIABLES: Set[Variable] = { + SubscriptionVariables.subscription_name(), + SubscriptionVariables.subscription_has_download_archive(), +} + +REQUIRED_OVERRIDE_VARIABLE_DEFINITIONS: Dict[str, str] = { + var.variable_name: var.definition for var in REQUIRED_OVERRIDE_VARIABLES +} + +REQUIRED_OVERRIDE_VARIABLE_NAMES: Set[str] = { + var.variable_name for var in REQUIRED_OVERRIDE_VARIABLES +} diff --git a/src/ytdl_sub/prebuilt_presets/helpers/url.yaml b/src/ytdl_sub/prebuilt_presets/helpers/url.yaml index 787e0713..3a9726dd 100644 --- a/src/ytdl_sub/prebuilt_presets/helpers/url.yaml +++ b/src/ytdl_sub/prebuilt_presets/helpers/url.yaml @@ -330,3 +330,418 @@ presets: url98: "{subscription_value_98}" url99: "{subscription_value_99}" url100: "{subscription_value_100}" + + + # multi-url with bilateral scraping built into it via + # inspection of the URL and conditionally adding another + # ytdl-sub url download with the scraping and download reversed + _multi_url_bilateral_inner: + preset: + - "_url_bilateral_overrides" + + download: + - url: "{%bilateral_url(url) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url2) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url3) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url4) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url5) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url6) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url7) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url8) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url9) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url10) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url11) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url12) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url13) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url14) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url15) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url16) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url17) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url18) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url19) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url20) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url21) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url22) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url23) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url24) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url25) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url26) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url27) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url28) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url29) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url30) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url31) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url32) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url33) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url34) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url35) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url36) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url37) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url38) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url39) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url40) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url41) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url42) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url43) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url44) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url45) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url46) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url47) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url48) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url49) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url50) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url51) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url52) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url53) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url54) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url55) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url56) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url57) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url58) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url59) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url60) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url61) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url62) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url63) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url64) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url65) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url66) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url67) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url68) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url69) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url70) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url71) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url72) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url73) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url74) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url75) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url76) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url77) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url78) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url79) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url80) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url81) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url82) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url83) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url84) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url85) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url86) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url87) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url88) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url89) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url90) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url91) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url92) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url93) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url94) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url95) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url96) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url97) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url98) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{ %bilateral_url(url99) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + - url: "{%bilateral_url(url100) }" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + _multi_url_bilateral: + preset: + - "_multi_url_bilateral_inner" + - "_multi_url" \ No newline at end of file diff --git a/src/ytdl_sub/prebuilt_presets/helpers/url_bilateral.yaml b/src/ytdl_sub/prebuilt_presets/helpers/url_bilateral.yaml new file mode 100644 index 00000000..b785fc5e --- /dev/null +++ b/src/ytdl_sub/prebuilt_presets/helpers/url_bilateral.yaml @@ -0,0 +1,21 @@ +presets: + # multi-url with bilateral scraping built into it via + # inspection of the URL and conditionally adding another + # ytdl-sub url download with the scraping and download reversed + _url_bilateral_overrides: + overrides: + enable_bilateral_scraping: True + "%is_bilateral_url": >- + { %contains( $0, "youtube.com/playlist" ) } + "%bilateral_url": >- + { + %if( + %and( + enable_bilateral_scraping, + subscription_has_download_archive, + %is_bilateral_url($0) + ), + $0, + "" + ) + } \ No newline at end of file diff --git a/src/ytdl_sub/prebuilt_presets/tv_show/tv_show.yaml b/src/ytdl_sub/prebuilt_presets/tv_show/tv_show.yaml index fb97e0fb..463f122b 100644 --- a/src/ytdl_sub/prebuilt_presets/tv_show/tv_show.yaml +++ b/src/ytdl_sub/prebuilt_presets/tv_show/tv_show.yaml @@ -29,15 +29,6 @@ presets: - "_kodi_base" - "_jellyfin_tv_show" -#################################################################################################### - - # TV show from one or more sources. Uses {url}'s avatar and banner as poster and fanart - _tv_show_by_date: - preset: "_multi_url" - overrides: - avatar_uncropped_thumbnail_file_name: "{tv_show_poster_file_name}" - banner_uncropped_thumbnail_file_name: "{tv_show_fanart_file_name}" - #################################################################################################### _episode_video_tags: diff --git a/src/ytdl_sub/prebuilt_presets/tv_show/tv_show_by_date.yaml b/src/ytdl_sub/prebuilt_presets/tv_show/tv_show_by_date.yaml index 30027444..e676ec82 100644 --- a/src/ytdl_sub/prebuilt_presets/tv_show/tv_show_by_date.yaml +++ b/src/ytdl_sub/prebuilt_presets/tv_show/tv_show_by_date.yaml @@ -31,6 +31,15 @@ presets: - "plex_tv_show_by_date" - "season_by_year__episode_by_month_day" +#################################################################################################### + + # TV show from one or more sources. Uses {url}'s avatar and banner as poster and fanart + _tv_show_by_date: + preset: "_multi_url_bilateral" + overrides: + avatar_uncropped_thumbnail_file_name: "{tv_show_poster_file_name}" + banner_uncropped_thumbnail_file_name: "{tv_show_fanart_file_name}" + #################################################################################################### _season_by_year: diff --git a/src/ytdl_sub/prebuilt_presets/tv_show/tv_show_collection.yaml b/src/ytdl_sub/prebuilt_presets/tv_show/tv_show_collection.yaml index 8554fda9..888068d1 100644 --- a/src/ytdl_sub/prebuilt_presets/tv_show/tv_show_collection.yaml +++ b/src/ytdl_sub/prebuilt_presets/tv_show/tv_show_collection.yaml @@ -60,6 +60,9 @@ presets: ############## _tv_show_collection: + preset: + - "_tv_show_collection_bilateral" + download: - url: "{collection_season_1_url}" variables: @@ -79,6 +82,7 @@ presets: uid: "avatar_uncropped" - name: "{tv_show_fanart_file_name}" uid: "banner_uncropped" + - url: "{collection_season_2_url}" variables: collection_season_number: "2" @@ -86,6 +90,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_3_url}" variables: collection_season_number: "3" @@ -93,6 +98,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_4_url}" variables: collection_season_number: "4" @@ -100,6 +106,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_5_url}" variables: collection_season_number: "5" @@ -107,6 +114,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_6_url}" variables: collection_season_number: "6" @@ -114,6 +122,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_7_url}" variables: collection_season_number: "7" @@ -121,6 +130,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_8_url}" variables: collection_season_number: "8" @@ -128,6 +138,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_9_url}" variables: collection_season_number: "9" @@ -135,6 +146,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_10_url}" variables: collection_season_number: "10" @@ -142,6 +154,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_11_url}" variables: collection_season_number: "11" @@ -149,6 +162,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_12_url}" variables: collection_season_number: "12" @@ -156,6 +170,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_13_url}" variables: collection_season_number: "13" @@ -163,6 +178,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_14_url}" variables: collection_season_number: "14" @@ -170,6 +186,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_15_url}" variables: collection_season_number: "15" @@ -177,6 +194,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_16_url}" variables: collection_season_number: "16" @@ -184,6 +202,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_17_url}" variables: collection_season_number: "17" @@ -191,6 +210,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_18_url}" variables: collection_season_number: "18" @@ -198,6 +218,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_19_url}" variables: collection_season_number: "19" @@ -205,6 +226,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_20_url}" variables: collection_season_number: "20" @@ -212,6 +234,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_21_url}" variables: collection_season_number: "21" @@ -219,6 +242,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_22_url}" variables: collection_season_number: "22" @@ -226,6 +250,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_23_url}" variables: collection_season_number: "23" @@ -233,6 +258,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_24_url}" variables: collection_season_number: "24" @@ -240,6 +266,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_25_url}" variables: collection_season_number: "25" @@ -247,6 +274,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_26_url}" variables: collection_season_number: "26" @@ -254,6 +282,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_27_url}" variables: collection_season_number: "27" @@ -261,6 +290,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_28_url}" variables: collection_season_number: "28" @@ -268,6 +298,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_29_url}" variables: collection_season_number: "29" @@ -275,6 +306,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_30_url}" variables: collection_season_number: "30" @@ -282,6 +314,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_31_url}" variables: collection_season_number: "31" @@ -289,6 +322,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_32_url}" variables: collection_season_number: "32" @@ -296,6 +330,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_33_url}" variables: collection_season_number: "33" @@ -303,6 +338,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_34_url}" variables: collection_season_number: "34" @@ -310,6 +346,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_35_url}" variables: collection_season_number: "35" @@ -317,6 +354,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_36_url}" variables: collection_season_number: "36" @@ -324,6 +362,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_37_url}" variables: collection_season_number: "37" @@ -331,6 +370,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_38_url}" variables: collection_season_number: "38" @@ -338,6 +378,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_39_url}" variables: collection_season_number: "39" @@ -345,6 +386,7 @@ presets: playlist_thumbnails: - name: "{season_poster_file_name}" uid: "latest_entry" + - url: "{collection_season_40_url}" variables: collection_season_number: "40" @@ -648,6 +690,332 @@ presets: s39_url: "" s40_url: "" + _tv_show_collection_bilateral: + preset: + - "_url_bilateral_overrides" + + download: + - url: "{ %bilateral_url(collection_season_1_url) }" + variables: + collection_season_number: "1" + collection_season_name: "{collection_season_1_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_2_url) }" + variables: + collection_season_number: "2" + collection_season_name: "{collection_season_2_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_3_url) }" + variables: + collection_season_number: "3" + collection_season_name: "{collection_season_3_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_4_url) }" + variables: + collection_season_number: "4" + collection_season_name: "{collection_season_4_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_5_url) }" + variables: + collection_season_number: "5" + collection_season_name: "{collection_season_5_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_6_url) }" + variables: + collection_season_number: "6" + collection_season_name: "{collection_season_6_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_7_url) }" + variables: + collection_season_number: "7" + collection_season_name: "{collection_season_7_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_8_url) }" + variables: + collection_season_number: "8" + collection_season_name: "{collection_season_8_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_9_url) }" + variables: + collection_season_number: "9" + collection_season_name: "{collection_season_9_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_10_url) }" + variables: + collection_season_number: "10" + collection_season_name: "{collection_season_10_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_11_url) }" + variables: + collection_season_number: "11" + collection_season_name: "{collection_season_11_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_12_url) }" + variables: + collection_season_number: "12" + collection_season_name: "{collection_season_12_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_13_url) }" + variables: + collection_season_number: "13" + collection_season_name: "{collection_season_13_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_14_url) }" + variables: + collection_season_number: "14" + collection_season_name: "{collection_season_14_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_15_url) }" + variables: + collection_season_number: "15" + collection_season_name: "{collection_season_15_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_16_url) }" + variables: + collection_season_number: "16" + collection_season_name: "{collection_season_16_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_17_url) }" + variables: + collection_season_number: "17" + collection_season_name: "{collection_season_17_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_18_url) }" + variables: + collection_season_number: "18" + collection_season_name: "{collection_season_18_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_19_url) }" + variables: + collection_season_number: "19" + collection_season_name: "{collection_season_19_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_20_url) }" + variables: + collection_season_number: "20" + collection_season_name: "{collection_season_20_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_21_url) }" + variables: + collection_season_number: "21" + collection_season_name: "{collection_season_21_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_22_url) }" + variables: + collection_season_number: "22" + collection_season_name: "{collection_season_22_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_23_url) }" + variables: + collection_season_number: "23" + collection_season_name: "{collection_season_23_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_24_url) }" + variables: + collection_season_number: "24" + collection_season_name: "{collection_season_24_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_25_url) }" + variables: + collection_season_number: "25" + collection_season_name: "{collection_season_25_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_26_url) }" + variables: + collection_season_number: "26" + collection_season_name: "{collection_season_26_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_27_url) }" + variables: + collection_season_number: "27" + collection_season_name: "{collection_season_27_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_28_url) }" + variables: + collection_season_number: "28" + collection_season_name: "{collection_season_28_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_29_url) }" + variables: + collection_season_number: "29" + collection_season_name: "{collection_season_29_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_30_url) }" + variables: + collection_season_number: "30" + collection_season_name: "{collection_season_30_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_31_url) }" + variables: + collection_season_number: "31" + collection_season_name: "{collection_season_31_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_32_url) }" + variables: + collection_season_number: "32" + collection_season_name: "{collection_season_32_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_33_url) }" + variables: + collection_season_number: "33" + collection_season_name: "{collection_season_33_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_34_url) }" + variables: + collection_season_number: "34" + collection_season_name: "{collection_season_34_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_35_url) }" + variables: + collection_season_number: "35" + collection_season_name: "{collection_season_35_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_36_url) }" + variables: + collection_season_number: "36" + collection_season_name: "{collection_season_36_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_37_url) }" + variables: + collection_season_number: "37" + collection_season_name: "{collection_season_37_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_38_url) }" + variables: + collection_season_number: "38" + collection_season_name: "{collection_season_38_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_39_url) }" + variables: + collection_season_number: "39" + collection_season_name: "{collection_season_39_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + - url: "{ %bilateral_url(collection_season_40_url) }" + variables: + collection_season_number: "40" + collection_season_name: "{collection_season_40_name}" + download_reverse: False + ytdl_options: + playlist_items: "-1:0:-1" + + #################################################################################################### # DEPRECATED SEASON PRESETS diff --git a/src/ytdl_sub/subscriptions/base_subscription.py b/src/ytdl_sub/subscriptions/base_subscription.py index c7872e8a..6ec7bb9e 100644 --- a/src/ytdl_sub/subscriptions/base_subscription.py +++ b/src/ytdl_sub/subscriptions/base_subscription.py @@ -9,6 +9,7 @@ from ytdl_sub.config.preset import Preset from ytdl_sub.config.preset_options import OutputOptions from ytdl_sub.config.preset_options import YTDLOptions from ytdl_sub.downloaders.url.validators import MultiUrlValidator +from ytdl_sub.entries.variables.override_variables import SubscriptionVariables from ytdl_sub.utils.file_handler import FileHandlerTransactionLog from ytdl_sub.utils.logger import Logger from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive @@ -16,6 +17,24 @@ from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadAr logger = Logger.get("subscription") +def _initialize_download_archive( + output_options: OutputOptions, + overrides: Overrides, + working_directory: str, + output_directory: str, +) -> EnhancedDownloadArchive: + migrated_file_name: Optional[str] = None + if migrated_file_name_option := output_options.migrated_download_archive_name: + migrated_file_name = overrides.apply_formatter(migrated_file_name_option) + + return EnhancedDownloadArchive( + file_name=overrides.apply_formatter(output_options.download_archive_name), + working_directory=working_directory, + output_directory=output_directory, + migrated_file_name=migrated_file_name, + ).reinitialize(dry_run=True) + + class BaseSubscription(ABC): """ Subscription classes are the 'controllers' that perform... @@ -48,20 +67,43 @@ class BaseSubscription(ABC): self._config_options = config_options self._preset_options = preset_options - migrated_file_name: Optional[str] = None - if migrated_file_name_option := self.output_options.migrated_download_archive_name: - migrated_file_name = self.overrides.apply_formatter(migrated_file_name_option) + # Add overrides pre-archive + self.overrides.add( + { + SubscriptionVariables.subscription_name(): self.name, + } + ) - # TODO: Do not include this as part of the subscription - self._enhanced_download_archive = EnhancedDownloadArchive( - file_name=self.overrides.apply_formatter(self.output_options.download_archive_name), + self._enhanced_download_archive: Optional[ + EnhancedDownloadArchive + ] = _initialize_download_archive( + output_options=self.output_options, + overrides=self.overrides, working_directory=self.working_directory, output_directory=self.output_directory, - migrated_file_name=migrated_file_name, + ) + + # Add post-archive variables + self.overrides.add( + { + SubscriptionVariables.subscription_has_download_archive(): f"""{{ + %bool({self.download_archive.num_entries > 0}) + }}""", + } ) self._exception: Optional[Exception] = None + @property + def download_archive(self) -> EnhancedDownloadArchive: + """ + Returns + ------- + Initialized download archive + """ + assert self._enhanced_download_archive is not None + return self._enhanced_download_archive + @property def downloader_options(self) -> MultiUrlValidator: """ @@ -141,7 +183,7 @@ class BaseSubscription(ABC): ------- Number of entries added """ - return self._enhanced_download_archive.num_entries_added + return self.download_archive.num_entries_added @property def num_entries_modified(self) -> int: @@ -150,7 +192,7 @@ class BaseSubscription(ABC): ------- Number of entries modified """ - return self._enhanced_download_archive.num_entries_modified + return self.download_archive.num_entries_modified @property def num_entries_removed(self) -> int: @@ -159,7 +201,7 @@ class BaseSubscription(ABC): ------- Number of entries removed """ - return self._enhanced_download_archive.num_entries_removed + return self.download_archive.num_entries_removed @property def num_entries(self) -> int: @@ -168,7 +210,7 @@ class BaseSubscription(ABC): ------- The number of entries """ - return self._enhanced_download_archive.num_entries + return self.download_archive.num_entries @property def transaction_log(self) -> FileHandlerTransactionLog: @@ -177,7 +219,7 @@ class BaseSubscription(ABC): ------- Transaction log from the subscription """ - return self._enhanced_download_archive.get_file_handler_transaction_log() + return self.download_archive.get_file_handler_transaction_log() @property def exception(self) -> Optional[Exception]: diff --git a/src/ytdl_sub/subscriptions/subscription_download.py b/src/ytdl_sub/subscriptions/subscription_download.py index 06ce1bd2..b017d005 100644 --- a/src/ytdl_sub/subscriptions/subscription_download.py +++ b/src/ytdl_sub/subscriptions/subscription_download.py @@ -67,7 +67,7 @@ class SubscriptionDownload(BaseSubscription, ABC): output_file_name = self.overrides.apply_formatter( formatter=self.output_options.file_name, entry=entry ) - self._enhanced_download_archive.save_file_to_output_directory( + self.download_archive.save_file_to_output_directory( file_name=entry.get_download_file_name(), file_metadata=entry_metadata, output_file_name=output_file_name, @@ -81,7 +81,7 @@ class SubscriptionDownload(BaseSubscription, ABC): ) # Copy the thumbnails since they could be used later for other things - self._enhanced_download_archive.save_file_to_output_directory( + self.download_archive.save_file_to_output_directory( file_name=entry.get_download_thumbnail_name(), output_file_name=output_thumbnail_name, entry=entry, @@ -101,7 +101,7 @@ class SubscriptionDownload(BaseSubscription, ABC): if not dry_run: entry.write_info_json() - self._enhanced_download_archive.save_file_to_output_directory( + self.download_archive.save_file_to_output_directory( file_name=entry.get_download_info_json_name(), output_file_name=output_info_json_name, entry=entry, @@ -135,7 +135,7 @@ class SubscriptionDownload(BaseSubscription, ABC): Context manager to initialize the enhanced download archive """ if self.maintain_download_archive: - self._enhanced_download_archive.prepare_download_archive() + self.download_archive.prepare_download_archive() yield @@ -156,19 +156,19 @@ class SubscriptionDownload(BaseSubscription, ABC): ) if date_range_to_keep or self.output_options.keep_max_files is not None: - self._enhanced_download_archive.remove_stale_files( + self.download_archive.remove_stale_files( date_range=date_range_to_keep, keep_max_files=keep_max_files ) - self._enhanced_download_archive.save_download_mappings() - FileHandler.delete(self._enhanced_download_archive.working_file_path) + self.download_archive.save_download_mappings() + FileHandler.delete(self.download_archive.working_file_path) @contextlib.contextmanager def _remove_empty_directories_in_output_directory(self): try: yield finally: - if not self._enhanced_download_archive.is_dry_run: + if not self.download_archive.is_dry_run: for root, dir_names, _ in os.walk(Path(self.output_directory), topdown=False): for dir_name in dir_names: dir_path = Path(root) / dir_name @@ -194,7 +194,7 @@ class SubscriptionDownload(BaseSubscription, ABC): plugin_type( options=plugin_options, overrides=self.overrides, - enhanced_download_archive=self._enhanced_download_archive, + enhanced_download_archive=self.download_archive, ) for plugin_type, plugin_options in self.plugins.zipped() ] @@ -233,7 +233,7 @@ class SubscriptionDownload(BaseSubscription, ABC): # Re-save the download archive after each entry is moved to the output directory if self.maintain_download_archive: - self._enhanced_download_archive.save_download_mappings() + self.download_archive.save_download_mappings() def _process_entry( self, plugins: List[Plugin], dry_run: bool, entry: Entry, entry_metadata: FileMetadata @@ -323,7 +323,7 @@ class SubscriptionDownload(BaseSubscription, ABC): for plugin in plugins: plugin.post_process_subscription() - return self._enhanced_download_archive.get_file_handler_transaction_log() + return self.download_archive.get_file_handler_transaction_log() def download(self, dry_run: bool = False) -> FileHandlerTransactionLog: """ @@ -336,14 +336,14 @@ class SubscriptionDownload(BaseSubscription, ABC): directory. """ self._exception = None - self._enhanced_download_archive.reinitialize(dry_run=dry_run) + self.download_archive.reinitialize(dry_run=dry_run) plugins = self._initialize_plugins() subscription_ytdl_options = SubscriptionYTDLOptions( preset=self._preset_options, plugins=plugins, - enhanced_download_archive=self._enhanced_download_archive, + enhanced_download_archive=self.download_archive, overrides=self.overrides, working_directory=self.working_directory, dry_run=dry_run, @@ -351,7 +351,7 @@ class SubscriptionDownload(BaseSubscription, ABC): downloader = MultiUrlDownloader( options=self.downloader_options, - enhanced_download_archive=self._enhanced_download_archive, + enhanced_download_archive=self.download_archive, download_ytdl_options=subscription_ytdl_options.download_builder(), metadata_ytdl_options=subscription_ytdl_options.metadata_builder(), overrides=self.overrides, @@ -389,14 +389,14 @@ class SubscriptionDownload(BaseSubscription, ABC): If true, do not modify any video/audio files or move anything to the output directory. """ self._exception = None - self._enhanced_download_archive.reinitialize(dry_run=dry_run) + self.download_archive.reinitialize(dry_run=dry_run) plugins = self._initialize_plugins() subscription_ytdl_options = SubscriptionYTDLOptions( preset=self._preset_options, plugins=plugins, - enhanced_download_archive=self._enhanced_download_archive, + enhanced_download_archive=self.download_archive, overrides=self.overrides, working_directory=self.working_directory, dry_run=dry_run, @@ -406,7 +406,7 @@ class SubscriptionDownload(BaseSubscription, ABC): plugins.extend( MultiUrlDownloader( options=self.downloader_options, - enhanced_download_archive=self._enhanced_download_archive, + enhanced_download_archive=self.download_archive, download_ytdl_options=subscription_ytdl_options.download_builder(), metadata_ytdl_options=subscription_ytdl_options.metadata_builder(), overrides=self.overrides, @@ -415,7 +415,7 @@ class SubscriptionDownload(BaseSubscription, ABC): downloader = InfoJsonDownloader( options=InfoJsonDownloaderOptions(name="no-op", value={}), - enhanced_download_archive=self._enhanced_download_archive, + enhanced_download_archive=self.download_archive, download_ytdl_options=YTDLOptionsBuilder(), metadata_ytdl_options=YTDLOptionsBuilder(), overrides=self.overrides, diff --git a/src/ytdl_sub/subscriptions/subscription_validators.py b/src/ytdl_sub/subscriptions/subscription_validators.py index c247779a..6ce95cc6 100644 --- a/src/ytdl_sub/subscriptions/subscription_validators.py +++ b/src/ytdl_sub/subscriptions/subscription_validators.py @@ -32,7 +32,7 @@ class SubscriptionOutput(Validator, ABC): indent overrides to merge with the preset dict's overrides """ return { - SubscriptionVariables.subscription_indent_i(i): self._indent_overrides[i] + SubscriptionVariables.subscription_indent_i(i).variable_name: self._indent_overrides[i] for i in range(len(self._indent_overrides)) } @@ -143,7 +143,9 @@ class SubscriptionValueValidator(SubscriptionLeafValidator, StringValidator): presets=presets, indent_overrides=indent_overrides, ) - self._overrides_to_add[SubscriptionVariables.subscription_value()] = self.value + self._overrides_to_add[ + SubscriptionVariables.subscription_value().variable_name + ] = self.value class SubscriptionListValuesValidator(SubscriptionLeafValidator, StringListValidator): @@ -169,11 +171,11 @@ class SubscriptionListValuesValidator(SubscriptionLeafValidator, StringListValid # Write the first list value into subscription_value as well if idx == 0: self._overrides_to_add[ - SubscriptionVariables.subscription_value() + SubscriptionVariables.subscription_value().variable_name ] = list_value.value self._overrides_to_add[ - SubscriptionVariables.subscription_value_i(index=idx) + SubscriptionVariables.subscription_value_i(index=idx).variable_name ] = list_value.value @@ -217,9 +219,9 @@ class SubscriptionMapValidator(SubscriptionLeafValidator, LiteralDictValidator): presets=presets, indent_overrides=indent_overrides, ) - self._overrides_to_add[SubscriptionVariables.subscription_map()] = ScriptUtils.to_script( - self.dict - ) + self._overrides_to_add[ + SubscriptionVariables.subscription_map().variable_name + ] = ScriptUtils.to_script(self.dict) class SubscriptionValidator(SubscriptionOutput): diff --git a/src/ytdl_sub/utils/scriptable.py b/src/ytdl_sub/utils/scriptable.py index 6aff1db3..38780024 100644 --- a/src/ytdl_sub/utils/scriptable.py +++ b/src/ytdl_sub/utils/scriptable.py @@ -9,13 +9,16 @@ from ytdl_sub.entries.script.function_scripts import CUSTOM_FUNCTION_SCRIPTS from ytdl_sub.entries.script.variable_definitions import UNRESOLVED_VARIABLES from ytdl_sub.entries.script.variable_definitions import VARIABLE_SCRIPTS from ytdl_sub.entries.script.variable_types import Variable +from ytdl_sub.entries.variables.override_variables import REQUIRED_OVERRIDE_VARIABLE_DEFINITIONS from ytdl_sub.script.script import Script from ytdl_sub.script.utils.exceptions import RuntimeException from ytdl_sub.utils.exceptions import StringFormattingException from ytdl_sub.utils.script import ScriptUtils BASE_SCRIPT: Script = Script( - dict(ScriptUtils.add_sanitized_variables(VARIABLE_SCRIPTS), **CUSTOM_FUNCTION_SCRIPTS) + ScriptUtils.add_sanitized_variables(VARIABLE_SCRIPTS) + | ScriptUtils.add_sanitized_variables(REQUIRED_OVERRIDE_VARIABLE_DEFINITIONS) + | CUSTOM_FUNCTION_SCRIPTS ) diff --git a/tests/e2e/entries/__init__.py b/tests/e2e/entries/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e/entries/script/__init__.py b/tests/e2e/entries/script/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e/entries/script/test_custom_functions.py b/tests/e2e/entries/script/test_custom_functions.py new file mode 100644 index 00000000..61c9a6ca --- /dev/null +++ b/tests/e2e/entries/script/test_custom_functions.py @@ -0,0 +1,22 @@ +from unit.script.conftest import single_variable_output + + +class TestCustomFunctions: + def test_is_playlist_ordered_by_newest_true(self): + assert ( + single_variable_output( + "{%is_playlist_ordered_by_newest('https://www.youtube.com/playlist?list=PL5BC0FC26BECA5A35')}" + ) + is True + ) + + def test_is_playlist_ordered_by_newest_false(self): + assert ( + single_variable_output( + "{%is_playlist_ordered_by_newest('https://www.youtube.com/playlist?list=PL2KvlCGf4yFftX466OnFS8wvuSosBfUgm')}" + ) + is False + ) + + def test_is_playlist_ordered_by_newest_defaults_false(self): + assert single_variable_output("{%is_playlist_ordered_by_newest('aaaaaa')}") is False diff --git a/tests/e2e/youtube/test_playlist.py b/tests/e2e/youtube/test_playlist.py index 3c8e142f..e173c568 100644 --- a/tests/e2e/youtube/test_playlist.py +++ b/tests/e2e/youtube/test_playlist.py @@ -51,6 +51,49 @@ def playlist_preset_dict(output_directory): } +@pytest.fixture +def tv_show_by_date_bilateral_dict(output_directory): + return { + "preset": [ + "Jellyfin TV Show by Date", + ], + "format": "worst[ext=mp4]", + "match_filters": {"filters": ["title *= Feb.1"]}, + "overrides": { + "url": "https://www.youtube.com/playlist?list=PLd4Q7G88JqoekF0b30NYQcOTnTiIe9Ali", + "tv_show_directory": output_directory, + }, + "nfo_tags": { + "tags": { + "subscription_has_download_archive": "{subscription_has_download_archive}", + "download_index": "{download_index}", + } + }, + } + + +@pytest.fixture +def tv_show_collection_bilateral_dict(output_directory): + return { + "preset": [ + "Jellyfin TV Show Collection", + ], + "format": "worst[ext=mp4]", + "match_filters": {"filters": ["title *= Feb.1"]}, + "overrides": { + "s01_url": "https://www.youtube.com/playlist?list=PLd4Q7G88JqoekF0b30NYQcOTnTiIe9Ali", + "s01_name": "bilateral test", + "tv_show_directory": output_directory, + }, + "nfo_tags": { + "tags": { + "subscription_has_download_archive": "{subscription_has_download_archive}", + "download_index": "{download_index}", + } + }, + } + + class TestPlaylist: """ Downloads my old minecraft youtube channel, pretends they are music videos. Ensure the above @@ -222,3 +265,71 @@ class TestPlaylist: assert len(subscriptions) == 1 assert subscriptions[0].transaction_log.is_empty + + def test_tv_show_by_date_downloads_bilateral( + self, + tv_show_by_date_bilateral_dict: Dict, + output_directory: str, + default_config: ConfigFile, + ): + playlist_subscription = Subscription.from_dict( + config=default_config, + preset_name="bilateral_test", + preset_dict=tv_show_by_date_bilateral_dict, + ) + + transaction_log = playlist_subscription.download(dry_run=False) + assert_transaction_log_matches( + output_directory=output_directory, + transaction_log=transaction_log, + transaction_log_summary_file_name="youtube/test_playlist_bilateral_p1.txt", + ) + + # Now that one vid is downloaded, attempt to download all and see if bilateral + # logic kicks in + del tv_show_by_date_bilateral_dict["match_filters"] + playlist_subscription = Subscription.from_dict( + config=default_config, + preset_name="bilateral_test", + preset_dict=tv_show_by_date_bilateral_dict, + ) + transaction_log = playlist_subscription.download(dry_run=True) + assert_transaction_log_matches( + output_directory=output_directory, + transaction_log=transaction_log, + transaction_log_summary_file_name="youtube/test_playlist_bilateral_p2.txt", + ) + + def test_tv_show_collection_downloads_bilateral( + self, + tv_show_collection_bilateral_dict: Dict, + output_directory: str, + default_config: ConfigFile, + ): + playlist_subscription = Subscription.from_dict( + config=default_config, + preset_name="bilateral_test", + preset_dict=tv_show_collection_bilateral_dict, + ) + + transaction_log = playlist_subscription.download(dry_run=False) + assert_transaction_log_matches( + output_directory=output_directory, + transaction_log=transaction_log, + transaction_log_summary_file_name="youtube/test_playlist_bilateral_collection_p1.txt", + ) + + # Now that one vid is downloaded, attempt to download all and see if bilateral + # logic kicks in + del tv_show_collection_bilateral_dict["match_filters"] + playlist_subscription = Subscription.from_dict( + config=default_config, + preset_name="bilateral_test", + preset_dict=tv_show_collection_bilateral_dict, + ) + transaction_log = playlist_subscription.download(dry_run=True) + assert_transaction_log_matches( + output_directory=output_directory, + transaction_log=transaction_log, + transaction_log_summary_file_name="youtube/test_playlist_bilateral_collection_p2.txt", + ) diff --git a/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_0_many_urls_reformatted_to_season_by_year__episode_by_download_index.txt b/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_0_many_urls_reformatted_to_season_by_year__episode_by_download_index.txt index 0e8ee84a..bf8c85ca 100644 --- a/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_0_many_urls_reformatted_to_season_by_year__episode_by_download_index.txt +++ b/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_0_many_urls_reformatted_to_season_by_year__episode_by_download_index.txt @@ -3,6 +3,19 @@ Files created: {output_directory} .ytdl-sub-Best Prebuilt TV Show by Date-download-archive.json +Files modified: +---------------------------------------- +{output_directory}/Season 2020 + s2020.e000001 - Mock Entry 20-3.info.json + s2020.e000002 - Mock Entry 20-2.info.json + s2020.e000003 - Mock Entry 20-1.info.json + s2020.e000005 - Mock Entry 20-7.info.json + s2020.e000006 - Mock Entry 20-6.info.json + s2020.e000007 - Mock Entry 20-5.info.json + s2020.e000008 - Mock Entry 20-4.info.json +{output_directory}/Season 2021 + s2021.e000004 - Mock Entry 21-1.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_0_reformatted_to_season_by_year__episode_by_download_index.txt b/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_0_reformatted_to_season_by_year__episode_by_download_index.txt index 0e8ee84a..68b55020 100644 --- a/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_0_reformatted_to_season_by_year__episode_by_download_index.txt +++ b/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_0_reformatted_to_season_by_year__episode_by_download_index.txt @@ -3,6 +3,15 @@ Files created: {output_directory} .ytdl-sub-Best Prebuilt TV Show by Date-download-archive.json +Files modified: +---------------------------------------- +{output_directory}/Season 2020 + s2020.e000001 - Mock Entry 20-3.info.json + s2020.e000002 - Mock Entry 20-2.info.json + s2020.e000003 - Mock Entry 20-1.info.json +{output_directory}/Season 2021 + s2021.e000004 - Mock Entry 21-1.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_1_many_urls_reformatted_to_season_by_year__episode_by_download_index.txt b/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_1_many_urls_reformatted_to_season_by_year__episode_by_download_index.txt index 0e8ee84a..bf8c85ca 100644 --- a/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_1_many_urls_reformatted_to_season_by_year__episode_by_download_index.txt +++ b/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_1_many_urls_reformatted_to_season_by_year__episode_by_download_index.txt @@ -3,6 +3,19 @@ Files created: {output_directory} .ytdl-sub-Best Prebuilt TV Show by Date-download-archive.json +Files modified: +---------------------------------------- +{output_directory}/Season 2020 + s2020.e000001 - Mock Entry 20-3.info.json + s2020.e000002 - Mock Entry 20-2.info.json + s2020.e000003 - Mock Entry 20-1.info.json + s2020.e000005 - Mock Entry 20-7.info.json + s2020.e000006 - Mock Entry 20-6.info.json + s2020.e000007 - Mock Entry 20-5.info.json + s2020.e000008 - Mock Entry 20-4.info.json +{output_directory}/Season 2021 + s2021.e000004 - Mock Entry 21-1.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_1_reformatted_to_season_by_year__episode_by_download_index.txt b/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_1_reformatted_to_season_by_year__episode_by_download_index.txt index 0e8ee84a..68b55020 100644 --- a/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_1_reformatted_to_season_by_year__episode_by_download_index.txt +++ b/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_1_reformatted_to_season_by_year__episode_by_download_index.txt @@ -3,6 +3,15 @@ Files created: {output_directory} .ytdl-sub-Best Prebuilt TV Show by Date-download-archive.json +Files modified: +---------------------------------------- +{output_directory}/Season 2020 + s2020.e000001 - Mock Entry 20-3.info.json + s2020.e000002 - Mock Entry 20-2.info.json + s2020.e000003 - Mock Entry 20-1.info.json +{output_directory}/Season 2021 + s2021.e000004 - Mock Entry 21-1.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_collection/season_by_collection__episode_by_playlist_index/s_2/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt b/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_collection/season_by_collection__episode_by_playlist_index/s_2/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt index 5df3afee..a74b5293 100644 --- a/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_collection/season_by_collection__episode_by_playlist_index/s_2/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt +++ b/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_collection/season_by_collection__episode_by_playlist_index/s_2/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt @@ -208,6 +208,11 @@ Files created: title: 2021-08-08 - Mock Entry 21-1 year: 2021 +Files modified: +---------------------------------------- +{output_directory}/Season 01 + s01.e000003 - Mock Entry 20-5.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_collection/season_by_collection__episode_by_playlist_index/s_2/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt b/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_collection/season_by_collection__episode_by_playlist_index/s_2/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt index 5df3afee..a74b5293 100644 --- a/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_collection/season_by_collection__episode_by_playlist_index/s_2/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt +++ b/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_collection/season_by_collection__episode_by_playlist_index/s_2/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt @@ -208,6 +208,11 @@ Files created: title: 2021-08-08 - Mock Entry 21-1 year: 2021 +Files modified: +---------------------------------------- +{output_directory}/Season 01 + s01.e000003 - Mock Entry 20-5.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_1/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt b/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_1/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt index a74ca655..e5eedf1e 100644 --- a/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_1/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt +++ b/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_1/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt @@ -3,6 +3,14 @@ Files created: {output_directory} .ytdl-sub-Best Prebuilt TV Show Collection-download-archive.json +Files modified: +---------------------------------------- +{output_directory}/Season 01 + s01.e000001 - Mock Entry 20-3.info.json + s01.e000002 - Mock Entry 20-2.info.json + s01.e000003 - Mock Entry 20-1.info.json + s01.e000004 - Mock Entry 21-1.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_1/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt b/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_1/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt index a74ca655..e5eedf1e 100644 --- a/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_1/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt +++ b/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_1/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt @@ -3,6 +3,14 @@ Files created: {output_directory} .ytdl-sub-Best Prebuilt TV Show Collection-download-archive.json +Files modified: +---------------------------------------- +{output_directory}/Season 01 + s01.e000001 - Mock Entry 20-3.info.json + s01.e000002 - Mock Entry 20-2.info.json + s01.e000003 - Mock Entry 20-1.info.json + s01.e000004 - Mock Entry 21-1.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_2/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt b/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_2/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt index a74ca655..3b4a10f6 100644 --- a/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_2/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt +++ b/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_2/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt @@ -3,6 +3,19 @@ Files created: {output_directory} .ytdl-sub-Best Prebuilt TV Show Collection-download-archive.json +Files modified: +---------------------------------------- +{output_directory}/Season 01 + s01.e000001 - Mock Entry 20-7.info.json + s01.e000002 - Mock Entry 20-6.info.json + s01.e000003 - Mock Entry 20-5.info.json + s01.e000004 - Mock Entry 20-4.info.json +{output_directory}/Season 02 + s02.e000001 - Mock Entry 20-3.info.json + s02.e000002 - Mock Entry 20-2.info.json + s02.e000003 - Mock Entry 20-1.info.json + s02.e000004 - Mock Entry 21-1.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_2/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt b/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_2/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt index a74ca655..3b4a10f6 100644 --- a/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_2/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt +++ b/tests/resources/transaction_log_summaries/unit/jellyfin_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_2/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt @@ -3,6 +3,19 @@ Files created: {output_directory} .ytdl-sub-Best Prebuilt TV Show Collection-download-archive.json +Files modified: +---------------------------------------- +{output_directory}/Season 01 + s01.e000001 - Mock Entry 20-7.info.json + s01.e000002 - Mock Entry 20-6.info.json + s01.e000003 - Mock Entry 20-5.info.json + s01.e000004 - Mock Entry 20-4.info.json +{output_directory}/Season 02 + s02.e000001 - Mock Entry 20-3.info.json + s02.e000002 - Mock Entry 20-2.info.json + s02.e000003 - Mock Entry 20-1.info.json + s02.e000004 - Mock Entry 21-1.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/kodi_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_0_many_urls_reformatted_to_season_by_year__episode_by_download_index.txt b/tests/resources/transaction_log_summaries/unit/kodi_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_0_many_urls_reformatted_to_season_by_year__episode_by_download_index.txt index 0e8ee84a..bf8c85ca 100644 --- a/tests/resources/transaction_log_summaries/unit/kodi_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_0_many_urls_reformatted_to_season_by_year__episode_by_download_index.txt +++ b/tests/resources/transaction_log_summaries/unit/kodi_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_0_many_urls_reformatted_to_season_by_year__episode_by_download_index.txt @@ -3,6 +3,19 @@ Files created: {output_directory} .ytdl-sub-Best Prebuilt TV Show by Date-download-archive.json +Files modified: +---------------------------------------- +{output_directory}/Season 2020 + s2020.e000001 - Mock Entry 20-3.info.json + s2020.e000002 - Mock Entry 20-2.info.json + s2020.e000003 - Mock Entry 20-1.info.json + s2020.e000005 - Mock Entry 20-7.info.json + s2020.e000006 - Mock Entry 20-6.info.json + s2020.e000007 - Mock Entry 20-5.info.json + s2020.e000008 - Mock Entry 20-4.info.json +{output_directory}/Season 2021 + s2021.e000004 - Mock Entry 21-1.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/kodi_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_0_reformatted_to_season_by_year__episode_by_download_index.txt b/tests/resources/transaction_log_summaries/unit/kodi_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_0_reformatted_to_season_by_year__episode_by_download_index.txt index 0e8ee84a..68b55020 100644 --- a/tests/resources/transaction_log_summaries/unit/kodi_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_0_reformatted_to_season_by_year__episode_by_download_index.txt +++ b/tests/resources/transaction_log_summaries/unit/kodi_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_0_reformatted_to_season_by_year__episode_by_download_index.txt @@ -3,6 +3,15 @@ Files created: {output_directory} .ytdl-sub-Best Prebuilt TV Show by Date-download-archive.json +Files modified: +---------------------------------------- +{output_directory}/Season 2020 + s2020.e000001 - Mock Entry 20-3.info.json + s2020.e000002 - Mock Entry 20-2.info.json + s2020.e000003 - Mock Entry 20-1.info.json +{output_directory}/Season 2021 + s2021.e000004 - Mock Entry 21-1.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/kodi_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_1_many_urls_reformatted_to_season_by_year__episode_by_download_index.txt b/tests/resources/transaction_log_summaries/unit/kodi_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_1_many_urls_reformatted_to_season_by_year__episode_by_download_index.txt index 0e8ee84a..bf8c85ca 100644 --- a/tests/resources/transaction_log_summaries/unit/kodi_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_1_many_urls_reformatted_to_season_by_year__episode_by_download_index.txt +++ b/tests/resources/transaction_log_summaries/unit/kodi_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_1_many_urls_reformatted_to_season_by_year__episode_by_download_index.txt @@ -3,6 +3,19 @@ Files created: {output_directory} .ytdl-sub-Best Prebuilt TV Show by Date-download-archive.json +Files modified: +---------------------------------------- +{output_directory}/Season 2020 + s2020.e000001 - Mock Entry 20-3.info.json + s2020.e000002 - Mock Entry 20-2.info.json + s2020.e000003 - Mock Entry 20-1.info.json + s2020.e000005 - Mock Entry 20-7.info.json + s2020.e000006 - Mock Entry 20-6.info.json + s2020.e000007 - Mock Entry 20-5.info.json + s2020.e000008 - Mock Entry 20-4.info.json +{output_directory}/Season 2021 + s2021.e000004 - Mock Entry 21-1.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/kodi_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_1_reformatted_to_season_by_year__episode_by_download_index.txt b/tests/resources/transaction_log_summaries/unit/kodi_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_1_reformatted_to_season_by_year__episode_by_download_index.txt index 0e8ee84a..68b55020 100644 --- a/tests/resources/transaction_log_summaries/unit/kodi_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_1_reformatted_to_season_by_year__episode_by_download_index.txt +++ b/tests/resources/transaction_log_summaries/unit/kodi_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_1_reformatted_to_season_by_year__episode_by_download_index.txt @@ -3,6 +3,15 @@ Files created: {output_directory} .ytdl-sub-Best Prebuilt TV Show by Date-download-archive.json +Files modified: +---------------------------------------- +{output_directory}/Season 2020 + s2020.e000001 - Mock Entry 20-3.info.json + s2020.e000002 - Mock Entry 20-2.info.json + s2020.e000003 - Mock Entry 20-1.info.json +{output_directory}/Season 2021 + s2021.e000004 - Mock Entry 21-1.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/kodi_tv_show_collection/season_by_collection__episode_by_playlist_index/s_2/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt b/tests/resources/transaction_log_summaries/unit/kodi_tv_show_collection/season_by_collection__episode_by_playlist_index/s_2/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt index 5df3afee..a74b5293 100644 --- a/tests/resources/transaction_log_summaries/unit/kodi_tv_show_collection/season_by_collection__episode_by_playlist_index/s_2/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt +++ b/tests/resources/transaction_log_summaries/unit/kodi_tv_show_collection/season_by_collection__episode_by_playlist_index/s_2/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt @@ -208,6 +208,11 @@ Files created: title: 2021-08-08 - Mock Entry 21-1 year: 2021 +Files modified: +---------------------------------------- +{output_directory}/Season 01 + s01.e000003 - Mock Entry 20-5.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/kodi_tv_show_collection/season_by_collection__episode_by_playlist_index/s_2/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt b/tests/resources/transaction_log_summaries/unit/kodi_tv_show_collection/season_by_collection__episode_by_playlist_index/s_2/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt index 5df3afee..a74b5293 100644 --- a/tests/resources/transaction_log_summaries/unit/kodi_tv_show_collection/season_by_collection__episode_by_playlist_index/s_2/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt +++ b/tests/resources/transaction_log_summaries/unit/kodi_tv_show_collection/season_by_collection__episode_by_playlist_index/s_2/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt @@ -208,6 +208,11 @@ Files created: title: 2021-08-08 - Mock Entry 21-1 year: 2021 +Files modified: +---------------------------------------- +{output_directory}/Season 01 + s01.e000003 - Mock Entry 20-5.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/kodi_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_1/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt b/tests/resources/transaction_log_summaries/unit/kodi_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_1/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt index a74ca655..e5eedf1e 100644 --- a/tests/resources/transaction_log_summaries/unit/kodi_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_1/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt +++ b/tests/resources/transaction_log_summaries/unit/kodi_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_1/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt @@ -3,6 +3,14 @@ Files created: {output_directory} .ytdl-sub-Best Prebuilt TV Show Collection-download-archive.json +Files modified: +---------------------------------------- +{output_directory}/Season 01 + s01.e000001 - Mock Entry 20-3.info.json + s01.e000002 - Mock Entry 20-2.info.json + s01.e000003 - Mock Entry 20-1.info.json + s01.e000004 - Mock Entry 21-1.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/kodi_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_1/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt b/tests/resources/transaction_log_summaries/unit/kodi_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_1/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt index a74ca655..e5eedf1e 100644 --- a/tests/resources/transaction_log_summaries/unit/kodi_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_1/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt +++ b/tests/resources/transaction_log_summaries/unit/kodi_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_1/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt @@ -3,6 +3,14 @@ Files created: {output_directory} .ytdl-sub-Best Prebuilt TV Show Collection-download-archive.json +Files modified: +---------------------------------------- +{output_directory}/Season 01 + s01.e000001 - Mock Entry 20-3.info.json + s01.e000002 - Mock Entry 20-2.info.json + s01.e000003 - Mock Entry 20-1.info.json + s01.e000004 - Mock Entry 21-1.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/kodi_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_2/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt b/tests/resources/transaction_log_summaries/unit/kodi_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_2/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt index a74ca655..3b4a10f6 100644 --- a/tests/resources/transaction_log_summaries/unit/kodi_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_2/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt +++ b/tests/resources/transaction_log_summaries/unit/kodi_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_2/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt @@ -3,6 +3,19 @@ Files created: {output_directory} .ytdl-sub-Best Prebuilt TV Show Collection-download-archive.json +Files modified: +---------------------------------------- +{output_directory}/Season 01 + s01.e000001 - Mock Entry 20-7.info.json + s01.e000002 - Mock Entry 20-6.info.json + s01.e000003 - Mock Entry 20-5.info.json + s01.e000004 - Mock Entry 20-4.info.json +{output_directory}/Season 02 + s02.e000001 - Mock Entry 20-3.info.json + s02.e000002 - Mock Entry 20-2.info.json + s02.e000003 - Mock Entry 20-1.info.json + s02.e000004 - Mock Entry 21-1.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/kodi_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_2/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt b/tests/resources/transaction_log_summaries/unit/kodi_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_2/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt index a74ca655..3b4a10f6 100644 --- a/tests/resources/transaction_log_summaries/unit/kodi_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_2/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt +++ b/tests/resources/transaction_log_summaries/unit/kodi_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_2/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt @@ -3,6 +3,19 @@ Files created: {output_directory} .ytdl-sub-Best Prebuilt TV Show Collection-download-archive.json +Files modified: +---------------------------------------- +{output_directory}/Season 01 + s01.e000001 - Mock Entry 20-7.info.json + s01.e000002 - Mock Entry 20-6.info.json + s01.e000003 - Mock Entry 20-5.info.json + s01.e000004 - Mock Entry 20-4.info.json +{output_directory}/Season 02 + s02.e000001 - Mock Entry 20-3.info.json + s02.e000002 - Mock Entry 20-2.info.json + s02.e000003 - Mock Entry 20-1.info.json + s02.e000004 - Mock Entry 21-1.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/plex_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_0_many_urls_reformatted_to_season_by_year__episode_by_download_index.txt b/tests/resources/transaction_log_summaries/unit/plex_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_0_many_urls_reformatted_to_season_by_year__episode_by_download_index.txt index 0e8ee84a..03b8d35c 100644 --- a/tests/resources/transaction_log_summaries/unit/plex_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_0_many_urls_reformatted_to_season_by_year__episode_by_download_index.txt +++ b/tests/resources/transaction_log_summaries/unit/plex_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_0_many_urls_reformatted_to_season_by_year__episode_by_download_index.txt @@ -3,6 +3,19 @@ Files created: {output_directory} .ytdl-sub-Best Prebuilt TV Show by Date-download-archive.json +Files modified: +---------------------------------------- +{output_directory}/Season 2020 + s2020.e000001 - Mock Entry 20-3.info.json + s2020.e000002 - Mock Entry 20-2.info.json + s2020.e000003 - Mock Entry 20-1.info.json + s2020.e000005 - Mock Entry 20-7.info.json + s2020.e000006 - Mock Entry 20-6.info.json + s2020.e000007 - Mock Entry 20-5.info.json + s2020.e000008 - Mock Entry 20-4.info.json +{output_directory}/Season 2021 + s2021.e000004 - Mock Entry 21-1.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/plex_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_0_reformatted_to_season_by_year__episode_by_download_index.txt b/tests/resources/transaction_log_summaries/unit/plex_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_0_reformatted_to_season_by_year__episode_by_download_index.txt index 0e8ee84a..7fce7e69 100644 --- a/tests/resources/transaction_log_summaries/unit/plex_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_0_reformatted_to_season_by_year__episode_by_download_index.txt +++ b/tests/resources/transaction_log_summaries/unit/plex_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_0_reformatted_to_season_by_year__episode_by_download_index.txt @@ -3,6 +3,15 @@ Files created: {output_directory} .ytdl-sub-Best Prebuilt TV Show by Date-download-archive.json +Files modified: +---------------------------------------- +{output_directory}/Season 2020 + s2020.e000001 - Mock Entry 20-3.info.json + s2020.e000002 - Mock Entry 20-2.info.json + s2020.e000003 - Mock Entry 20-1.info.json +{output_directory}/Season 2021 + s2021.e000004 - Mock Entry 21-1.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/plex_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_1_many_urls_reformatted_to_season_by_year__episode_by_download_index.txt b/tests/resources/transaction_log_summaries/unit/plex_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_1_many_urls_reformatted_to_season_by_year__episode_by_download_index.txt index 0e8ee84a..03b8d35c 100644 --- a/tests/resources/transaction_log_summaries/unit/plex_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_1_many_urls_reformatted_to_season_by_year__episode_by_download_index.txt +++ b/tests/resources/transaction_log_summaries/unit/plex_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_1_many_urls_reformatted_to_season_by_year__episode_by_download_index.txt @@ -3,6 +3,19 @@ Files created: {output_directory} .ytdl-sub-Best Prebuilt TV Show by Date-download-archive.json +Files modified: +---------------------------------------- +{output_directory}/Season 2020 + s2020.e000001 - Mock Entry 20-3.info.json + s2020.e000002 - Mock Entry 20-2.info.json + s2020.e000003 - Mock Entry 20-1.info.json + s2020.e000005 - Mock Entry 20-7.info.json + s2020.e000006 - Mock Entry 20-6.info.json + s2020.e000007 - Mock Entry 20-5.info.json + s2020.e000008 - Mock Entry 20-4.info.json +{output_directory}/Season 2021 + s2021.e000004 - Mock Entry 21-1.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/plex_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_1_reformatted_to_season_by_year__episode_by_download_index.txt b/tests/resources/transaction_log_summaries/unit/plex_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_1_reformatted_to_season_by_year__episode_by_download_index.txt index 0e8ee84a..7fce7e69 100644 --- a/tests/resources/transaction_log_summaries/unit/plex_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_1_reformatted_to_season_by_year__episode_by_download_index.txt +++ b/tests/resources/transaction_log_summaries/unit/plex_tv_show_by_date/season_by_year__episode_by_download_index/is_yt_1_reformatted_to_season_by_year__episode_by_download_index.txt @@ -3,6 +3,15 @@ Files created: {output_directory} .ytdl-sub-Best Prebuilt TV Show by Date-download-archive.json +Files modified: +---------------------------------------- +{output_directory}/Season 2020 + s2020.e000001 - Mock Entry 20-3.info.json + s2020.e000002 - Mock Entry 20-2.info.json + s2020.e000003 - Mock Entry 20-1.info.json +{output_directory}/Season 2021 + s2021.e000004 - Mock Entry 21-1.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/plex_tv_show_collection/season_by_collection__episode_by_playlist_index/s_2/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt b/tests/resources/transaction_log_summaries/unit/plex_tv_show_collection/season_by_collection__episode_by_playlist_index/s_2/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt index eb4eea57..12ec9ae4 100644 --- a/tests/resources/transaction_log_summaries/unit/plex_tv_show_collection/season_by_collection__episode_by_playlist_index/s_2/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt +++ b/tests/resources/transaction_log_summaries/unit/plex_tv_show_collection/season_by_collection__episode_by_playlist_index/s_2/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt @@ -110,6 +110,11 @@ Files created: title: 2021-08-08 - Mock Entry 21-1 year: 2021 +Files modified: +---------------------------------------- +{output_directory}/Season 01 + s01.e000003 - Mock Entry 20-5.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/plex_tv_show_collection/season_by_collection__episode_by_playlist_index/s_2/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt b/tests/resources/transaction_log_summaries/unit/plex_tv_show_collection/season_by_collection__episode_by_playlist_index/s_2/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt index eb4eea57..12ec9ae4 100644 --- a/tests/resources/transaction_log_summaries/unit/plex_tv_show_collection/season_by_collection__episode_by_playlist_index/s_2/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt +++ b/tests/resources/transaction_log_summaries/unit/plex_tv_show_collection/season_by_collection__episode_by_playlist_index/s_2/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt @@ -110,6 +110,11 @@ Files created: title: 2021-08-08 - Mock Entry 21-1 year: 2021 +Files modified: +---------------------------------------- +{output_directory}/Season 01 + s01.e000003 - Mock Entry 20-5.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/plex_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_1/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt b/tests/resources/transaction_log_summaries/unit/plex_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_1/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt index a74ca655..e5130f6a 100644 --- a/tests/resources/transaction_log_summaries/unit/plex_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_1/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt +++ b/tests/resources/transaction_log_summaries/unit/plex_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_1/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt @@ -3,6 +3,14 @@ Files created: {output_directory} .ytdl-sub-Best Prebuilt TV Show Collection-download-archive.json +Files modified: +---------------------------------------- +{output_directory}/Season 01 + s01.e000001 - Mock Entry 20-3.info.json + s01.e000002 - Mock Entry 20-2.info.json + s01.e000003 - Mock Entry 20-1.info.json + s01.e000004 - Mock Entry 21-1.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/plex_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_1/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt b/tests/resources/transaction_log_summaries/unit/plex_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_1/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt index a74ca655..e5130f6a 100644 --- a/tests/resources/transaction_log_summaries/unit/plex_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_1/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt +++ b/tests/resources/transaction_log_summaries/unit/plex_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_1/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt @@ -3,6 +3,14 @@ Files created: {output_directory} .ytdl-sub-Best Prebuilt TV Show Collection-download-archive.json +Files modified: +---------------------------------------- +{output_directory}/Season 01 + s01.e000001 - Mock Entry 20-3.info.json + s01.e000002 - Mock Entry 20-2.info.json + s01.e000003 - Mock Entry 20-1.info.json + s01.e000004 - Mock Entry 21-1.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/plex_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_2/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt b/tests/resources/transaction_log_summaries/unit/plex_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_2/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt index a74ca655..a2ea72f9 100644 --- a/tests/resources/transaction_log_summaries/unit/plex_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_2/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt +++ b/tests/resources/transaction_log_summaries/unit/plex_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_2/is_yt_0_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt @@ -3,6 +3,19 @@ Files created: {output_directory} .ytdl-sub-Best Prebuilt TV Show Collection-download-archive.json +Files modified: +---------------------------------------- +{output_directory}/Season 01 + s01.e000001 - Mock Entry 20-7.info.json + s01.e000002 - Mock Entry 20-6.info.json + s01.e000003 - Mock Entry 20-5.info.json + s01.e000004 - Mock Entry 20-4.info.json +{output_directory}/Season 02 + s02.e000001 - Mock Entry 20-3.info.json + s02.e000002 - Mock Entry 20-2.info.json + s02.e000003 - Mock Entry 20-1.info.json + s02.e000004 - Mock Entry 21-1.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/unit/plex_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_2/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt b/tests/resources/transaction_log_summaries/unit/plex_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_2/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt index a74ca655..a2ea72f9 100644 --- a/tests/resources/transaction_log_summaries/unit/plex_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_2/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt +++ b/tests/resources/transaction_log_summaries/unit/plex_tv_show_collection/season_by_collection__episode_by_playlist_index_reversed/s_2/is_yt_1_reformatted_to_season_by_collection__episode_by_playlist_index_reversed.txt @@ -3,6 +3,19 @@ Files created: {output_directory} .ytdl-sub-Best Prebuilt TV Show Collection-download-archive.json +Files modified: +---------------------------------------- +{output_directory}/Season 01 + s01.e000001 - Mock Entry 20-7.info.json + s01.e000002 - Mock Entry 20-6.info.json + s01.e000003 - Mock Entry 20-5.info.json + s01.e000004 - Mock Entry 20-4.info.json +{output_directory}/Season 02 + s02.e000001 - Mock Entry 20-3.info.json + s02.e000002 - Mock Entry 20-2.info.json + s02.e000003 - Mock Entry 20-1.info.json + s02.e000004 - Mock Entry 21-1.info.json + Files removed: ---------------------------------------- {output_directory} diff --git a/tests/resources/transaction_log_summaries/youtube/test_playlist_bilateral_collection_p1.txt b/tests/resources/transaction_log_summaries/youtube/test_playlist_bilateral_collection_p1.txt new file mode 100644 index 00000000..91030c53 --- /dev/null +++ b/tests/resources/transaction_log_summaries/youtube/test_playlist_bilateral_collection_p1.txt @@ -0,0 +1,61 @@ +Files created: +---------------------------------------- +{output_directory} + .ytdl-sub-bilateral_test-download-archive.json + fanart.jpg + poster.jpg + season01-poster.jpg + tvshow.nfo + NFO tags: + tvshow: + genre: ytdl-sub + mpaa: TV-14 + namedseason: + attributes: + number: 1 + tag: bilateral test + title: bilateral_test +{output_directory}/Season 01 + s01.e11020101 - Jesse's Minecraft Server [Trailer - Feb.1]-thumb.jpg + s01.e11020101 - Jesse's Minecraft Server [Trailer - Feb.1].info.json + s01.e11020101 - Jesse's Minecraft Server [Trailer - Feb.1].mp4 + Video Tags: + contentRating: TV-14 + date: 2011-02-01 + episode_id: 11020101 + genre: ytdl-sub + show: bilateral_test + synopsis: + https://www.youtube.com/watch?v=0SVukUyys10 + + To join the server, you must apply at: + http://www.jesseminecraft.webs.com/ + + This is just a brief video of the server as of Feb. 1, 2011. + + Texture Pack I Use: + http://www.minecraftforum.net/viewtopic.php?f=25&t=29164 + title: 2011-02-01 - Jesse's Minecraft Server [Trailer - Feb.1] + year: 2011 + s01.e11020101 - Jesse's Minecraft Server [Trailer - Feb.1].nfo + NFO tags: + episodedetails: + aired: 2011-02-01 + download_index: 1 + episode: 11020101 + genre: ytdl-sub + mpaa: TV-14 + plot: + https://www.youtube.com/watch?v=0SVukUyys10 + + To join the server, you must apply at: + http://www.jesseminecraft.webs.com/ + + This is just a brief video of the server as of Feb. 1, 2011. + + Texture Pack I Use: + http://www.minecraftforum.net/viewtopic.php?f=25&t=29164 + season: 1 + subscription_has_download_archive: false + title: 2011-02-01 - Jesse's Minecraft Server [Trailer - Feb.1] + year: 2011 \ No newline at end of file diff --git a/tests/resources/transaction_log_summaries/youtube/test_playlist_bilateral_collection_p2.txt b/tests/resources/transaction_log_summaries/youtube/test_playlist_bilateral_collection_p2.txt new file mode 100644 index 00000000..2b9254c0 --- /dev/null +++ b/tests/resources/transaction_log_summaries/youtube/test_playlist_bilateral_collection_p2.txt @@ -0,0 +1,170 @@ +Files created: +---------------------------------------- +{output_directory}/Season 01 + s01.e11022701 - Jesse's Minecraft Server [Trailer - Feb.27]-thumb.jpg + s01.e11022701 - Jesse's Minecraft Server [Trailer - Feb.27].info.json + s01.e11022701 - Jesse's Minecraft Server [Trailer - Feb.27].mp4 + Video Tags: + contentRating: TV-14 + date: 2011-02-27 + episode_id: 11022701 + genre: ytdl-sub + show: bilateral_test + synopsis: + https://www.youtube.com/watch?v=qPybBrXspds + + Website Link: + http://jesseminecraft.webs.com/ + + All you have to do is read the rules, and fill out a quick, little application to join the Server so we know you read them. We do this to keep the griefers/newbs out, it only takes three minutes, it's not that big of a deal. + + Jesse's Minecraft Server is a City Roleplay/Survival Server, there is a currency, public transportation, strict rules for immersive gameplay, and has a decent size population. Over 750 people have logged onto the server. It is very important you read the rules, we don't tolerate idiots. + + There are over 200 empty properties that are ready for anyone to own! Join now! + + This is the server state as of Feb. 27, 2011. + ---------------------------------------------------------------------------------- + + Texture Pack: + http://www.minecraftforum.net/viewtopic.php?f=25&t=29164 + + Recording Software: + http://www.fraps.com/download.php + + Video Editing Software: + http://explore.live.com/windows-live-movie-maker?os=other + + Song: + Given to Fly - Pearl Jam + (Off of the 'Yield' album) + + I claim no ownership of this song, all the credit goes to Pearl Jam and their producers. + title: 2011-02-27 - Jesse's Minecraft Server [Trailer - Feb.27] + year: 2011 + s01.e11022701 - Jesse's Minecraft Server [Trailer - Feb.27].nfo + NFO tags: + episodedetails: + aired: 2011-02-27 + download_index: 2 + episode: 11022701 + genre: ytdl-sub + mpaa: TV-14 + plot: + https://www.youtube.com/watch?v=qPybBrXspds + + Website Link: + http://jesseminecraft.webs.com/ + + All you have to do is read the rules, and fill out a quick, little application to join the Server so we know you read them. We do this to keep the griefers/newbs out, it only takes three minutes, it's not that big of a deal. + + Jesse's Minecraft Server is a City Roleplay/Survival Server, there is a currency, public transportation, strict rules for immersive gameplay, and has a decent size population. Over 750 people have logged onto the server. It is very important you read the rules, we don't tolerate idiots. + + There are over 200 empty properties that are ready for anyone to own! Join now! + + This is the server state as of Feb. 27, 2011. + ---------------------------------------------------------------------------------- + + Texture Pack: + http://www.minecraftforum.net/viewtopic.php?f=25&t=29164 + + Recording Software: + http://www.fraps.com/download.php + + Video Editing Software: + http://explore.live.com/windows-live-movie-maker?os=other + + Song: + Given to Fly - Pearl Jam + (Off of the 'Yield' album) + + I claim no ownership of this song, all the credit goes to Pearl Jam and their producers. + season: 1 + subscription_has_download_archive: true + title: 2011-02-27 - Jesse's Minecraft Server [Trailer - Feb.27] + year: 2011 + s01.e11032101 - Jesse's Minecraft Server [Trailer - Mar.21]-thumb.jpg + s01.e11032101 - Jesse's Minecraft Server [Trailer - Mar.21].info.json + s01.e11032101 - Jesse's Minecraft Server [Trailer - Mar.21].mp4 + Video Tags: + contentRating: TV-14 + date: 2011-03-21 + episode_id: 11032101 + genre: ytdl-sub + show: bilateral_test + synopsis: + https://www.youtube.com/watch?v=DBjFvs6HafU + + Website Link: + http://jesseminecraft.webs.com/ + + To get on the whitelist, please look at the website linked above (^^^). Due to the overwhelming amount of people trying to join, I've made it so it costs $2 to become a member through paypal. All of it is explained on the website. + + Jesse's Minecraft Server is a City Roleplay/Survival Server, there is a currency, public transportation, strict rules for immersive gameplay, and has a decent size population. Over 1000 people have logged onto the server. It is very important you read the rules, we don't tolerate idiots. + + There are over 300 empty properties that are ready for anyone to own! Join now! + + This is the server state as of Mar. 21, 2011. + --------------------------------------------------------------------------------­-- + + Texture Pack: + http://www.minecraftforum.net/viewtopic.php?f=25&t=29164 + + Recording Software: + http://www.fraps.com/download.php + + Video Editing Software: + http://explore.live.com/windows-live-movie-maker?os=other + + Song: + Indifference - Pearl Jam + (Off of the 'Vs.' album) + + I claim no ownership of this song, all the credit goes to Pearl Jam and their producers. + title: 2011-03-21 - Jesse's Minecraft Server [Trailer - Mar.21] + year: 2011 + s01.e11032101 - Jesse's Minecraft Server [Trailer - Mar.21].nfo + NFO tags: + episodedetails: + aired: 2011-03-21 + download_index: 3 + episode: 11032101 + genre: ytdl-sub + mpaa: TV-14 + plot: + https://www.youtube.com/watch?v=DBjFvs6HafU + + Website Link: + http://jesseminecraft.webs.com/ + + To get on the whitelist, please look at the website linked above (^^^). Due to the overwhelming amount of people trying to join, I've made it so it costs $2 to become a member through paypal. All of it is explained on the website. + + Jesse's Minecraft Server is a City Roleplay/Survival Server, there is a currency, public transportation, strict rules for immersive gameplay, and has a decent size population. Over 1000 people have logged onto the server. It is very important you read the rules, we don't tolerate idiots. + + There are over 300 empty properties that are ready for anyone to own! Join now! + + This is the server state as of Mar. 21, 2011. + --------------------------------------------------------------------------------­-- + + Texture Pack: + http://www.minecraftforum.net/viewtopic.php?f=25&t=29164 + + Recording Software: + http://www.fraps.com/download.php + + Video Editing Software: + http://explore.live.com/windows-live-movie-maker?os=other + + Song: + Indifference - Pearl Jam + (Off of the 'Vs.' album) + + I claim no ownership of this song, all the credit goes to Pearl Jam and their producers. + season: 1 + subscription_has_download_archive: true + title: 2011-03-21 - Jesse's Minecraft Server [Trailer - Mar.21] + year: 2011 + +Files modified: +---------------------------------------- +{output_directory} + .ytdl-sub-bilateral_test-download-archive.json \ No newline at end of file diff --git a/tests/resources/transaction_log_summaries/youtube/test_playlist_bilateral_p1.txt b/tests/resources/transaction_log_summaries/youtube/test_playlist_bilateral_p1.txt new file mode 100644 index 00000000..d1476d0b --- /dev/null +++ b/tests/resources/transaction_log_summaries/youtube/test_playlist_bilateral_p1.txt @@ -0,0 +1,56 @@ +Files created: +---------------------------------------- +{output_directory} + .ytdl-sub-bilateral_test-download-archive.json + fanart.jpg + poster.jpg + tvshow.nfo + NFO tags: + tvshow: + genre: ytdl-sub + mpaa: TV-14 + title: bilateral_test +{output_directory}/Season 2011 + s2011.e020101 - Jesse's Minecraft Server [Trailer - Feb.1]-thumb.jpg + s2011.e020101 - Jesse's Minecraft Server [Trailer - Feb.1].info.json + s2011.e020101 - Jesse's Minecraft Server [Trailer - Feb.1].mp4 + Video Tags: + contentRating: TV-14 + date: 2011-02-01 + episode_id: 20101 + genre: ytdl-sub + show: bilateral_test + synopsis: + https://www.youtube.com/watch?v=0SVukUyys10 + + To join the server, you must apply at: + http://www.jesseminecraft.webs.com/ + + This is just a brief video of the server as of Feb. 1, 2011. + + Texture Pack I Use: + http://www.minecraftforum.net/viewtopic.php?f=25&t=29164 + title: 2011-02-01 - Jesse's Minecraft Server [Trailer - Feb.1] + year: 2011 + s2011.e020101 - Jesse's Minecraft Server [Trailer - Feb.1].nfo + NFO tags: + episodedetails: + aired: 2011-02-01 + download_index: 1 + episode: 20101 + genre: ytdl-sub + mpaa: TV-14 + plot: + https://www.youtube.com/watch?v=0SVukUyys10 + + To join the server, you must apply at: + http://www.jesseminecraft.webs.com/ + + This is just a brief video of the server as of Feb. 1, 2011. + + Texture Pack I Use: + http://www.minecraftforum.net/viewtopic.php?f=25&t=29164 + season: 2011 + subscription_has_download_archive: false + title: 2011-02-01 - Jesse's Minecraft Server [Trailer - Feb.1] + year: 2011 \ No newline at end of file diff --git a/tests/resources/transaction_log_summaries/youtube/test_playlist_bilateral_p2.txt b/tests/resources/transaction_log_summaries/youtube/test_playlist_bilateral_p2.txt new file mode 100644 index 00000000..bb7ca07e --- /dev/null +++ b/tests/resources/transaction_log_summaries/youtube/test_playlist_bilateral_p2.txt @@ -0,0 +1,170 @@ +Files created: +---------------------------------------- +{output_directory}/Season 2011 + s2011.e022701 - Jesse's Minecraft Server [Trailer - Feb.27]-thumb.jpg + s2011.e022701 - Jesse's Minecraft Server [Trailer - Feb.27].info.json + s2011.e022701 - Jesse's Minecraft Server [Trailer - Feb.27].mp4 + Video Tags: + contentRating: TV-14 + date: 2011-02-27 + episode_id: 22701 + genre: ytdl-sub + show: bilateral_test + synopsis: + https://www.youtube.com/watch?v=qPybBrXspds + + Website Link: + http://jesseminecraft.webs.com/ + + All you have to do is read the rules, and fill out a quick, little application to join the Server so we know you read them. We do this to keep the griefers/newbs out, it only takes three minutes, it's not that big of a deal. + + Jesse's Minecraft Server is a City Roleplay/Survival Server, there is a currency, public transportation, strict rules for immersive gameplay, and has a decent size population. Over 750 people have logged onto the server. It is very important you read the rules, we don't tolerate idiots. + + There are over 200 empty properties that are ready for anyone to own! Join now! + + This is the server state as of Feb. 27, 2011. + ---------------------------------------------------------------------------------- + + Texture Pack: + http://www.minecraftforum.net/viewtopic.php?f=25&t=29164 + + Recording Software: + http://www.fraps.com/download.php + + Video Editing Software: + http://explore.live.com/windows-live-movie-maker?os=other + + Song: + Given to Fly - Pearl Jam + (Off of the 'Yield' album) + + I claim no ownership of this song, all the credit goes to Pearl Jam and their producers. + title: 2011-02-27 - Jesse's Minecraft Server [Trailer - Feb.27] + year: 2011 + s2011.e022701 - Jesse's Minecraft Server [Trailer - Feb.27].nfo + NFO tags: + episodedetails: + aired: 2011-02-27 + download_index: 2 + episode: 22701 + genre: ytdl-sub + mpaa: TV-14 + plot: + https://www.youtube.com/watch?v=qPybBrXspds + + Website Link: + http://jesseminecraft.webs.com/ + + All you have to do is read the rules, and fill out a quick, little application to join the Server so we know you read them. We do this to keep the griefers/newbs out, it only takes three minutes, it's not that big of a deal. + + Jesse's Minecraft Server is a City Roleplay/Survival Server, there is a currency, public transportation, strict rules for immersive gameplay, and has a decent size population. Over 750 people have logged onto the server. It is very important you read the rules, we don't tolerate idiots. + + There are over 200 empty properties that are ready for anyone to own! Join now! + + This is the server state as of Feb. 27, 2011. + ---------------------------------------------------------------------------------- + + Texture Pack: + http://www.minecraftforum.net/viewtopic.php?f=25&t=29164 + + Recording Software: + http://www.fraps.com/download.php + + Video Editing Software: + http://explore.live.com/windows-live-movie-maker?os=other + + Song: + Given to Fly - Pearl Jam + (Off of the 'Yield' album) + + I claim no ownership of this song, all the credit goes to Pearl Jam and their producers. + season: 2011 + subscription_has_download_archive: true + title: 2011-02-27 - Jesse's Minecraft Server [Trailer - Feb.27] + year: 2011 + s2011.e032101 - Jesse's Minecraft Server [Trailer - Mar.21]-thumb.jpg + s2011.e032101 - Jesse's Minecraft Server [Trailer - Mar.21].info.json + s2011.e032101 - Jesse's Minecraft Server [Trailer - Mar.21].mp4 + Video Tags: + contentRating: TV-14 + date: 2011-03-21 + episode_id: 32101 + genre: ytdl-sub + show: bilateral_test + synopsis: + https://www.youtube.com/watch?v=DBjFvs6HafU + + Website Link: + http://jesseminecraft.webs.com/ + + To get on the whitelist, please look at the website linked above (^^^). Due to the overwhelming amount of people trying to join, I've made it so it costs $2 to become a member through paypal. All of it is explained on the website. + + Jesse's Minecraft Server is a City Roleplay/Survival Server, there is a currency, public transportation, strict rules for immersive gameplay, and has a decent size population. Over 1000 people have logged onto the server. It is very important you read the rules, we don't tolerate idiots. + + There are over 300 empty properties that are ready for anyone to own! Join now! + + This is the server state as of Mar. 21, 2011. + --------------------------------------------------------------------------------­-- + + Texture Pack: + http://www.minecraftforum.net/viewtopic.php?f=25&t=29164 + + Recording Software: + http://www.fraps.com/download.php + + Video Editing Software: + http://explore.live.com/windows-live-movie-maker?os=other + + Song: + Indifference - Pearl Jam + (Off of the 'Vs.' album) + + I claim no ownership of this song, all the credit goes to Pearl Jam and their producers. + title: 2011-03-21 - Jesse's Minecraft Server [Trailer - Mar.21] + year: 2011 + s2011.e032101 - Jesse's Minecraft Server [Trailer - Mar.21].nfo + NFO tags: + episodedetails: + aired: 2011-03-21 + download_index: 3 + episode: 32101 + genre: ytdl-sub + mpaa: TV-14 + plot: + https://www.youtube.com/watch?v=DBjFvs6HafU + + Website Link: + http://jesseminecraft.webs.com/ + + To get on the whitelist, please look at the website linked above (^^^). Due to the overwhelming amount of people trying to join, I've made it so it costs $2 to become a member through paypal. All of it is explained on the website. + + Jesse's Minecraft Server is a City Roleplay/Survival Server, there is a currency, public transportation, strict rules for immersive gameplay, and has a decent size population. Over 1000 people have logged onto the server. It is very important you read the rules, we don't tolerate idiots. + + There are over 300 empty properties that are ready for anyone to own! Join now! + + This is the server state as of Mar. 21, 2011. + --------------------------------------------------------------------------------­-- + + Texture Pack: + http://www.minecraftforum.net/viewtopic.php?f=25&t=29164 + + Recording Software: + http://www.fraps.com/download.php + + Video Editing Software: + http://explore.live.com/windows-live-movie-maker?os=other + + Song: + Indifference - Pearl Jam + (Off of the 'Vs.' album) + + I claim no ownership of this song, all the credit goes to Pearl Jam and their producers. + season: 2011 + subscription_has_download_archive: true + title: 2011-03-21 - Jesse's Minecraft Server [Trailer - Mar.21] + year: 2011 + +Files modified: +---------------------------------------- +{output_directory} + .ytdl-sub-bilateral_test-download-archive.json \ No newline at end of file diff --git a/tests/unit/config/test_config_file.py b/tests/unit/config/test_config_file.py index 74ec03d2..352ead2d 100644 --- a/tests/unit/config/test_config_file.py +++ b/tests/unit/config/test_config_file.py @@ -74,7 +74,7 @@ class TestConfigFilePartiallyValidatesPresets: expected_error_message="Validation error in partial_preset.download.1: " "'partial_preset.download.1' contains the field 'bad_key' which is not allowed. " "Allowed fields: download_reverse, include_sibling_metadata, playlist_thumbnails, " - "source_thumbnails, url, variables", + "source_thumbnails, url, variables, ytdl_options", ) @pytest.mark.parametrize( diff --git a/tests/unit/script/test_script.py b/tests/unit/script/test_script.py index dfa7994e..555bc429 100644 --- a/tests/unit/script/test_script.py +++ b/tests/unit/script/test_script.py @@ -80,6 +80,7 @@ class TestScript: } ) + assert script.resolve_once({"url": "{ %bilateral_url('nope') }"})["url"].native == "nope" script.add({"%bilateral_url_wrap": "{ %bilateral_url($0) }"}) assert ( From 598c574b8af022e7ef9b04916a2b1bbca0a8432e Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Mon, 1 Apr 2024 04:41:12 -0700 Subject: [PATCH 18/39] [DOCS] Show ytdl_options per url (#954) --- docs/source/config_reference/plugins.rst | 2 ++ src/ytdl_sub/downloaders/url/validators.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/docs/source/config_reference/plugins.rst b/docs/source/config_reference/plugins.rst index 52010d57..5cb786d3 100644 --- a/docs/source/config_reference/plugins.rst +++ b/docs/source/config_reference/plugins.rst @@ -231,6 +231,8 @@ URL. variables: season_index: "2" season_name: "Playlist as Season" + ytdl_options: + break_on_existing: False playlist_thumbnails: - name: "season{season_index}-poster.jpg" uid: "latest_entry" diff --git a/src/ytdl_sub/downloaders/url/validators.py b/src/ytdl_sub/downloaders/url/validators.py index 61d794dd..a5a2616c 100644 --- a/src/ytdl_sub/downloaders/url/validators.py +++ b/src/ytdl_sub/downloaders/url/validators.py @@ -272,6 +272,8 @@ class MultiUrlValidator(OptionsValidator): variables: season_index: "2" season_name: "Playlist as Season" + ytdl_options: + break_on_existing: False playlist_thumbnails: - name: "season{season_index}-poster.jpg" uid: "latest_entry" From c622cf68b5e4947d3fc3d1ce98f2d7a00ff0a63b Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Mon, 1 Apr 2024 22:40:56 -0700 Subject: [PATCH 19/39] [DEV] Escapable curly braces (#955) --- .../scripting/scripting_types.rst | 6 ++++++ src/ytdl_sub/script/parser.py | 12 +++++++++--- tests/unit/script/test_parser.py | 15 +++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/docs/source/config_reference/scripting/scripting_types.rst b/docs/source/config_reference/scripting/scripting_types.rst index 381f8cda..9275d7a1 100644 --- a/docs/source/config_reference/scripting/scripting_types.rst +++ b/docs/source/config_reference/scripting/scripting_types.rst @@ -88,6 +88,12 @@ triple-quotes can be used to avoid *closing* the String. %string("""This has both " and ' in it.""") } +If you want a plain string that contains literal curly braces, you can escape them like so: + +.. code-block:: yaml + + string_variable: "This contains \\{ literal curly braces \\}" + Integer ~~~~~~~ diff --git a/src/ytdl_sub/script/parser.py b/src/ytdl_sub/script/parser.py index 82b66bd9..a6109f4d 100644 --- a/src/ytdl_sub/script/parser.py +++ b/src/ytdl_sub/script/parser.py @@ -73,6 +73,8 @@ CUSTOM_FUNCTION_ARGUMENTS_ONLY_ARGS = InvalidSyntaxException( FUNCTION_INVALID_CHAR = InvalidSyntaxException("Invalid value when parsing a function") +BRACKET_INVALID_CHAR = InvalidSyntaxException("Invalid value within brackets") + def _UNEXPECTED_CHAR_ARGUMENT(arg_type: ParsedArgType): return InvalidSyntaxException(f"Unexpected character when parsing {arg_type.value} arguments") @@ -505,6 +507,10 @@ class _Parser: raise UNREACHABLE def _parse_main_loop(self, ch: str) -> bool: + if ch == "\\" and self._read(increment_pos=False) in {"{", "}"}: + # Escape brackets are \{ and \}, only add the second char + self._literal_str += self._read() + return True if ch == "}": if self._bracket_counter == 0: raise BRACKET_NOT_CLOSED @@ -558,9 +564,9 @@ class _Parser: elif self._bracket_counter == 0: # Only accumulate literal str if not in brackets self._literal_str += ch - else: - # Should only be possible to get here if it's a space - assert ch.isspace() + elif not ch.isspace(): + self._set_highlight_position(pos=self._pos - 1) + raise BRACKET_INVALID_CHAR return True diff --git a/tests/unit/script/test_parser.py b/tests/unit/script/test_parser.py index 196ae8f2..b40e13ca 100644 --- a/tests/unit/script/test_parser.py +++ b/tests/unit/script/test_parser.py @@ -5,6 +5,7 @@ from typing import Union import pytest from ytdl_sub.script.parser import _UNEXPECTED_CHAR_ARGUMENT +from ytdl_sub.script.parser import BRACKET_INVALID_CHAR from ytdl_sub.script.parser import BRACKET_NOT_CLOSED from ytdl_sub.script.parser import ParsedArgType from ytdl_sub.script.parser import parse @@ -192,6 +193,15 @@ class TestParser: ] ) + def test_escaped_bracket(self): + assert parse("\\{ This is escape {%string('{}')} and here \\}") == SyntaxTree( + [ + String(value="{ This is escape "), + BuiltInFunction(name="string", args=[String(value="{}")]), + String(value=" and here }"), + ] + ) + class TestParserBracketFailures: def test_bracket_open(self): @@ -208,3 +218,8 @@ class TestParserBracketFailures: match=re.escape(str(_UNEXPECTED_CHAR_ARGUMENT(ParsedArgType.MAP_KEY))), ): parse("hello {%capitalize({as_arg)}") + + @pytest.mark.parametrize("char", [")", ",", "*", "]"]) + def test_extra_char_in_brackets(self, char: str): + with pytest.raises(InvalidSyntaxException, match=re.escape(str(BRACKET_INVALID_CHAR))): + parse(f"{{ %string('hi') {char} }}") From ad72e1a6dee4d48aea137e4bc9e3778bd506b3c7 Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Tue, 2 Apr 2024 17:27:21 -0700 Subject: [PATCH 20/39] [BACKEND] Debug log exception on retry (#957) Help diagnose retry errors better --- src/ytdl_sub/utils/retry.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ytdl_sub/utils/retry.py b/src/ytdl_sub/utils/retry.py index 01712381..4704a2e5 100644 --- a/src/ytdl_sub/utils/retry.py +++ b/src/ytdl_sub/utils/retry.py @@ -34,12 +34,14 @@ def retry(times: int, exceptions: Tuple[Type[Exception], ...], wait_sec: int = 5 while attempt < times: try: return func(*args, **kwargs) - except exceptions: + except exceptions as exc: logger.debug( - "Exception thrown when attempting to run %s, attempt %d of %d", + "Exception thrown when attempting to run %s, attempt %d of %d\n" + "Exception:\n%s", func.__name__, attempt + 1, times, + str(exc), ) attempt += 1 sleep(wait_sec) From 156c788689fc198c5b4234e1bde7b611bf538e7d Mon Sep 17 00:00:00 2001 From: Noah Kiss Date: Fri, 26 Apr 2024 02:12:08 -0400 Subject: [PATCH 21/39] [DOCS] Add Plex Agent Sources Information (#969) Co-authored-by: Noah Kiss --- docs/images/plex_agent_sources.png | Bin 0 -> 255286 bytes docs/source/faq/index.rst | 19 ++++++++++--------- 2 files changed, 10 insertions(+), 9 deletions(-) create mode 100644 docs/images/plex_agent_sources.png diff --git a/docs/images/plex_agent_sources.png b/docs/images/plex_agent_sources.png new file mode 100644 index 0000000000000000000000000000000000000000..12d2e789cb1b34188c1c3ebe49578e4a55ca86fb GIT binary patch literal 255286 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�M=v9ft5J|q{H3#EsUsZs{kW!?BBclI=zM5o$k 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ℜPr1~SgvfXfe^ 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

L0Z17t4-DfZ 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 and leave a comment in #development with where you think you can assist or what skills you would like to contribute. +* `Join our Discord `_ and leave a comment in #development with where you think you can assist or what skills you would like to contribute. * If you just want to fix one thing, you're welcome to :ytdl-sub-gh:`submit a pull request ` with information on what issue you're resolving and it will be reviewed as soon as possible. @@ -83,17 +83,18 @@ Most likely the video has a non-English language set to its 'native' language. Y ...Plex is not showing my TV shows correctly ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Set the following for your ytdl-sub library that has been added to Plex. +1. Set the following for your ytdl-sub library that has been added to Plex. .. figure:: ../../images/plex_scanner_agent.png :alt: The Plex library editor, under the advanced settings, showing the required options for Plex to show the TV shows correctly. -**Scanner:** Plex Series Scanner +- **Scanner:** Plex Series Scanner +- **Agent:** Personal Media shows +- **Visibility:** Exclude from home screen and global search +- **Episode sorting:** Library default +- **YES** Enable video preview thumbnails -**Agent:** Personal Media shows +2. Under **Settings** > **Agents**, confirm Plex Personal Media Shows/Movies scanner has **Local Media Assets** enabled. -**Visibility:** Exclude from home screen and global search - -**Episode sorting:** Library default - -**YES** Enable video preview thumbnails \ No newline at end of file +.. figure:: ../../images/plex_agent_sources.png + :alt: The Plex Agents settings page has Local Media Assets enabled for Personal Media Shows and Movies tabs. From 232c0acdfa9a3ed06a4161c6279a024239ad953a Mon Sep 17 00:00:00 2001 From: Nathaniel Barragan Date: Fri, 26 Apr 2024 07:09:02 +0000 Subject: [PATCH 22/39] [BACKEND] Update yt-dlp, move to pyproject.toml (#965) - Updates yt-dlp to 2024.4.9 - Moves setup.cfg to pyproject.toml - Updates other various backend packages Thanks @Noodlez1232 ! --- Makefile | 3 ++- pyproject.toml | 68 +++++++++++++++++++++++++++++++++++++++++++++++++- setup.cfg | 60 -------------------------------------------- 3 files changed, 69 insertions(+), 62 deletions(-) delete mode 100644 setup.cfg diff --git a/Makefile b/Makefile index dd52f307..826624e3 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,8 @@ check_lint: && black . --check \ && pylint src/ wheel: clean - $(shell echo "__pypi_version__ = \"$(PYPI_VERSION)\"\n__local_version__ = \"$(LOCAL_VERSION)\"" > src/ytdl_sub/__init__.py) + $(shell echo "__pypi_version__ = \"$(PYPI_VERSION)\"" > src/ytdl_sub/__init__.py) + $(shell echo "__local_version__ = \"$(LOCAL_VERSION)\"" >> src/ytdl_sub/__init__.py) cat src/ytdl_sub/__init__.py pip3 install build python3 -m build diff --git a/pyproject.toml b/pyproject.toml index a26368d9..9d2fc6c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,69 @@ +[project] +name ="ytdl-sub" +dynamic = [ "version" ] +authors = [ { name = "Jesse Bannon" } ] +description = "Automate downloading metadata generation with YoutubeDL" +readme = "README.md" +requires-python = ">=3.10" +license = { file = "LICENSE" } +classifiers = [ + "Topic :: Multimedia :: Sound/Audio", + "Topic :: Multimedia :: Video", + "License :: Public Domain", + "Environment :: Console", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", +] +dependencies = [ + "yt-dlp==2024.04.09", + "colorama~=0.4", + "mergedeep~=1.3", + "mediafile~=0.12", + "PyYAML~=6.0", +] +urls = { Homepage = "https://github.com/jmbannon/ytdl-sub" } + +[build-system] +requires = [ "setuptools >= 67.0" ] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +platforms = [ "Unix" ] + +[tool.setuptools.dynamic] +version = { attr = "ytdl_sub.__pypi_version__" } +[tool.setuptools.package-dir] +"" = "src" +[tool.setuptools.package-data] +"*" = ["*.yaml"] +[tool.setuptools.packages.find] +where = ["src"] + + +[project.optional-dependencies] +test = [ + "coverage[toml]~=6.3", + "pytest~=7.2", + "pytest-rerunfailures~=14.0", +] +lint = [ + "black==22.3.0", + "isort==5.10.1", + "pylint==2.13.5", +] +docs = [ + "sphinx~=7.0", + "sphinx-rtd-theme~=2.0", + "sphinx-book-theme~=1.0", +] +build = [ + "build~=1.2", + "twine~=5.0", + "pyinstaller~=6.5", +] +[project.scripts] +ytdl-sub = "ytdl_sub.main:main" + [tool.isort] profile = "black" line_length = 100 @@ -30,4 +96,4 @@ include = [ [tool.coverage.report] exclude_also = [ "raise UNREACHABLE.*", -] \ No newline at end of file +] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 011990fe..00000000 --- a/setup.cfg +++ /dev/null @@ -1,60 +0,0 @@ -[metadata] -name = ytdl-sub -version = attr:ytdl_sub.__pypi_version__ -author = Jesse Bannon -description = Automate downloading and metadata generation with YoutubeDL -long_description = file: README.md -long_description_content_type= text/markdown -author_email = use_github_issues@nope.com -url = https://github.com/jmbannon/ytdl-sub -license = GNUv3 -platforms = Unix -classifiers = - Topic :: Multimedia :: Sound/Audio - Topic :: Multimedia :: Video - License :: Public Domain - Environment :: Console - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - -[options.entry_points] -console_scripts = - ytdl-sub = ytdl_sub.main:main - -[options] -package_dir = - = src -packages=find: - -install_requires = - yt-dlp==2024.3.10 - argparse==1.4.0 - colorama==0.4.6 - mergedeep==1.3.4 - mediafile==0.12.0 - PyYAML==5.3.1 - -[options.package_data] -* = *.yaml - -[options.packages.find] -where=src - -[options.extras_require] -test = - coverage[toml]==6.3.2 - pytest==7.1.1 - pytest-rerunfailures==12.0 -lint = - black==22.3.0 - isort==5.10.1 - pylint==2.13.5 -docs = - sphinx==7.2.6 - sphinx-rtd-theme==2.0.0 - sphinx-book-theme==1.1.0 -build = - build - twine - pyinstaller - From 5b34df458841abb5fa676dd8a2520d00f2155d47 Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Sat, 27 Apr 2024 09:20:30 -0700 Subject: [PATCH 23/39] [DEV] Update fixtures (#970) * [DEV] Update fixtures * update yt-dlp * fanart --- .../chapters/test_chapters_from_comments.json | 2 +- ...ters_with_regex_no_chapters_video_pass.txt | 2 +- .../test_chapters_sb_and_embedded_subs.json | 2 +- .../test_soundcloud_discography.json | 26 +++++++++---------- .../youtube/test_channel_full.json | 2 +- .../youtube/test_video.json | 2 +- .../youtube/test_video_cli.json | 2 +- .../youtube/test_video_missing_thumb.json | 2 +- .../chapters/test_chapters_from_comments.txt | 10 ------- 9 files changed, 20 insertions(+), 30 deletions(-) diff --git a/tests/resources/expected_downloads_summaries/plugins/chapters/test_chapters_from_comments.json b/tests/resources/expected_downloads_summaries/plugins/chapters/test_chapters_from_comments.json index 49314662..38d69b12 100644 --- a/tests/resources/expected_downloads_summaries/plugins/chapters/test_chapters_from_comments.json +++ b/tests/resources/expected_downloads_summaries/plugins/chapters/test_chapters_from_comments.json @@ -1,6 +1,6 @@ { ".ytdl-sub-chapters_from_comments-download-archive.json": "2510b2ff3c54aa4813a4f23ea079e1ec", "JMC/Move 78 - Automated Improvisation [Full Album].jpg": "c12e6a6f242680d1096a1a99d74a62c6", - "JMC/Move 78 - Automated Improvisation [Full Album].mp4": "068526b2d8f85fdcf914df3e23d0b1fa", + "JMC/Move 78 - Automated Improvisation [Full Album].mp4": "63119bd17a035574263aeec188e11d5e", "JMC/Move 78 - Automated Improvisation [Full Album].nfo": "039268e97673a6f2b391772ec3b52fac" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_pass.txt b/tests/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_pass.txt index f9a235a4..a76f01ed 100644 --- a/tests/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_pass.txt +++ b/tests/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_pass.txt @@ -1,5 +1,5 @@ { ".ytdl-sub-split_by_chapters_with_regex_video_no_chapters-download-archive.json": "4008e43668447f1a3a6a55520a6ff475", - "Project Zombie/[2010] Oblivion Mod "Falcor" p.1/01 - Oblivion Mod "Falcor" p.1.mp3": "7813b727a1d3df89effe45c42e7c7e63", + "Project Zombie/[2010] Oblivion Mod "Falcor" p.1/01 - Oblivion Mod "Falcor" p.1.mp3": "f1c2e04fb84768fed69ed981d9a2dbcd", "Project Zombie/[2010] Oblivion Mod "Falcor" p.1/folder.jpg": "fb95b510681676e81c321171fc23143e" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/plugins/test_chapters_sb_and_embedded_subs.json b/tests/resources/expected_downloads_summaries/plugins/test_chapters_sb_and_embedded_subs.json index ed7ead7a..dcf23c96 100644 --- a/tests/resources/expected_downloads_summaries/plugins/test_chapters_sb_and_embedded_subs.json +++ b/tests/resources/expected_downloads_summaries/plugins/test_chapters_sb_and_embedded_subs.json @@ -1,6 +1,6 @@ { ".ytdl-sub-sponsorblock_with_embedded_subs_test-download-archive.json": "2cb4b9586fd5bb7f1fed76ed9195e6e4", "JMC/This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.jpg": "b5353a824a4800cc26f884e3025ed969", - "JMC/This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.mp4": "53590935ec5f801fd7e1b5aacf28fa5d", + "JMC/This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.mp4": "0539ff781c824f17ae3ffbd7dc830a8f", "JMC/This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.nfo": "ae73ec18a9f0e5a54c90061ccd32e7f4" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/soundcloud/test_soundcloud_discography.json b/tests/resources/expected_downloads_summaries/soundcloud/test_soundcloud_discography.json index 11a84c2d..242947bf 100644 --- a/tests/resources/expected_downloads_summaries/soundcloud/test_soundcloud_discography.json +++ b/tests/resources/expected_downloads_summaries/soundcloud/test_soundcloud_discography.json @@ -1,19 +1,19 @@ { ".ytdl-sub-j_b-download-archive.json": "1a99156e9ece62539fb2608416a07200", - "j_b/[2021] Baby Santana's Dorian Groove/01 - Baby Santana's Dorian Groove.mp3": "3b686f82ebb2ffe4bb7a491b00ea8137", + "j_b/[2021] Baby Santana's Dorian Groove/01 - Baby Santana's Dorian Groove.mp3": "b35d01351e5dd1195e90a41b2edeb5b7", "j_b/[2021] Baby Santana's Dorian Groove/folder.jpg": "967892be44b8c47e1be73f055a7c6f08", - "j_b/[2021] Purple Clouds/01 - Purple Clouds.mp3": "64ded79214390867c7ddc08d290183f4", + "j_b/[2021] Purple Clouds/01 - Purple Clouds.mp3": "5c107c122048389aa40936c4fba0436e", "j_b/[2021] Purple Clouds/folder.jpg": "967892be44b8c47e1be73f055a7c6f08", - "j_b/[2022] Acoustic Treats/01 - 20160426 184214.mp3": "ef254985af511b8917fbe32feb2bf1a6", - "j_b/[2022] Acoustic Treats/02 - 20160502 123150.mp3": "b668004a76e06871ae8aa5a757f02928", - "j_b/[2022] Acoustic Treats/03 - 20160504 143832.mp3": "0d40f6261cb638d65e473d5c3172d1fa", - "j_b/[2022] Acoustic Treats/04 - 20160601 221234.mp3": "670f2f35e83f588023cabdaade2f5537", - "j_b/[2022] Acoustic Treats/05 - 20160601 222440.mp3": "032f59d0f2c7c3ce352a677ce5d30ee4", - "j_b/[2022] Acoustic Treats/06 - 20170604 190236.mp3": "faffcfb3d1b87b18be77ab4f86dd298f", - "j_b/[2022] Acoustic Treats/07 - 20170612 193646.mp3": "a7976b0380ec7b0c32193a58dc15cfa2", - "j_b/[2022] Acoustic Treats/08 - 20170628 215206.mp3": "b8de5803604102592564c0ebc46000a3", - "j_b/[2022] Acoustic Treats/09 - Finding Home.mp3": "29b40c0c66a3bce2da6fb86d9bad1b42", - "j_b/[2022] Acoustic Treats/10 - Shallow Water WIP.mp3": "8de2a91d10ce54deaa81980c03457963", - "j_b/[2022] Acoustic Treats/11 - Untold History.mp3": "966c57fcf80ab88e2f083625baf0b8bc", + "j_b/[2022] Acoustic Treats/01 - 20160426 184214.mp3": "e737c2ba118920643cec9eac09a283ea", + "j_b/[2022] Acoustic Treats/02 - 20160502 123150.mp3": "d5def3e4329b9b95c5354c838dd3bc91", + "j_b/[2022] Acoustic Treats/03 - 20160504 143832.mp3": "1694c4a2889aa7dacafc5f2fdb4fe1d7", + "j_b/[2022] Acoustic Treats/04 - 20160601 221234.mp3": "9c8ddee6aaf88b38706ce6bbdf907878", + "j_b/[2022] Acoustic Treats/05 - 20160601 222440.mp3": "4bf9e700137cb1b58e730dd312e00bb4", + "j_b/[2022] Acoustic Treats/06 - 20170604 190236.mp3": "c1920254046eda0d84b36dd34f9bafdb", + "j_b/[2022] Acoustic Treats/07 - 20170612 193646.mp3": "26f2eb8395b23cecc4000e2fd58304bf", + "j_b/[2022] Acoustic Treats/08 - 20170628 215206.mp3": "e3ceb5ae25c115d037a298b4620bce9f", + "j_b/[2022] Acoustic Treats/09 - Finding Home.mp3": "f3dcfe7c8569853a02eb12ad21b0e248", + "j_b/[2022] Acoustic Treats/10 - Shallow Water WIP.mp3": "4fb9a18b3fdd788ce9d9f4c8a07b6028", + "j_b/[2022] Acoustic Treats/11 - Untold History.mp3": "ad186ee1d0029f8de02357e8b8712a4b", "j_b/[2022] Acoustic Treats/folder.jpg": "967892be44b8c47e1be73f055a7c6f08" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/youtube/test_channel_full.json b/tests/resources/expected_downloads_summaries/youtube/test_channel_full.json index 772a9cfc..7b5e7b2f 100644 --- a/tests/resources/expected_downloads_summaries/youtube/test_channel_full.json +++ b/tests/resources/expected_downloads_summaries/youtube/test_channel_full.json @@ -2,7 +2,7 @@ "Project ⧸ Zombie/.ytdl-sub-pz-download-archive.json": "aadb59c92dcf14ee6617c77423a14584", "Project ⧸ Zombie/Season 2010/s2010.e081301 - Oblivion Mod "Falcor" p.1-thumb.jpg": "fb95b510681676e81c321171fc23143e", "Project ⧸ Zombie/Season 2010/s2010.e081301 - Oblivion Mod "Falcor" p.1.info.json": "INFO_JSON", - "Project ⧸ Zombie/Season 2010/s2010.e081301 - Oblivion Mod "Falcor" p.1.mp4": "246fa05b6443337785575987904848df", + "Project ⧸ Zombie/Season 2010/s2010.e081301 - Oblivion Mod "Falcor" p.1.mp4": "0571a944a25791bc3c6cbbf436cb3778", "Project ⧸ Zombie/Season 2010/s2010.e081301 - Oblivion Mod "Falcor" p.1.nfo": "a1970f06fbc4743fca6db0627de779f3", "Project ⧸ Zombie/Season 2010/s2010.e120201 - Oblivion Mod "Falcor" p.2-thumb.jpg": "8b32ee9c037fa669e444a0ac181525a1", "Project ⧸ Zombie/Season 2010/s2010.e120201 - Oblivion Mod "Falcor" p.2.info.json": "INFO_JSON", diff --git a/tests/resources/expected_downloads_summaries/youtube/test_video.json b/tests/resources/expected_downloads_summaries/youtube/test_video.json index 78aeb87a..dd69c218 100644 --- a/tests/resources/expected_downloads_summaries/youtube/test_video.json +++ b/tests/resources/expected_downloads_summaries/youtube/test_video.json @@ -1,5 +1,5 @@ { "JMC/Oblivion Mod "Falcor" p.1.jpg": "fb95b510681676e81c321171fc23143e", - "JMC/Oblivion Mod "Falcor" p.1.mp4": "f2be699684854bdb6e09c02d24bdd5b6", + "JMC/Oblivion Mod "Falcor" p.1.mp4": "318faf3eb1d7666491553cdfd2a2e9fb", "JMC/Oblivion Mod "Falcor" p.1.nfo": "58c2be339869b5d071c1758d55c72ddb" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/youtube/test_video_cli.json b/tests/resources/expected_downloads_summaries/youtube/test_video_cli.json index 78aeb87a..dd69c218 100644 --- a/tests/resources/expected_downloads_summaries/youtube/test_video_cli.json +++ b/tests/resources/expected_downloads_summaries/youtube/test_video_cli.json @@ -1,5 +1,5 @@ { "JMC/Oblivion Mod "Falcor" p.1.jpg": "fb95b510681676e81c321171fc23143e", - "JMC/Oblivion Mod "Falcor" p.1.mp4": "f2be699684854bdb6e09c02d24bdd5b6", + "JMC/Oblivion Mod "Falcor" p.1.mp4": "318faf3eb1d7666491553cdfd2a2e9fb", "JMC/Oblivion Mod "Falcor" p.1.nfo": "58c2be339869b5d071c1758d55c72ddb" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/youtube/test_video_missing_thumb.json b/tests/resources/expected_downloads_summaries/youtube/test_video_missing_thumb.json index b425d91d..bf1811b0 100644 --- a/tests/resources/expected_downloads_summaries/youtube/test_video_missing_thumb.json +++ b/tests/resources/expected_downloads_summaries/youtube/test_video_missing_thumb.json @@ -1,4 +1,4 @@ { - "JMC/Oblivion Mod "Falcor" p.1.mp4": "d9d2d12feee44ee97729b39ba981c542", + "JMC/Oblivion Mod "Falcor" p.1.mp4": "d3bdda5ec6822ea3b4b4bd8908eb327a", "JMC/Oblivion Mod "Falcor" p.1.nfo": "58c2be339869b5d071c1758d55c72ddb" } \ No newline at end of file diff --git a/tests/resources/transaction_log_summaries/plugins/chapters/test_chapters_from_comments.txt b/tests/resources/transaction_log_summaries/plugins/chapters/test_chapters_from_comments.txt index cdbbfa60..007c19b8 100644 --- a/tests/resources/transaction_log_summaries/plugins/chapters/test_chapters_from_comments.txt +++ b/tests/resources/transaction_log_summaries/plugins/chapters/test_chapters_from_comments.txt @@ -5,16 +5,6 @@ Files created: {output_directory}/JMC Move 78 - Automated Improvisation [Full Album].jpg Move 78 - Automated Improvisation [Full Album].mp4 - Chapters from comments: - 0:00: 01. The Lonely Tears of Lee Seedol - 4:30: 02. But What If We're Wrong - 9:16: 03. Follow the Earworm Pt.2 - 12:25: 04. Keyword Salad - 16:48: 05. Ultra Natural - 20:47: 06. Flight Instructions - 25:46: 07. Dawn of the Useless Class - 29:58: 08. Schnitzel Whisperer - 32:16: 09. Teilo Embedded subtitles with lang(s) en, de Video Tags: album: Music Videos From f3a979c818cd7033530f07e3425cffc1155be807 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Apr 2024 10:03:14 -0700 Subject: [PATCH 24/39] [DEV] Update pytest requirement from ~=7.2 to >=7.2,<9.0 (#976) Updates the requirements on [pytest](https://github.com/pytest-dev/pytest) to permit the latest version. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/7.2.0...8.1.2) --- updated-dependencies: - dependency-name: pytest dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9d2fc6c9..2fe6e339 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ where = ["src"] [project.optional-dependencies] test = [ "coverage[toml]~=6.3", - "pytest~=7.2", + "pytest>=7.2,<9.0", "pytest-rerunfailures~=14.0", ] lint = [ From 10387c925d6d951c7c37f04b7bd23f3f81445c34 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Apr 2024 10:32:38 -0700 Subject: [PATCH 25/39] [DEV] Update coverage[toml] requirement from ~=6.3 to >=6.3,<8.0 (#975) Updates the requirements on [coverage[toml]](https://github.com/nedbat/coveragepy) to permit the latest version. - [Release notes](https://github.com/nedbat/coveragepy/releases) - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst) - [Commits](https://github.com/nedbat/coveragepy/compare/6.3...7.5.0) --- updated-dependencies: - dependency-name: coverage[toml] dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2fe6e339..81547a0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ where = ["src"] [project.optional-dependencies] test = [ - "coverage[toml]~=6.3", + "coverage[toml]>=6.3,<8.0", "pytest>=7.2,<9.0", "pytest-rerunfailures~=14.0", ] From aa637d12dc6d622e9bbb6b1a7a837560517d63c4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Apr 2024 13:37:20 -0700 Subject: [PATCH 26/39] [DEV] Bump isort from 5.10.1 to 5.13.2 (#974) Bumps [isort](https://github.com/pycqa/isort) from 5.10.1 to 5.13.2. - [Release notes](https://github.com/pycqa/isort/releases) - [Changelog](https://github.com/PyCQA/isort/blob/main/CHANGELOG.md) - [Commits](https://github.com/pycqa/isort/compare/5.10.1...5.13.2) --- updated-dependencies: - dependency-name: isort dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 81547a0d..0a7617ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ test = [ ] lint = [ "black==22.3.0", - "isort==5.10.1", + "isort==5.13.2", "pylint==2.13.5", ] docs = [ From caad4598fcaea707a795d2c9bc03c926fcdbc2df Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Sat, 27 Apr 2024 14:06:16 -0700 Subject: [PATCH 27/39] [DEV] Regen function doc strings on `make docs` (#977) * [DEV] Regen docs with `make docs` * default 0 --- Makefile | 1 + tools/docgen/docgen.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 826624e3..39d8939d 100644 --- a/Makefile +++ b/Makefile @@ -40,6 +40,7 @@ executable: clean pyinstaller ytdl-sub.spec mv dist/ytdl-sub dist/ytdl-sub${EXEC_SUFFIX} docs: + REGENERATE_DOCS=1 pytest tests/unit/docgen/test_docgen.py sphinx-build -M html docs/source/ docs/build/ clean: rm -rf \ diff --git a/tools/docgen/docgen.py b/tools/docgen/docgen.py index f1307b29..a021e7ae 100644 --- a/tools/docgen/docgen.py +++ b/tools/docgen/docgen.py @@ -1,7 +1,8 @@ +import os from abc import abstractmethod from pathlib import Path -REGENERATE_DOCS: bool = False +REGENERATE_DOCS: bool = bool(os.environ.get("REGENERATE_DOCS", 0)) class DocGen: From ec58a80660007e09f4444a4e93a981b26a416b3c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Apr 2024 15:46:47 -0700 Subject: [PATCH 28/39] [DEV] Bump black from 22.3.0 to 24.4.2 (#973) * Bump black from 22.3.0 to 24.4.2 Bumps [black](https://github.com/psf/black) from 22.3.0 to 24.4.2. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/22.3.0...24.4.2) --- updated-dependencies: - dependency-name: black dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * run linter --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jesse Bannon --- pyproject.toml | 2 +- src/ytdl_sub/cli/parsers/main.py | 2 + .../config/validators/variable_validation.py | 2 +- src/ytdl_sub/downloaders/url/downloader.py | 16 +- src/ytdl_sub/plugins/chapters.py | 12 +- src/ytdl_sub/plugins/split_by_chapters.py | 6 +- .../subscriptions/base_subscription.py | 14 +- .../subscriptions/subscription_validators.py | 18 +- src/ytdl_sub/utils/logger.py | 7 +- tests/e2e/youtube/test_video.py | 12 +- tests/unit/cli/test_entrypoint.py | 42 ++-- tests/unit/cli/test_output_transaction_log.py | 60 +++--- tests/unit/config/test_subscription.py | 49 +++-- tests/unit/conftest.py | 9 +- tests/unit/main/test_main.py | 182 +++++++++++------- .../unit/plugins/test_throttle_protection.py | 51 +++-- 16 files changed, 280 insertions(+), 204 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0a7617ac..859c49a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ test = [ "pytest-rerunfailures~=14.0", ] lint = [ - "black==22.3.0", + "black==24.4.2", "isort==5.13.2", "pylint==2.13.5", ] diff --git a/src/ytdl_sub/cli/parsers/main.py b/src/ytdl_sub/cli/parsers/main.py index 99f6c231..3c31e900 100644 --- a/src/ytdl_sub/cli/parsers/main.py +++ b/src/ytdl_sub/cli/parsers/main.py @@ -150,6 +150,8 @@ parser.add_argument("-v", "--version", action="version", version="%(prog)s " + _ _add_shared_arguments(parser, suppress_defaults=False) subparsers = parser.add_subparsers(dest="subparser") + + ################################################################################################### # SUBSCRIPTION PARSER class SubArguments: diff --git a/src/ytdl_sub/config/validators/variable_validation.py b/src/ytdl_sub/config/validators/variable_validation.py index 466762d9..4d5cefc6 100644 --- a/src/ytdl_sub/config/validators/variable_validation.py +++ b/src/ytdl_sub/config/validators/variable_validation.py @@ -25,7 +25,7 @@ _DUMMY_ENTRY_VARIABLES: Dict[str, str] = { name: to_variable_dependency_format_string( # pylint: disable=protected-access script=BASE_SCRIPT, - parsed_format_string=BASE_SCRIPT._variables[name] + parsed_format_string=BASE_SCRIPT._variables[name], # pylint: enable=protected-access ) for name in BASE_SCRIPT.variable_names diff --git a/src/ytdl_sub/downloaders/url/downloader.py b/src/ytdl_sub/downloaders/url/downloader.py index 8d50f1c3..2b8d92ca 100644 --- a/src/ytdl_sub/downloaders/url/downloader.py +++ b/src/ytdl_sub/downloaders/url/downloader.py @@ -248,9 +248,11 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]): self._download_ytdl_options_builder.clone() .add(self.ytdl_option_defaults(), before=True) .add( - self.plugin_options.urls.list[url_idx].ytdl_options.dict - if url_idx is not None - else None, + ( + self.plugin_options.urls.list[url_idx].ytdl_options.dict + if url_idx is not None + else None + ), before=True, ) .to_dict() @@ -352,9 +354,11 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]): url_idx=entry.get(v.ytdl_sub_input_url_index, int) ), is_downloaded_fn=None if self.is_dry_run else entry.is_downloaded, - is_thumbnail_downloaded_fn=None - if (self.is_dry_run or not self.is_entry_thumbnails_enabled) - else entry.is_thumbnail_downloaded_via_ytdlp, + is_thumbnail_downloaded_fn=( + None + if (self.is_dry_run or not self.is_entry_thumbnails_enabled) + else entry.is_thumbnail_downloaded_via_ytdlp + ), url=entry.webpage_url, ) return Entry( diff --git a/src/ytdl_sub/plugins/chapters.py b/src/ytdl_sub/plugins/chapters.py index 198f91b5..55b7ac83 100644 --- a/src/ytdl_sub/plugins/chapters.py +++ b/src/ytdl_sub/plugins/chapters.py @@ -263,13 +263,13 @@ class ChaptersPlugin(Plugin[ChaptersOptions]): "force_keyframes": self.plugin_options.force_key_frames, } if self.plugin_options.remove_sponsorblock_categories is not None: - remove_chapters_post_processor[ - "remove_sponsor_segments" - ] = self.plugin_options.remove_sponsorblock_categories + remove_chapters_post_processor["remove_sponsor_segments"] = ( + self.plugin_options.remove_sponsorblock_categories + ) if self.plugin_options.remove_chapters_regex is not None: - remove_chapters_post_processor[ - "remove_chapters_patterns" - ] = self.plugin_options.remove_chapters_regex + remove_chapters_post_processor["remove_chapters_patterns"] = ( + self.plugin_options.remove_chapters_regex + ) if self.plugin_options.embed_chapters: builder.add( diff --git a/src/ytdl_sub/plugins/split_by_chapters.py b/src/ytdl_sub/plugins/split_by_chapters.py index 9233babf..e7a9f831 100644 --- a/src/ytdl_sub/plugins/split_by_chapters.py +++ b/src/ytdl_sub/plugins/split_by_chapters.py @@ -159,9 +159,9 @@ class SplitByChaptersPlugin(SplitPlugin[SplitByChaptersOptions]): metadata_value_dict = {} if self.is_dry_run: - metadata_value_dict[ - "Warning" - ] = "Dry-run assumes embedded chapters with no modifications" + metadata_value_dict["Warning"] = ( + "Dry-run assumes embedded chapters with no modifications" + ) metadata_value_dict["Source Title"] = new_entry.title metadata_value_dict["Segment"] = f"{timestamp_begin} - {timestamp_end}" diff --git a/src/ytdl_sub/subscriptions/base_subscription.py b/src/ytdl_sub/subscriptions/base_subscription.py index 6ec7bb9e..970f142d 100644 --- a/src/ytdl_sub/subscriptions/base_subscription.py +++ b/src/ytdl_sub/subscriptions/base_subscription.py @@ -74,13 +74,13 @@ class BaseSubscription(ABC): } ) - self._enhanced_download_archive: Optional[ - EnhancedDownloadArchive - ] = _initialize_download_archive( - output_options=self.output_options, - overrides=self.overrides, - working_directory=self.working_directory, - output_directory=self.output_directory, + self._enhanced_download_archive: Optional[EnhancedDownloadArchive] = ( + _initialize_download_archive( + output_options=self.output_options, + overrides=self.overrides, + working_directory=self.working_directory, + output_directory=self.output_directory, + ) ) # Add post-archive variables diff --git a/src/ytdl_sub/subscriptions/subscription_validators.py b/src/ytdl_sub/subscriptions/subscription_validators.py index 6ce95cc6..5994c218 100644 --- a/src/ytdl_sub/subscriptions/subscription_validators.py +++ b/src/ytdl_sub/subscriptions/subscription_validators.py @@ -143,9 +143,9 @@ class SubscriptionValueValidator(SubscriptionLeafValidator, StringValidator): presets=presets, indent_overrides=indent_overrides, ) - self._overrides_to_add[ - SubscriptionVariables.subscription_value().variable_name - ] = self.value + self._overrides_to_add[SubscriptionVariables.subscription_value().variable_name] = ( + self.value + ) class SubscriptionListValuesValidator(SubscriptionLeafValidator, StringListValidator): @@ -170,9 +170,9 @@ class SubscriptionListValuesValidator(SubscriptionLeafValidator, StringListValid for idx, list_value in enumerate(self.list): # Write the first list value into subscription_value as well if idx == 0: - self._overrides_to_add[ - SubscriptionVariables.subscription_value().variable_name - ] = list_value.value + self._overrides_to_add[SubscriptionVariables.subscription_value().variable_name] = ( + list_value.value + ) self._overrides_to_add[ SubscriptionVariables.subscription_value_i(index=idx).variable_name @@ -219,9 +219,9 @@ class SubscriptionMapValidator(SubscriptionLeafValidator, LiteralDictValidator): presets=presets, indent_overrides=indent_overrides, ) - self._overrides_to_add[ - SubscriptionVariables.subscription_map().variable_name - ] = ScriptUtils.to_script(self.dict) + self._overrides_to_add[SubscriptionVariables.subscription_map().variable_name] = ( + ScriptUtils.to_script(self.dict) + ) class SubscriptionValidator(SubscriptionOutput): diff --git a/src/ytdl_sub/utils/logger.py b/src/ytdl_sub/utils/logger.py index 62e40ba1..fcd734d5 100644 --- a/src/ytdl_sub/utils/logger.py +++ b/src/ytdl_sub/utils/logger.py @@ -213,9 +213,10 @@ class Logger: @classmethod def _append_to_error_log(cls): # Any time an exception occurs, dump all debug logs into the error log - with open(cls.debug_log_filename(), mode="r", encoding="utf-8") as debug_logs, open( - cls.error_log_filename(), mode="a", encoding="utf-8" - ) as error_logs: + with ( + open(cls.debug_log_filename(), mode="r", encoding="utf-8") as debug_logs, + open(cls.error_log_filename(), mode="a", encoding="utf-8") as error_logs, + ): error_logs.writelines(debug_logs.readlines()) @classmethod diff --git a/tests/e2e/youtube/test_video.py b/tests/e2e/youtube/test_video.py index 9faf44b2..911a72e1 100644 --- a/tests/e2e/youtube/test_video.py +++ b/tests/e2e/youtube/test_video.py @@ -120,11 +120,13 @@ class TestYoutubeVideo: try_convert_download_thumbnail(entry=entry) # Pretend the thumbnail did not download via returning nothing for its downloaded path - with patch.object(YTDLP, "_EXTRACT_ENTRY_NUM_RETRIES", 1), patch.object( - Entry, "try_get_ytdlp_download_thumbnail_path" - ) as mock_ytdlp_path, patch( - "ytdl_sub.downloaders.url.downloader.try_convert_download_thumbnail", - side_effect=delete_entry_thumb, + with ( + patch.object(YTDLP, "_EXTRACT_ENTRY_NUM_RETRIES", 1), + patch.object(Entry, "try_get_ytdlp_download_thumbnail_path") as mock_ytdlp_path, + patch( + "ytdl_sub.downloaders.url.downloader.try_convert_download_thumbnail", + side_effect=delete_entry_thumb, + ), ): mock_ytdlp_path.return_value = None transaction_log = single_video_subscription.download(dry_run=False) diff --git a/tests/unit/cli/test_entrypoint.py b/tests/unit/cli/test_entrypoint.py index 31c4c443..d7a472cc 100644 --- a/tests/unit/cli/test_entrypoint.py +++ b/tests/unit/cli/test_entrypoint.py @@ -42,12 +42,15 @@ def test_subscription_logs_write_to_file( config = persist_logs_config_factory(keep_successful_logs=keep_successful_logs) subscription_paths = [str(music_video_subscription_path)] * num_runs - with patch.object( - Subscription, - "download", - new=mock_subscription_download_factory(mock_success_output=mock_success_output), - # mock datetime to be an index to be able to run instantly - ), patch("ytdl_sub.cli.entrypoint._log_time", side_effect=[str(idx) for idx in range(10)]): + with ( + patch.object( + Subscription, + "download", + new=mock_subscription_download_factory(mock_success_output=mock_success_output), + # mock datetime to be an index to be able to run instantly + ), + patch("ytdl_sub.cli.entrypoint._log_time", side_effect=[str(idx) for idx in range(10)]), + ): try: _download_subscriptions_from_yaml_files( config=config, @@ -101,16 +104,19 @@ def test_update_with_info_json_requires_experimental_flag( default_config_path: Path, music_video_subscription_path: Path, ) -> None: - with patch.object( - sys, - "argv", - [ - "ytdl-sub", - "--config", - str(default_config_path), - "sub", - str(music_video_subscription_path), - "--update-with-info-json", - ], - ), pytest.raises(ExperimentalFeatureNotEnabled): + with ( + patch.object( + sys, + "argv", + [ + "ytdl-sub", + "--config", + str(default_config_path), + "sub", + str(music_video_subscription_path), + "--update-with-info-json", + ], + ), + pytest.raises(ExperimentalFeatureNotEnabled), + ): _ = main() diff --git a/tests/unit/cli/test_output_transaction_log.py b/tests/unit/cli/test_output_transaction_log.py index bc3b7f9e..bc6ad08d 100644 --- a/tests/unit/cli/test_output_transaction_log.py +++ b/tests/unit/cli/test_output_transaction_log.py @@ -32,19 +32,22 @@ def test_suppress_transaction_log( music_video_subscription_path: Path, file_transaction_log: Optional[str], ) -> None: - with patch.object( - sys, - "argv", - [ - "ytdl-sub", - "--config", - str(default_config_path), - "sub", - str(music_video_subscription_path), - "--suppress-transaction-log", - ] - + (["--transaction-log", file_transaction_log] if file_transaction_log else []), - ), patch("ytdl_sub.cli.output_transaction_log.output_transaction_log") as mock_transaction_log: + with ( + patch.object( + sys, + "argv", + [ + "ytdl-sub", + "--config", + str(default_config_path), + "sub", + str(music_video_subscription_path), + "--suppress-transaction-log", + ] + + (["--transaction-log", file_transaction_log] if file_transaction_log else []), + ), + patch("ytdl_sub.cli.output_transaction_log.output_transaction_log") as mock_transaction_log, + ): subscriptions = main() assert subscriptions @@ -82,20 +85,23 @@ def test_transaction_log_to_logger( default_config_path: Path, music_video_subscription_path: Path, ) -> None: - with patch.object( - sys, - "argv", - [ - "ytdl-sub", - "--config", - str(default_config_path), - "sub", - str(music_video_subscription_path), - ], - ), assert_logs( - logger=transaction_logger, - expected_message="Transaction log for Rick Astley:\n", - log_level="info", + with ( + patch.object( + sys, + "argv", + [ + "ytdl-sub", + "--config", + str(default_config_path), + "sub", + str(music_video_subscription_path), + ], + ), + assert_logs( + logger=transaction_logger, + expected_message="Transaction log for Rick Astley:\n", + log_level="info", + ), ): subscriptions = main() assert subscriptions diff --git a/tests/unit/config/test_subscription.py b/tests/unit/config/test_subscription.py index 1415926e..e01525b7 100644 --- a/tests/unit/config/test_subscription.py +++ b/tests/unit/config/test_subscription.py @@ -375,38 +375,47 @@ def test_subscription_file_value_applies_from_config_and_nested_and_indent_varia config_file: ConfigFile, preset_with_subscription_value_nested_presets_and_indent_variables_same_line_old_format_errors: Dict, ): - with mock_load_yaml( - preset_dict=preset_with_subscription_value_nested_presets_and_indent_variables_same_line_old_format_errors - ), pytest.raises( - ValidationException, - match=re.escape( - "Validation error in parent_preset_2.=INDENT_1: 'INDENT_3' in '= INDENT_2 | INDENT_3' is not a preset name. " - "To use as a subscription indent value, define it as '= INDENT_3'" + with ( + mock_load_yaml( + preset_dict=preset_with_subscription_value_nested_presets_and_indent_variables_same_line_old_format_errors + ), + pytest.raises( + ValidationException, + match=re.escape( + "Validation error in parent_preset_2.=INDENT_1: 'INDENT_3' in '= INDENT_2 | INDENT_3' is not a preset name. " + "To use as a subscription indent value, define it as '= INDENT_3'" + ), ), ): Subscription.from_file_path(config=config_file, subscription_path="mocked") def test_subscription_file_using_conflicting_preset_name(config_file: ConfigFile): - with mock_load_yaml( - preset_dict={ - "= INDENTS_IN_ERR_MSG ": {"=ANOTHER": {"jellyfin_tv_show_by_date": "single value"}} - } - ), pytest.raises( - ValidationException, - match=re.escape( - "Validation error in = INDENTS_IN_ERR_MSG .=ANOTHER.jellyfin_tv_show_by_date: " - "jellyfin_tv_show_by_date conflicts with an existing preset name and cannot be used " - "as a subscription name" + with ( + mock_load_yaml( + preset_dict={ + "= INDENTS_IN_ERR_MSG ": {"=ANOTHER": {"jellyfin_tv_show_by_date": "single value"}} + } + ), + pytest.raises( + ValidationException, + match=re.escape( + "Validation error in = INDENTS_IN_ERR_MSG .=ANOTHER.jellyfin_tv_show_by_date: " + "jellyfin_tv_show_by_date conflicts with an existing preset name and cannot be used " + "as a subscription name" + ), ), ): _ = Subscription.from_file_path(config=config_file, subscription_path="mocked") def test_subscription_file_invalid_form(config_file: ConfigFile): - with mock_load_yaml(preset_dict={"sub_name": 4332}), pytest.raises( - ValidationException, - match=re.escape(f"Subscription value should either be a string, list, or object"), + with ( + mock_load_yaml(preset_dict={"sub_name": 4332}), + pytest.raises( + ValidationException, + match=re.escape(f"Subscription value should either be a string, list, or object"), + ), ): _ = Subscription.from_file_path(config=config_file, subscription_path="mocked") diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index b20b302c..7685a9d3 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -228,10 +228,11 @@ def mock_download_collection_entries( ), ] - with patch.object( - YTDLP, "extract_info_via_info_json", new=_write_entries_to_working_dir - ), patch.object( - MultiUrlDownloader, "_extract_entry_info_with_retry", new=lambda _, entry: entry + with ( + patch.object(YTDLP, "extract_info_via_info_json", new=_write_entries_to_working_dir), + patch.object( + MultiUrlDownloader, "_extract_entry_info_with_retry", new=lambda _, entry: entry + ), ): # Stub out metadata. TODO: update this if we do metadata plugins yield diff --git a/tests/unit/main/test_main.py b/tests/unit/main/test_main.py index a88faa13..b9a6b07d 100644 --- a/tests/unit/main/test_main.py +++ b/tests/unit/main/test_main.py @@ -40,9 +40,11 @@ def mock_sys_exit(): @pytest.mark.parametrize("return_code", [0, 1]) def test_main_exit_code(mock_sys_exit, return_code: int): - with mock_sys_exit(expected_exit_code=return_code), patch( - "src.ytdl_sub.main._main" - ) as mock_inner_main, patch.object(Logger, "cleanup") as mock_logger_cleanup: + with ( + mock_sys_exit(expected_exit_code=return_code), + patch("src.ytdl_sub.main._main") as mock_inner_main, + patch.object(Logger, "cleanup") as mock_logger_cleanup, + ): mock_inner_main.return_value = return_code main() @@ -54,9 +56,11 @@ def test_main_exit_code(mock_sys_exit, return_code: int): def test_main_validation_error(capsys, mock_sys_exit): validation_exception = ValidationException("test exc") - with mock_sys_exit(expected_exit_code=1), patch( - "src.ytdl_sub.main._main", side_effect=validation_exception - ), patch.object(logging.Logger, "error") as mock_logger: + with ( + mock_sys_exit(expected_exit_code=1), + patch("src.ytdl_sub.main._main", side_effect=validation_exception), + patch.object(logging.Logger, "error") as mock_logger, + ): main() assert mock_logger.call_count == 1 @@ -65,11 +69,12 @@ def test_main_validation_error(capsys, mock_sys_exit): def test_main_uncaught_error(capsys, mock_sys_exit, expected_uncaught_error_message): uncaught_error = ValueError("test") - with mock_sys_exit(expected_exit_code=1), patch( - "src.ytdl_sub.main._main", side_effect=uncaught_error - ), patch.object(logging.Logger, "exception") as mock_exception, patch.object( - logging.Logger, "error" - ) as mock_error: + with ( + mock_sys_exit(expected_exit_code=1), + patch("src.ytdl_sub.main._main", side_effect=uncaught_error), + patch.object(logging.Logger, "exception") as mock_exception, + patch.object(logging.Logger, "error") as mock_error, + ): main() assert mock_exception.call_count == 1 @@ -83,9 +88,11 @@ def test_main_uncaught_error(capsys, mock_sys_exit, expected_uncaught_error_mess def test_main_permission_error(capsys, mock_sys_exit, expected_uncaught_error_message): permission_error = PermissionError("test") - with mock_sys_exit(expected_exit_code=1), patch( - "src.ytdl_sub.main._main", side_effect=permission_error - ), patch.object(logging.Logger, "error") as mock_error: + with ( + mock_sys_exit(expected_exit_code=1), + patch("src.ytdl_sub.main._main", side_effect=permission_error), + patch.object(logging.Logger, "error") as mock_error, + ): main() assert mock_error.call_count == 1 @@ -97,11 +104,15 @@ def test_main_permission_error(capsys, mock_sys_exit, expected_uncaught_error_me def test_args_after_sub_work(mock_sys_exit, tv_show_config_path): - with mock_sys_exit(expected_exit_code=0), patch.object( - sys, - "argv", - ["ytdl-sub", "-c", tv_show_config_path, "sub", "--log-level", "verbose"], - ), patch("ytdl_sub.cli.entrypoint._download_subscriptions_from_yaml_files") as mock_sub: + with ( + mock_sys_exit(expected_exit_code=0), + patch.object( + sys, + "argv", + ["ytdl-sub", "-c", tv_show_config_path, "sub", "--log-level", "verbose"], + ), + patch("ytdl_sub.cli.entrypoint._download_subscriptions_from_yaml_files") as mock_sub, + ): main() assert mock_sub.call_count == 1 @@ -112,21 +123,25 @@ def test_args_after_sub_work(mock_sys_exit, tv_show_config_path): def test_sub_match_arguments_before(mock_sys_exit, tv_show_config_path): - with mock_sys_exit(expected_exit_code=0), patch.object( - sys, - "argv", - [ - "ytdl-sub", - "--match", - "testA", - "testB", - "-c", - tv_show_config_path, - "sub", - "--log-level", - "verbose", - ], - ), patch("ytdl_sub.cli.entrypoint._download_subscriptions_from_yaml_files") as mock_sub: + with ( + mock_sys_exit(expected_exit_code=0), + patch.object( + sys, + "argv", + [ + "ytdl-sub", + "--match", + "testA", + "testB", + "-c", + tv_show_config_path, + "sub", + "--log-level", + "verbose", + ], + ), + patch("ytdl_sub.cli.entrypoint._download_subscriptions_from_yaml_files") as mock_sub, + ): main() assert mock_sub.call_count == 1 @@ -137,22 +152,26 @@ def test_sub_match_arguments_before(mock_sys_exit, tv_show_config_path): def test_sub_match_arguments_after_many(mock_sys_exit, tv_show_config_path): - with mock_sys_exit(expected_exit_code=0), patch.object( - sys, - "argv", - [ - "ytdl-sub", - "-c", - tv_show_config_path, - "sub", - "--log-level", - "verbose", - "--match", - "testA", - "--match", - "testB", - ], - ), patch("ytdl_sub.cli.entrypoint._download_subscriptions_from_yaml_files") as mock_sub: + with ( + mock_sys_exit(expected_exit_code=0), + patch.object( + sys, + "argv", + [ + "ytdl-sub", + "-c", + tv_show_config_path, + "sub", + "--log-level", + "verbose", + "--match", + "testA", + "--match", + "testB", + ], + ), + patch("ytdl_sub.cli.entrypoint._download_subscriptions_from_yaml_files") as mock_sub, + ): main() assert mock_sub.call_count == 1 @@ -163,11 +182,15 @@ def test_sub_match_arguments_after_many(mock_sys_exit, tv_show_config_path): def test_no_config_works(mock_sys_exit): - with mock_sys_exit(expected_exit_code=0), patch.object( - sys, - "argv", - ["ytdl-sub", "sub", "--log-level", "verbose"], - ), patch("ytdl_sub.cli.entrypoint._download_subscriptions_from_yaml_files") as mock_sub: + with ( + mock_sys_exit(expected_exit_code=0), + patch.object( + sys, + "argv", + ["ytdl-sub", "sub", "--log-level", "verbose"], + ), + patch("ytdl_sub.cli.entrypoint._download_subscriptions_from_yaml_files") as mock_sub, + ): main() assert mock_sub.call_count == 1 @@ -183,14 +206,19 @@ def test_uses_default_config_if_present(mock_sys_exit): open(DEFAULT_CONFIG_FILE_NAME, "a").close() try: - with mock_sys_exit(expected_exit_code=0), patch.object( - sys, - "argv", - ["ytdl-sub", "sub", "--log-level", "verbose"], - ), patch( - "ytdl_sub.cli.entrypoint._download_subscriptions_from_yaml_files" - ) as mock_sub, patch.object( - ConfigFile, "from_file_path", new=lambda _: ConfigFile(name="test default", value={}) + with ( + mock_sys_exit(expected_exit_code=0), + patch.object( + sys, + "argv", + ["ytdl-sub", "sub", "--log-level", "verbose"], + ), + patch("ytdl_sub.cli.entrypoint._download_subscriptions_from_yaml_files") as mock_sub, + patch.object( + ConfigFile, + "from_file_path", + new=lambda _: ConfigFile(name="test default", value={}), + ), ): main() @@ -204,11 +232,15 @@ def test_uses_default_config_if_present(mock_sys_exit): def test_no_positional_arg_command(mock_sys_exit, tv_show_config_path): - with mock_sys_exit(expected_exit_code=1), patch.object( - sys, - "argv", - ["ytdl-sub", "-c", tv_show_config_path, "--log-level", "verbose"], - ), patch.object(logging.Logger, "error") as mock_error: + with ( + mock_sys_exit(expected_exit_code=1), + patch.object( + sys, + "argv", + ["ytdl-sub", "-c", tv_show_config_path, "--log-level", "verbose"], + ), + patch.object(logging.Logger, "error") as mock_error, + ): main() assert mock_error.call_count == 1 @@ -216,11 +248,15 @@ def test_no_positional_arg_command(mock_sys_exit, tv_show_config_path): def test_bad_config_path(mock_sys_exit): - with mock_sys_exit(expected_exit_code=1), patch.object( - sys, - "argv", - ["ytdl-sub", "-c", "does_not_exist.yaml", "sub", "--log-level", "verbose"], - ), patch.object(logging.Logger, "error") as mock_error: + with ( + mock_sys_exit(expected_exit_code=1), + patch.object( + sys, + "argv", + ["ytdl-sub", "-c", "does_not_exist.yaml", "sub", "--log-level", "verbose"], + ), + patch.object(logging.Logger, "error") as mock_error, + ): main() assert mock_error.call_count == 1 diff --git a/tests/unit/plugins/test_throttle_protection.py b/tests/unit/plugins/test_throttle_protection.py index 9dcdb89b..c0cc8045 100644 --- a/tests/unit/plugins/test_throttle_protection.py +++ b/tests/unit/plugins/test_throttle_protection.py @@ -39,23 +39,29 @@ class TestThrottleProtectionPlugin: preset_dict=preset_dict, ) - with mock_download_collection_entries( - is_youtube_channel=False, num_urls=1, is_extracted_audio=False - ), assert_logs( - logger=throttle_protection_logger, - expected_message="Sleeping between downloads for %0.2f seconds", - log_level="debug", - expected_occurrences=4, + with ( + mock_download_collection_entries( + is_youtube_channel=False, num_urls=1, is_extracted_audio=False + ), + assert_logs( + logger=throttle_protection_logger, + expected_message="Sleeping between downloads for %0.2f seconds", + log_level="debug", + expected_occurrences=4, + ), ): _ = subscription.download(dry_run=False) - with mock_download_collection_entries( - is_youtube_channel=False, num_urls=1, is_extracted_audio=False - ), assert_logs( - logger=throttle_protection_logger, - expected_message="Sleeping between subscriptions for %0.2f seconds", - log_level="debug", - expected_occurrences=1, + with ( + mock_download_collection_entries( + is_youtube_channel=False, num_urls=1, is_extracted_audio=False + ), + assert_logs( + logger=throttle_protection_logger, + expected_message="Sleeping between subscriptions for %0.2f seconds", + log_level="debug", + expected_occurrences=1, + ), ): _ = subscription.download(dry_run=False) @@ -105,12 +111,15 @@ class TestThrottleProtectionPlugin: preset_dict=preset_dict, ) - with mock_download_collection_entries( - is_youtube_channel=False, num_urls=1, is_extracted_audio=False - ), assert_logs( - logger=throttle_protection_logger, - expected_message="Sleeping between downloads for %0.2f seconds", - log_level="debug", - expected_occurrences=0, + with ( + mock_download_collection_entries( + is_youtube_channel=False, num_urls=1, is_extracted_audio=False + ), + assert_logs( + logger=throttle_protection_logger, + expected_message="Sleeping between downloads for %0.2f seconds", + log_level="debug", + expected_occurrences=0, + ), ): _ = subscription.download(dry_run=False) From 1d176050a72ca4824448a320d92205aceadc5a35 Mon Sep 17 00:00:00 2001 From: Tomas Babej Date: Sun, 28 Apr 2024 01:47:29 -0400 Subject: [PATCH 29/39] [FEATURE] %regex_sub built-in script function (#971) This implements %regex_sub built-in function to enhance string-processing capabilities, allowing users to perform substitutes like: - removing non-ascii characters - replacing subsequent whitespace characters with a single whitespace Thanks @tbabej ! --- .../scripting/scripting_functions.rst | 9 +++++++++ src/ytdl_sub/script/functions/regex_functions.py | 10 ++++++++++ .../script/functions/test_regex_functions.py | 16 ++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/docs/source/config_reference/scripting/scripting_functions.rst b/docs/source/config_reference/scripting/scripting_functions.rst index db773b17..d5948fb5 100644 --- a/docs/source/config_reference/scripting/scripting_functions.rst +++ b/docs/source/config_reference/scripting/scripting_functions.rst @@ -560,6 +560,15 @@ regex_search the string as the first element of the Array. If there are capture groups, returns each group as a subsequent element in the Array. +regex_sub +~~~~~~~~~ +:spec: ``regex_sub(regex: String, replacement: String, string: String) -> String`` + +:description: + Returns the string obtained by replacing the leftmost non-overlapping occurrences of the + pattern in string by the replacement string. The replacement string can reference the + match groups via backslash escapes. Callables as replacement argument are not supported. + ---------------------------------------------------------------------------------------------------- String Functions diff --git a/src/ytdl_sub/script/functions/regex_functions.py b/src/ytdl_sub/script/functions/regex_functions.py index f837be32..feb204ce 100644 --- a/src/ytdl_sub/script/functions/regex_functions.py +++ b/src/ytdl_sub/script/functions/regex_functions.py @@ -52,3 +52,13 @@ class RegexFunctions: Returns number of capture groups in regex """ return Integer(re.compile(regex.value).groups) + + @staticmethod + def regex_sub(regex: String, replacement: String, string: String) -> String: + """ + :description: + Returns the string obtained by replacing the leftmost non-overlapping occurrences of the + pattern in string by the replacement string. The replacement string can reference the + match groups via backslash escapes. Callables as replacement argument are not supported. + """ + return String(re.sub(regex.value, replacement.value, string.value)) diff --git a/tests/unit/script/functions/test_regex_functions.py b/tests/unit/script/functions/test_regex_functions.py index b0ca2e16..3322134d 100644 --- a/tests/unit/script/functions/test_regex_functions.py +++ b/tests/unit/script/functions/test_regex_functions.py @@ -43,3 +43,19 @@ class TestNumericFunctions: def test_regex_fullmatch(self, values: str, expected_output: str): output = single_variable_output(f"{{%regex_fullmatch({values})}}") assert output == expected_output + + @pytest.mark.parametrize( + "values, expected_output", + [ + ("'[^A-Za-z0-9 ]', '', 'This title is AWESOME!!'", "This title is AWESOME"), + ("'\s+', '_', 'Consolidate spaces'", "Consolidate_spaces"), + ( + "'(words) are (reordered)', '\\2 are \\1', 'Oh words are reordered'", + "Oh reordered are words", + ), + ("'MATCH', '', 'matcha is great'", "matcha is great"), + ], + ) + def test_regex_sub(self, values: str, expected_output: str): + output = single_variable_output(f"{{%regex_sub({values})}}") + assert output == expected_output From 64d3082a8a3d7aab21d725692e3cd34eae1a5bb9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 28 Apr 2024 10:40:15 -0700 Subject: [PATCH 30/39] [DEV] Bump pylint from 2.13.5 to 3.1.0 (#972) * Bump pylint from 2.13.5 to 3.1.0 Bumps [pylint](https://github.com/pylint-dev/pylint) from 2.13.5 to 3.1.0. - [Release notes](https://github.com/pylint-dev/pylint/releases) - [Commits](https://github.com/pylint-dev/pylint/compare/v2.13.5...v3.1.0) --- updated-dependencies: - dependency-name: pylint dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * bend knee to pylint --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jesse Bannon --- pyproject.toml | 2 +- src/ytdl_sub/config/plugin/plugin.py | 14 +++++------ src/ytdl_sub/config/plugin/preset_plugins.py | 4 +-- src/ytdl_sub/config/validators/options.py | 3 +-- src/ytdl_sub/downloaders/source_plugin.py | 8 +++--- src/ytdl_sub/downloaders/url/downloader.py | 25 ++++++++----------- src/ytdl_sub/entries/base_entry.py | 8 +++--- src/ytdl_sub/entries/entry_parent.py | 4 +-- .../entries/script/variable_definitions.py | 1 + src/ytdl_sub/entries/script/variable_types.py | 10 ++++---- src/ytdl_sub/script/parser.py | 6 +++-- .../script/utils/exception_formatters.py | 6 ++--- src/ytdl_sub/script/utils/type_checking.py | 4 +-- .../subscriptions/subscription_download.py | 4 +-- .../validators/string_formatter_validators.py | 4 --- src/ytdl_sub/validators/validators.py | 1 + 16 files changed, 49 insertions(+), 55 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 859c49a8..b58a8723 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ test = [ lint = [ "black==24.4.2", "isort==5.13.2", - "pylint==2.13.5", + "pylint==3.1.0", ] docs = [ "sphinx~=7.0", diff --git a/src/ytdl_sub/config/plugin/plugin.py b/src/ytdl_sub/config/plugin/plugin.py index 33e98a1b..e3c6d1d8 100644 --- a/src/ytdl_sub/config/plugin/plugin.py +++ b/src/ytdl_sub/config/plugin/plugin.py @@ -9,27 +9,27 @@ from typing import Tuple from typing import Type from ytdl_sub.config.overrides import Overrides +from ytdl_sub.config.validators.options import OptionsValidatorT from ytdl_sub.config.validators.options import ToggleableOptionsDictValidator -from ytdl_sub.config.validators.options import TOptionsValidator from ytdl_sub.entries.entry import Entry from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.utils.script import ScriptUtils from ytdl_sub.ytdl_additions.enhanced_download_archive import DownloadArchiver from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive -# pylint: disable=no-self-use,unused-argument +# pylint: disable=unused-argument -class BasePlugin(DownloadArchiver, Generic[TOptionsValidator], ABC): +class BasePlugin(DownloadArchiver, Generic[OptionsValidatorT], ABC): """ Shared code amongst all SourcePlugins (downloaders) and Plugins (post-download modification) """ - plugin_options_type: Type[TOptionsValidator] + plugin_options_type: Type[OptionsValidatorT] def __init__( self, - options: TOptionsValidator, + options: OptionsValidatorT, overrides: Overrides, enhanced_download_archive: EnhancedDownloadArchive, ): @@ -38,7 +38,7 @@ class BasePlugin(DownloadArchiver, Generic[TOptionsValidator], ABC): self.overrides = overrides -class Plugin(BasePlugin[TOptionsValidator], Generic[TOptionsValidator], ABC): +class Plugin(BasePlugin[OptionsValidatorT], Generic[OptionsValidatorT], ABC): """ Class to define the new plugin functionality """ @@ -121,7 +121,7 @@ class Plugin(BasePlugin[TOptionsValidator], Generic[TOptionsValidator], ABC): """ -class SplitPlugin(Plugin[TOptionsValidator], Generic[TOptionsValidator], ABC): +class SplitPlugin(Plugin[OptionsValidatorT], Generic[OptionsValidatorT], ABC): """ Plugin that splits entries into zero or more entries """ diff --git a/src/ytdl_sub/config/plugin/preset_plugins.py b/src/ytdl_sub/config/plugin/preset_plugins.py index 2a86af6a..7eda8a9d 100644 --- a/src/ytdl_sub/config/plugin/preset_plugins.py +++ b/src/ytdl_sub/config/plugin/preset_plugins.py @@ -5,7 +5,7 @@ from typing import Type from ytdl_sub.config.plugin.plugin import Plugin from ytdl_sub.config.validators.options import OptionsValidator -from ytdl_sub.config.validators.options import TOptionsValidator +from ytdl_sub.config.validators.options import OptionsValidatorT class PresetPlugins: @@ -29,7 +29,7 @@ class PresetPlugins: """ return list(zip(self.plugin_types, self.plugin_options)) - def get(self, plugin_type: Type[TOptionsValidator]) -> Optional[TOptionsValidator]: + def get(self, plugin_type: Type[OptionsValidatorT]) -> Optional[OptionsValidatorT]: """ Parameters ---------- diff --git a/src/ytdl_sub/config/validators/options.py b/src/ytdl_sub/config/validators/options.py index 324b39e1..76b6d3ad 100644 --- a/src/ytdl_sub/config/validators/options.py +++ b/src/ytdl_sub/config/validators/options.py @@ -9,7 +9,6 @@ from ytdl_sub.validators.strict_dict_validator import StrictDictValidator from ytdl_sub.validators.string_formatter_validators import OverridesBooleanFormatterValidator from ytdl_sub.validators.validators import Validator -# pylint: disable=no-self-use # pylint: disable=unused-argument @@ -53,7 +52,7 @@ class OptionsValidator(Validator, ABC): return {} -TOptionsValidator = TypeVar("TOptionsValidator", bound=OptionsValidator) +OptionsValidatorT = TypeVar("OptionsValidatorT", bound=OptionsValidator) class OptionsDictValidator(StrictDictValidator, OptionsValidator, ABC): diff --git a/src/ytdl_sub/downloaders/source_plugin.py b/src/ytdl_sub/downloaders/source_plugin.py index f5357fa5..70127c63 100644 --- a/src/ytdl_sub/downloaders/source_plugin.py +++ b/src/ytdl_sub/downloaders/source_plugin.py @@ -11,13 +11,13 @@ from typing import final from ytdl_sub.config.overrides import Overrides from ytdl_sub.config.plugin.plugin import BasePlugin from ytdl_sub.config.plugin.plugin import Plugin -from ytdl_sub.config.validators.options import TOptionsValidator +from ytdl_sub.config.validators.options import OptionsValidatorT from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder from ytdl_sub.entries.entry import Entry from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive -class SourcePluginExtension(Plugin[TOptionsValidator], Generic[TOptionsValidator], ABC): +class SourcePluginExtension(Plugin[OptionsValidatorT], Generic[OptionsValidatorT], ABC): """ Plugins that get added automatically by using a downloader. Downloader options are the plugin options. @@ -32,12 +32,12 @@ class SourcePluginExtension(Plugin[TOptionsValidator], Generic[TOptionsValidator return None -class SourcePlugin(BasePlugin[TOptionsValidator], Generic[TOptionsValidator], ABC): +class SourcePlugin(BasePlugin[OptionsValidatorT], Generic[OptionsValidatorT], ABC): plugin_extensions: List[Type[SourcePluginExtension]] = [] def __init__( self, - options: TOptionsValidator, + options: OptionsValidatorT, enhanced_download_archive: EnhancedDownloadArchive, download_ytdl_options: YTDLOptionsBuilder, metadata_ytdl_options: YTDLOptionsBuilder, diff --git a/src/ytdl_sub/downloaders/url/downloader.py b/src/ytdl_sub/downloaders/url/downloader.py index 2b8d92ca..81f310a7 100644 --- a/src/ytdl_sub/downloaders/url/downloader.py +++ b/src/ytdl_sub/downloaders/url/downloader.py @@ -397,17 +397,15 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]): def _iterate_parent_entry( self, parent: EntryParent, download_reversed: bool ) -> Iterator[Entry]: - for entry_child in self._iterate_child_entries( + yield from self._iterate_child_entries( entries=parent.entry_children(), download_reversed=download_reversed - ): - yield entry_child + ) # Recursion the parent's parent entries for parent_child in reversed(parent.parent_children()): - for entry_child in self._iterate_parent_entry( + yield from self._iterate_parent_entry( parent=parent_child, download_reversed=download_reversed - ): - yield entry_child + ) def _download_url_metadata( self, url: str, include_sibling_metadata: bool, ytdl_options_overrides: Dict @@ -449,15 +447,13 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]): # Delete info json files afterwards so other collection URLs do not use them with self._separate_download_archives(clear_info_json_files=True): for parent in parents: - for entry_child in self._iterate_parent_entry( + yield from self._iterate_parent_entry( parent=parent, download_reversed=download_reversed - ): - yield entry_child + ) - for orphan in self._iterate_child_entries( + yield from self._iterate_child_entries( entries=orphans, download_reversed=download_reversed - ): - yield orphan + ) def _download_metadata(self, url: str, validator: UrlValidator) -> Iterable[Entry]: metadata_ytdl_options = self.metadata_ytdl_options( @@ -479,12 +475,11 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]): ) download_logger.info("Beginning downloads for %s", url) - for entry in self._iterate_entries( + yield from self._iterate_entries( parents=parents, orphans=orphan_entries, download_reversed=download_reversed, - ): - yield entry + ) def download_metadata(self) -> Iterable[Entry]: """The function to perform the download of all media entries""" diff --git a/src/ytdl_sub/entries/base_entry.py b/src/ytdl_sub/entries/base_entry.py index d863bc42..c8321774 100644 --- a/src/ytdl_sub/entries/base_entry.py +++ b/src/ytdl_sub/entries/base_entry.py @@ -16,7 +16,7 @@ from ytdl_sub.entries.script.variable_definitions import VariableDefinitions v: VariableDefinitions = VARIABLES -TBaseEntry = TypeVar("TBaseEntry", bound="BaseEntry") +BaseEntryT = TypeVar("BaseEntryT", bound="BaseEntry") class BaseEntry(ABC): @@ -140,7 +140,7 @@ class BaseEntry(ABC): return str(Path(self.working_directory()) / self.get_download_info_json_name()) @final - def to_type(self, entry_type: Type[TBaseEntry]) -> TBaseEntry: + def to_type(self, entry_type: Type[BaseEntryT]) -> BaseEntryT: """ Returns ------- @@ -149,7 +149,7 @@ class BaseEntry(ABC): return entry_type(entry_dict=self._kwargs, working_directory=self._working_directory) @classmethod - def is_entry_parent(cls, entry_dict: Dict | TBaseEntry): + def is_entry_parent(cls, entry_dict: Dict | BaseEntryT): """ Returns ------- @@ -164,7 +164,7 @@ class BaseEntry(ABC): return entry_type == "playlist" @classmethod - def is_entry(cls, entry_dict: Dict | TBaseEntry): + def is_entry(cls, entry_dict: Dict | BaseEntryT): """ Returns ------- diff --git a/src/ytdl_sub/entries/entry_parent.py b/src/ytdl_sub/entries/entry_parent.py index 797c4d62..ae32bcc4 100644 --- a/src/ytdl_sub/entries/entry_parent.py +++ b/src/ytdl_sub/entries/entry_parent.py @@ -7,7 +7,7 @@ from typing import Set from urllib.parse import urlparse from ytdl_sub.entries.base_entry import BaseEntry -from ytdl_sub.entries.base_entry import TBaseEntry +from ytdl_sub.entries.base_entry import BaseEntryT from ytdl_sub.entries.entry import Entry from ytdl_sub.entries.script.variable_definitions import VARIABLES from ytdl_sub.entries.script.variable_definitions import VariableDefinitions @@ -21,7 +21,7 @@ v: VariableDefinitions = VARIABLES class EntryParent(BaseEntry): @classmethod - def _sort_entries(cls, entries: List[TBaseEntry]) -> List[TBaseEntry]: + def _sort_entries(cls, entries: List[BaseEntryT]) -> List[BaseEntryT]: """Try sorting by playlist_id first, then fall back to uid""" return sorted( entries, diff --git a/src/ytdl_sub/entries/script/variable_definitions.py b/src/ytdl_sub/entries/script/variable_definitions.py index 4fc9e29e..5526780a 100644 --- a/src/ytdl_sub/entries/script/variable_definitions.py +++ b/src/ytdl_sub/entries/script/variable_definitions.py @@ -21,6 +21,7 @@ from ytdl_sub.entries.script.variable_types import Variable # pylint: disable=no-member # pylint: disable=too-many-public-methods # pylint: disable=too-many-lines +# pylint: disable=method-cache-max-size-none class MetadataVariableDefinitions(ABC): diff --git a/src/ytdl_sub/entries/script/variable_types.py b/src/ytdl_sub/entries/script/variable_types.py index 214d8a7c..f406baf6 100644 --- a/src/ytdl_sub/entries/script/variable_types.py +++ b/src/ytdl_sub/entries/script/variable_types.py @@ -17,8 +17,8 @@ ENTRY_METADATA_VARIABLE_NAME = "entry_metadata" PLAYLIST_METADATA_VARIABLE_NAME = "playlist_metadata" SOURCE_METADATA_VARIABLE_NAME = "source_metadata" -TMetadataVariable = TypeVar("TMetadataVariable", bound="MetadataVariable") -TVariable = TypeVar("TVariable", bound="Variable") +MetadataVariableT = TypeVar("MetadataVariableT", bound="MetadataVariable") +VariableT = TypeVar("VariableT", bound="Variable") def _get( @@ -26,9 +26,9 @@ def _get( metadata_variable_name: str, metadata_key: str, variable_name: Optional[str], - default: Optional[TVariable | str | int | Dict | List], - as_type: Type[TMetadataVariable], -) -> TMetadataVariable: + default: Optional[VariableT | str | int | Dict | List], + as_type: Type[MetadataVariableT], +) -> MetadataVariableT: if default is None: # TODO: assert with good error message if key DNE out = f"%map_get({metadata_variable_name}, '{metadata_key}')" diff --git a/src/ytdl_sub/script/parser.py b/src/ytdl_sub/script/parser.py index a6109f4d..c6a78136 100644 --- a/src/ytdl_sub/script/parser.py +++ b/src/ytdl_sub/script/parser.py @@ -247,7 +247,7 @@ class _Parser: if self._read(increment_pos=False) == "-": numeric_string += "-" self._pos += 1 - if has_decimal := (self._read(increment_pos=False) == "."): + if has_decimal := self._read(increment_pos=False) == ".": numeric_string += "." self._pos += 1 @@ -504,7 +504,9 @@ class _Parser: output[key] = value_args[0] key = None else: - raise UNREACHABLE + break + + raise UNREACHABLE def _parse_main_loop(self, ch: str) -> bool: if ch == "\\" and self._read(increment_pos=False) in {"{", "}"}: diff --git a/src/ytdl_sub/script/utils/exception_formatters.py b/src/ytdl_sub/script/utils/exception_formatters.py index 6e14354e..b2cab76d 100644 --- a/src/ytdl_sub/script/utils/exception_formatters.py +++ b/src/ytdl_sub/script/utils/exception_formatters.py @@ -8,11 +8,11 @@ from ytdl_sub.script.utils.exceptions import UserException from ytdl_sub.script.utils.type_checking import FunctionSpec from ytdl_sub.script.utils.type_checking import is_union -TUserException = TypeVar("TUserException", bound=UserException) +UserExceptionT = TypeVar("UserExceptionT", bound=UserException) class ParserExceptionFormatter: - def __init__(self, text: str, start: int, end: int, exception: TUserException): + def __init__(self, text: str, start: int, end: int, exception: UserExceptionT): self._text = text self._start = start self._end = end @@ -78,7 +78,7 @@ class ParserExceptionFormatter: return "\n" + "\n".join(to_return) - def highlight(self) -> TUserException: + def highlight(self) -> UserExceptionT: """ Returns ------- diff --git a/src/ytdl_sub/script/utils/type_checking.py b/src/ytdl_sub/script/utils/type_checking.py index 2170c3e0..4fa22ffc 100644 --- a/src/ytdl_sub/script/utils/type_checking.py +++ b/src/ytdl_sub/script/utils/type_checking.py @@ -23,7 +23,7 @@ from ytdl_sub.script.types.resolvable import Resolvable from ytdl_sub.script.types.variable import Variable from ytdl_sub.script.utils.exceptions import UNREACHABLE -TLambda = TypeVar("TLambda", bound=Lambda) +LambdaT = TypeVar("LambdaT", bound=Lambda) def is_union(arg_type: Type) -> bool: @@ -212,7 +212,7 @@ class FunctionSpec: return None @property - def is_lambda_like(self) -> Optional[Type[TLambda]]: + def is_lambda_like(self) -> Optional[Type[LambdaT]]: """ Returns ------- diff --git a/src/ytdl_sub/subscriptions/subscription_download.py b/src/ytdl_sub/subscriptions/subscription_download.py index b017d005..1655a633 100644 --- a/src/ytdl_sub/subscriptions/subscription_download.py +++ b/src/ytdl_sub/subscriptions/subscription_download.py @@ -126,8 +126,8 @@ class SubscriptionDownload(BaseSubscription, ABC): except Exception as exc: self._delete_working_directory(is_error=True) raise exc - else: - self._delete_working_directory() + + self._delete_working_directory() @contextlib.contextmanager def _maintain_archive_file(self): diff --git a/src/ytdl_sub/validators/string_formatter_validators.py b/src/ytdl_sub/validators/string_formatter_validators.py index c561e47e..15e76e70 100644 --- a/src/ytdl_sub/validators/string_formatter_validators.py +++ b/src/ytdl_sub/validators/string_formatter_validators.py @@ -63,8 +63,6 @@ class StringFormatterValidator(StringValidator): """ return self._value - # pylint: disable=no-self-use - def post_process(self, resolved: str) -> str: """ Returns @@ -73,8 +71,6 @@ class StringFormatterValidator(StringValidator): """ return resolved - # pylint: enable=no-self-use - # pylint: disable=line-too-long class OverridesStringFormatterValidator(StringFormatterValidator): diff --git a/src/ytdl_sub/validators/validators.py b/src/ytdl_sub/validators/validators.py index 5bd4dbaa..fbb13c95 100644 --- a/src/ytdl_sub/validators/validators.py +++ b/src/ytdl_sub/validators/validators.py @@ -169,6 +169,7 @@ class ListValidator(Validator, ABC, Generic[ValidatorT]): Validates a list of objects to validate """ + # pylint: disable=used-before-assignment _expected_value_type = list _expected_value_type_name = "list" From 325d229061339fe01d8f22ea0365fd4f8ef33820 Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Fri, 10 May 2024 19:57:48 -0700 Subject: [PATCH 31/39] [BUGFIX] Fix usage of ~ in paths (#981) Fixes path with tildes in them, i.e. `~/videos/youtube` --- .../scripting/scripting_functions.rst | 2 +- src/ytdl_sub/config/config_validator.py | 5 ++++- src/ytdl_sub/entries/script/custom_functions.py | 4 ++-- tests/unit/config/test_config_file.py | 14 ++++++++++++++ tests/unit/config/test_preset.py | 16 ++++++++++++++++ 5 files changed, 37 insertions(+), 4 deletions(-) diff --git a/docs/source/config_reference/scripting/scripting_functions.rst b/docs/source/config_reference/scripting/scripting_functions.rst index d5948fb5..ebb777d4 100644 --- a/docs/source/config_reference/scripting/scripting_functions.rst +++ b/docs/source/config_reference/scripting/scripting_functions.rst @@ -716,7 +716,7 @@ to_native_filepath :spec: ``to_native_filepath(filepath: String) -> String`` Convert any unix-based path separators ('/') with the OS's native -separator. +separator. In addition, expand ~ to absolute directories. truncate_filepath_if_too_long ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/ytdl_sub/config/config_validator.py b/src/ytdl_sub/config/config_validator.py index ff18b3fc..55a91f55 100644 --- a/src/ytdl_sub/config/config_validator.py +++ b/src/ytdl_sub/config/config_validator.py @@ -1,3 +1,5 @@ +import os +import posixpath from typing import Any from typing import Dict from typing import Optional @@ -147,7 +149,8 @@ class ConfigOptions(StrictDictValidator): The directory to temporarily store downloaded files before moving them into their final directory. Defaults to .ytdl-sub-working-directory """ - return self._working_directory.value + # Expands tildas to actual paths, use native os sep + return os.path.expanduser(self._working_directory.value.replace(posixpath.sep, os.sep)) @property def umask(self) -> Optional[str]: diff --git a/src/ytdl_sub/entries/script/custom_functions.py b/src/ytdl_sub/entries/script/custom_functions.py index 690095de..ded8c54f 100644 --- a/src/ytdl_sub/entries/script/custom_functions.py +++ b/src/ytdl_sub/entries/script/custom_functions.py @@ -36,9 +36,9 @@ class CustomFunctions: def to_native_filepath(filepath: String) -> String: """ Convert any unix-based path separators ('/') with the OS's native - separator. + separator. In addition, expand ~ to absolute directories. """ - return String(filepath.value.replace(posixpath.sep, os.sep)) + return String(os.path.expanduser(filepath.value.replace(posixpath.sep, os.sep))) @staticmethod def truncate_filepath_if_too_long(filepath: String) -> String: diff --git a/tests/unit/config/test_config_file.py b/tests/unit/config/test_config_file.py index 352ead2d..f18a41e0 100644 --- a/tests/unit/config/test_config_file.py +++ b/tests/unit/config/test_config_file.py @@ -1,4 +1,6 @@ +import os.path import re +from pathlib import Path from typing import Dict from typing import Optional @@ -94,6 +96,18 @@ class TestConfigFilePartiallyValidatesPresets: }, ) + def test_config_file_working_dir_home_dir(self): + out = ConfigFile( + name="test_tilda", + value={ + "configuration": {"working_directory": "~/working/dir"}, + }, + ) + + assert out.config_options.working_directory == str( + Path(os.path.expanduser("~")) / "working" / "dir" + ) + @pytest.mark.parametrize( "preset_dict", [ diff --git a/tests/unit/config/test_preset.py b/tests/unit/config/test_preset.py index de651ada..cb3c0b81 100644 --- a/tests/unit/config/test_preset.py +++ b/tests/unit/config/test_preset.py @@ -1,4 +1,6 @@ +import os.path import re +from pathlib import Path import pytest @@ -37,6 +39,20 @@ class TestPreset: }, ) + def test_preset_with_output_directory_tilda(self, config_file, output_options, youtube_video): + out = Preset( + config=config_file, + name="test", + value={ + "download": youtube_video, + "output_options": {"output_directory": "~/output/dir", "file_name": "{dne_var}"}, + "overrides": {"dne_var": "not dne"}, + }, + ) + assert out.overrides.apply_formatter(out.output_options.output_directory) == str( + Path(os.path.expanduser("~")) / "output" / "dir" + ) + def test_preset_parent(self, config_file, output_options, youtube_video): preset = Preset( config=config_file, From 8461f5f229b77a9c144310162dcaa30343e5d5d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 May 2024 19:40:17 -0700 Subject: [PATCH 32/39] [DEV] Bump pylint from 3.1.0 to 3.1.1 (#987) Bumps [pylint](https://github.com/pylint-dev/pylint) from 3.1.0 to 3.1.1. - [Release notes](https://github.com/pylint-dev/pylint/releases) - [Commits](https://github.com/pylint-dev/pylint/compare/v3.1.0...v3.1.1) --- updated-dependencies: - dependency-name: pylint dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b58a8723..1c3baea3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ test = [ lint = [ "black==24.4.2", "isort==5.13.2", - "pylint==3.1.0", + "pylint==3.1.1", ] docs = [ "sphinx~=7.0", From 02db0c41a5cbdc9e558b52727b049d47ce51d450 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 May 2024 16:17:21 -0700 Subject: [PATCH 33/39] [DEV] Bump pylint from 3.1.1 to 3.2.0 (#988) Bumps [pylint](https://github.com/pylint-dev/pylint) from 3.1.1 to 3.2.0. - [Release notes](https://github.com/pylint-dev/pylint/releases) - [Commits](https://github.com/pylint-dev/pylint/compare/v3.1.1...v3.2.0) --- updated-dependencies: - dependency-name: pylint dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1c3baea3..756ed8b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ test = [ lint = [ "black==24.4.2", "isort==5.13.2", - "pylint==3.1.1", + "pylint==3.2.0", ] docs = [ "sphinx~=7.0", From 87f9378d33f324a657c0ad0879ec4f918ac6d978 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 May 2024 19:48:22 -0700 Subject: [PATCH 34/39] [BACKEND] Bump yt-dlp from 2024.04.09 to 2024.5.27 (#994) Bumps [yt-dlp](https://github.com/yt-dlp/yt-dlp) from 2024.04.09 to 2024.5.27. - [Release notes](https://github.com/yt-dlp/yt-dlp/releases) - [Changelog](https://github.com/yt-dlp/yt-dlp/blob/master/Changelog.md) - [Commits](https://github.com/yt-dlp/yt-dlp/compare/2024.04.09...2024.05.27) --- updated-dependencies: - dependency-name: yt-dlp dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 756ed8b0..841a1743 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", ] dependencies = [ - "yt-dlp==2024.04.09", + "yt-dlp==2024.5.27", "colorama~=0.4", "mergedeep~=1.3", "mediafile~=0.12", From ed969b84d21ac055fc13b5ed8e20229353869b36 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 May 2024 17:04:26 -0700 Subject: [PATCH 35/39] [DEV] Bump pylint from 3.2.0 to 3.2.2 (#991) updated-dependencies: - dependency-name: pylint dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 841a1743..ce803f71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ test = [ lint = [ "black==24.4.2", "isort==5.13.2", - "pylint==3.2.0", + "pylint==3.2.2", ] docs = [ "sphinx~=7.0", From 74625c293d56a223663e41fa7ad7b0ecaf13c552 Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Sun, 2 Jun 2024 00:05:44 -0700 Subject: [PATCH 36/39] [DEV] Update fixtures (#995) * [DEV] Update fixtures * thumbs fixed, longer timeout * ugh * fanart --- src/ytdl_sub/utils/thumbnail.py | 3 +- .../bandcamp/test_artist_url.json | 13 +- .../chapters/test_chapters_from_comments.json | 2 +- .../test_match_filters_partial.json | 2 +- .../plugins/test_audio_extract_playlist.json | 2 +- .../test_chapters_sb_and_embedded_subs.json | 2 +- .../youtube/test_channel_full.json | 2 +- .../youtube/test_playlist.json | 2 +- .../test_playlist_archive_migrated.json | 2 +- .../bandcamp/test_artist_url.txt | 116 +++++++++--------- .../chapters/test_chapters_from_comments.txt | 10 ++ 11 files changed, 81 insertions(+), 75 deletions(-) diff --git a/src/ytdl_sub/utils/thumbnail.py b/src/ytdl_sub/utils/thumbnail.py index a999f01c..8f7ba153 100644 --- a/src/ytdl_sub/utils/thumbnail.py +++ b/src/ytdl_sub/utils/thumbnail.py @@ -72,8 +72,7 @@ def download_and_convert_url_thumbnail( if not thumbnail_url: return None - # timeout after 8 seconds - with urlopen(thumbnail_url, timeout=1.0) as file: + with urlopen(thumbnail_url, timeout=7.0) as file: with tempfile.NamedTemporaryFile(delete=False) as thumbnail: thumbnail.write(file.read()) diff --git a/tests/resources/expected_downloads_summaries/bandcamp/test_artist_url.json b/tests/resources/expected_downloads_summaries/bandcamp/test_artist_url.json index f56b6371..268b0aab 100644 --- a/tests/resources/expected_downloads_summaries/bandcamp/test_artist_url.json +++ b/tests/resources/expected_downloads_summaries/bandcamp/test_artist_url.json @@ -1,10 +1,5 @@ { - ".ytdl-sub-Sithu Aye-download-archive.json": "1c4bcf58581eac1851be92ee29a3c4d7", - "Sithu Aye/[2021] 10 Years: Remixes and Reimaginings/01 - Double Helix Reimagined.mp3": "ac0e6a2936c309765c69a4c98c42ad10", - "Sithu Aye/[2021] 10 Years: Remixes and Reimaginings/02 - Skye Reimagined.mp3": "38387bd0ec5fc229e30b1a8ce5b9cddb", - "Sithu Aye/[2021] 10 Years: Remixes and Reimaginings/03 - Baryofusion.mp3": "344dbb939b09713dcc33894a8b1b8459", - "Sithu Aye/[2021] 10 Years: Remixes and Reimaginings/04 - Mandalay Reimagined.mp3": "9bacbd5166a740c06d3329dcfae0d9aa", - "Sithu Aye/[2021] 10 Years: Remixes and Reimaginings/folder.jpg": "bf6f70d51557a71b69fed85b2cb476f0", + ".ytdl-sub-Sithu Aye-download-archive.json": "93ad50c4f4cca753f40c46bc6e31cabf", "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/01 - Invent the Universe.mp3": "482e97a4f9a30aa4413f45f5f3f5dbba", "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/02 - Grand Unification (feat. David Maxim Micic).mp3": "34e43fd80b4c61295abbb8b794e523a7", "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/03 - Expansion.mp3": "0ba4b8ef65e274cbf065b6f0a5f444e5", @@ -16,6 +11,10 @@ "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/09 - Formation.mp3": "d588ed8edc324ea657abb2be96d557a1", "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/10 - Pale Blue Dot.mp3": "8245d285ba2403bf512f83c4a585eb81", "Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster)/folder.jpg": "d8cffeca026afaa619f641a95143f803", - "Sithu Aye/[2024] Kindness/01 - Run it Down.mp3": "9570867fd53419c1297411c9bf877a62", + "Sithu Aye/[2024] Kindness/01 - Run it Down.mp3": "227c366845913f3476ae83fce0b72c56", + "Sithu Aye/[2024] Kindness/02 - Zero Sum Groove.mp3": "0bd3b51acefc2a363309a8f94dd146d7", + "Sithu Aye/[2024] Kindness/03 - Obsidian.mp3": "2a6b87e1ab5a69fc6c4503dc0dd03623", + "Sithu Aye/[2024] Kindness/04 - Fear is the Kindness Killer.mp3": "4593d02a83f722d481846552554d13ba", + "Sithu Aye/[2024] Kindness/05 - Ghost;Cleanse.mp3": "f89156a892b597be9b594ce10131ab60", "Sithu Aye/[2024] Kindness/folder.jpg": "8f72c9acb1a2e49fcbdc2624a46b229c" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/plugins/chapters/test_chapters_from_comments.json b/tests/resources/expected_downloads_summaries/plugins/chapters/test_chapters_from_comments.json index 38d69b12..fc5ff67b 100644 --- a/tests/resources/expected_downloads_summaries/plugins/chapters/test_chapters_from_comments.json +++ b/tests/resources/expected_downloads_summaries/plugins/chapters/test_chapters_from_comments.json @@ -1,6 +1,6 @@ { ".ytdl-sub-chapters_from_comments-download-archive.json": "2510b2ff3c54aa4813a4f23ea079e1ec", "JMC/Move 78 - Automated Improvisation [Full Album].jpg": "c12e6a6f242680d1096a1a99d74a62c6", - "JMC/Move 78 - Automated Improvisation [Full Album].mp4": "63119bd17a035574263aeec188e11d5e", + "JMC/Move 78 - Automated Improvisation [Full Album].mp4": "fbe825bf5fb8ce193d47c3da3540e815", "JMC/Move 78 - Automated Improvisation [Full Album].nfo": "039268e97673a6f2b391772ec3b52fac" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/plugins/match_filters/test_match_filters_partial.json b/tests/resources/expected_downloads_summaries/plugins/match_filters/test_match_filters_partial.json index 2a6691fb..31e5109d 100644 --- a/tests/resources/expected_downloads_summaries/plugins/match_filters/test_match_filters_partial.json +++ b/tests/resources/expected_downloads_summaries/plugins/match_filters/test_match_filters_partial.json @@ -1,6 +1,6 @@ { ".ytdl-sub-match_filter_test-download-archive.json": "30f10646149d9eea4eb970749f352f7d", "match_filter_test/Jesse's Minecraft Server [Trailer - Mar.21].jpg": "e7830aa8a64b0cde65ba3f7e5fc56530", - "match_filter_test/Jesse's Minecraft Server [Trailer - Mar.21].mp4": "f17a540070964a199b35f981561d94e4", + "match_filter_test/Jesse's Minecraft Server [Trailer - Mar.21].mp4": "a0dc417dbf369c47bbac571bdb18e0e4", "match_filter_test/Jesse's Minecraft Server [Trailer - Mar.21].nfo": "d85f4500bb5d8a2425d734a23b5a944c" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/plugins/test_audio_extract_playlist.json b/tests/resources/expected_downloads_summaries/plugins/test_audio_extract_playlist.json index 83ed5f8b..7075adc2 100644 --- a/tests/resources/expected_downloads_summaries/plugins/test_audio_extract_playlist.json +++ b/tests/resources/expected_downloads_summaries/plugins/test_audio_extract_playlist.json @@ -1,6 +1,6 @@ { ".ytdl-sub-multiple_songs_test-download-archive.json": "54237df5e00d1598dfd39f341ee03d75", - "Project Zombie/[2011] Jesse's Minecraft Server/01 - Jesse's Minecraft Server [Trailer - Mar.21].ogg": "9e53a68a39f290899a3dce03fdca6490", + "Project Zombie/[2011] Jesse's Minecraft Server/01 - Jesse's Minecraft Server [Trailer - Mar.21].ogg": "221dab6e9d6840a0b4b7bb24444c87ff", "Project Zombie/[2011] Jesse's Minecraft Server/02 - Jesse's Minecraft Server [Trailer - Feb.27].ogg": "02dc8e368de9555d062bde31dcc82852", "Project Zombie/[2011] Jesse's Minecraft Server/03 - Jesse's Minecraft Server [Trailer - Feb.1].ogg": "c808e1da2bccd419201eeeffc32c3729", "Project Zombie/[2011] Jesse's Minecraft Server/folder.jpg": "e7830aa8a64b0cde65ba3f7e5fc56530" diff --git a/tests/resources/expected_downloads_summaries/plugins/test_chapters_sb_and_embedded_subs.json b/tests/resources/expected_downloads_summaries/plugins/test_chapters_sb_and_embedded_subs.json index dcf23c96..8c0c2b41 100644 --- a/tests/resources/expected_downloads_summaries/plugins/test_chapters_sb_and_embedded_subs.json +++ b/tests/resources/expected_downloads_summaries/plugins/test_chapters_sb_and_embedded_subs.json @@ -1,6 +1,6 @@ { ".ytdl-sub-sponsorblock_with_embedded_subs_test-download-archive.json": "2cb4b9586fd5bb7f1fed76ed9195e6e4", "JMC/This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.jpg": "b5353a824a4800cc26f884e3025ed969", - "JMC/This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.mp4": "0539ff781c824f17ae3ffbd7dc830a8f", + "JMC/This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.mp4": "19889ac778397147e55fe640c9a0f490", "JMC/This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.nfo": "ae73ec18a9f0e5a54c90061ccd32e7f4" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/youtube/test_channel_full.json b/tests/resources/expected_downloads_summaries/youtube/test_channel_full.json index 7b5e7b2f..a1024507 100644 --- a/tests/resources/expected_downloads_summaries/youtube/test_channel_full.json +++ b/tests/resources/expected_downloads_summaries/youtube/test_channel_full.json @@ -18,7 +18,7 @@ "Project ⧸ Zombie/Season 2011/s2011.e022701 - Jesse's Minecraft Server [Trailer - Feb.27].nfo": "20af231e30a035fb2bc0c946f4b4026d", "Project ⧸ Zombie/Season 2011/s2011.e032101 - Jesse's Minecraft Server [Trailer - Mar.21]-thumb.jpg": "e7830aa8a64b0cde65ba3f7e5fc56530", "Project ⧸ Zombie/Season 2011/s2011.e032101 - Jesse's Minecraft Server [Trailer - Mar.21].info.json": "INFO_JSON", - "Project ⧸ Zombie/Season 2011/s2011.e032101 - Jesse's Minecraft Server [Trailer - Mar.21].mp4": "eac3dfce44c2a723f2fc74e9552aa510", + "Project ⧸ Zombie/Season 2011/s2011.e032101 - Jesse's Minecraft Server [Trailer - Mar.21].mp4": "7df8f1469adb02f1aecfbb049276cae7", "Project ⧸ Zombie/Season 2011/s2011.e032101 - Jesse's Minecraft Server [Trailer - Mar.21].nfo": "f8bdc463c0cb2ffdc82aba2bcc27ad5d", "Project ⧸ Zombie/Season 2011/s2011.e052901 - Project Zombie |Official Trailer| (IP: mc.projectzombie.beastnode.net)-thumb.jpg": "c956192a379b3661595c9920972d4819", "Project ⧸ Zombie/Season 2011/s2011.e052901 - Project Zombie |Official Trailer| (IP: mc.projectzombie.beastnode.net).info.json": "INFO_JSON", diff --git a/tests/resources/expected_downloads_summaries/youtube/test_playlist.json b/tests/resources/expected_downloads_summaries/youtube/test_playlist.json index d7ed7312..91b6495d 100644 --- a/tests/resources/expected_downloads_summaries/youtube/test_playlist.json +++ b/tests/resources/expected_downloads_summaries/youtube/test_playlist.json @@ -10,7 +10,7 @@ "JMC/Season 01/s01.e11022701 - Jesse's Minecraft Server [Trailer - Feb.27].nfo": "da7645e8826586388ae0d8278ef6a1c1", "JMC/Season 01/s01.e11032101 - Jesse's Minecraft Server [Trailer - Mar.21]-thumb.jpg": "e7830aa8a64b0cde65ba3f7e5fc56530", "JMC/Season 01/s01.e11032101 - Jesse's Minecraft Server [Trailer - Mar.21].info.json": "INFO_JSON", - "JMC/Season 01/s01.e11032101 - Jesse's Minecraft Server [Trailer - Mar.21].mp4": "dc04853870bbb811f6b312e20b58253b", + "JMC/Season 01/s01.e11032101 - Jesse's Minecraft Server [Trailer - Mar.21].mp4": "91f22944f797513fcda9856e244dd3b3", "JMC/Season 01/s01.e11032101 - Jesse's Minecraft Server [Trailer - Mar.21].nfo": "c56083e2f3545fa2cafc4d67cbfdacf8", "JMC/fanart.jpg": "129c6639b47299bc48062f0365e670ee", "JMC/poster.jpg": "5de28eea5a921a041452ab3ce1041f73", diff --git a/tests/resources/expected_downloads_summaries/youtube/test_playlist_archive_migrated.json b/tests/resources/expected_downloads_summaries/youtube/test_playlist_archive_migrated.json index a35908dc..50f91133 100644 --- a/tests/resources/expected_downloads_summaries/youtube/test_playlist_archive_migrated.json +++ b/tests/resources/expected_downloads_summaries/youtube/test_playlist_archive_migrated.json @@ -10,7 +10,7 @@ "JMC/Season 01/s01.e11022701 - Jesse's Minecraft Server [Trailer - Feb.27].nfo": "da7645e8826586388ae0d8278ef6a1c1", "JMC/Season 01/s01.e11032101 - Jesse's Minecraft Server [Trailer - Mar.21]-thumb.jpg": "e7830aa8a64b0cde65ba3f7e5fc56530", "JMC/Season 01/s01.e11032101 - Jesse's Minecraft Server [Trailer - Mar.21].info.json": "INFO_JSON", - "JMC/Season 01/s01.e11032101 - Jesse's Minecraft Server [Trailer - Mar.21].mp4": "dc04853870bbb811f6b312e20b58253b", + "JMC/Season 01/s01.e11032101 - Jesse's Minecraft Server [Trailer - Mar.21].mp4": "91f22944f797513fcda9856e244dd3b3", "JMC/Season 01/s01.e11032101 - Jesse's Minecraft Server [Trailer - Mar.21].nfo": "c56083e2f3545fa2cafc4d67cbfdacf8", "JMC/fanart.jpg": "129c6639b47299bc48062f0365e670ee", "JMC/poster.jpg": "5de28eea5a921a041452ab3ce1041f73", diff --git a/tests/resources/transaction_log_summaries/bandcamp/test_artist_url.txt b/tests/resources/transaction_log_summaries/bandcamp/test_artist_url.txt index 60d1e689..b5a0cc29 100644 --- a/tests/resources/transaction_log_summaries/bandcamp/test_artist_url.txt +++ b/tests/resources/transaction_log_summaries/bandcamp/test_artist_url.txt @@ -2,64 +2,6 @@ Files created: ---------------------------------------- {output_directory} .ytdl-sub-Sithu Aye-download-archive.json -{output_directory}/Sithu Aye/[2021] 10 Years: Remixes and Reimaginings - 01 - Double Helix Reimagined.mp3 - Music Tags: - album: 10 Years: Remixes and Reimaginings - albumartist: Sithu Aye - albumartists: Sithu Aye - artist: Sithu Aye - artists: Sithu Aye - date: 2021-11-26 - genres: Progressive Metal - original_date: 2021-11-26 - title: Double Helix Reimagined - track: 1 - tracktotal: 10 - year: 2021 - 02 - Skye Reimagined.mp3 - Music Tags: - album: 10 Years: Remixes and Reimaginings - albumartist: Sithu Aye - albumartists: Sithu Aye - artist: Sithu Aye - artists: Sithu Aye - date: 2021-11-26 - genres: Progressive Metal - original_date: 2021-11-26 - title: Skye Reimagined - track: 2 - tracktotal: 10 - year: 2021 - 03 - Baryofusion.mp3 - Music Tags: - album: 10 Years: Remixes and Reimaginings - albumartist: Sithu Aye - albumartists: Sithu Aye - artist: Sithu Aye - artists: Sithu Aye - date: 2021-11-26 - genres: Progressive Metal - original_date: 2021-11-26 - title: Baryofusion - track: 3 - tracktotal: 10 - year: 2021 - 04 - Mandalay Reimagined.mp3 - Music Tags: - album: 10 Years: Remixes and Reimaginings - albumartist: Sithu Aye - albumartists: Sithu Aye - artist: Sithu Aye - artists: Sithu Aye - date: 2021-11-26 - genres: Progressive Metal - original_date: 2021-11-26 - title: Mandalay Reimagined - track: 4 - tracktotal: 10 - year: 2021 - folder.jpg {output_directory}/Sithu Aye/[2022] Re:Invent the Universe (10th Anniversary Remaster) 01 - Invent the Universe.mp3 Music Tags: @@ -215,6 +157,62 @@ Files created: original_date: 2024-03-23 title: Run it Down track: 1 - tracktotal: 1 + tracktotal: 5 + year: 2024 + 02 - Zero Sum Groove.mp3 + Music Tags: + album: Kindness + albumartist: Sithu Aye + albumartists: Sithu Aye + artist: Sithu Aye + artists: Sithu Aye + date: 2024-03-23 + genres: Progressive Metal + original_date: 2024-03-23 + title: Zero Sum Groove + track: 2 + tracktotal: 5 + year: 2024 + 03 - Obsidian.mp3 + Music Tags: + album: Kindness + albumartist: Sithu Aye + albumartists: Sithu Aye + artist: Sithu Aye + artists: Sithu Aye + date: 2024-03-23 + genres: Progressive Metal + original_date: 2024-03-23 + title: Obsidian + track: 3 + tracktotal: 5 + year: 2024 + 04 - Fear is the Kindness Killer.mp3 + Music Tags: + album: Kindness + albumartist: Sithu Aye + albumartists: Sithu Aye + artist: Sithu Aye + artists: Sithu Aye + date: 2024-03-23 + genres: Progressive Metal + original_date: 2024-03-23 + title: Fear is the Kindness Killer + track: 4 + tracktotal: 5 + year: 2024 + 05 - Ghost;Cleanse.mp3 + Music Tags: + album: Kindness + albumartist: Sithu Aye + albumartists: Sithu Aye + artist: Sithu Aye + artists: Sithu Aye + date: 2024-03-23 + genres: Progressive Metal + original_date: 2024-03-23 + title: Ghost;Cleanse + track: 5 + tracktotal: 5 year: 2024 folder.jpg \ No newline at end of file diff --git a/tests/resources/transaction_log_summaries/plugins/chapters/test_chapters_from_comments.txt b/tests/resources/transaction_log_summaries/plugins/chapters/test_chapters_from_comments.txt index 007c19b8..cdbbfa60 100644 --- a/tests/resources/transaction_log_summaries/plugins/chapters/test_chapters_from_comments.txt +++ b/tests/resources/transaction_log_summaries/plugins/chapters/test_chapters_from_comments.txt @@ -5,6 +5,16 @@ Files created: {output_directory}/JMC Move 78 - Automated Improvisation [Full Album].jpg Move 78 - Automated Improvisation [Full Album].mp4 + Chapters from comments: + 0:00: 01. The Lonely Tears of Lee Seedol + 4:30: 02. But What If We're Wrong + 9:16: 03. Follow the Earworm Pt.2 + 12:25: 04. Keyword Salad + 16:48: 05. Ultra Natural + 20:47: 06. Flight Instructions + 25:46: 07. Dawn of the Useless Class + 29:58: 08. Schnitzel Whisperer + 32:16: 09. Teilo Embedded subtitles with lang(s) en, de Video Tags: album: Music Videos From 30a2ad7a638c84d6b79e77e5162e8d2f92a1f1e4 Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Sun, 2 Jun 2024 10:53:19 -0700 Subject: [PATCH 37/39] [BUGFIX] Prevent corrupt writes to download archive (#983) Attempts to make writes to the download archive safer (https://github.com/jmbannon/ytdl-sub/issues/982). `ytdl-sub` will now *copy* the download archive from the working directory to the output directory with a temp name, then perform a *move* to store it with its final expected name. This will drastically lower the window of time where the process could die mid-write and corrupt it on the next read. --- .../subscriptions/subscription_download.py | 2 +- .../subscription_ytdl_options.py | 4 +++- src/ytdl_sub/utils/file_handler.py | 6 +++++- .../enhanced_download_archive.py | 20 +++++++++++++++---- 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/ytdl_sub/subscriptions/subscription_download.py b/src/ytdl_sub/subscriptions/subscription_download.py index 1655a633..95108ece 100644 --- a/src/ytdl_sub/subscriptions/subscription_download.py +++ b/src/ytdl_sub/subscriptions/subscription_download.py @@ -161,7 +161,7 @@ class SubscriptionDownload(BaseSubscription, ABC): ) self.download_archive.save_download_mappings() - FileHandler.delete(self.download_archive.working_file_path) + FileHandler.delete(self.download_archive.working_ytdl_file_path) @contextlib.contextmanager def _remove_empty_directories_in_output_directory(self): diff --git a/src/ytdl_sub/subscriptions/subscription_ytdl_options.py b/src/ytdl_sub/subscriptions/subscription_ytdl_options.py index 230134db..1cc6f317 100644 --- a/src/ytdl_sub/subscriptions/subscription_ytdl_options.py +++ b/src/ytdl_sub/subscriptions/subscription_ytdl_options.py @@ -89,7 +89,9 @@ class SubscriptionYTDLOptions: ytdl_options = {} if self._preset.output_options.maintain_download_archive: - ytdl_options["download_archive"] = self._enhanced_download_archive.working_file_path + ytdl_options["download_archive"] = ( + self._enhanced_download_archive.working_ytdl_file_path + ) if self._preset.output_options.keep_max_files: keep_max_files = int( self._overrides.apply_formatter(self._preset.output_options.keep_max_files) diff --git a/src/ytdl_sub/utils/file_handler.py b/src/ytdl_sub/utils/file_handler.py index 646d44e1..faa7d2dc 100644 --- a/src/ytdl_sub/utils/file_handler.py +++ b/src/ytdl_sub/utils/file_handler.py @@ -385,7 +385,11 @@ class FileHandler: dst_file_path Destination file """ - shutil.copyfile(src=src_file_path, dst=dst_file_path) + # Perform the copy by first writing to a temp file, then moving it. + # This tries to prevent corrupted writes if the processed dies mid-write, + atomic_dst = f"{dst_file_path}-ytdl-sub-incomplete" + shutil.copyfile(src=src_file_path, dst=atomic_dst) + shutil.move(src=atomic_dst, dst=dst_file_path) @classmethod def move(cls, src_file_path: Union[str, Path], dst_file_path: Union[str, Path]): diff --git a/src/ytdl_sub/ytdl_additions/enhanced_download_archive.py b/src/ytdl_sub/ytdl_additions/enhanced_download_archive.py index 616fe2f9..ec155245 100644 --- a/src/ytdl_sub/ytdl_additions/enhanced_download_archive.py +++ b/src/ytdl_sub/ytdl_additions/enhanced_download_archive.py @@ -506,10 +506,19 @@ class EnhancedDownloadArchive: """ Returns ------- - The download mapping's file path in the working directory. + The download mapping's file path in the working directory for ytdl usage """ return str(Path(self.working_directory) / self.file_name) + @property + def working_ytdl_file_path(self) -> str: + """ + Returns + ------- + The download mapping's file path in the working directory for ytdl usage + """ + return f"{self.working_file_path}-ytdl-archive" + @property def mapping(self) -> DownloadMappings: """ @@ -541,7 +550,7 @@ class EnhancedDownloadArchive: return self # Otherwise, create a ytdl download archive file in the working directory. - self.mapping.to_download_archive().to_file(self.working_file_path) + self.mapping.to_download_archive().to_file(self.working_ytdl_file_path) return self @@ -603,15 +612,18 @@ class EnhancedDownloadArchive: if self._migrated_file_name: self._download_mapping.to_file(output_json_file=self.working_file_path) self.save_file_to_output_directory( - file_name=self.file_name, output_file_name=self._migrated_file_name + file_name=self.file_name, output_file_name=self._migrated_file_name, copy_file=True ) + FileHandler.delete(file_path=self.working_file_path) + # and delete the old one if the name differs if self._file_name != self._migrated_file_name: self.delete_file_from_output_directory(file_name=self.file_name) # Otherwise, only save if there are changes to the transaction log elif not self.get_file_handler_transaction_log().is_empty: self._download_mapping.to_file(output_json_file=self.working_file_path) - self.save_file_to_output_directory(file_name=self.file_name) + self.save_file_to_output_directory(file_name=self.file_name, copy_file=True) + FileHandler.delete(file_path=self.working_file_path) return self def delete_file_from_output_directory(self, file_name: str): From 6e31ad360032a62e8404ac13252454a7b40e8c2d Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Sun, 2 Jun 2024 10:57:32 -0700 Subject: [PATCH 38/39] [BACKEND] Include .info.json files for Music video presets (#996) Music video presets now include .info.json files by default --- .../prebuilt_presets/music_videos/music_video_base.yaml | 1 + .../plugins/chapters/test_chapters_from_comments.json | 3 ++- .../plugins/file_convert/output.json | 5 +++-- .../plugins/file_convert/output_custom_ffmpeg.json | 3 ++- .../plugins/match_filters/test_match_filters_partial.json | 3 ++- .../plugins/test_chapters_sb_and_embedded_subs.json | 3 ++- .../plugins/test_subtitles_embedded.json | 3 ++- .../plugins/test_subtitles_embedded_and_file.json | 3 ++- .../unit/music_videos/Jellyfin Music Videos.json | 6 +++++- .../unit/music_videos/Kodi Music Videos.json | 6 +++++- .../unit/music_videos/Plex Music Videos.json | 6 +++++- .../expected_downloads_summaries/youtube/test_video.json | 1 + .../youtube/test_video_cli.json | 1 + .../youtube/test_video_missing_thumb.json | 1 + .../plugins/chapters/test_chapters_from_comments.txt | 1 + .../plugins/file_convert/output.txt | 1 + .../plugins/file_convert/output_custom_ffmpeg.txt | 1 + .../plugins/match_filters/test_match_filters_partial.txt | 1 + .../transaction_log_summaries/plugins/nfo_tags/test_nfo.txt | 1 + .../plugins/nfo_tags/test_nfo_kodi_safe.txt | 1 + .../plugins/test_chapters_sb_and_embedded_subs.txt | 1 + .../transaction_log_summaries/plugins/test_regex.txt | 2 ++ .../plugins/test_regex_exclude.txt | 1 + .../plugins/test_regex_match_and_exclude.txt | 1 + .../plugins/test_regex_overrides.txt | 1 + .../plugins/test_subtitles_embedded.txt | 1 + .../plugins/test_subtitles_embedded_and_file.txt | 1 + .../unit/music_videos/Jellyfin Music Videos.txt | 4 ++++ .../unit/music_videos/Kodi Music Videos.txt | 4 ++++ .../unit/music_videos/Plex Music Videos.txt | 4 ++++ .../transaction_log_summaries/youtube/test_video.txt | 1 + .../transaction_log_summaries/youtube/test_video_cli.txt | 1 + .../youtube/test_video_missing_thumb.txt | 1 + 33 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/ytdl_sub/prebuilt_presets/music_videos/music_video_base.yaml b/src/ytdl_sub/prebuilt_presets/music_videos/music_video_base.yaml index 9f85dd46..ac810745 100644 --- a/src/ytdl_sub/prebuilt_presets/music_videos/music_video_base.yaml +++ b/src/ytdl_sub/prebuilt_presets/music_videos/music_video_base.yaml @@ -7,6 +7,7 @@ presets: output_directory: "{music_video_directory}" file_name: "{music_video_file_name}.{ext}" thumbnail_name: "{music_video_file_name}.jpg" + info_json_name: "{music_video_file_name}.{info_json_ext}" maintain_download_archive: True ytdl_options: diff --git a/tests/resources/expected_downloads_summaries/plugins/chapters/test_chapters_from_comments.json b/tests/resources/expected_downloads_summaries/plugins/chapters/test_chapters_from_comments.json index fc5ff67b..d7ca529e 100644 --- a/tests/resources/expected_downloads_summaries/plugins/chapters/test_chapters_from_comments.json +++ b/tests/resources/expected_downloads_summaries/plugins/chapters/test_chapters_from_comments.json @@ -1,5 +1,6 @@ { - ".ytdl-sub-chapters_from_comments-download-archive.json": "2510b2ff3c54aa4813a4f23ea079e1ec", + ".ytdl-sub-chapters_from_comments-download-archive.json": "70bc3bb62af3a344b0256e1d3b98dcb0", + "JMC/Move 78 - Automated Improvisation [Full Album].info.json": "INFO_JSON", "JMC/Move 78 - Automated Improvisation [Full Album].jpg": "c12e6a6f242680d1096a1a99d74a62c6", "JMC/Move 78 - Automated Improvisation [Full Album].mp4": "fbe825bf5fb8ce193d47c3da3540e815", "JMC/Move 78 - Automated Improvisation [Full Album].nfo": "039268e97673a6f2b391772ec3b52fac" diff --git a/tests/resources/expected_downloads_summaries/plugins/file_convert/output.json b/tests/resources/expected_downloads_summaries/plugins/file_convert/output.json index 019997af..3d9d244f 100644 --- a/tests/resources/expected_downloads_summaries/plugins/file_convert/output.json +++ b/tests/resources/expected_downloads_summaries/plugins/file_convert/output.json @@ -1,6 +1,7 @@ { - ".ytdl-sub-file_convert_test-download-archive.json": "f720289a704349fbe38ef5ed451af724", + ".ytdl-sub-file_convert_test-download-archive.json": "36ac81b6a021c1479a588477d74afd38", + "file_convert_test/When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.info.json": "INFO_JSON", "file_convert_test/When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.jpg": "662fcaadf6e80d63591bac19a5fdffb0", - "file_convert_test/When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.mp4": "24f5057254471bc6ecc3056e91af1444", + "file_convert_test/When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.mp4": "7609c4f53565aa8f9eac50ad755b5c47", "file_convert_test/When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.nfo": "752e6b6eea853c8a1f62faa4b841b292" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/plugins/file_convert/output_custom_ffmpeg.json b/tests/resources/expected_downloads_summaries/plugins/file_convert/output_custom_ffmpeg.json index 361a6c30..b82a93ce 100644 --- a/tests/resources/expected_downloads_summaries/plugins/file_convert/output_custom_ffmpeg.json +++ b/tests/resources/expected_downloads_summaries/plugins/file_convert/output_custom_ffmpeg.json @@ -1,5 +1,6 @@ { - ".ytdl-sub-file_convert_test-download-archive.json": "8fa4bf42c9686f8520ce107e48b73215", + ".ytdl-sub-file_convert_test-download-archive.json": "83e57376a260ab3a58978dee4a06df4a", + "file_convert_test/When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.info.json": "INFO_JSON", "file_convert_test/When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.jpg": "662fcaadf6e80d63591bac19a5fdffb0", "file_convert_test/When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.mkv": "175320a51dc3efcea84daebec3c1d7e1", "file_convert_test/When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.nfo": "752e6b6eea853c8a1f62faa4b841b292" diff --git a/tests/resources/expected_downloads_summaries/plugins/match_filters/test_match_filters_partial.json b/tests/resources/expected_downloads_summaries/plugins/match_filters/test_match_filters_partial.json index 31e5109d..3eeb24ed 100644 --- a/tests/resources/expected_downloads_summaries/plugins/match_filters/test_match_filters_partial.json +++ b/tests/resources/expected_downloads_summaries/plugins/match_filters/test_match_filters_partial.json @@ -1,5 +1,6 @@ { - ".ytdl-sub-match_filter_test-download-archive.json": "30f10646149d9eea4eb970749f352f7d", + ".ytdl-sub-match_filter_test-download-archive.json": "c2154694d771b47e7878622851b379b7", + "match_filter_test/Jesse's Minecraft Server [Trailer - Mar.21].info.json": "INFO_JSON", "match_filter_test/Jesse's Minecraft Server [Trailer - Mar.21].jpg": "e7830aa8a64b0cde65ba3f7e5fc56530", "match_filter_test/Jesse's Minecraft Server [Trailer - Mar.21].mp4": "a0dc417dbf369c47bbac571bdb18e0e4", "match_filter_test/Jesse's Minecraft Server [Trailer - Mar.21].nfo": "d85f4500bb5d8a2425d734a23b5a944c" diff --git a/tests/resources/expected_downloads_summaries/plugins/test_chapters_sb_and_embedded_subs.json b/tests/resources/expected_downloads_summaries/plugins/test_chapters_sb_and_embedded_subs.json index 8c0c2b41..675ae0ec 100644 --- a/tests/resources/expected_downloads_summaries/plugins/test_chapters_sb_and_embedded_subs.json +++ b/tests/resources/expected_downloads_summaries/plugins/test_chapters_sb_and_embedded_subs.json @@ -1,5 +1,6 @@ { - ".ytdl-sub-sponsorblock_with_embedded_subs_test-download-archive.json": "2cb4b9586fd5bb7f1fed76ed9195e6e4", + ".ytdl-sub-sponsorblock_with_embedded_subs_test-download-archive.json": "53eda1f626aaec8814ff005bf8d46ef7", + "JMC/This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.info.json": "INFO_JSON", "JMC/This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.jpg": "b5353a824a4800cc26f884e3025ed969", "JMC/This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.mp4": "19889ac778397147e55fe640c9a0f490", "JMC/This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.nfo": "ae73ec18a9f0e5a54c90061ccd32e7f4" diff --git a/tests/resources/expected_downloads_summaries/plugins/test_subtitles_embedded.json b/tests/resources/expected_downloads_summaries/plugins/test_subtitles_embedded.json index 74a88813..b219d9fd 100644 --- a/tests/resources/expected_downloads_summaries/plugins/test_subtitles_embedded.json +++ b/tests/resources/expected_downloads_summaries/plugins/test_subtitles_embedded.json @@ -1,5 +1,6 @@ { - ".ytdl-sub-subtitles_embedded_test-download-archive.json": "a74ecea9f7844be23f470bbe702788f3", + ".ytdl-sub-subtitles_embedded_test-download-archive.json": "538f36d011bb6077de044263d511a45b", + "JMC/YouTube Rewind 2019: For the Record | #YouTubeRewind.info.json": "INFO_JSON", "JMC/YouTube Rewind 2019: For the Record | #YouTubeRewind.jpg": "50ee47c80f679029f5d3503bb91b045a", "JMC/YouTube Rewind 2019: For the Record | #YouTubeRewind.mp4": "f90f3bb948014420931c337b09007a18", "JMC/YouTube Rewind 2019: For the Record | #YouTubeRewind.nfo": "e6ac56ce52c747e2e271f12208f9a538" diff --git a/tests/resources/expected_downloads_summaries/plugins/test_subtitles_embedded_and_file.json b/tests/resources/expected_downloads_summaries/plugins/test_subtitles_embedded_and_file.json index 7c4baeb6..4c288220 100644 --- a/tests/resources/expected_downloads_summaries/plugins/test_subtitles_embedded_and_file.json +++ b/tests/resources/expected_downloads_summaries/plugins/test_subtitles_embedded_and_file.json @@ -1,7 +1,8 @@ { - ".ytdl-sub-subtitles_embedded_and_file_test-download-archive.json": "a05bbc3b8851e92da10c67cd9acb32d0", + ".ytdl-sub-subtitles_embedded_and_file_test-download-archive.json": "470a4652395e01c970aaa40fe8fcea6c", "JMC/YouTube Rewind 2019: For the Record | #YouTubeRewind.de.srt": "b343c3bb9257b7ee7ba38f570a115b37", "JMC/YouTube Rewind 2019: For the Record | #YouTubeRewind.en.srt": "fe8c6ee92cae6e059fd80fd61691adbe", + "JMC/YouTube Rewind 2019: For the Record | #YouTubeRewind.info.json": "INFO_JSON", "JMC/YouTube Rewind 2019: For the Record | #YouTubeRewind.jpg": "50ee47c80f679029f5d3503bb91b045a", "JMC/YouTube Rewind 2019: For the Record | #YouTubeRewind.mp4": "f90f3bb948014420931c337b09007a18", "JMC/YouTube Rewind 2019: For the Record | #YouTubeRewind.nfo": "e6ac56ce52c747e2e271f12208f9a538" diff --git a/tests/resources/expected_downloads_summaries/unit/music_videos/Jellyfin Music Videos.json b/tests/resources/expected_downloads_summaries/unit/music_videos/Jellyfin Music Videos.json index 60381877..72e4fbce 100644 --- a/tests/resources/expected_downloads_summaries/unit/music_videos/Jellyfin Music Videos.json +++ b/tests/resources/expected_downloads_summaries/unit/music_videos/Jellyfin Music Videos.json @@ -1,14 +1,18 @@ { - ".ytdl-sub-subscription_test-download-archive.json": "d9f5fdb6fc3fc3c3bf87772c406448ec", + ".ytdl-sub-subscription_test-download-archive.json": "b98a30417f259daeec888e1a5047b9ed", + "subscription_test/Mock Entry 20-1.info.json": "INFO_JSON", "subscription_test/Mock Entry 20-1.jpg": "e80c508c4818454300133fe1dc1a9cd7", "subscription_test/Mock Entry 20-1.mp4": "34916dc26e723c3464adcc4687898ca8", "subscription_test/Mock Entry 20-1.nfo": "68a6a9e51a12e75d4c68e6e644b409d1", + "subscription_test/Mock Entry 20-2.info.json": "INFO_JSON", "subscription_test/Mock Entry 20-2.jpg": "e80c508c4818454300133fe1dc1a9cd7", "subscription_test/Mock Entry 20-2.mp4": "01a910b7a023325aa9d4ad3a013a1e02", "subscription_test/Mock Entry 20-2.nfo": "1cbe51945c1a7a9ced0eb1222e9d2405", + "subscription_test/Mock Entry 20-3.info.json": "INFO_JSON", "subscription_test/Mock Entry 20-3.jpg": "e80c508c4818454300133fe1dc1a9cd7", "subscription_test/Mock Entry 20-3.mp4": "812b6db9ff08bf1822e99e43d371400d", "subscription_test/Mock Entry 20-3.nfo": "ef07f30d3e882de3f7cb3ad675125352", + "subscription_test/Mock Entry 21-1.info.json": "INFO_JSON", "subscription_test/Mock Entry 21-1.jpg": "e80c508c4818454300133fe1dc1a9cd7", "subscription_test/Mock Entry 21-1.mp4": "079832b6e49b283d80be402c9c928b90", "subscription_test/Mock Entry 21-1.nfo": "dc714fd9f3c3729f74927ad245b50d9d" diff --git a/tests/resources/expected_downloads_summaries/unit/music_videos/Kodi Music Videos.json b/tests/resources/expected_downloads_summaries/unit/music_videos/Kodi Music Videos.json index 60381877..72e4fbce 100644 --- a/tests/resources/expected_downloads_summaries/unit/music_videos/Kodi Music Videos.json +++ b/tests/resources/expected_downloads_summaries/unit/music_videos/Kodi Music Videos.json @@ -1,14 +1,18 @@ { - ".ytdl-sub-subscription_test-download-archive.json": "d9f5fdb6fc3fc3c3bf87772c406448ec", + ".ytdl-sub-subscription_test-download-archive.json": "b98a30417f259daeec888e1a5047b9ed", + "subscription_test/Mock Entry 20-1.info.json": "INFO_JSON", "subscription_test/Mock Entry 20-1.jpg": "e80c508c4818454300133fe1dc1a9cd7", "subscription_test/Mock Entry 20-1.mp4": "34916dc26e723c3464adcc4687898ca8", "subscription_test/Mock Entry 20-1.nfo": "68a6a9e51a12e75d4c68e6e644b409d1", + "subscription_test/Mock Entry 20-2.info.json": "INFO_JSON", "subscription_test/Mock Entry 20-2.jpg": "e80c508c4818454300133fe1dc1a9cd7", "subscription_test/Mock Entry 20-2.mp4": "01a910b7a023325aa9d4ad3a013a1e02", "subscription_test/Mock Entry 20-2.nfo": "1cbe51945c1a7a9ced0eb1222e9d2405", + "subscription_test/Mock Entry 20-3.info.json": "INFO_JSON", "subscription_test/Mock Entry 20-3.jpg": "e80c508c4818454300133fe1dc1a9cd7", "subscription_test/Mock Entry 20-3.mp4": "812b6db9ff08bf1822e99e43d371400d", "subscription_test/Mock Entry 20-3.nfo": "ef07f30d3e882de3f7cb3ad675125352", + "subscription_test/Mock Entry 21-1.info.json": "INFO_JSON", "subscription_test/Mock Entry 21-1.jpg": "e80c508c4818454300133fe1dc1a9cd7", "subscription_test/Mock Entry 21-1.mp4": "079832b6e49b283d80be402c9c928b90", "subscription_test/Mock Entry 21-1.nfo": "dc714fd9f3c3729f74927ad245b50d9d" diff --git a/tests/resources/expected_downloads_summaries/unit/music_videos/Plex Music Videos.json b/tests/resources/expected_downloads_summaries/unit/music_videos/Plex Music Videos.json index b2bc1dda..78727e67 100644 --- a/tests/resources/expected_downloads_summaries/unit/music_videos/Plex Music Videos.json +++ b/tests/resources/expected_downloads_summaries/unit/music_videos/Plex Music Videos.json @@ -1,11 +1,15 @@ { - ".ytdl-sub-subscription_test-download-archive.json": "7d31b00238ca1f66c5e7f4735ca7aa87", + ".ytdl-sub-subscription_test-download-archive.json": "6cb47203ec56d8318a0d428b5b4efa5d", + "subscription_test/Mock Entry 20-1.info.json": "INFO_JSON", "subscription_test/Mock Entry 20-1.jpg": "e80c508c4818454300133fe1dc1a9cd7", "subscription_test/Mock Entry 20-1.mp4": "34916dc26e723c3464adcc4687898ca8", + "subscription_test/Mock Entry 20-2.info.json": "INFO_JSON", "subscription_test/Mock Entry 20-2.jpg": "e80c508c4818454300133fe1dc1a9cd7", "subscription_test/Mock Entry 20-2.mp4": "01a910b7a023325aa9d4ad3a013a1e02", + "subscription_test/Mock Entry 20-3.info.json": "INFO_JSON", "subscription_test/Mock Entry 20-3.jpg": "e80c508c4818454300133fe1dc1a9cd7", "subscription_test/Mock Entry 20-3.mp4": "812b6db9ff08bf1822e99e43d371400d", + "subscription_test/Mock Entry 21-1.info.json": "INFO_JSON", "subscription_test/Mock Entry 21-1.jpg": "e80c508c4818454300133fe1dc1a9cd7", "subscription_test/Mock Entry 21-1.mp4": "079832b6e49b283d80be402c9c928b90" } \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/youtube/test_video.json b/tests/resources/expected_downloads_summaries/youtube/test_video.json index dd69c218..f4690775 100644 --- a/tests/resources/expected_downloads_summaries/youtube/test_video.json +++ b/tests/resources/expected_downloads_summaries/youtube/test_video.json @@ -1,4 +1,5 @@ { + "JMC/Oblivion Mod "Falcor" p.1.info.json": "INFO_JSON", "JMC/Oblivion Mod "Falcor" p.1.jpg": "fb95b510681676e81c321171fc23143e", "JMC/Oblivion Mod "Falcor" p.1.mp4": "318faf3eb1d7666491553cdfd2a2e9fb", "JMC/Oblivion Mod "Falcor" p.1.nfo": "58c2be339869b5d071c1758d55c72ddb" diff --git a/tests/resources/expected_downloads_summaries/youtube/test_video_cli.json b/tests/resources/expected_downloads_summaries/youtube/test_video_cli.json index dd69c218..f4690775 100644 --- a/tests/resources/expected_downloads_summaries/youtube/test_video_cli.json +++ b/tests/resources/expected_downloads_summaries/youtube/test_video_cli.json @@ -1,4 +1,5 @@ { + "JMC/Oblivion Mod "Falcor" p.1.info.json": "INFO_JSON", "JMC/Oblivion Mod "Falcor" p.1.jpg": "fb95b510681676e81c321171fc23143e", "JMC/Oblivion Mod "Falcor" p.1.mp4": "318faf3eb1d7666491553cdfd2a2e9fb", "JMC/Oblivion Mod "Falcor" p.1.nfo": "58c2be339869b5d071c1758d55c72ddb" diff --git a/tests/resources/expected_downloads_summaries/youtube/test_video_missing_thumb.json b/tests/resources/expected_downloads_summaries/youtube/test_video_missing_thumb.json index bf1811b0..b1eec149 100644 --- a/tests/resources/expected_downloads_summaries/youtube/test_video_missing_thumb.json +++ b/tests/resources/expected_downloads_summaries/youtube/test_video_missing_thumb.json @@ -1,4 +1,5 @@ { + "JMC/Oblivion Mod "Falcor" p.1.info.json": "INFO_JSON", "JMC/Oblivion Mod "Falcor" p.1.mp4": "d3bdda5ec6822ea3b4b4bd8908eb327a", "JMC/Oblivion Mod "Falcor" p.1.nfo": "58c2be339869b5d071c1758d55c72ddb" } \ No newline at end of file diff --git a/tests/resources/transaction_log_summaries/plugins/chapters/test_chapters_from_comments.txt b/tests/resources/transaction_log_summaries/plugins/chapters/test_chapters_from_comments.txt index cdbbfa60..40f060d6 100644 --- a/tests/resources/transaction_log_summaries/plugins/chapters/test_chapters_from_comments.txt +++ b/tests/resources/transaction_log_summaries/plugins/chapters/test_chapters_from_comments.txt @@ -3,6 +3,7 @@ Files created: {output_directory} .ytdl-sub-chapters_from_comments-download-archive.json {output_directory}/JMC + Move 78 - Automated Improvisation [Full Album].info.json Move 78 - Automated Improvisation [Full Album].jpg Move 78 - Automated Improvisation [Full Album].mp4 Chapters from comments: diff --git a/tests/resources/transaction_log_summaries/plugins/file_convert/output.txt b/tests/resources/transaction_log_summaries/plugins/file_convert/output.txt index e5550820..6f9d3c13 100644 --- a/tests/resources/transaction_log_summaries/plugins/file_convert/output.txt +++ b/tests/resources/transaction_log_summaries/plugins/file_convert/output.txt @@ -3,6 +3,7 @@ Files created: {output_directory} .ytdl-sub-file_convert_test-download-archive.json {output_directory}/file_convert_test + When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.info.json When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.jpg When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.mp4 Video Tags: diff --git a/tests/resources/transaction_log_summaries/plugins/file_convert/output_custom_ffmpeg.txt b/tests/resources/transaction_log_summaries/plugins/file_convert/output_custom_ffmpeg.txt index d42cf277..4b82d0b4 100644 --- a/tests/resources/transaction_log_summaries/plugins/file_convert/output_custom_ffmpeg.txt +++ b/tests/resources/transaction_log_summaries/plugins/file_convert/output_custom_ffmpeg.txt @@ -3,6 +3,7 @@ Files created: {output_directory} .ytdl-sub-file_convert_test-download-archive.json {output_directory}/file_convert_test + When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.info.json When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.jpg When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.mkv Converted from mp4 diff --git a/tests/resources/transaction_log_summaries/plugins/match_filters/test_match_filters_partial.txt b/tests/resources/transaction_log_summaries/plugins/match_filters/test_match_filters_partial.txt index 89645782..74b7e886 100644 --- a/tests/resources/transaction_log_summaries/plugins/match_filters/test_match_filters_partial.txt +++ b/tests/resources/transaction_log_summaries/plugins/match_filters/test_match_filters_partial.txt @@ -3,6 +3,7 @@ Files created: {output_directory} .ytdl-sub-match_filter_test-download-archive.json {output_directory}/match_filter_test + Jesse's Minecraft Server [Trailer - Mar.21].info.json Jesse's Minecraft Server [Trailer - Mar.21].jpg Jesse's Minecraft Server [Trailer - Mar.21].mp4 Video Tags: diff --git a/tests/resources/transaction_log_summaries/plugins/nfo_tags/test_nfo.txt b/tests/resources/transaction_log_summaries/plugins/nfo_tags/test_nfo.txt index 268f1451..7db40b0e 100644 --- a/tests/resources/transaction_log_summaries/plugins/nfo_tags/test_nfo.txt +++ b/tests/resources/transaction_log_summaries/plugins/nfo_tags/test_nfo.txt @@ -35,6 +35,7 @@ Files created: the tag 🎸🎸 {output_directory}/kodi_safe_xml + Can you hear the difference? 🎸🔥 #shorts.info.json Can you hear the difference? 🎸🔥 #shorts.jpg Can you hear the difference? 🎸🔥 #shorts.mp4 Video Tags: diff --git a/tests/resources/transaction_log_summaries/plugins/nfo_tags/test_nfo_kodi_safe.txt b/tests/resources/transaction_log_summaries/plugins/nfo_tags/test_nfo_kodi_safe.txt index 12d167e5..7438981f 100644 --- a/tests/resources/transaction_log_summaries/plugins/nfo_tags/test_nfo_kodi_safe.txt +++ b/tests/resources/transaction_log_summaries/plugins/nfo_tags/test_nfo_kodi_safe.txt @@ -35,6 +35,7 @@ Files created: the tag □□ {output_directory}/kodi_safe_xml + Can you hear the difference? 🎸🔥 #shorts.info.json Can you hear the difference? 🎸🔥 #shorts.jpg Can you hear the difference? 🎸🔥 #shorts.mp4 Video Tags: diff --git a/tests/resources/transaction_log_summaries/plugins/test_chapters_sb_and_embedded_subs.txt b/tests/resources/transaction_log_summaries/plugins/test_chapters_sb_and_embedded_subs.txt index 8aa485cd..8315936f 100644 --- a/tests/resources/transaction_log_summaries/plugins/test_chapters_sb_and_embedded_subs.txt +++ b/tests/resources/transaction_log_summaries/plugins/test_chapters_sb_and_embedded_subs.txt @@ -3,6 +3,7 @@ Files created: {output_directory} .ytdl-sub-sponsorblock_with_embedded_subs_test-download-archive.json {output_directory}/JMC + This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.info.json This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.jpg This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.mp4 Embedded Chapters: diff --git a/tests/resources/transaction_log_summaries/plugins/test_regex.txt b/tests/resources/transaction_log_summaries/plugins/test_regex.txt index 3ec6c7c9..02113654 100644 --- a/tests/resources/transaction_log_summaries/plugins/test_regex.txt +++ b/tests/resources/transaction_log_summaries/plugins/test_regex.txt @@ -3,6 +3,7 @@ Files created: {output_directory} .ytdl-sub-regex_capture_playlist_test-download-archive.json {output_directory}/regex_capture_playlist_test + Jesse's Minecraft Server [Trailer - Feb.1].info.json Jesse's Minecraft Server [Trailer - Feb.1].jpg Jesse's Minecraft Server [Trailer - Feb.1].mp4 Video Tags: @@ -27,6 +28,7 @@ Files created: title_cap_1_sanitized: Trailer title_cap_2: Feb.1 upload_date_both_caps: First and Second containing in regex default + Jesse's Minecraft Server [Trailer - Feb.27].info.json Jesse's Minecraft Server [Trailer - Feb.27].jpg Jesse's Minecraft Server [Trailer - Feb.27].mp4 Video Tags: diff --git a/tests/resources/transaction_log_summaries/plugins/test_regex_exclude.txt b/tests/resources/transaction_log_summaries/plugins/test_regex_exclude.txt index 7bdf2485..6ea8e91a 100644 --- a/tests/resources/transaction_log_summaries/plugins/test_regex_exclude.txt +++ b/tests/resources/transaction_log_summaries/plugins/test_regex_exclude.txt @@ -3,6 +3,7 @@ Files created: {output_directory} .ytdl-sub-regex_exclude_playlist_test-download-archive.json {output_directory}/regex_exclude_playlist_test + Jesse's Minecraft Server [Trailer - Mar.21].info.json Jesse's Minecraft Server [Trailer - Mar.21].jpg Jesse's Minecraft Server [Trailer - Mar.21].mp4 Video Tags: diff --git a/tests/resources/transaction_log_summaries/plugins/test_regex_match_and_exclude.txt b/tests/resources/transaction_log_summaries/plugins/test_regex_match_and_exclude.txt index 74f9b7c9..683f6dfd 100644 --- a/tests/resources/transaction_log_summaries/plugins/test_regex_match_and_exclude.txt +++ b/tests/resources/transaction_log_summaries/plugins/test_regex_match_and_exclude.txt @@ -3,6 +3,7 @@ Files created: {output_directory} .ytdl-sub-regex_match_and_exclude_playlist_test-download-archive.json {output_directory}/regex_match_and_exclude_playlist_test + Jesse's Minecraft Server [Trailer - Feb.1].info.json Jesse's Minecraft Server [Trailer - Feb.1].jpg Jesse's Minecraft Server [Trailer - Feb.1].mp4 Video Tags: diff --git a/tests/resources/transaction_log_summaries/plugins/test_regex_overrides.txt b/tests/resources/transaction_log_summaries/plugins/test_regex_overrides.txt index f8444df7..82c0644a 100644 --- a/tests/resources/transaction_log_summaries/plugins/test_regex_overrides.txt +++ b/tests/resources/transaction_log_summaries/plugins/test_regex_overrides.txt @@ -3,6 +3,7 @@ Files created: {output_directory} .ytdl-sub-regex_using_overrides_test-download-archive.json {output_directory}/regex_using_overrides_test + Jesse's Minecraft Server [Trailer - Mar.21].info.json Jesse's Minecraft Server [Trailer - Mar.21].jpg Jesse's Minecraft Server [Trailer - Mar.21].mp4 Video Tags: diff --git a/tests/resources/transaction_log_summaries/plugins/test_subtitles_embedded.txt b/tests/resources/transaction_log_summaries/plugins/test_subtitles_embedded.txt index c4b02af2..8831a18c 100644 --- a/tests/resources/transaction_log_summaries/plugins/test_subtitles_embedded.txt +++ b/tests/resources/transaction_log_summaries/plugins/test_subtitles_embedded.txt @@ -3,6 +3,7 @@ Files created: {output_directory} .ytdl-sub-subtitles_embedded_test-download-archive.json {output_directory}/JMC + YouTube Rewind 2019: For the Record | #YouTubeRewind.info.json YouTube Rewind 2019: For the Record | #YouTubeRewind.jpg YouTube Rewind 2019: For the Record | #YouTubeRewind.mp4 Embedded subtitles with lang(s) en, de diff --git a/tests/resources/transaction_log_summaries/plugins/test_subtitles_embedded_and_file.txt b/tests/resources/transaction_log_summaries/plugins/test_subtitles_embedded_and_file.txt index c466b11f..3d2996df 100644 --- a/tests/resources/transaction_log_summaries/plugins/test_subtitles_embedded_and_file.txt +++ b/tests/resources/transaction_log_summaries/plugins/test_subtitles_embedded_and_file.txt @@ -5,6 +5,7 @@ Files created: {output_directory}/JMC YouTube Rewind 2019: For the Record | #YouTubeRewind.de.srt YouTube Rewind 2019: For the Record | #YouTubeRewind.en.srt + YouTube Rewind 2019: For the Record | #YouTubeRewind.info.json YouTube Rewind 2019: For the Record | #YouTubeRewind.jpg YouTube Rewind 2019: For the Record | #YouTubeRewind.mp4 Embedded subtitles with lang(s) en, de diff --git a/tests/resources/transaction_log_summaries/unit/music_videos/Jellyfin Music Videos.txt b/tests/resources/transaction_log_summaries/unit/music_videos/Jellyfin Music Videos.txt index b1440018..52e2c27d 100644 --- a/tests/resources/transaction_log_summaries/unit/music_videos/Jellyfin Music Videos.txt +++ b/tests/resources/transaction_log_summaries/unit/music_videos/Jellyfin Music Videos.txt @@ -3,6 +3,7 @@ Files created: {output_directory} .ytdl-sub-subscription_test-download-archive.json {output_directory}/subscription_test + Mock Entry 20-1.info.json Mock Entry 20-1.jpg Mock Entry 20-1.mp4 Video Tags: @@ -20,6 +21,7 @@ Files created: genre: ytdl-sub premiered: 2020-08-08 title: Mock Entry 20-1 + Mock Entry 20-2.info.json Mock Entry 20-2.jpg Mock Entry 20-2.mp4 Video Tags: @@ -37,6 +39,7 @@ Files created: genre: ytdl-sub premiered: 2020-08-08 title: Mock Entry 20-2 + Mock Entry 20-3.info.json Mock Entry 20-3.jpg Mock Entry 20-3.mp4 Video Tags: @@ -54,6 +57,7 @@ Files created: genre: ytdl-sub premiered: 2020-08-07 title: Mock Entry 20-3 + Mock Entry 21-1.info.json Mock Entry 21-1.jpg Mock Entry 21-1.mp4 Video Tags: diff --git a/tests/resources/transaction_log_summaries/unit/music_videos/Kodi Music Videos.txt b/tests/resources/transaction_log_summaries/unit/music_videos/Kodi Music Videos.txt index b1440018..52e2c27d 100644 --- a/tests/resources/transaction_log_summaries/unit/music_videos/Kodi Music Videos.txt +++ b/tests/resources/transaction_log_summaries/unit/music_videos/Kodi Music Videos.txt @@ -3,6 +3,7 @@ Files created: {output_directory} .ytdl-sub-subscription_test-download-archive.json {output_directory}/subscription_test + Mock Entry 20-1.info.json Mock Entry 20-1.jpg Mock Entry 20-1.mp4 Video Tags: @@ -20,6 +21,7 @@ Files created: genre: ytdl-sub premiered: 2020-08-08 title: Mock Entry 20-1 + Mock Entry 20-2.info.json Mock Entry 20-2.jpg Mock Entry 20-2.mp4 Video Tags: @@ -37,6 +39,7 @@ Files created: genre: ytdl-sub premiered: 2020-08-08 title: Mock Entry 20-2 + Mock Entry 20-3.info.json Mock Entry 20-3.jpg Mock Entry 20-3.mp4 Video Tags: @@ -54,6 +57,7 @@ Files created: genre: ytdl-sub premiered: 2020-08-07 title: Mock Entry 20-3 + Mock Entry 21-1.info.json Mock Entry 21-1.jpg Mock Entry 21-1.mp4 Video Tags: diff --git a/tests/resources/transaction_log_summaries/unit/music_videos/Plex Music Videos.txt b/tests/resources/transaction_log_summaries/unit/music_videos/Plex Music Videos.txt index 32813f22..520d916d 100644 --- a/tests/resources/transaction_log_summaries/unit/music_videos/Plex Music Videos.txt +++ b/tests/resources/transaction_log_summaries/unit/music_videos/Plex Music Videos.txt @@ -3,6 +3,7 @@ Files created: {output_directory} .ytdl-sub-subscription_test-download-archive.json {output_directory}/subscription_test + Mock Entry 20-1.info.json Mock Entry 20-1.jpg Mock Entry 20-1.mp4 Video Tags: @@ -12,6 +13,7 @@ Files created: premiered: 2020-08-08 title: Mock Entry 20-1 year: 2020 + Mock Entry 20-2.info.json Mock Entry 20-2.jpg Mock Entry 20-2.mp4 Video Tags: @@ -21,6 +23,7 @@ Files created: premiered: 2020-08-08 title: Mock Entry 20-2 year: 2020 + Mock Entry 20-3.info.json Mock Entry 20-3.jpg Mock Entry 20-3.mp4 Video Tags: @@ -30,6 +33,7 @@ Files created: premiered: 2020-08-07 title: Mock Entry 20-3 year: 2020 + Mock Entry 21-1.info.json Mock Entry 21-1.jpg Mock Entry 21-1.mp4 Video Tags: diff --git a/tests/resources/transaction_log_summaries/youtube/test_video.txt b/tests/resources/transaction_log_summaries/youtube/test_video.txt index 48cb51eb..97392956 100644 --- a/tests/resources/transaction_log_summaries/youtube/test_video.txt +++ b/tests/resources/transaction_log_summaries/youtube/test_video.txt @@ -1,6 +1,7 @@ Files created: ---------------------------------------- {output_directory}/JMC + Oblivion Mod "Falcor" p.1.info.json Oblivion Mod "Falcor" p.1.jpg Oblivion Mod "Falcor" p.1.mp4 Video Tags: diff --git a/tests/resources/transaction_log_summaries/youtube/test_video_cli.txt b/tests/resources/transaction_log_summaries/youtube/test_video_cli.txt index 48cb51eb..97392956 100644 --- a/tests/resources/transaction_log_summaries/youtube/test_video_cli.txt +++ b/tests/resources/transaction_log_summaries/youtube/test_video_cli.txt @@ -1,6 +1,7 @@ Files created: ---------------------------------------- {output_directory}/JMC + Oblivion Mod "Falcor" p.1.info.json Oblivion Mod "Falcor" p.1.jpg Oblivion Mod "Falcor" p.1.mp4 Video Tags: diff --git a/tests/resources/transaction_log_summaries/youtube/test_video_missing_thumb.txt b/tests/resources/transaction_log_summaries/youtube/test_video_missing_thumb.txt index 2c196a39..ccd7b91c 100644 --- a/tests/resources/transaction_log_summaries/youtube/test_video_missing_thumb.txt +++ b/tests/resources/transaction_log_summaries/youtube/test_video_missing_thumb.txt @@ -1,6 +1,7 @@ Files created: ---------------------------------------- {output_directory}/JMC + Oblivion Mod "Falcor" p.1.info.json Oblivion Mod "Falcor" p.1.mp4 Video Tags: album: Music Videos From 5e335b195cee7ff72887277559d0f697e3e8f514 Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Sun, 2 Jun 2024 20:02:48 -0700 Subject: [PATCH 39/39] [FEATURE] Allow YAML maps and lists in overrides, convert to script format (#956) Adds the ability to create map and list-based override variables. For example, you can now create lists like this: ``` overrides: urls: - "https://...1" - "https://...2" ``` which is equivalent to: ``` overrides: urls: >- { [ "https://...1", "https://...2", ] } ``` Likewise, maps can now look like: ``` overrides: music_video_category: concerts: - "https://...1" - "https://...2" interviews: - "https://...3" ``` which is equivalent to: ``` overrides: music_video_category: >- { "concerts": [ "https://...1", "https://...2" ], "interviews": [ "https://...3" ] } ``` --- src/ytdl_sub/config/overrides.py | 8 +- src/ytdl_sub/script/parser.py | 2 + src/ytdl_sub/script/types/function.py | 3 + src/ytdl_sub/utils/script.py | 87 ++++++++++++++++++- .../validators/string_formatter_validators.py | 13 +++ tests/e2e/youtube/test_video.py | 2 + tests/unit/config/test_config_file.py | 1 - tests/unit/script/types/test_map.py | 13 +++ tests/unit/utils/test_script_utils.py | 28 ++++++ .../test_string_formatter_validator.py | 49 +++++++++++ 10 files changed, 199 insertions(+), 7 deletions(-) diff --git a/src/ytdl_sub/config/overrides.py b/src/ytdl_sub/config/overrides.py index 76092735..2fd930e0 100644 --- a/src/ytdl_sub/config/overrides.py +++ b/src/ytdl_sub/config/overrides.py @@ -17,11 +17,11 @@ from ytdl_sub.utils.exceptions import StringFormattingException from ytdl_sub.utils.exceptions import ValidationException from ytdl_sub.utils.script import ScriptUtils from ytdl_sub.utils.scriptable import Scriptable -from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator +from ytdl_sub.validators.string_formatter_validators import UnstructuredDictFormatterValidator -class Overrides(DictFormatterValidator, Scriptable): +class Overrides(UnstructuredDictFormatterValidator, Scriptable): """ Allows you to define variables that can be used in any EntryFormatter or OverridesFormatter. @@ -51,11 +51,11 @@ class Overrides(DictFormatterValidator, Scriptable): @classmethod def partial_validate(cls, name: str, value: Any) -> None: - dict_formatter = DictFormatterValidator(name=name, value=value) + dict_formatter = UnstructuredDictFormatterValidator(name=name, value=value) _ = [parse(format_string) for format_string in dict_formatter.dict_with_format_strings] def __init__(self, name, value): - DictFormatterValidator.__init__(self, name, value) + UnstructuredDictFormatterValidator.__init__(self, name, value) Scriptable.__init__(self, initialize_base_script=True) for key in self._keys: diff --git a/src/ytdl_sub/script/parser.py b/src/ytdl_sub/script/parser.py index c6a78136..737d6ffa 100644 --- a/src/ytdl_sub/script/parser.py +++ b/src/ytdl_sub/script/parser.py @@ -498,6 +498,8 @@ class _Parser: raise MAP_KEY_WITH_NO_VALUE if isinstance(key, NonHashable): raise MAP_KEY_NOT_HASHABLE + if isinstance(key, BuiltInFunction) and issubclass(key.output_type(), NonHashable): + raise MAP_KEY_NOT_HASHABLE if len(value_args) > 1: raise MAP_KEY_MULTIPLE_VALUES diff --git a/src/ytdl_sub/script/types/function.py b/src/ytdl_sub/script/types/function.py index 7d1cea85..a3b39a9c 100644 --- a/src/ytdl_sub/script/types/function.py +++ b/src/ytdl_sub/script/types/function.py @@ -291,3 +291,6 @@ class BuiltInFunction(Function, BuiltInFunctionType): raise FunctionRuntimeException( f"Runtime error occurred when executing the function %{self.name}: {str(exc)}" ) from exc + + def __hash__(self): + return hash((self.name, *self.args)) diff --git a/src/ytdl_sub/utils/script.py b/src/ytdl_sub/utils/script.py index e2fc6d4c..f74b835b 100644 --- a/src/ytdl_sub/utils/script.py +++ b/src/ytdl_sub/utils/script.py @@ -3,7 +3,21 @@ import re from typing import Any from typing import Dict +from ytdl_sub.script.parser import parse from ytdl_sub.script.script import _is_function +from ytdl_sub.script.types.array import UnresolvedArray +from ytdl_sub.script.types.function import BuiltInFunction +from ytdl_sub.script.types.function import Function +from ytdl_sub.script.types.map import UnresolvedMap +from ytdl_sub.script.types.resolvable import Argument +from ytdl_sub.script.types.resolvable import Boolean +from ytdl_sub.script.types.resolvable import Float +from ytdl_sub.script.types.resolvable import Integer +from ytdl_sub.script.types.resolvable import String +from ytdl_sub.script.types.variable import Variable +from ytdl_sub.script.utils.exceptions import UNREACHABLE + +# pylint: disable=too-many-return-statements class ScriptUtils: @@ -28,12 +42,12 @@ class ScriptUtils: out = "" elif isinstance(value, str): out = value + elif isinstance(value, bool): + out = f"{{%bool({value})}}" elif isinstance(value, int): out = f"{{%int({value})}}" elif isinstance(value, float): out = f"{{%float({value})}}" - elif isinstance(value, bool): - out = f"{{%bool({value})}}" else: dumped_json = json.dumps(value, ensure_ascii=False, sort_keys=True) # Remove triple-single-quotes from JSON to avoid parsing issues @@ -43,6 +57,75 @@ class ScriptUtils: return out + @classmethod + def _to_script_argument(cls, value: Any) -> Argument: + # Handle simple types as above + if value is None or (isinstance(value, str) and value == ""): + return String("") + if isinstance(value, str): + ast = parse(text=value).ast + if len(ast) == 1: + return ast[0] + return BuiltInFunction( + name="concat", args=[BuiltInFunction(name="string", args=[arg]) for arg in ast] + ) + if isinstance(value, bool): + return Boolean(value) + if isinstance(value, int): + return Integer(value) + if isinstance(value, float): + return Float(value) + if isinstance(value, list): + return UnresolvedArray([cls._to_script_argument(val) for val in value]) + if isinstance(value, dict): + return UnresolvedMap( + { + cls._to_script_argument(key): cls._to_script_argument(val) + for key, val in value.items() + } + ) + + raise UNREACHABLE + + @classmethod + def _to_script_code(cls, arg: Argument, top_level: bool = False) -> str: + if not top_level and isinstance(arg, (Integer, Boolean, Float)): + return str(arg.native) + + if isinstance(arg, String): + if arg.native == "": + return "" if top_level else "''" + return arg.native if top_level else f"'''{arg.native}'''" + + if isinstance(arg, Integer): + out = f"%int({arg.native})" + elif isinstance(arg, Boolean): + out = f"%bool({arg.native})" + elif isinstance(arg, Float): + out = f"%float({arg.native})" + elif isinstance(arg, UnresolvedArray): + out = f"[ {', '.join(cls._to_script_code(val) for val in arg.value)} ]" + elif isinstance(arg, UnresolvedMap): + kv_list = ( + f"{cls._to_script_code(key)}: {cls._to_script_code(val)}" + for key, val in arg.value.items() + ) + out = f"{{ {', '.join(kv_list)} }}" + elif isinstance(arg, Variable): + out = arg.name + elif isinstance(arg, Function): + out = f"%{arg.name}( {', '.join(cls._to_script_code(val) for val in arg.args)} )" + else: + raise UNREACHABLE + return f"{{ {out} }}" if top_level else out + + @classmethod + def to_native_script(cls, value: Any) -> str: + """ + Converts any JSON-compatible value into equivalent script syntax + """ + return cls._to_script_code(cls._to_script_argument(value), top_level=True) + @classmethod def bool_formatter_output(cls, output: str) -> bool: """ diff --git a/src/ytdl_sub/validators/string_formatter_validators.py b/src/ytdl_sub/validators/string_formatter_validators.py index 15e76e70..2c709ebb 100644 --- a/src/ytdl_sub/validators/string_formatter_validators.py +++ b/src/ytdl_sub/validators/string_formatter_validators.py @@ -11,6 +11,7 @@ from ytdl_sub.script.utils.exceptions import RuntimeException from ytdl_sub.script.utils.exceptions import ScriptVariableNotResolved from ytdl_sub.script.utils.exceptions import UserException from ytdl_sub.utils.exceptions import StringFormattingVariableNotFoundException +from ytdl_sub.utils.script import ScriptUtils from ytdl_sub.validators.validators import DictValidator from ytdl_sub.validators.validators import ListValidator from ytdl_sub.validators.validators import LiteralDictValidator @@ -144,6 +145,18 @@ class OverridesDictFormatterValidator(DictFormatterValidator): _key_validator = OverridesStringFormatterValidator +class UnstructuredDictFormatterValidator(DictFormatterValidator): + def __init__(self, name, value): + # Convert the unstructured-ness into a script + if isinstance(value, dict): + value = {key: ScriptUtils.to_native_script(val) for key, val in value.items()} + super().__init__(name, value) + + +class UnstructuredOverridesDictFormatterValidator(UnstructuredDictFormatterValidator): + _key_validator = OverridesStringFormatterValidator + + def to_variable_dependency_format_string(script: Script, parsed_format_string: SyntaxTree) -> str: """ Create a dummy format string that contains all variable deps as a string. diff --git a/tests/e2e/youtube/test_video.py b/tests/e2e/youtube/test_video.py index 911a72e1..7435a60a 100644 --- a/tests/e2e/youtube/test_video.py +++ b/tests/e2e/youtube/test_video.py @@ -34,6 +34,8 @@ def single_video_preset_dict(output_directory): "overrides": { "music_video_artist": "JMC", "music_video_directory": output_directory, + "test_override_map": {"{music_video_artist}": "{music_video_directory}"}, + "test_override_map_get": "{ %map_get(test_override_map, music_video_artist) }", }, } diff --git a/tests/unit/config/test_config_file.py b/tests/unit/config/test_config_file.py index f18a41e0..3bd98d6e 100644 --- a/tests/unit/config/test_config_file.py +++ b/tests/unit/config/test_config_file.py @@ -112,7 +112,6 @@ class TestConfigFilePartiallyValidatesPresets: "preset_dict", [ {"overrides": "not a dict"}, - {"overrides": {"nested": {"dict": "value"}}}, {"overrides": ["list"]}, ], ) diff --git a/tests/unit/script/types/test_map.py b/tests/unit/script/types/test_map.py index 3a6608cf..576eba35 100644 --- a/tests/unit/script/types/test_map.py +++ b/tests/unit/script/types/test_map.py @@ -198,3 +198,16 @@ class TestMap: "key_variable": "{['non-hashable']}", } ).resolve() + + def test_map_key_is_function(self): + assert Script( + { + "dict": "{{ %concat('hi', %string(' world')) : 'value' }}", + "key_variable": "hashable", + } + ).resolve() == ScriptOutput( + { + "key_variable": String("hashable"), + "dict": Map(value={String(value="hi world"): String(value="value")}), + } + ) diff --git a/tests/unit/utils/test_script_utils.py b/tests/unit/utils/test_script_utils.py index d3ff8921..f9d9b395 100644 --- a/tests/unit/utils/test_script_utils.py +++ b/tests/unit/utils/test_script_utils.py @@ -3,6 +3,12 @@ import copy import pytest from unit.script.conftest import single_variable_output +from ytdl_sub.script.parser import parse +from ytdl_sub.script.types.function import BuiltInFunction +from ytdl_sub.script.types.map import UnresolvedMap +from ytdl_sub.script.types.resolvable import String +from ytdl_sub.script.types.syntax_tree import SyntaxTree +from ytdl_sub.script.types.variable import Variable from ytdl_sub.utils.script import ScriptUtils @@ -51,3 +57,25 @@ class TestScriptUtils: ) def test_bool_formatter_output(self, input_str: str, expected_output: bool): assert ScriptUtils.bool_formatter_output(input_str) == expected_output + + def test_to_syntax_tree(self): + out = ScriptUtils.to_native_script( + {"{var_a}": "{var_b}", "static_a": "string with {var_c} in it"} + ) + assert parse(out) == SyntaxTree( + ast=[ + UnresolvedMap( + value={ + Variable(name="var_a"): Variable(name="var_b"), + String(value="static_a"): BuiltInFunction( + name="concat", + args=[ + BuiltInFunction(name="string", args=[String(value="string with ")]), + BuiltInFunction(name="string", args=[Variable(name="var_c")]), + BuiltInFunction(name="string", args=[String(value=" in it")]), + ], + ), + } + ) + ] + ) diff --git a/tests/unit/validators/test_string_formatter_validator.py b/tests/unit/validators/test_string_formatter_validator.py index 0928fc24..4b2baef6 100644 --- a/tests/unit/validators/test_string_formatter_validator.py +++ b/tests/unit/validators/test_string_formatter_validator.py @@ -7,6 +7,10 @@ from ytdl_sub.validators.string_formatter_validators import DictFormatterValidat from ytdl_sub.validators.string_formatter_validators import OverridesDictFormatterValidator from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator +from ytdl_sub.validators.string_formatter_validators import UnstructuredDictFormatterValidator +from ytdl_sub.validators.string_formatter_validators import ( + UnstructuredOverridesDictFormatterValidator, +) @pytest.mark.parametrize( @@ -75,3 +79,48 @@ class TestDictFormatterValidator(object): "key1": key1_format_string, "key2": key2_format_string, } + + +class TestUnstructuredDictFormatterValidator(object): + @pytest.mark.parametrize( + "dict_validator_class, expected_formatter_class", + [ + (UnstructuredDictFormatterValidator, StringFormatterValidator), + (UnstructuredOverridesDictFormatterValidator, OverridesStringFormatterValidator), + ], + ) + def test_validates_values(self, dict_validator_class, expected_formatter_class): + key1_format_string = "string with {variable}" + key2_format_string = "no variables" + key3_int = 3 + key4_float = 4.132 + key5_bool = True + key6_map = {"{variable}_key": "value", "static_key": "{variable}_value"} + key7_list = ["list_1", "list_{variable_2}"] + key8_many_vars = "string {variable1} with multiple {variable2}" + validator = dict_validator_class( + name="validator", + value={ + "key1": key1_format_string, + "key2": key2_format_string, + "key3": key3_int, + "key4": key4_float, + "key5": key5_bool, + "key6": key6_map, + "key7": key7_list, + "key8": key8_many_vars, + }, + ) + + 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": "{ %concat( %string( '''string with ''' ), %string( variable ) ) }", + "key2": "no variables", + "key3": "{ %int(3) }", + "key4": "{ %float(4.132) }", + "key5": "{ %bool(True) }", + "key6": "{ { %concat( %string( variable ), %string( '''_key''' ) ): '''value''', '''static_key''': %concat( %string( variable ), %string( '''_value''' ) ) } }", + "key7": "{ [ '''list_1''', %concat( %string( '''list_''' ), %string( variable_2 ) ) ] }", + "key8": "{ %concat( %string( '''string ''' ), %string( variable1 ), %string( ''' with multiple ''' ), %string( variable2 ) ) }", + }