From 8538887029b6e635df3dc8a6ead3cdfd459e747d Mon Sep 17 00:00:00 2001 From: jbannon Date: Sun, 14 Aug 2022 06:41:22 +0000 Subject: [PATCH] audio extract wip --- examples/youtube_extract_and_tag_audio.yaml | 28 ++++++ src/ytdl_sub/config/preset_class_mappings.py | 2 + src/ytdl_sub/plugins/audio_extract.py | 98 +++++++++++++++++++ src/ytdl_sub/validators/validators.py | 43 ++++---- tests/e2e/conftest.py | 10 ++ tests/e2e/plugins/test_audio_extract.py | 45 +++++++++ .../plugins/test_audio_extract_single.txt | 11 +++ 7 files changed, 213 insertions(+), 24 deletions(-) create mode 100644 examples/youtube_extract_and_tag_audio.yaml create mode 100644 src/ytdl_sub/plugins/audio_extract.py create mode 100644 tests/e2e/plugins/test_audio_extract.py create mode 100644 tests/e2e/resources/transaction_log_summaries/plugins/test_audio_extract_single.txt diff --git a/examples/youtube_extract_and_tag_audio.yaml b/examples/youtube_extract_and_tag_audio.yaml new file mode 100644 index 00000000..000dd7ac --- /dev/null +++ b/examples/youtube_extract_and_tag_audio.yaml @@ -0,0 +1,28 @@ +configuration: + working_directory: '.ytdl-sub-downloads' + +presets: + yt_song: + youtube: + download_strategy: "video" + + output_options: + output_directory: "{music_directory}" + file_name: "{title_sanitized}.{ext}" + + audio_extract: + codec: "mp3" + quality: 128 + + music_tags: + tags: + artist: "{artist}" + albumartist: "{artist}" + title: "{title}" + album: "Singles" + track: "1" + year: "{upload_year}" + genre: "Unset" + + overrides: + music_directory: "/path/to/music" \ No newline at end of file diff --git a/src/ytdl_sub/config/preset_class_mappings.py b/src/ytdl_sub/config/preset_class_mappings.py index 3221160a..d3bd1386 100644 --- a/src/ytdl_sub/config/preset_class_mappings.py +++ b/src/ytdl_sub/config/preset_class_mappings.py @@ -9,6 +9,7 @@ from ytdl_sub.downloaders.youtube.merge_playlist import YoutubeMergePlaylistDown from ytdl_sub.downloaders.youtube.playlist import YoutubePlaylistDownloader from ytdl_sub.downloaders.youtube.split_video import YoutubeSplitVideoDownloader from ytdl_sub.downloaders.youtube.video import YoutubeVideoDownloader +from ytdl_sub.plugins.audio_extract import AudioExtractPlugin from ytdl_sub.plugins.music_tags import MusicTagsPlugin from ytdl_sub.plugins.nfo_tags import NfoTagsPlugin from ytdl_sub.plugins.output_directory_nfo_tags import OutputDirectoryNfoTagsPlugin @@ -107,6 +108,7 @@ class PluginMapping: """ _MAPPING: Dict[str, Type[Plugin]] = { + "audio_extract": AudioExtractPlugin, "music_tags": MusicTagsPlugin, "video_tags": VideoTagsPlugin, "nfo_tags": NfoTagsPlugin, diff --git a/src/ytdl_sub/plugins/audio_extract.py b/src/ytdl_sub/plugins/audio_extract.py new file mode 100644 index 00000000..a5cd9c4f --- /dev/null +++ b/src/ytdl_sub/plugins/audio_extract.py @@ -0,0 +1,98 @@ +import os.path +from typing import Dict +from typing import Optional + +from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder +from ytdl_sub.entries.entry import Entry +from ytdl_sub.plugins.plugin import Plugin +from ytdl_sub.plugins.plugin import PluginOptions +from ytdl_sub.utils.exceptions import FileNotDownloadedException +from ytdl_sub.validators.string_select_validator import StringSelectValidator +from ytdl_sub.validators.validators import FloatValidator + +CODEC_TYPES_EXTENSION_MAPPING: Dict[str, str] = { + "aac": "aac", + "flac": "flac", + "mp3": "mp3", + "m4a": "m4a", + "opus": "opus", + "vorbis": "ogg", + "wav": "wav", +} + + +class CodecTypeValidator(StringSelectValidator): + _expected_value_type_name = "codec" + _select_values = set(CODEC_TYPES_EXTENSION_MAPPING.keys()) + + +class AudioExtractOptions(PluginOptions): + """ + Extracts audio from a video file + + Usage: + + .. code-block:: yaml + + presets: + my_example_preset: + audio_extract: + codec: "mp3" + quality: 128 + """ + + _optional_keys = {"codec", "quality"} + + def __init__(self, name, value): + super().__init__(name, value) + self._codec = self._validate_key(key="codec", validator=CodecTypeValidator).value + self._quality = self._validate_key_if_present(key="quality", validator=FloatValidator) + + @property + def codec(self) -> str: + """ + The codec to output after extracting the audio. Supported codecs are aac, flac, mp3, m4a, + opus, vorbis, wav. + """ + return self._codec + + @property + def quality(self) -> Optional[float]: + """ + Optional. Specify ffmpeg audio quality. Insert a value between ``0`` (better) and ``9`` + (worse) for VBR or a specific bitrate like ``128`` for 128k. + """ + if self._quality is not None: + return self._quality.value + return None + + +class AudioExtractPlugin(Plugin[AudioExtractOptions]): + plugin_options_type = AudioExtractOptions + + def ytdl_options(self) -> Optional[Dict]: + ytdl_options_builder = YTDLOptionsBuilder() + + postprocessor_dict = { + "key": "FFmpegExtractAudio", + "when": "post_process", + "preferredcodec": self.plugin_options.codec, + } + if self.plugin_options.quality is not None: + postprocessor_dict["preferredquality"] = self.plugin_options.quality + + return ytdl_options_builder.add( + { + "postprocessors": [postprocessor_dict], + } + ).to_dict() + + def modify_entry(self, entry: Entry) -> Optional[Entry]: + new_ext = CODEC_TYPES_EXTENSION_MAPPING[self.plugin_options.codec] + extracted_audio_file = entry.get_download_file_path().removesuffix(entry.ext) + new_ext + if not self.is_dry_run: + if not os.path.isfile(extracted_audio_file): + raise FileNotDownloadedException("Failed to find the extracted audio file") + + entry._kwargs["ext"] = new_ext + return entry diff --git a/src/ytdl_sub/validators/validators.py b/src/ytdl_sub/validators/validators.py index 5831ed8c..694ed6c7 100644 --- a/src/ytdl_sub/validators/validators.py +++ b/src/ytdl_sub/validators/validators.py @@ -11,6 +11,7 @@ from typing import final from ytdl_sub.utils.exceptions import ValidationException +ValueT = TypeVar("ValueT", bound=object) ValidationExceptionT = TypeVar("ValidationExceptionT", bound=ValidationException) ValidatorT = TypeVar("ValidatorT", bound="Validator") @@ -59,40 +60,34 @@ class Validator(ABC): return exception_class(f"{prefix}{error_message}") -class BoolValidator(Validator): +class ValueValidator(Validator, ABC, Generic[ValueT]): """ - Validates boolean fields. + Native type validator that returns the value as-is """ + @property + def value(self) -> ValueT: + """ + Returns + ------- + The value, unmodified + """ + return self._value + + +class BoolValidator(ValueValidator[bool]): _expected_value_type: Type = bool _expected_value_type_name = "boolean" - @property - def value(self) -> bool: - """ - Returns - ------- - Boolean value - """ - return self._value - - -class StringValidator(Validator): - """ - Validates string fields. - """ +class StringValidator(ValueValidator[str]): _expected_value_type = str _expected_value_type_name = "string" - @property - def value(self) -> str: - """ - Returns - ------- - String value - """ - return self._value + +class FloatValidator(ValueValidator[float]): + _expected_value_type = (int, float) + _expected_value_type_name = "float" class ListValidator(Validator, ABC, Generic[ValidatorT]): diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 3b988eba..bc9a78b4 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -38,6 +38,16 @@ def soundcloud_discography_config(): return ConfigFile.from_file_path(config_path="examples/soundcloud_discography_config.yaml") +@pytest.fixture() +def youtube_audio_config_path(): + return "examples/youtube_extract_and_tag_audio.yaml" + + +@pytest.fixture() +def youtube_audio_config(youtube_audio_config_path): + return ConfigFile.from_file_path(config_path=youtube_audio_config_path) + + @pytest.fixture def timestamps_file_path(): timestamps = [ diff --git a/tests/e2e/plugins/test_audio_extract.py b/tests/e2e/plugins/test_audio_extract.py new file mode 100644 index 00000000..0ea127e4 --- /dev/null +++ b/tests/e2e/plugins/test_audio_extract.py @@ -0,0 +1,45 @@ +import mergedeep +import pytest +from e2e.expected_download import assert_expected_downloads +from e2e.expected_transaction_log import assert_transaction_log_matches + +from ytdl_sub.subscriptions.subscription import Subscription + + +@pytest.fixture +def single_song_preset_dict(output_directory): + return { + "preset": "yt_song", + "youtube": {"video_url": "https://www.youtube.com/watch?v=2lAe1cqCOXo"}, + "output_options": {"output_directory": output_directory}, + } + + +class TestAudioExtract: + @pytest.mark.parametrize("dry_run", [True, False]) + def test_audio_extract_single_song( + self, + youtube_audio_config, + single_song_preset_dict, + output_directory, + dry_run, + ): + subscription = Subscription.from_dict( + config=youtube_audio_config, + preset_name="single_song_test", + preset_dict=single_song_preset_dict, + ) + + transaction_log = subscription.download(dry_run=dry_run) + assert_transaction_log_matches( + output_directory=output_directory, + transaction_log=transaction_log, + transaction_log_summary_file_name="plugins/test_audio_extract_single.txt", + regenerate_transaction_log=True, + ) + assert_expected_downloads( + output_directory=output_directory, + dry_run=dry_run, + expected_download_summary_file_name="plugins/test_audio_extract_single.json", + regenerate_expected_download_summary=True, + ) 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 new file mode 100644 index 00000000..2ee2d4a4 --- /dev/null +++ b/tests/e2e/resources/transaction_log_summaries/plugins/test_audio_extract_single.txt @@ -0,0 +1,11 @@ +Files created in '{output_directory}' +---------------------------------------- +YouTube Rewind 2019: For the Record | #YouTubeRewind.mp3 + Music Tags: + album: Singles + albumartist: YouTube + artist: YouTube + genre: Unset + title: YouTube Rewind 2019: For the Record | #YouTubeRewind + track: 1 + year: 2019 \ No newline at end of file