[FEATURE] Add custom ffmpeg post-processing (#364)

This commit is contained in:
Jesse Bannon 2022-11-26 14:56:34 -08:00 committed by GitHub
parent 2edfffcc5f
commit 2f792f774c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 187 additions and 20 deletions

View file

@ -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, This is not reflected in the entry. See if the mkv file exists and return "mkv" if so,
otherwise, return the original extension. otherwise, return the original extension.
""" """
mkv_file_path = str(Path(self.working_directory()) / f"{self.uid}.mkv") for possible_ext in [super().ext, "mkv"]:
if os.path.isfile(mkv_file_path): file_path = str(Path(self.working_directory()) / f"{self.uid}.{possible_ext}")
return "mkv" if os.path.isfile(file_path):
return possible_ext
return super().ext return super().ext
def get_download_file_name(self) -> str: def get_download_file_name(self) -> str:

View file

@ -7,9 +7,19 @@ from ytdl_sub.entries.entry import Entry
from ytdl_sub.entries.variables.kwargs import EXT from ytdl_sub.entries.variables.kwargs import EXT
from ytdl_sub.plugins.plugin import Plugin from ytdl_sub.plugins.plugin import Plugin
from ytdl_sub.plugins.plugin import PluginOptions 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 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.utils.file_handler import FileMetadata
from ytdl_sub.validators.audo_codec_validator import FileTypeValidator 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): class FileConvertOptions(PluginOptions):
@ -24,9 +34,25 @@ class FileConvertOptions(PluginOptions):
my_example_preset: my_example_preset:
file_convert: file_convert:
convert_to: "mp4" 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"} _required_keys = {"convert_to"}
_optional_keys = {"convert_with", "ffmpeg_post_process_args"}
@classmethod @classmethod
def partial_validate(cls, name: str, value: Any) -> None: def partial_validate(cls, name: str, value: Any) -> None:
@ -39,7 +65,20 @@ class FileConvertOptions(PluginOptions):
def __init__(self, name, value): def __init__(self, name, value):
super().__init__(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 @property
def convert_to(self) -> str: def convert_to(self) -> str:
@ -51,9 +90,38 @@ class FileConvertOptions(PluginOptions):
""" """
return self._convert_to 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]): class FileConvertPlugin(Plugin[FileConvertOptions]):
plugin_options_type = 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]: def ytdl_options(self) -> Optional[Dict]:
""" """
@ -61,15 +129,17 @@ class FileConvertPlugin(Plugin[FileConvertOptions]):
------- -------
ffmpeg video remuxing post processing dict ffmpeg video remuxing post processing dict
""" """
return { if self.plugin_options.convert_with == "yt-dlp":
"postprocessors": [ return {
{ "postprocessors": [
"key": "FFmpegVideoRemuxer", {
"when": "post_process", "key": "FFmpegVideoRemuxer",
"preferedformat": self.plugin_options.convert_to, "when": "post_process",
} "preferedformat": self.plugin_options.convert_to,
] }
} ]
}
return None
def modify_entry(self, entry: Entry) -> Optional[Entry]: def modify_entry(self, entry: Entry) -> Optional[Entry]:
""" """
@ -85,18 +155,51 @@ class FileConvertPlugin(Plugin[FileConvertOptions]):
Raises Raises
------ ------
FileNotDownloadedException 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 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( entry.add_kwargs(
{ {
"__converted_from": entry.ext, "__converted_from": original_ext,
} }
) )

View file

@ -1,6 +1,7 @@
import pytest import pytest
from expected_download import assert_expected_downloads from expected_download import assert_expected_downloads
from expected_transaction_log import assert_transaction_log_matches from expected_transaction_log import assert_transaction_log_matches
from mergedeep import mergedeep
from ytdl_sub.subscriptions.subscription import Subscription from ytdl_sub.subscriptions.subscription import Subscription
@ -45,3 +46,39 @@ class TestFileConvert:
dry_run=dry_run, dry_run=dry_run,
expected_download_summary_file_name="plugins/file_convert/output.json", 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",
)

View file

@ -47,6 +47,8 @@ def assert_transaction_log_matches(
# Split, ensure there are the same number of new lines # Split, ensure there are the same number of new lines
summary_lines: List[str] = summary.split("\n") summary_lines: List[str] = summary.split("\n")
expected_summary_lines: List[str] = expected_summary.split("\n") expected_summary_lines: List[str] = expected_summary.split("\n")
print(summary_lines)
print(expected_summary_lines)
assert len(summary_lines) == len( assert len(summary_lines) == len(
expected_summary_lines expected_summary_lines
), f"Summary number of lines differ: {len(summary_lines) != len(expected_summary_lines)}" ), f"Summary number of lines differ: {len(summary_lines) != len(expected_summary_lines)}"

View file

@ -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"
}

View file

@ -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