diff --git a/src/ytdl_sub/plugins/chapters.py b/src/ytdl_sub/plugins/chapters.py new file mode 100644 index 00000000..7bcb2b0f --- /dev/null +++ b/src/ytdl_sub/plugins/chapters.py @@ -0,0 +1,107 @@ +import re +from typing import Dict +from typing import List +from typing import Optional +from typing import Set + +from ytdl_sub.entries.entry import Entry +from ytdl_sub.plugins.plugin import Plugin +from ytdl_sub.plugins.plugin import PluginOptions +from ytdl_sub.validators.regex_validator import RegexListValidator +from ytdl_sub.validators.string_select_validator import StringSelectValidator +from ytdl_sub.validators.validators import ListValidator + +SPONSORBLOCK_HIGHLIGHT_CATEGORIES: Set[str] = {"poi_highlight"} +SPONSORBLOCK_CATEGORIES: Set[str] = SPONSORBLOCK_HIGHLIGHT_CATEGORIES | { + "sponsor", + "intro", + "outro", + "selfpromo", + "preview", + "filler", + "interaction", + "music_offtopic", +} + + +class SponsorBlockCategoriesValidator(StringSelectValidator): + _expected_value_type = "sponsorblock category" + _select_values = {"all"} | SPONSORBLOCK_CATEGORIES + + +class SponsorBlockCategoryListValidator(ListValidator[SponsorBlockCategoriesValidator]): + _expected_value_type = "sponsorblock category" + _inner_list_type = SponsorBlockCategoriesValidator + + +class ChaptersOptions(PluginOptions): + """ + Add chapters to video files if they are present. Options to add SponsorBlock chapters and + remove them or existing chapters. + + Usage: + + .. code-block:: yaml + + presets: + my_example_preset: + chapters: + sponsorblock_categories: + - "outro" + - "selfpromo" + - "preview" + - "interaction" + - "sponsor" + - "music_offtopic" + - "intro" + remove_sponsorblock_categories: "all" + remove_chapters_regex: + - "sponsor" + + """ + + _optional_keys = { + "sponsorblock_categories", + "remove_sponsorblock_categories", + "remove_chapters_regex", + } + + def __init__(self, name, value): + super().__init__(name, value) + self._sponsorblock_categories = self._validate_key_if_present( + key="sponsorblock_categories", validator=SponsorBlockCategoryListValidator + ) + self._remove_sponsorblock_categories = self._validate_key_if_present( + key="remove_sponsorblock_categories", validator=SponsorBlockCategoryListValidator + ) + self._remove_chapters_regex = self._validate_key_if_present( + key="remove_chapters_regex", validator=RegexListValidator + ) + + @property + def sponsorblock_categories(self) -> Optional[List[str]]: + if self._sponsorblock_categories: + return [validator.value for validator in self._sponsorblock_categories.list] + return None + + @property + def remove_sponsorblock_categories(self) -> Optional[List[str]]: + if self._remove_sponsorblock_categories: + return [validator.value for validator in self._remove_sponsorblock_categories.list] + return None + + @property + def remove_chapters_regex(self) -> Optional[List[re.Pattern]]: + if self._remove_chapters_regex: + return [validator.compiled_regex for validator in self._remove_chapters_regex.list] + return None + + +class ChaptersPlugin(Plugin[ChaptersOptions]): + plugin_options_type = ChaptersOptions + + def modify_entry(self, entry: Entry) -> Optional[Entry]: + """ + Tags the entry's audio file using values defined in the metadata options + """ + return entry diff --git a/src/ytdl_sub/plugins/split_by_chapters.py b/src/ytdl_sub/plugins/split_by_chapters.py new file mode 100644 index 00000000..a608a9db --- /dev/null +++ b/src/ytdl_sub/plugins/split_by_chapters.py @@ -0,0 +1,54 @@ +from typing import Dict +from typing import Optional + +from ytdl_sub.entries.entry import Entry +from ytdl_sub.plugins.plugin import Plugin +from ytdl_sub.plugins.plugin import PluginOptions +from ytdl_sub.validators.string_select_validator import StringSelectValidator + + +class WhenNoChaptersValidator(StringSelectValidator): + _expected_value_type = "when no chapters option" + _select_values = {"pass", "drop", "error"} + + +class SplitByChaptersOptions(PluginOptions): + """ + Splits a file by chapters into multiple files. Each file becomes its own entry with ``title`` + set to its chapter name, and is processed separately by other plugins. + + Usage: + + .. code-block:: yaml + + presets: + my_example_preset: + split_by_chapters: + when_no_chapters: "pass" # "drop"/"error" + """ + + _required_keys = {"when_no_chapters"} + + def __init__(self, name, value): + super().__init__(name, value) + self._when_no_chapters = self._validate_key( + key="when_no_chapters", validator=WhenNoChaptersValidator + ).value + + @property + def when_no_chapters(self) -> str: + """ + Behavior to perform when no chapters are present. Supports "pass" (continue processing), + "drop" (exclude it from output), and "error" (stop processing for everything). + """ + return self._when_no_chapters + + +class SplitByChaptersPlugin(Plugin[SplitByChaptersOptions]): + plugin_options_type = SplitByChaptersOptions + + def modify_entry(self, entry: Entry) -> Optional[Entry]: + """ + Tags the entry's audio file using values defined in the metadata options + """ + return entry diff --git a/src/ytdl_sub/validators/regex_validator.py b/src/ytdl_sub/validators/regex_validator.py index c9087b20..f7af98c7 100644 --- a/src/ytdl_sub/validators/regex_validator.py +++ b/src/ytdl_sub/validators/regex_validator.py @@ -28,6 +28,15 @@ class RegexValidator(StringValidator): """ return self._compiled_regex.groups + @property + def compiled_regex(self) -> re.Pattern: + """ + Returns + ------- + The regex compiled + """ + return self._compiled_regex + def match(self, input_str: str) -> Optional[List[str]]: """ Parameters diff --git a/tests/e2e/plugins/test_audio_extract.py b/tests/e2e/plugins/test_audio_extract.py index 198a3df4..2c10ce4f 100644 --- a/tests/e2e/plugins/test_audio_extract.py +++ b/tests/e2e/plugins/test_audio_extract.py @@ -7,10 +7,12 @@ from ytdl_sub.subscriptions.subscription import Subscription @pytest.fixture -def single_song_preset_dict(output_directory): +def single_song_preset_dict(output_directory, timestamps_file_path): return { "preset": "yt_song", - "youtube": {"video_url": "https://www.youtube.com/watch?v=2lAe1cqCOXo"}, + "youtube": { + "video_url": "https://www.youtube.com/watch?v=gr0XWmEbiMQ", + }, "output_options": {"output_directory": output_directory}, # download the worst format so it is fast "ytdl_options": { diff --git a/tests/e2e/resources/transaction_log_summaries/plugins/test_audio_extract_single.txt b/tests/e2e/resources/transaction_log_summaries/plugins/test_audio_extract_single.txt index 2ee2d4a4..6ff96145 100644 --- a/tests/e2e/resources/transaction_log_summaries/plugins/test_audio_extract_single.txt +++ b/tests/e2e/resources/transaction_log_summaries/plugins/test_audio_extract_single.txt @@ -1,11 +1,11 @@ Files created in '{output_directory}' ---------------------------------------- -YouTube Rewind 2019: For the Record | #YouTubeRewind.mp3 +Gabor Szabo - Dreams (1968) [full album].mp3 Music Tags: album: Singles - albumartist: YouTube - artist: YouTube + albumartist: Cubensis Records + artist: Cubensis Records genre: Unset - title: YouTube Rewind 2019: For the Record | #YouTubeRewind + title: Gabor Szabo - Dreams (1968) [full album] track: 1 - year: 2019 \ No newline at end of file + year: 2015 \ No newline at end of file