[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,
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)}"
|
||||||
|
|
|
||||||
|
|
@ -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