[FEATURE] Add custom ffmpeg post-processing (#364)
This commit is contained in:
parent
2edfffcc5f
commit
2f792f774c
6 changed files with 187 additions and 20 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
Loading…
Reference in a new issue