diff --git a/src/ytdl_sub/entries/entry.py b/src/ytdl_sub/entries/entry.py index 5a90b8b2..08d2285d 100644 --- a/src/ytdl_sub/entries/entry.py +++ b/src/ytdl_sub/entries/entry.py @@ -23,9 +23,11 @@ class Entry(EntryVariables, BaseEntry): This is not reflected in the entry. See if the mkv file exists and return "mkv" if so, otherwise, return the original extension. """ - mkv_file_path = str(Path(self.working_directory()) / f"{self.uid}.mkv") - if os.path.isfile(mkv_file_path): - return "mkv" + for possible_ext in [super().ext, "mkv"]: + file_path = str(Path(self.working_directory()) / f"{self.uid}.{possible_ext}") + if os.path.isfile(file_path): + return possible_ext + return super().ext def get_download_file_name(self) -> str: diff --git a/src/ytdl_sub/plugins/file_convert.py b/src/ytdl_sub/plugins/file_convert.py index f1428dd5..3ae1ebcb 100644 --- a/src/ytdl_sub/plugins/file_convert.py +++ b/src/ytdl_sub/plugins/file_convert.py @@ -7,9 +7,19 @@ from ytdl_sub.entries.entry import Entry from ytdl_sub.entries.variables.kwargs import EXT from ytdl_sub.plugins.plugin import Plugin from ytdl_sub.plugins.plugin import PluginOptions +from ytdl_sub.plugins.plugin import PluginPriority from ytdl_sub.utils.exceptions import FileNotDownloadedException +from ytdl_sub.utils.exceptions import ValidationException +from ytdl_sub.utils.ffmpeg import FFMPEG +from ytdl_sub.utils.file_handler import FileHandler from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.validators.audo_codec_validator import FileTypeValidator +from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator +from ytdl_sub.validators.string_select_validator import StringSelectValidator + + +class FileConvertWithValidator(StringSelectValidator): + _select_values = {"yt-dlp", "ffmpeg"} class FileConvertOptions(PluginOptions): @@ -24,9 +34,25 @@ class FileConvertOptions(PluginOptions): my_example_preset: file_convert: convert_to: "mp4" + + Supports custom ffmpeg conversions: + + .. code-block:: yaml + + presets: + my_example_preset: + file_convert: + convert_to: "mkv" + convert_with: "ffmpeg" + ffmpeg_post_process_args: > + -bitexact + -vcodec copy + -acodec copy + -scodec mov_text """ _required_keys = {"convert_to"} + _optional_keys = {"convert_with", "ffmpeg_post_process_args"} @classmethod def partial_validate(cls, name: str, value: Any) -> None: @@ -39,7 +65,20 @@ class FileConvertOptions(PluginOptions): def __init__(self, name, value): super().__init__(name, value) - self._convert_to = self._validate_key(key="convert_to", validator=FileTypeValidator).value + self._convert_to: str = self._validate_key( + key="convert_to", validator=FileTypeValidator + ).value + self._convert_with: str = self._validate_key_if_present( + key="convert_with", validator=FileConvertWithValidator, default="yt-dlp" + ).value + self._ffmpeg_post_process_args = self._validate_key_if_present( + key="ffmpeg_post_process_args", validator=OverridesStringFormatterValidator + ) + + if self._convert_to == "ffmpeg" and not self._ffmpeg_post_process_args: + raise self._validation_exception( + "Must specify 'ffmpeg_post_process_args' if 'convert_with' is set to ffmpeg" + ) @property def convert_to(self) -> str: @@ -51,9 +90,38 @@ class FileConvertOptions(PluginOptions): """ return self._convert_to + @property + def convert_with(self) -> Optional[str]: + """ + Optional. Supports ``yt-dlp`` and ``ffmpeg``. ``yt-dlp`` will convert files within + yt-dlp whereas ``ffmpeg`` specifies it will be converted using a custom command specified + with ``ffmpeg_post_process_args``. Defaults to ``yt-dlp``. + + """ + return self._convert_with + + @property + def ffmpeg_post_process_args(self) -> Optional[OverridesStringFormatterValidator]: + """ + Optional. ffmpeg args to post-process an entry file with. The args will be inserted in the + form of: + + .. code-block:: bash + + ffmpeg -i input_file.ext {ffmpeg_post_process_args) output_file.output_ext + + The output file will use the extension specified in ``convert_to``. Post-processing args + can still be set with ``convert_with`` set to ``yt-dlp``. + """ + return self._ffmpeg_post_process_args + class FileConvertPlugin(Plugin[FileConvertOptions]): plugin_options_type = FileConvertOptions + # Perform this after regex + priority: PluginPriority = PluginPriority( + modify_entry=PluginPriority.MODIFY_ENTRY_AFTER_SPLIT + 1 + ) def ytdl_options(self) -> Optional[Dict]: """ @@ -61,15 +129,17 @@ class FileConvertPlugin(Plugin[FileConvertOptions]): ------- ffmpeg video remuxing post processing dict """ - return { - "postprocessors": [ - { - "key": "FFmpegVideoRemuxer", - "when": "post_process", - "preferedformat": self.plugin_options.convert_to, - } - ] - } + if self.plugin_options.convert_with == "yt-dlp": + return { + "postprocessors": [ + { + "key": "FFmpegVideoRemuxer", + "when": "post_process", + "preferedformat": self.plugin_options.convert_to, + } + ] + } + return None def modify_entry(self, entry: Entry) -> Optional[Entry]: """ @@ -85,18 +155,51 @@ class FileConvertPlugin(Plugin[FileConvertOptions]): Raises ------ FileNotDownloadedException - If the audio file is not found + If the downloaded file is not found + ValidationException + User ffmpeg arguments errored """ + # Get original_ext here since there is mkv/yt-dlp shenanigans + original_ext = entry.ext new_ext = self.plugin_options.convert_to - converted_video_file = entry.get_download_file_path().removesuffix(entry.ext) + new_ext - if not self.is_dry_run: - if not os.path.isfile(converted_video_file): - raise FileNotDownloadedException("Failed to find the converted video file") - if entry.ext != new_ext: + input_video_file_path = entry.get_download_file_path() + converted_video_file_path = entry.get_download_file_path().removesuffix(entry.ext) + new_ext + + # FFMpeg input video file should already be converted + if self.plugin_options.convert_with == "yt-dlp": + input_video_file_path = converted_video_file_path + + if not self.is_dry_run: + if not os.path.isfile(input_video_file_path): + raise FileNotDownloadedException("Failed to find the input file") + + if self.plugin_options.ffmpeg_post_process_args: + tmp_output_file = converted_video_file_path.removesuffix(new_ext) + f"tmp.{new_ext}" + ffmpeg_args_list = self.overrides.apply_formatter( + self.plugin_options.ffmpeg_post_process_args + ).split() + ffmpeg_args = ["-i", input_video_file_path] + ffmpeg_args_list + [tmp_output_file] + try: + FFMPEG.run(ffmpeg_args) + except Exception as exc: + raise ValidationException( + f"ffmpeg_post_process_args {' '.join(ffmpeg_args)} result in an error" + ) from exc + + if not os.path.isfile(tmp_output_file): + raise ValidationException( + "file_convert ffmpeg_post_process_args did not produce an output file" + ) + + FileHandler.move(tmp_output_file, converted_video_file_path) + FileHandler.delete(tmp_output_file) + FileHandler.delete(input_video_file_path) + + if original_ext != new_ext: entry.add_kwargs( { - "__converted_from": entry.ext, + "__converted_from": original_ext, } ) diff --git a/tests/e2e/plugins/test_file_convert.py b/tests/e2e/plugins/test_file_convert.py index fdb24dfe..d084b8ce 100644 --- a/tests/e2e/plugins/test_file_convert.py +++ b/tests/e2e/plugins/test_file_convert.py @@ -1,6 +1,7 @@ import pytest from expected_download import assert_expected_downloads from expected_transaction_log import assert_transaction_log_matches +from mergedeep import mergedeep from ytdl_sub.subscriptions.subscription import Subscription @@ -45,3 +46,39 @@ class TestFileConvert: dry_run=dry_run, expected_download_summary_file_name="plugins/file_convert/output.json", ) + + @pytest.mark.parametrize("dry_run", [True, False]) + def test_file_convert_custom_ffmpeg( + self, + music_video_config, + preset_dict, + output_directory, + dry_run, + ): + mergedeep.merge( + preset_dict, + { + "file_convert": { + "convert_to": "mkv", + "convert_with": "ffmpeg", + "ffmpeg_post_process_args": "-bitexact -vcodec copy -acodec copy -scodec mov_text", + } + }, + ) + subscription = Subscription.from_dict( + config=music_video_config, + preset_name="file_convert_test", + preset_dict=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/file_convert/output_custom_ffmpeg.txt", + ) + assert_expected_downloads( + output_directory=output_directory, + dry_run=dry_run, + expected_download_summary_file_name="plugins/file_convert/output_custom_ffmpeg.json", + ) diff --git a/tests/expected_transaction_log.py b/tests/expected_transaction_log.py index 0f274970..e214ce14 100644 --- a/tests/expected_transaction_log.py +++ b/tests/expected_transaction_log.py @@ -47,6 +47,8 @@ def assert_transaction_log_matches( # Split, ensure there are the same number of new lines summary_lines: List[str] = summary.split("\n") expected_summary_lines: List[str] = expected_summary.split("\n") + print(summary_lines) + print(expected_summary_lines) assert len(summary_lines) == len( expected_summary_lines ), f"Summary number of lines differ: {len(summary_lines) != len(expected_summary_lines)}" 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 new file mode 100644 index 00000000..0aaca2c2 --- /dev/null +++ b/tests/resources/expected_downloads_summaries/plugins/file_convert/output_custom_ffmpeg.json @@ -0,0 +1,7 @@ +{ + ".ytdl-sub-file_convert_test-download-archive.json": "74813dccf4e9732e49f5dc3c2d66f3be", + "Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3-thumb.jpg": "662fcaadf6e80d63591bac19a5fdffb0", + "Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.info.json": "3022131504a9df5d2fce266fada1ee3b", + "Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.mkv": "b5f248b560f89f3f2a83fcdcd197d486", + "Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.nfo": "cacf09ab38f9b3085da9c5af516cf22a" +} \ No newline at end of file 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 new file mode 100644 index 00000000..a8f570fb --- /dev/null +++ b/tests/resources/transaction_log_summaries/plugins/file_convert/output_custom_ffmpeg.txt @@ -0,0 +1,16 @@ +Files created: +---------------------------------------- +{output_directory} + .ytdl-sub-file_convert_test-download-archive.json +{output_directory}/Beyond The Guitar + Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3-thumb.jpg + Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.info.json + Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.mkv + Converted from webm + Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.nfo + NFO tags: + musicvideo: + album: Music Videos + artist: Beyond The Guitar + title: When you hear Hugh Jackman is returning as Wolverine in Deadpool 3 + year: 2022 \ No newline at end of file