diff --git a/source/audio_transcoder/README.md b/source/audio_transcoder/README.md index a4043223e..909f7cc11 100644 --- a/source/audio_transcoder/README.md +++ b/source/audio_transcoder/README.md @@ -1,4 +1,5 @@ -# Transcode Audio Files +# Transcode Audio + Plugin for [Unmanic](https://github.com/Unmanic) --- @@ -7,3 +8,43 @@ Plugin for [Unmanic](https://github.com/Unmanic) - [Description](description.md) - [Changelog](changelog.md) + +--- + +### Plugin Coordination + +`Transcode Audio` can be configured to work alongside other audio-manipulation plugins in two ways: + +- **Minimum input channel count**: only process streams at or above a configured source channel-count threshold. This is useful when another plugin creates derived stereo streams and you only want this plugin to continue standardizing surround streams. +- **Unmanic stream ignore markers**: skip streams that have been explicitly marked by another plugin as derived or otherwise not intended for further audio-transcoder processing. + +#### Recommended Marker Convention + +Where the target container preserves stream metadata reliably, other plugins should write a stream `comment` tag containing semicolon-separated key/value pairs, for example: + +```text +unmanic.managed=true;unmanic.source_plugin=add_extra_stereo_audio;unmanic.ignore_for=audio_transcoder +``` + +`Transcode Audio` currently understands these ignore markers: + +- `unmanic.ignore=true` +- `unmanic_ignore=1` +- `unmanic.ignore_for=audio_transcoder` +- `unmanic.ignore_for=all` +- `unmanic_ignore_for=audio_transcoder` +- `unmanic_ignore_for=all` + +The plugin reads these markers from: + +- explicit stream tags with those names, when the container preserves them +- `comment` +- `description` + +#### Notes For Plugin Authors + +- This convention is intended for **audio streams inside media containers**, not as a universal guarantee for raw audio-only formats. +- `MKV` is the safest target for preserving these markers. +- `WebM` may preserve them depending on the muxing path and player support. +- `MP4` is more restrictive, so plugins should treat these markers as best-effort there. +- If you generate derivative stereo or commentary/helper streams, prefer marking only the derived stream rather than suppressing the whole file. diff --git a/source/audio_transcoder/changelog.md b/source/audio_transcoder/changelog.md index 879f5dcd4..90d736651 100644 --- a/source/audio_transcoder/changelog.md +++ b/source/audio_transcoder/changelog.md @@ -1,4 +1,18 @@ +**0.0.5** +- Add smart audio filters for maximum channel count and loudness normalization, and warn when a selected codec such as MP3 is a poor fit for multichannel output +- Add new encoder libraries for FLAC, Opus, AC3, and EAC3 +- Make the maximum channel-count selector codec-aware so unsupported channel layouts are clamped to the selected codec's supported limit +- Refresh the plugin description and metadata to document the current settings surface and codec channel support warnings +- Add a minimum input channel-count processing threshold so derived stereo streams can be skipped while surround streams continue to be processed +- Add support for generic Unmanic stream ignore markers and document the coordination convention for other plugin authors in the README + +**0.0.4** +- Rebrand the plugin as Transcode Audio and add support for transcoding audio streams in video/media files while copying non-audio streams and preserving the source container + +**0.0.3** +- Add smart output target to Basic mode so matching audio codecs can be re-encoded when they are significantly above the recommended bitrate window + **0.0.2** - Update runner signatures to accept keyword helper args for Unmanic compatibility diff --git a/source/audio_transcoder/description.md b/source/audio_transcoder/description.md index 07741d063..025dacd32 100644 --- a/source/audio_transcoder/description.md +++ b/source/audio_transcoder/description.md @@ -1,6 +1,10 @@ - --- +Audio Transcoder transcodes audio streams in both audio-only files and video/media containers. + +For audio-only files, the output extension is determined by the selected encoder. +For video/media files, the plugin preserves the original container and copies all non-audio streams unchanged while transcoding the audio stream or streams. + ##### Links: - [Support](https://unmanic.app/discord) @@ -9,53 +13,101 @@ --- -##### Documentation: +##### Configuration Modes: + +- **Basic**: choose the target codec and encoder, optionally enable smart output target, and optionally enable the plugin's smart audio filters. +- **Standard**: exposes encoder-specific bitrate and rate-control settings, plus optional custom audio filters. +- **Advanced**: write the FFmpeg audio options directly. + +--- + +##### Smart Features: -For information on the available encoder settings: -- MP3 encoders - - [FFmpeg - MP3](https://trac.ffmpeg.org/wiki/Encode/MP3) -- AAC - - [FFmpeg - AAC](https://trac.ffmpeg.org/wiki/Encode/AAC) +- **Smart output target** estimates a suitable destination bitrate from the source stream and the selected codec. +- **Re-encode matching codecs above target** is optional and only applies when smart output target is enabled in Basic mode. +- **Only process streams with at least this many channels** can be used to skip lower-channel-count streams such as derived stereo tracks while still processing surround streams in the same file. +- **Enable plugin's smart audio filters** exposes: + - **Set maximum channel count** to cap the output stream to `7.1`, `5.1`, `2.0`, `1.0`, or keep it the same as the source. + - **Normalise audio volume levels** to apply FFmpeg loudness normalization. +- The plugin also supports ignoring streams marked with generic Unmanic metadata markers when the container preserves stream metadata reliably. --- -##### Additional Information: +##### Codec Support: + +:::warning +Object-based formats such as Dolby Atmos are not preserved by this plugin when transcoding with FFmpeg's open, channel-based encoders. +In practice, the object metadata and immersive rendering information are discarded and the output becomes a conventional channel-based stream such as `7.1`, `5.1`, or `2.0`. +This is largely a limitation of the available FFmpeg/open-source encoder stack and Dolby licensing, not just a plugin choice. +::: + +| Codec | Practical Max | Good For | Avoid When | Notes | +| ------------------------- | --------------------------------------------- | --------------------------------------------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| AAC | `7.1` technically, `5.1` or below recommended | Stereo music, simple media files, broad client compatibility | `7.1` home-theater movie audio | Very widely supported, especially for stereo. `5.1` can work, but `7.1` AAC is a poor fit for movies because channel mapping and playback behavior can be unreliable. | +| FLAC | `7.1` | Lossless music, archival use, controlled playback setups | Maximum device compatibility | Best lossless option in this plugin. Great quality, but support across devices and containers is less consistent than AAC or AC3. | +| Opus | `7.1` | Efficient compression, modern clients, MKV/WebM libraries | Older devices and broad compatibility targets | Very efficient and supports high channel counts, but playback support is less consistent than AAC or AC3 in many consumer setups. | +| AC3 (Dolby Digital) | `5.1` | Movie libraries, AVR compatibility, simple surround playback | `7.1` source preservation | Good fit for home-theater use. FFmpeg's AC3 encoder is limited to `5.1`, so anything above that will be downmixed. | +| EAC3 (Dolby Digital Plus) | `5.1` in this FFmpeg-based plugin | Movie libraries, AVR compatibility, efficient surround playback | `7.1` source preservation | Similar to AC3 here, but a little more efficient. In this FFmpeg/open-encoder workflow it is effectively capped at `5.1`. | +| MP3 | `2.0` | Stereo music, simple audio files, legacy client support | Surround movie audio | Treat MP3 as stereo-only here. It is a good fit for basic stereo playback, but multichannel sources will be downmixed. | :::note -The output file extension will always be determined by the selected audio encoder. -For example, selecting an MP3 encoder will always produce a *XXXX.mp3* file, and selecting an AAC encoder will always produce a *XXXX.m4a* file. +Codec channel limits are enforced by the plugin because they reflect FFmpeg encoder support and practical playback behavior, not arbitrary plugin rules. +When you choose a codec that supports fewer channels than the source, the output will be downmixed to the selected or supported maximum. ::: +--- + +##### Container Support Breakdown for Codecs: + +| Audio Codec | MP4 (`.mp4`) | MKV (`.mkv`) | WebM (`.webm`) | MOV (`.mov`) | AVI (`.avi`) | TS (`.ts`) | +| ----------- | ------------ | ------------ | -------------- | ------------ | ------------ | ---------- | +| AAC | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | ✅ Yes | ✅ Yes | +| AC-3 / EAC3 | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | ⚠️ Partial | ✅ Yes | +| MP3 | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | ✅ Yes | ✅ Yes | +| Opus | ⚠️ Hacky | ✅ Yes | ✅ Yes | ❌ No | ❌ No | ❌ No | +| FLAC | ⚠️ Hacky | ✅ Yes | ✅ Yes | ❌ No | ❌ No | ❌ No | + :::note -**Advanced** +This table is a practical compatibility guide for common FFmpeg/container combinations, not a guarantee that every player or device will behave the same way. +Entries marked **Hacky** generally rely on combinations that may mux but are not broadly interoperable. +Entries marked **Partial** may work in some workflows but are less predictable across playback environments. +::: + +--- -If you set the Config mode to *"Advanced"*, the input text provides the ability to add FFmpeg commandline args in three different places: -1. **MAIN OPTIONS** - After the default generic options. - ([Main Options Docs](https://ffmpeg.org/ffmpeg.html#Main-options)) -1. **ADVANCED OPTIONS** - After the input file has been specified. - ([Advanced Options Docs](https://ffmpeg.org/ffmpeg.html#Advanced-options)) -1. **AUDIO OPTIONS** - After the audio is mapped. Here you can specify the audio encoder, its params and any additional audio options. - ([Audio Options Docs](https://ffmpeg.org/ffmpeg.html#Audio-Options)) - ([Advanced Audio Options Docs](https://ffmpeg.org/ffmpeg.html#Advanced-Audio-options)) +##### Encoder Notes: -``` +- **AAC** is a good general-purpose target for music files and simpler media workflows, but it is not the best choice for high-channel-count home-theater movie audio. +- **FLAC** is the lossless option. +- **Opus** is efficient and supports multichannel output, but some playback environments are less compatible than AAC or AC3. +- **AC3 / EAC3** are useful for home-theater-oriented compatibility, but FFmpeg's available encoders here are capped below `7.1`, so the plugin enforces that same limit. +- **MP3** is best treated as a stereo-oriented target for music and simple audio files. + +--- + +##### Advanced Mode: + +If you set the config mode to **Advanced**, the input text provides the ability to add FFmpeg command-line args in three different places: + +1. **Main Options**: inserted after the default generic options. +1. **Advanced Options**: inserted after the input file has been specified. +1. **Audio Options**: inserted after the audio stream is mapped. Here you specify the encoder and any additional audio options. + +```bash ffmpeg \ -hide_banner \ -loglevel info \ \ - -i /path/to/input/audio.wav \ + -i /path/to/input/media.file \ \ - -map 0:0 -c:a:0 \ - -y /path/to/output/audio.mp3 + -map 0:a:0 -c:a:0 \ + -y /path/to/output/media.file ``` -::: -:::note -**Force transcoding** +--- -Enabling the *"Force transcoding ..."* option will force a transcode of the audio stream even if it matches the selected audio codec. +##### Force Transcoding: -A file will only be forced to be transcoded once. It will then be flagged in a local `.unmanic` file to prevent it being added to the pending tasks list in a loop. +Enabling **Force transcoding even if the file is already using the desired audio codec** will re-encode matching-codec files one time. -However, a file previously flagged to be ignored by this will still be transcoded to apply any matching smart filters such as scaling, stripping data streams, etc. -::: +The plugin writes a marker so that force-transcoded files are not added back into the pending task list in a loop on subsequent scans. diff --git a/source/audio_transcoder/info.json b/source/audio_transcoder/info.json index fe4fcdb61..9961142d6 100644 --- a/source/audio_transcoder/info.json +++ b/source/audio_transcoder/info.json @@ -3,14 +3,14 @@ "compatibility": [ 2 ], - "description": "Transcode the audio streams of a audio file", + "description": "Transcode audio streams in audio and video media files", "icon": "https://raw.githubusercontent.com/Unmanic/plugin.audio_transcoder/master/icon.png", "id": "audio_transcoder", - "name": "Transcode Audio Files", + "name": "Transcode Audio", "priorities": { "on_library_management_file_test": 10, "on_worker_process": 1 }, "tags": "audio,ffmpeg", - "version": "0.0.2" + "version": "0.0.5" } diff --git a/source/audio_transcoder/lib/encoders/aac.py b/source/audio_transcoder/lib/encoders/aac.py index 2e77910ae..3c73fa452 100644 --- a/source/audio_transcoder/lib/encoders/aac.py +++ b/source/audio_transcoder/lib/encoders/aac.py @@ -21,104 +21,140 @@ If not, see . """ +from audio_transcoder.lib.encoders.base import Encoder +from audio_transcoder.lib.smart_audio_bitrate import SmartAudioBitrateHelper -class AacEncoder: - encoders = [ - "aac", - ] +class AacEncoder(Encoder): + def __init__(self, settings=None, probe=None): + super().__init__(settings=settings, probe=probe) - def __init__(self, settings): - self.settings = settings + def provides(self): + return { + "aac": { + "codec": "aac", + "label": "Native FFmpeg AAC encoder", + "max_channels": 8, + }, + } - @staticmethod - def options(): + def options(self): return { "aac_encoder_ratecontrol_method": "CBR", "aac_constant_quality_scale": "2", "aac_average_bitrate": "96", } - @staticmethod - def generate_default_args(settings): - """ - Generate a list of args for using a lib decoder + def generate_default_args(self): + return {}, {} - :param settings: - :return: - """ - # No default args required - generic_kwargs = {} - advanced_kwargs = {} - return generic_kwargs, advanced_kwargs - - @staticmethod - def generate_filtergraphs(): - """ - Generate the required filter for this encoder - No filters are required for lib encoders - - :return: - """ - return [] + def generate_filtergraphs(self, current_filter_args, smart_filters, encoder_name): + return { + "generic_kwargs": {}, + "advanced_kwargs": {}, + "smart_filters": smart_filters, + "start_filter_args": [], + "end_filter_args": [], + } - @staticmethod - def get_output_file_extension(encoder): - """ - Given an encoder, return the required file extension for that codec - :param encoder: - :return: - """ + def get_output_file_extension(self, encoder): if encoder == "aac": return "m4a" return "" - def args(self, stream_id): - stream_encoding = [] + def stream_args(self, stream_info, stream_id, encoder_name, filter_state=None): + generic_kwargs = {} + advanced_kwargs = {} + encoder_args = [] + stream_args = [] + target_channels = None + if filter_state: + try: + target_channels = int(filter_state.get('target_channels')) + except (TypeError, ValueError): + target_channels = None - # Use defaults for basic mode if self.settings.get_setting('mode') in ['basic']: - defaults = self.options() - stream_encoding += [ - '-qscale:a', str(self.settings.get_setting('aac_constant_quality_scale')), + if self.settings.get_setting('enable_smart_output_target') and self.probe: + helper = SmartAudioBitrateHelper(self.probe) + recommendation = helper.recommend_params( + stream_info, + self.settings.get_setting('audio_codec'), + encoder_name, + self.settings.get_setting('smart_output_target'), + target_channels=target_channels, + ) + target_kbps = recommendation.get('recommended_target_kbps') + if target_kbps: + stream_args += [ + '-b:a', "{}k".format(target_kbps), + ] + if target_channels: + stream_args += [ + '-ac:a:{}'.format(stream_id), str(target_channels), + ] + return { + "generic_kwargs": generic_kwargs, + "advanced_kwargs": advanced_kwargs, + "encoder_args": encoder_args, + "stream_args": stream_args, + } + + stream_args += [ + '-q:a', str(self.settings.get_setting('aac_constant_quality_scale')), ] - return stream_encoding + if target_channels: + stream_args += [ + '-ac:a:{}'.format(stream_id), str(target_channels), + ] + return { + "generic_kwargs": generic_kwargs, + "advanced_kwargs": advanced_kwargs, + "encoder_args": encoder_args, + "stream_args": stream_args, + } if self.settings.get_setting('aac_encoder_ratecontrol_method') in ['VBR']: - # Set values for constant quantizer scale - stream_encoding += [ + stream_args += [ '-q:a', str(self.settings.get_setting('aac_constant_quality_scale')), ] elif self.settings.get_setting('aac_encoder_ratecontrol_method') in ['CBR']: - # Set values for constant quantizer scale - stream_encoding += [ + stream_args += [ '-b:a', "{}k".format(self.settings.get_setting('aac_average_bitrate')), ] - return stream_encoding + if target_channels: + stream_args += [ + '-ac:a:{}'.format(stream_id), str(target_channels), + ] + + return { + "generic_kwargs": generic_kwargs, + "advanced_kwargs": advanced_kwargs, + "encoder_args": encoder_args, + "stream_args": stream_args, + } def __set_default_option(self, select_options, key, default_option=None): - """ - Sets the default option if the currently set option is not available - - :param select_options: - :param key: - :return: - """ available_options = [] for option in select_options: available_options.append(option.get('value')) if not default_option: default_option = option.get('value') - if self.settings.get_setting(key) not in available_options: - self.settings.set_setting(key, default_option) + current_value = self.settings.get_setting(key) + if not getattr(self.settings, 'apply_default_fallbacks', True): + return current_value + if current_value not in available_options: + self.settings.settings_configured[key] = default_option + return default_option + return current_value def get_aac_encoder_ratecontrol_method_form_settings(self): values = { "label": "Encoder ratecontrol method", "sub_setting": True, "input_type": "select", - "select_options": [] + "select_options": [], } common_select_options = [ { @@ -133,13 +169,20 @@ def get_aac_encoder_ratecontrol_method_form_settings(self): "label": "CBR - Constant Bitrate", }, ] - self.__set_default_option(values['select_options'], 'aac_encoder_ratecontrol_method', default_option='VBR') + selected_method = self.__set_default_option( + values['select_options'], + 'aac_encoder_ratecontrol_method', + default_option='VBR', + ) + if getattr(self.settings, 'apply_default_fallbacks', True): + current_value = self.settings.get_setting('aac_encoder_ratecontrol_method') + if selected_method and selected_method != current_value: + self.settings.set_setting('aac_encoder_ratecontrol_method', selected_method) if self.settings.get_setting('mode') not in ['standard']: values["display"] = "hidden" return values def get_aac_constant_quality_scale_form_settings(self): - # Lower is better values = { "label": "Quality scale", "description": "", @@ -165,107 +208,32 @@ def get_aac_average_bitrate_form_settings(self): "sub_setting": True, "input_type": "select", "select_options": [ - { - "value": "10", - "label": "10kbps", - }, - { - "value": "12", - "label": "12kbps", - }, - { - "value": "14", - "label": "14kbps", - }, - { - "value": "16", - "label": "16kbps", - }, - { - "value": "20", - "label": "20kbps", - }, - { - "value": "24", - "label": "24kbps", - }, - { - "value": "28", - "label": "28kbps", - }, - { - "value": "32", - "label": "32kbps", - }, - { - "value": "40", - "label": "40kbps", - }, - { - "value": "48", - "label": "48kbps", - }, - { - "value": "56", - "label": "56kbps", - }, - { - "value": "64", - "label": "64kbps", - }, - { - "value": "80", - "label": "80kbps", - }, - { - "value": "96", - "label": "96kbps", - }, - { - "value": "112", - "label": "112kbps", - }, - { - "value": "128", - "label": "128kbps", - }, - { - "value": "160", - "label": "160kbps", - }, - { - "value": "192", - "label": "192kbps", - }, - { - "value": "224", - "label": "224kbps", - }, - { - "value": "256", - "label": "256kbps", - }, - { - "value": "320", - "label": "320kbps", - }, - { - "value": "384", - "label": "384kbps", - }, - { - "value": "448", - "label": "448kbps", - }, - { - "value": "512", - "label": "512kbps", - }, - { - "value": "576", - "label": "576kbps", - }, - ] + {"value": "10", "label": "10kbps"}, + {"value": "12", "label": "12kbps"}, + {"value": "14", "label": "14kbps"}, + {"value": "16", "label": "16kbps"}, + {"value": "20", "label": "20kbps"}, + {"value": "24", "label": "24kbps"}, + {"value": "28", "label": "28kbps"}, + {"value": "32", "label": "32kbps"}, + {"value": "40", "label": "40kbps"}, + {"value": "48", "label": "48kbps"}, + {"value": "56", "label": "56kbps"}, + {"value": "64", "label": "64kbps"}, + {"value": "80", "label": "80kbps"}, + {"value": "96", "label": "96kbps"}, + {"value": "112", "label": "112kbps"}, + {"value": "128", "label": "128kbps"}, + {"value": "160", "label": "160kbps"}, + {"value": "192", "label": "192kbps"}, + {"value": "224", "label": "224kbps"}, + {"value": "256", "label": "256kbps"}, + {"value": "320", "label": "320kbps"}, + {"value": "384", "label": "384kbps"}, + {"value": "448", "label": "448kbps"}, + {"value": "512", "label": "512kbps"}, + {"value": "576", "label": "576kbps"}, + ], } if self.settings.get_setting('mode') not in ['standard']: values["display"] = "hidden" diff --git a/source/audio_transcoder/lib/encoders/ac3.py b/source/audio_transcoder/lib/encoders/ac3.py new file mode 100644 index 000000000..061123a5d --- /dev/null +++ b/source/audio_transcoder/lib/encoders/ac3.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" + plugins.ac3.py + + Written by: Josh.5 + Date: 15 June 2026 + + Copyright: + Copyright (C) 2021 Josh Sunnex + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU General + Public License as published by the Free Software Foundation, version 3. + + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the + implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + for more details. + + You should have received a copy of the GNU General Public License along with this program. + If not, see . + +""" + +from audio_transcoder.lib.encoders.base import Encoder +from audio_transcoder.lib.smart_audio_bitrate import SmartAudioBitrateHelper + + +class Ac3Encoder(Encoder): + def __init__(self, settings=None, probe=None): + super().__init__(settings=settings, probe=probe) + + def provides(self): + return { + "ac3": { + "codec": "ac3", + "label": "AC3 (Dolby Digital)", + "max_channels": 6, + }, + } + + def options(self): + return { + "ac3_average_bitrate": "448", + } + + def generate_default_args(self): + return {}, {} + + def generate_filtergraphs(self, current_filter_args, smart_filters, encoder_name): + return { + "generic_kwargs": {}, + "advanced_kwargs": {}, + "smart_filters": smart_filters, + "start_filter_args": [], + "end_filter_args": [], + } + + def get_output_file_extension(self, encoder): + if encoder == "ac3": + return "ac3" + return "" + + def stream_args(self, stream_info, stream_id, encoder_name, filter_state=None): + generic_kwargs = {} + advanced_kwargs = {} + encoder_args = [] + stream_args = [] + + target_channels = None + if filter_state: + try: + target_channels = int(filter_state.get('target_channels')) + except (TypeError, ValueError): + target_channels = None + + if self.settings.get_setting('mode') in ['basic']: + if self.settings.get_setting('enable_smart_output_target') and self.probe: + helper = SmartAudioBitrateHelper(self.probe) + recommendation = helper.recommend_params( + stream_info, + self.settings.get_setting('audio_codec'), + encoder_name, + self.settings.get_setting('smart_output_target'), + target_channels=target_channels, + ) + target_kbps = recommendation.get('recommended_target_kbps') + if target_kbps: + stream_args += [ + '-b:a', "{}k".format(target_kbps), + ] + if target_channels: + stream_args += [ + '-ac:a:{}'.format(stream_id), str(target_channels), + ] + return { + "generic_kwargs": generic_kwargs, + "advanced_kwargs": advanced_kwargs, + "encoder_args": encoder_args, + "stream_args": stream_args, + } + + stream_args += [ + '-b:a', "{}k".format(self.settings.get_setting('ac3_average_bitrate')), + ] + if target_channels: + stream_args += [ + '-ac:a:{}'.format(stream_id), str(target_channels), + ] + + return { + "generic_kwargs": generic_kwargs, + "advanced_kwargs": advanced_kwargs, + "encoder_args": encoder_args, + "stream_args": stream_args, + } + + def get_ac3_average_bitrate_form_settings(self): + values = { + "label": "Bitrate", + "sub_setting": True, + "input_type": "select", + "select_options": [ + {"value": "96", "label": "96kbps"}, + {"value": "128", "label": "128kbps"}, + {"value": "160", "label": "160kbps"}, + {"value": "192", "label": "192kbps"}, + {"value": "224", "label": "224kbps"}, + {"value": "256", "label": "256kbps"}, + {"value": "320", "label": "320kbps"}, + {"value": "384", "label": "384kbps"}, + {"value": "448", "label": "448kbps"}, + {"value": "512", "label": "512kbps"}, + {"value": "576", "label": "576kbps"}, + {"value": "640", "label": "640kbps"}, + ], + } + if self.settings.get_setting('mode') not in ['standard']: + values["display"] = "hidden" + return values diff --git a/source/audio_transcoder/lib/encoders/base.py b/source/audio_transcoder/lib/encoders/base.py new file mode 100644 index 000000000..6cf534221 --- /dev/null +++ b/source/audio_transcoder/lib/encoders/base.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" + plugins.base.py + + Written by: Josh.5 + Date: 14 June 2026 + + Copyright: + Copyright (C) 2021 Josh Sunnex + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU General + Public License as published by the Free Software Foundation, version 3. + + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the + implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + for more details. + + You should have received a copy of the GNU General Public License along with this program. + If not, see . + +""" + + +class Encoder: + def __init__(self, settings=None, probe=None): + self.settings = settings + self.probe = probe + + def provides(self): + raise NotImplementedError("This method must be implemented by a child class.") + + def options(self): + raise NotImplementedError("This method must be implemented by a child class.") + + def generate_default_args(self): + raise NotImplementedError("This method must be implemented by a child class.") + + def generate_filtergraphs(self, current_filter_args, smart_filters, encoder_name): + raise NotImplementedError("This method must be implemented by a child class.") + + def stream_args(self, stream_info, stream_id, encoder_name, filter_state=None): + raise NotImplementedError("This method must be implemented by a child class.") + + def encoder_details(self, encoder): + return self.provides().get(encoder, {}) + + def get_output_file_extension(self, encoder): + raise NotImplementedError("This method must be implemented by a child class.") diff --git a/source/audio_transcoder/lib/encoders/eac3.py b/source/audio_transcoder/lib/encoders/eac3.py new file mode 100644 index 000000000..74204ef23 --- /dev/null +++ b/source/audio_transcoder/lib/encoders/eac3.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" + plugins.eac3.py + + Written by: Josh.5 + Date: 15 June 2026 + + Copyright: + Copyright (C) 2021 Josh Sunnex + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU General + Public License as published by the Free Software Foundation, version 3. + + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the + implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + for more details. + + You should have received a copy of the GNU General Public License along with this program. + If not, see . + +""" + +from audio_transcoder.lib.encoders.base import Encoder +from audio_transcoder.lib.smart_audio_bitrate import SmartAudioBitrateHelper + + +class Eac3Encoder(Encoder): + def __init__(self, settings=None, probe=None): + super().__init__(settings=settings, probe=probe) + + def provides(self): + return { + "eac3": { + "codec": "eac3", + "label": "EAC3 (Dolby Digital Plus)", + "max_channels": 6, + }, + } + + def options(self): + return { + "eac3_average_bitrate": "384", + } + + def generate_default_args(self): + return {}, {} + + def generate_filtergraphs(self, current_filter_args, smart_filters, encoder_name): + return { + "generic_kwargs": {}, + "advanced_kwargs": {}, + "smart_filters": smart_filters, + "start_filter_args": [], + "end_filter_args": [], + } + + def get_output_file_extension(self, encoder): + if encoder == "eac3": + return "eac3" + return "" + + def stream_args(self, stream_info, stream_id, encoder_name, filter_state=None): + generic_kwargs = {} + advanced_kwargs = {} + encoder_args = [] + stream_args = [] + + target_channels = None + if filter_state: + try: + target_channels = int(filter_state.get('target_channels')) + except (TypeError, ValueError): + target_channels = None + + if self.settings.get_setting('mode') in ['basic']: + if self.settings.get_setting('enable_smart_output_target') and self.probe: + helper = SmartAudioBitrateHelper(self.probe) + recommendation = helper.recommend_params( + stream_info, + self.settings.get_setting('audio_codec'), + encoder_name, + self.settings.get_setting('smart_output_target'), + target_channels=target_channels, + ) + target_kbps = recommendation.get('recommended_target_kbps') + if target_kbps: + stream_args += [ + '-b:a', "{}k".format(target_kbps), + ] + if target_channels: + stream_args += [ + '-ac:a:{}'.format(stream_id), str(target_channels), + ] + return { + "generic_kwargs": generic_kwargs, + "advanced_kwargs": advanced_kwargs, + "encoder_args": encoder_args, + "stream_args": stream_args, + } + + stream_args += [ + '-b:a', "{}k".format(self.settings.get_setting('eac3_average_bitrate')), + ] + if target_channels: + stream_args += [ + '-ac:a:{}'.format(stream_id), str(target_channels), + ] + + return { + "generic_kwargs": generic_kwargs, + "advanced_kwargs": advanced_kwargs, + "encoder_args": encoder_args, + "stream_args": stream_args, + } + + def get_eac3_average_bitrate_form_settings(self): + values = { + "label": "Bitrate", + "sub_setting": True, + "input_type": "select", + "select_options": [ + {"value": "96", "label": "96kbps"}, + {"value": "128", "label": "128kbps"}, + {"value": "160", "label": "160kbps"}, + {"value": "192", "label": "192kbps"}, + {"value": "224", "label": "224kbps"}, + {"value": "256", "label": "256kbps"}, + {"value": "320", "label": "320kbps"}, + {"value": "384", "label": "384kbps"}, + {"value": "448", "label": "448kbps"}, + {"value": "512", "label": "512kbps"}, + {"value": "576", "label": "576kbps"}, + {"value": "640", "label": "640kbps"}, + {"value": "768", "label": "768kbps"}, + {"value": "896", "label": "896kbps"}, + {"value": "1024", "label": "1024kbps"}, + ], + } + if self.settings.get_setting('mode') not in ['standard']: + values["display"] = "hidden" + return values diff --git a/source/audio_transcoder/lib/encoders/flac.py b/source/audio_transcoder/lib/encoders/flac.py new file mode 100644 index 000000000..2f83492b9 --- /dev/null +++ b/source/audio_transcoder/lib/encoders/flac.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" + plugins.flac.py + + Written by: Josh.5 + Date: 15 June 2026 + + Copyright: + Copyright (C) 2021 Josh Sunnex + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU General + Public License as published by the Free Software Foundation, version 3. + + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the + implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + for more details. + + You should have received a copy of the GNU General Public License along with this program. + If not, see . + +""" + +from audio_transcoder.lib.encoders.base import Encoder + + +class FlacEncoder(Encoder): + def __init__(self, settings=None, probe=None): + super().__init__(settings=settings, probe=probe) + + def provides(self): + return { + "flac": { + "codec": "flac", + "label": "FLAC", + "max_channels": 8, + }, + } + + def options(self): + return {} + + def generate_default_args(self): + return {}, {} + + def generate_filtergraphs(self, current_filter_args, smart_filters, encoder_name): + return { + "generic_kwargs": {}, + "advanced_kwargs": {}, + "smart_filters": smart_filters, + "start_filter_args": [], + "end_filter_args": [], + } + + def get_output_file_extension(self, encoder): + if encoder == "flac": + return "flac" + return "" + + def stream_args(self, stream_info, stream_id, encoder_name, filter_state=None): + generic_kwargs = {} + advanced_kwargs = {} + encoder_args = [] + stream_args = [] + + target_channels = None + if filter_state: + try: + target_channels = int(filter_state.get('target_channels')) + except (TypeError, ValueError): + target_channels = None + + if target_channels: + stream_args += [ + '-ac:a:{}'.format(stream_id), str(target_channels), + ] + + return { + "generic_kwargs": generic_kwargs, + "advanced_kwargs": advanced_kwargs, + "encoder_args": encoder_args, + "stream_args": stream_args, + } diff --git a/source/audio_transcoder/lib/encoders/lame.py b/source/audio_transcoder/lib/encoders/lame.py index bf32cd95c..9ef4c3bdd 100644 --- a/source/audio_transcoder/lib/encoders/lame.py +++ b/source/audio_transcoder/lib/encoders/lame.py @@ -21,105 +21,140 @@ If not, see . """ +from audio_transcoder.lib.encoders.base import Encoder +from audio_transcoder.lib.smart_audio_bitrate import SmartAudioBitrateHelper -class LameEncoder: - encoders = [ - "libmp3lame", - ] +class LameEncoder(Encoder): + def __init__(self, settings=None, probe=None): + super().__init__(settings=settings, probe=probe) - def __init__(self, settings): - self.settings = settings + def provides(self): + return { + "libmp3lame": { + "codec": "mp3", + "label": "LAME - libmp3lame", + "max_channels": 2, + }, + } - @staticmethod - def options(): + def options(self): return { "encoder_ratecontrol_method": "VBR", "constant_quality_scale": "4", "average_bitrate": "192", } - @staticmethod - def generate_default_args(settings): - """ - Generate a list of args for using a lib decoder + def generate_default_args(self): + return {}, {} - :param settings: - :return: - """ - # No default args required - generic_kwargs = {} - advanced_kwargs = {} - return generic_kwargs, advanced_kwargs - - @staticmethod - def generate_filtergraphs(): - """ - Generate the required filter for this encoder - No filters are required for lib encoders - - :return: - """ - return [] - - @staticmethod - def get_output_file_extension(encoder): - """ - Given an encoder, return the required file extension for that codec - :param encoder: - :return: - """ + def generate_filtergraphs(self, current_filter_args, smart_filters, encoder_name): + return { + "generic_kwargs": {}, + "advanced_kwargs": {}, + "smart_filters": smart_filters, + "start_filter_args": [], + "end_filter_args": [], + } + + def get_output_file_extension(self, encoder): if encoder == "libmp3lame": return "mp3" return "" - def args(self, stream_id): - stream_encoding = [] + def stream_args(self, stream_info, stream_id, encoder_name, filter_state=None): + generic_kwargs = {} + advanced_kwargs = {} + encoder_args = [] + stream_args = [] + target_channels = None + if filter_state: + try: + target_channels = int(filter_state.get('target_channels')) + except (TypeError, ValueError): + target_channels = None - # Use defaults for basic mode if self.settings.get_setting('mode') in ['basic']: - defaults = self.options() - stream_encoding += [ - '-qscale:a', str(self.settings.get_setting('constant_quality_scale')), + if self.settings.get_setting('enable_smart_output_target') and self.probe: + helper = SmartAudioBitrateHelper(self.probe) + recommendation = helper.recommend_params( + stream_info, + self.settings.get_setting('audio_codec'), + encoder_name, + self.settings.get_setting('smart_output_target'), + target_channels=target_channels, + ) + target_kbps = recommendation.get('recommended_target_kbps') + if target_kbps: + stream_args += [ + '-b:a', "{}k".format(target_kbps), + ] + if target_channels: + stream_args += [ + '-ac:a:{}'.format(stream_id), str(target_channels), + ] + return { + "generic_kwargs": generic_kwargs, + "advanced_kwargs": advanced_kwargs, + "encoder_args": encoder_args, + "stream_args": stream_args, + } + + stream_args += [ + '-q:a', str(self.settings.get_setting('constant_quality_scale')), ] - return stream_encoding + if target_channels: + stream_args += [ + '-ac:a:{}'.format(stream_id), str(target_channels), + ] + return { + "generic_kwargs": generic_kwargs, + "advanced_kwargs": advanced_kwargs, + "encoder_args": encoder_args, + "stream_args": stream_args, + } if self.settings.get_setting('encoder_ratecontrol_method') in ['VBR']: - # Set values for constant quantizer scale - stream_encoding += [ + stream_args += [ '-q:a', str(self.settings.get_setting('constant_quality_scale')), ] elif self.settings.get_setting('encoder_ratecontrol_method') in ['CBR']: - # Set values for constant quantizer scale - stream_encoding += [ + stream_args += [ '-b:a', "{}k".format(self.settings.get_setting('average_bitrate')), ] - return stream_encoding + if target_channels: + stream_args += [ + '-ac:a:{}'.format(stream_id), str(target_channels), + ] - def __set_default_option(self, select_options, key, default_option=None): - """ - Sets the default option if the currently set option is not available + return { + "generic_kwargs": generic_kwargs, + "advanced_kwargs": advanced_kwargs, + "encoder_args": encoder_args, + "stream_args": stream_args, + } - :param select_options: - :param key: - :return: - """ + def __set_default_option(self, select_options, key, default_option=None): available_options = [] for option in select_options: available_options.append(option.get('value')) if not default_option: default_option = option.get('value') - if self.settings.get_setting(key) not in available_options: - self.settings.set_setting(key, default_option) + current_value = self.settings.get_setting(key) + if not getattr(self.settings, 'apply_default_fallbacks', True): + return current_value + if current_value not in available_options: + self.settings.settings_configured[key] = default_option + return default_option + return current_value def get_encoder_ratecontrol_method_form_settings(self): - # TODO: Add Two-Pass values = { "label": "Encoder ratecontrol method", "sub_setting": True, "input_type": "select", - "select_options": [] + "select_options": [], } common_select_options = [ { @@ -134,13 +169,20 @@ def get_encoder_ratecontrol_method_form_settings(self): "label": "CBR - Constant Bitrate", }, ] - self.__set_default_option(values['select_options'], 'encoder_ratecontrol_method', default_option='VBR') + selected_method = self.__set_default_option( + values['select_options'], + 'encoder_ratecontrol_method', + default_option='VBR', + ) + if getattr(self.settings, 'apply_default_fallbacks', True): + current_value = self.settings.get_setting('encoder_ratecontrol_method') + if selected_method and selected_method != current_value: + self.settings.set_setting('encoder_ratecontrol_method', selected_method) if self.settings.get_setting('mode') not in ['standard']: values["display"] = "hidden" return values def get_constant_quality_scale_form_settings(self): - # Lower is better values = { "label": "Quality scale", "description": "", @@ -168,71 +210,23 @@ def get_average_bitrate_form_settings(self): "sub_setting": True, "input_type": "select", "select_options": [ - { - "value": "8", - "label": "8Kbit/s", - }, - { - "value": "16", - "label": "16Kbit/s", - }, - { - "value": "24", - "label": "24Kbit/s", - }, - { - "value": "32", - "label": "32Kbit/s", - }, - { - "value": "40", - "label": "40Kbit/s", - }, - { - "value": "48", - "label": "48Kbit/s", - }, - { - "value": "64", - "label": "64Kbit/s", - }, - { - "value": "80", - "label": "80Kbit/s", - }, - { - "value": "96", - "label": "96Kbit/s", - }, - { - "value": "112", - "label": "112Kbit/s", - }, - { - "value": "128", - "label": "128Kbit/s", - }, - { - "value": "160", - "label": "160Kbit/s", - }, - { - "value": "192", - "label": "192Kbit/s", - }, - { - "value": "224", - "label": "224Kbit/s", - }, - { - "value": "256", - "label": "256Kbit/s", - }, - { - "value": "320", - "label": "320Kbit/s", - }, - ] + {"value": "8", "label": "8Kbit/s"}, + {"value": "16", "label": "16Kbit/s"}, + {"value": "24", "label": "24Kbit/s"}, + {"value": "32", "label": "32Kbit/s"}, + {"value": "40", "label": "40Kbit/s"}, + {"value": "48", "label": "48Kbit/s"}, + {"value": "64", "label": "64Kbit/s"}, + {"value": "80", "label": "80Kbit/s"}, + {"value": "96", "label": "96Kbit/s"}, + {"value": "112", "label": "112Kbit/s"}, + {"value": "128", "label": "128Kbit/s"}, + {"value": "160", "label": "160Kbit/s"}, + {"value": "192", "label": "192Kbit/s"}, + {"value": "224", "label": "224Kbit/s"}, + {"value": "256", "label": "256Kbit/s"}, + {"value": "320", "label": "320Kbit/s"}, + ], } if self.settings.get_setting('mode') not in ['standard']: values["display"] = "hidden" diff --git a/source/audio_transcoder/lib/encoders/opus.py b/source/audio_transcoder/lib/encoders/opus.py new file mode 100644 index 000000000..5eb10ba4b --- /dev/null +++ b/source/audio_transcoder/lib/encoders/opus.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" + plugins.opus.py + + Written by: Josh.5 + Date: 15 June 2026 + + Copyright: + Copyright (C) 2021 Josh Sunnex + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU General + Public License as published by the Free Software Foundation, version 3. + + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the + implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + for more details. + + You should have received a copy of the GNU General Public License along with this program. + If not, see . + +""" + +from audio_transcoder.lib.encoders.base import Encoder +from audio_transcoder.lib.smart_audio_bitrate import SmartAudioBitrateHelper + + +class OpusEncoder(Encoder): + def __init__(self, settings=None, probe=None): + super().__init__(settings=settings, probe=probe) + + def provides(self): + return { + "libopus": { + "codec": "opus", + "label": "Opus - libopus", + "max_channels": 8, + }, + } + + def options(self): + return { + "opus_encoder_ratecontrol_method": "VBR", + "opus_average_bitrate": "128", + } + + def generate_default_args(self): + return {}, {} + + def generate_filtergraphs(self, current_filter_args, smart_filters, encoder_name): + return { + "generic_kwargs": {}, + "advanced_kwargs": {}, + "smart_filters": smart_filters, + "start_filter_args": [], + "end_filter_args": [], + } + + def get_output_file_extension(self, encoder): + if encoder == "libopus": + return "opus" + return "" + + def stream_args(self, stream_info, stream_id, encoder_name, filter_state=None): + generic_kwargs = {} + advanced_kwargs = {} + encoder_args = [] + stream_args = [] + + target_channels = None + if filter_state: + try: + target_channels = int(filter_state.get('target_channels')) + except (TypeError, ValueError): + target_channels = None + + if self.settings.get_setting('mode') in ['basic']: + if self.settings.get_setting('enable_smart_output_target') and self.probe: + helper = SmartAudioBitrateHelper(self.probe) + recommendation = helper.recommend_params( + stream_info, + self.settings.get_setting('audio_codec'), + encoder_name, + self.settings.get_setting('smart_output_target'), + target_channels=target_channels, + ) + target_kbps = recommendation.get('recommended_target_kbps') + if target_kbps: + stream_args += [ + '-b:a', "{}k".format(target_kbps), + ] + if target_channels: + stream_args += [ + '-ac:a:{}'.format(stream_id), str(target_channels), + ] + return { + "generic_kwargs": generic_kwargs, + "advanced_kwargs": advanced_kwargs, + "encoder_args": encoder_args, + "stream_args": stream_args, + } + + if self.settings.get_setting('opus_encoder_ratecontrol_method') in ['CBR']: + stream_args += ['-vbr', 'off'] + elif self.settings.get_setting('opus_encoder_ratecontrol_method') in ['CVBR']: + stream_args += ['-vbr', 'constrained'] + else: + stream_args += ['-vbr', 'on'] + + stream_args += [ + '-b:a', "{}k".format(self.settings.get_setting('opus_average_bitrate')), + ] + + if target_channels: + stream_args += [ + '-ac:a:{}'.format(stream_id), str(target_channels), + ] + + return { + "generic_kwargs": generic_kwargs, + "advanced_kwargs": advanced_kwargs, + "encoder_args": encoder_args, + "stream_args": stream_args, + } + + def __set_default_option(self, select_options, key, default_option=None): + available_options = [] + for option in select_options: + available_options.append(option.get('value')) + if not default_option: + default_option = option.get('value') + current_value = self.settings.get_setting(key) + if not getattr(self.settings, 'apply_default_fallbacks', True): + return current_value + if current_value not in available_options: + self.settings.settings_configured[key] = default_option + return default_option + return current_value + + def get_opus_encoder_ratecontrol_method_form_settings(self): + values = { + "label": "Encoder ratecontrol method", + "sub_setting": True, + "input_type": "select", + "select_options": [ + {"value": "VBR", "label": "VBR - Variable Bitrate"}, + {"value": "CVBR", "label": "CVBR - Constrained Variable Bitrate"}, + {"value": "CBR", "label": "CBR - Constant Bitrate"}, + ], + } + selected_method = self.__set_default_option( + values['select_options'], + 'opus_encoder_ratecontrol_method', + default_option='VBR', + ) + if getattr(self.settings, 'apply_default_fallbacks', True): + current_value = self.settings.get_setting('opus_encoder_ratecontrol_method') + if selected_method and selected_method != current_value: + self.settings.set_setting('opus_encoder_ratecontrol_method', selected_method) + if self.settings.get_setting('mode') not in ['standard']: + values["display"] = "hidden" + return values + + def get_opus_average_bitrate_form_settings(self): + values = { + "label": "Bitrate", + "sub_setting": True, + "input_type": "select", + "select_options": [ + {"value": "48", "label": "48kbps"}, + {"value": "64", "label": "64kbps"}, + {"value": "80", "label": "80kbps"}, + {"value": "96", "label": "96kbps"}, + {"value": "112", "label": "112kbps"}, + {"value": "128", "label": "128kbps"}, + {"value": "160", "label": "160kbps"}, + {"value": "192", "label": "192kbps"}, + {"value": "224", "label": "224kbps"}, + {"value": "256", "label": "256kbps"}, + {"value": "320", "label": "320kbps"}, + {"value": "384", "label": "384kbps"}, + {"value": "448", "label": "448kbps"}, + {"value": "512", "label": "512kbps"}, + ], + } + if self.settings.get_setting('mode') not in ['standard']: + values["display"] = "hidden" + return values diff --git a/source/audio_transcoder/lib/ffmpeg/README.md b/source/audio_transcoder/lib/ffmpeg/README.md index f1209743e..894d8afa8 100644 --- a/source/audio_transcoder/lib/ffmpeg/README.md +++ b/source/audio_transcoder/lib/ffmpeg/README.md @@ -3,14 +3,427 @@ This python module is a helper library for any Unmanic plugin that needs to build FFmpeg commands to be executed. -## Using the module +# Using the module -### Adding it to your project -It should be included in your plugin project as a submodule. +## Adding it to your project +```bash +└── my_plugin_id/ + ├── changelog.md + ├── description.md + ├── .gitignore + ├── icon.png + ├── info.json + ├── lib/ + | └── ffmpeg/ + | ├── __init__.py + | ├── LICENSE + | ├── mimetype_overrides.py + | ├── parser.py + | ├── probe.py + | ├── README.md + | └── stream_mapper.py + ├── LICENSE + ├── plugin.py + └── requirements.txt +``` + +### Git Submodule +It can be included in your plugin project as a submodule. ``` git submodule add https://github.com/Josh5/unmanic.plugin.helpers.ffmpeg.git ./lib/ffmpeg ``` +If you use it sure to include all files in the lib directory when publishing your project to the Unmanic plugin repository. + +### Project source download +Download the git repository as zip file and extract it to `lib` directory. +``` +mkdir -p ./lib +curl -L "https://github.com/Josh5/unmanic.plugin.helpers.ffmpeg/archive/refs/heads/master.zip" --output /tmp/unmanic.plugin.helpers.ffmpeg.zip +unzip /tmp/unmanic.plugin.helpers.ffmpeg.zip -d ./lib/ +mv -v ./lib/unmanic.plugin.helpers.ffmpeg-master ./lib/ffmpeg +``` + +--- + +## Importing it in your project + +This module comes with x3 classes to assist in generating FFmpeg commands for your Unmanic plugin. + +You can import all 3 classes into your plugin like this: + +```python +from my_plugin_id.lib.ffmpeg import Parser, Probe, StreamMapper +``` +> **Note** +> Be sure to rename 'my_plugin_id' in the example above. + +--- + +## Using the `Probe` class + +The Probe class is a wrapper around the `ffprobe` cli. This can be used to generate a file probe object containing file format and stream info. + +Add this to your plugin runner function: +```python + # Get file probe + probe = Probe.init_probe(data, logger, allowed_mimetypes=['video', 'audio']) + if not probe: + # File not able to be probed by ffprobe. The file is probably not a audio/video file. + return +``` + +You can then use this newly created Probe object in your plugin. To read the FFprobe data, add this: +```python + ffprobe_data = probe.get_probe() +``` + +### FFprobe Example +
+ Show + + ```json +{ + "streams": [ + { + "index": 0, + "codec_name": "hevc", + "codec_long_name": "H.265 / HEVC (High Efficiency Video Coding)", + "profile": "Main", + "codec_type": "video", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "width": 1920, + "height": 1080, + "coded_width": 1920, + "coded_height": 1080, + "closed_captions": 0, + "film_grain": 0, + "has_b_frames": 2, + "sample_aspect_ratio": "1:1", + "display_aspect_ratio": "16:9", + "pix_fmt": "yuv420p", + "level": 120, + "color_range": "tv", + "color_space": "bt709", + "color_transfer": "bt709", + "color_primaries": "bt709", + "chroma_location": "left", + "refs": 1, + "r_frame_rate": "24000/1001", + "avg_frame_rate": "24000/1001", + "time_base": "1/1000", + "start_pts": 21, + "start_time": "0.021000", + "extradata_size": 2471, + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0, + "captions": 0, + "descriptions": 0, + "metadata": 0, + "dependent": 0, + "still_image": 0 + }, + "tags": { + "DURATION": "00:00:10.239000000" + } + }, + { + "index": 1, + "codec_name": "aac", + "codec_long_name": "AAC (Advanced Audio Coding)", + "profile": "LC", + "codec_type": "audio", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "sample_fmt": "fltp", + "sample_rate": "48000", + "channels": 6, + "channel_layout": "5.1", + "bits_per_sample": 0, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/1000", + "start_pts": 0, + "start_time": "0.000000", + "extradata_size": 5, + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0, + "captions": 0, + "descriptions": 0, + "metadata": 0, + "dependent": 0, + "still_image": 0 + }, + "tags": { + "language": "eng", + "title": "Surround", + "DURATION": "00:00:10.005000000" + } + }, + { + "index": 2, + "codec_name": "ass", + "codec_long_name": "ASS (Advanced SSA) subtitle", + "codec_type": "subtitle", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/1000", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 10614, + "duration": "10.614000", + "extradata_size": 487, + "disposition": { + "default": 0, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0, + "captions": 0, + "descriptions": 0, + "metadata": 0, + "dependent": 0, + "still_image": 0 + }, + "tags": { + "language": "bul", + "DURATION": "00:00:10.614000000" + } + } + ], + "chapters": [ + { + "id": 1, + "time_base": "1/1000000000", + "start": 0, + "start_time": "0.000000", + "end": 10000000000, + "end_time": "10.000000", + "tags": { + "title": "Chapter 1" + } + } + ], + "format": { + "filename": "TEST_FILE.mkv", + "nb_streams": 3, + "nb_programs": 0, + "format_name": "matroska,webm", + "format_long_name": "Matroska / WebM", + "start_time": "0.000000", + "duration": "10.614000", + "size": "1280059", + "bit_rate": "964807", + "probe_score": 100, + "tags": { + "ENCODER": "Lavf59.27.100" + } + } +} + ``` +
+ +--- + +## Using the `StreamMapper` class + +The StreamMapper class is used to simplify building a ffmpeg command. It uses a previously initialised probe object as an input and uses it to define stream mapping from the input file to the output. + +This class should be extended with a child class to configure it and implement the custom functions required to manage streams that will need to be processed. + +```python +class PluginStreamMapper(StreamMapper): + def __init__(self): + super(PluginStreamMapper, self).__init__(logger, ['video']) + self.settings = None + + def set_settings(self, settings): + self.settings = settings + + def test_stream_needs_processing(self, stream_info: dict): + """ + Run through a set of test against the given stream_info. + + Return 'True' if it needs to be process. + Return 'False' if it should just be copied over to the new file. + + :param stream_info: + :return: bool + """ + if stream_info.get('codec_name').lower() in ['h264']: + return False + return True + + def custom_stream_mapping(self, stream_info: dict, stream_id: int): + """ + Will be provided with stream_info and the stream_id of a stream that has been + determined to need processing by the `test_stream_needs_processing` function. + + Use this function to `-map` (select) an input stream to be included in the output file + and apply a `-c` (codec) selection and encoder arguments to the command. + + This function must return a dictionary containing 2 key values: + { + 'stream_mapping': [], + 'stream_encoding': [], + } + + Where: + - 'stream_mapping' is a list of arguments for input streams to map. Eg. ['-map', '0:v:1'] + - 'stream_encoding' is a list of encoder arguments. Eg. ['-c:v:1', 'libx264', '-preset', 'slow'] + + + :param stream_info: + :param stream_id: + :return: dict + """ + if self.settings.get_setting('advanced'): + stream_encoding = ['-c:v:{}'.format(stream_id), 'libx264'] + stream_encoding += self.settings.get_setting('custom_options').split() + else: + stream_encoding = [ + '-c:v:{}'.format(stream_id), 'libx264', + '-preset', str(self.settings.get_setting('preset')), + '-crf', str(self.settings.get_setting('crf')), + ] + + return { + 'stream_mapping': ['-map', '0:v:{}'.format(stream_id)], + 'stream_encoding': stream_encoding, + } +``` + +Once you have created your stream mapper class, you can use it to determine if a file needs a FFmpeg command executed against it using its `streams_need_processing` function. + +```python +def on_library_management_file_test(data): + + ... + + # Get plugin settings + settings = Settings(library_id=data.get('library_id')) + + # Get file probe + probe = Probe.init_probe(data, logger, allowed_mimetypes=['audio']) + if not probe: + # File not able to be probed by FFprobe. The file is probably not a audio/video file. + return + + # Get stream mapper + mapper = PluginStreamMapper() + mapper.set_settings(settings) + mapper.set_probe(probe) + + # Check if file needs a FFmpeg command run against it + if mapper.streams_need_processing(): + # Mark this file to be added to the pending tasks + data['add_file_to_pending_tasks'] = True + + +def on_worker_process(data): + + ... + + # Get plugin settings + settings = Settings(library_id=data.get('library_id')) + + # Get file probe + probe = Probe.init_probe(data, logger, allowed_mimetypes=['audio']) + if not probe: + # File not able to be probed by FFprobe. The file is probably not a audio/video file. + return + + # Get stream mapper + mapper = PluginStreamMapper() + mapper.set_settings(settings) + mapper.set_probe(probe) + + # Check if file needs a FFmpeg command run against it + if mapper.streams_need_processing(): + + """ + HERE: Configure FFmpeg command args as required for this plugin + """ + + # Set the input and output file + mapper.set_input_file(data.get('file_in')) + mapper.set_output_file(data.get('file_out')) + + # Get final generated FFmpeg args + ffmpeg_args = mapper.get_ffmpeg_args() + + # Apply FFmpeg args to command for Unmanic to execute + data['exec_command'] = ['ffmpeg'] + data['exec_command'] += ffmpeg_args + +``` + +--- + +## Using the `Parser` class + +Unmanic has the ability to execute a command provided by a plugin and display a output of that command's progress. As Unmanic is able to execute any command a plugin provides it, we need a way + +This progress is only possible if the provided command is accompanied with a progress parser function. If such a function is not provided to Unmanic, then the command will still be executed, but the Unmanic worker will only report an indeterminate progress status with the logs. + +This python module provides a function for parsing the output of a FFmpeg command to determine progress of that command's execution. + +This should be returned with the built command in the `on_worker_process` plugin function: + + +```python +def on_worker_process(data): + + ... + + # Get file probe + probe = Probe.init_probe(data, logger, allowed_mimetypes=['audio']) + if not probe: + # File not able to be probed by FFprobe. The file is probably not a audio/video file. + return + + # Set the parser + parser = Parser(logger) + parser.set_probe(probe) + data['command_progress_parser'] = parser.parse_progress +``` + +--- -### Calling it in your project -For an example of how to use this module, see the [libx264 encoder plugin](https://github.com/Josh5/unmanic.plugin.encoder_video_h264_libx264). +## Examples +For examples of how to use this module, see these plugin sources: +- [Limit Library Search by FFprobe Data](https://github.com/Unmanic/plugin.limit_library_search_by_ffprobe_data/blob/master/plugin.py) +- [Re-order audio streams by language](https://github.com/Unmanic/plugin.reorder_audio_streams_by_language/blob/master/plugin.py) +- [Transcode Video Files](https://github.com/Unmanic/plugin.video_transcoder/blob/master/plugin.py) diff --git a/source/audio_transcoder/lib/ffmpeg/parser.py b/source/audio_transcoder/lib/ffmpeg/parser.py index ba46cb813..d7394070e 100644 --- a/source/audio_transcoder/lib/ffmpeg/parser.py +++ b/source/audio_transcoder/lib/ffmpeg/parser.py @@ -97,7 +97,7 @@ def parse_progress(self, line_text): :return: """ # Fetch data from line text - if line_text and 'frame=' in line_text: + if line_text and ('frame=' in line_text or 'time=' in line_text): # Update time _time = self.get_progress_from_regex_of_string(line_text, r"time=(\s+|)(\d+:\d+:\d+\.\d+)", self.time) if _time: diff --git a/source/audio_transcoder/lib/ffmpeg/probe.py b/source/audio_transcoder/lib/ffmpeg/probe.py index f89c08c4d..835e21fed 100644 --- a/source/audio_transcoder/lib/ffmpeg/probe.py +++ b/source/audio_transcoder/lib/ffmpeg/probe.py @@ -37,7 +37,7 @@ class FFProbeError(Exception): Custom exception for errors encountered while executing the ffprobe command. """ - def __init___(self, path, info): + def __init__(self, path, info): Exception.__init__(self, "Unable to fetch data from file {}. {}".format(path, info)) self.path = path self.info = info @@ -60,7 +60,13 @@ def ffprobe_cmd(params): raw_output = out.decode("utf-8") except Exception as e: raise FFProbeError(command, str(e)) - if pipe.returncode == 1 or 'error' in raw_output: + + if 'error' in raw_output: + try: + info = json.loads(raw_output) + except Exception as e: + raise FFProbeError(command, raw_output) + if pipe.returncode != 0: raise FFProbeError(command, raw_output) if not raw_output: raise FFProbeError(command, 'No info found') @@ -84,6 +90,7 @@ def ffprobe_file(vid_file_path): "-show_format", "-show_streams", "-show_error", + "-show_chapters", vid_file_path ] @@ -160,16 +167,22 @@ def init_probe(data, logger, allowed_mimetypes=None): :return: """ probe = Probe(logger, allowed_mimetypes=allowed_mimetypes) - if 'ffprobe' in data.get('shared_info', {}): - if not probe.set_probe(data.get('shared_info', {}).get('ffprobe')): - # Failed to set ffprobe from shared info. - # Probably due to it being for an incompatible mimetype declared above + # Start by fetching probe data from 'shared_info'. + ffprobe_data = data.get('shared_info', {}).get('ffprobe') + if ffprobe_data: + if not probe.set_probe(ffprobe_data): + # Failed to set ffprobe from 'shared_info'. + # Probably due to it being for an incompatible mimetype declared above. return - elif not probe.file(data.get('path')): - # File probe failed, skip the rest of this test + return probe + # No 'shared_info' ffprobe exists. Attempt to probe file. + if not probe.file(data.get('path')): + # File probe failed, skip the rest of this test. + # Again, probably due to it being for an incompatible mimetype. return - # Set file probe to shared infor for subsequent file test runners - if 'shared_info' in data: + # Successfully probed file. + # Set file probe to 'shared_info' for subsequent file test runners. + if 'shared_info' not in data: data['shared_info'] = {} data['shared_info']['ffprobe'] = probe.get_probe() return probe @@ -205,6 +218,7 @@ def set_probe(self, probe_info): """Sets the probe dictionary""" file_path = probe_info.get('format', {}).get('filename') if not file_path: + self.logger.error("Provided file probe information does not contain the expected 'filename' key.") return if not self.__test_valid_mimetype(file_path): return @@ -219,3 +233,127 @@ def get_probe(self): def get(self, key, default=None): """Return the value of the given key from the probe dictionary""" return self.probe_info.get(key, default) + + def get_first_video_stream(self): + for st in (self.probe_info.get("streams") or []): + if st.get("codec_type") == "video": + return st + return {} + + def get_video_stream_pix_fmt(self, default="nv12"): + """ + Return the pixel format of the first video stream in the probe info. + Falls back to `default` (nv12) if not available. + + Example outputs: + - 'nv12' + - 'yuv420p' + - 'p010le' + - 'yuv420p10le' + """ + vs = self.get_first_video_stream() + if vs: + return vs.get("pix_fmt", default) + return default + + def get_color_tags(self): + """ + Returns a dict of standard FFmpeg color tags for HDR or SDR content. + For example: {'color_primaries':'bt2020', 'color_trc':'smpte2084', + 'colorspace':'bt2020nc', 'color_range':'tv'}. + Only includes keys that exist on the input. + """ + vs = self.get_first_video_stream() + + # Map ffprobe keys to FFmpeg command-line option names + # ffprobe uses 'color_transfer', but the command-line option is 'color_trc' + # ffprobe uses 'color_space', but the command-line option is 'colorspace' + mapper = { + "color_primaries": "color_primaries", + "color_transfer": "color_trc", + "color_space": "colorspace", + "color_range": "color_range", + } + + out = {} + for probe_key, cmd_key in mapper.items(): + value = vs.get(probe_key) + # Handle the case where ffprobe might use an old key + if not value: + if probe_key == "color_transfer": + value = vs.get("color_trc") + elif probe_key == "color_space": + value = vs.get("colorspace") + + if value: + # ffprobe values like 'bt2020nc' are correct for the command-line + out[cmd_key] = str(value) + + return out + + def get_hdr_static_metadata(self): + """ + Returns {'master_display': 'G(x,y)B(x,y)R(x,y)WP(x,y)L(max,min)', + 'max_cll': (MaxCLL, MaxFALL)} when present; else {}. + """ + vs = self.get_first_video_stream() + sdl = vs.get("side_data_list") or [] + md = {} + + def _parse_fraction_string(s): + """Safely parses a string that might be an int, float, or fraction.""" + if s is None: + return 0.0 + if isinstance(s, (int, float)): + return float(s) + try: + if isinstance(s, str) and '/' in s: + num, den = map(int, s.split('/')) + if den == 0: + return 0.0 + return num / den + return float(s) + except (ValueError, TypeError, ZeroDivisionError): + return 0.0 + + # Mastering Display + for sd in sdl: + if sd.get("side_data_type", "").lower() == "mastering display metadata": + # ffprobe gives floats in range [0,1] for x/y; luminance in cd/m^2. + # hevc_metadata bsf expects ints scaled by 50000 for x/y, and cd/m^2 * 10000 for luminance. + def xy(v): return int(round(_parse_fraction_string(v) * 50000)) + + prim = sd.get("red", {}), sd.get("green", {}), sd.get("blue", {}), sd.get("white_point", {}) + r, g, b, wp = prim + max_l = int(round(_parse_fraction_string(sd.get("max_luminance", 0)) * 10000)) + min_l = int(round(_parse_fraction_string(sd.get("min_luminance", 0)) * 10000)) + md["master_display"] = ( + f"G({xy(g.get('x', 0))},{xy(g.get('y', 0))})" + f"B({xy(b.get('x', 0))},{xy(b.get('y', 0))})" + f"R({xy(r.get('x', 0))},{xy(r.get('y', 0))})" + f"WP({xy(wp.get('x', 0))},{xy(wp.get('y', 0))})" + f"L({max_l},{min_l})" + ) + if sd.get("side_data_type", "").lower() == "content light level metadata": + md["max_cll"] = ( + int(sd.get("max_content", 0)), + int(sd.get("max_average", 0)) + ) + return md + + def is_hdr_source(self) -> bool: + """ + Checks if a probe object represents a HDR10 source. + """ + tags = self.get_color_tags() + + # Check for the core HDR10 metadata flags + is_bt2020 = tags.get("color_primaries") == "bt2020" + is_pq = tags.get("color_trc") == "smpte2084" + is_bt2020_csp = tags.get("colorspace") in ("bt2020nc", "bt2020ncl", "bt2020c") + + # Also require a 10-bit or higher pixel format + pix_fmt = self.get_video_stream_pix_fmt() + is_10bit_or_more = any(key in pix_fmt for key in ("p010", "yuv420p10", "p016", "yuv420p12")) + + return is_bt2020 and is_pq and is_bt2020_csp and is_10bit_or_more diff --git a/source/audio_transcoder/lib/ffmpeg/stream_mapper.py b/source/audio_transcoder/lib/ffmpeg/stream_mapper.py index db72ef719..55cfb17ad 100644 --- a/source/audio_transcoder/lib/ffmpeg/stream_mapper.py +++ b/source/audio_transcoder/lib/ffmpeg/stream_mapper.py @@ -137,7 +137,7 @@ def test_stream_needs_processing(self, stream_info: dict): """ Overwrite this function to test a stream. Return 'True' if it needs to be process. - Return 'False' if it should just be copied over to the new file + Return 'False' if it should just be copied over to the new file. :param stream_info: :return: bool @@ -233,8 +233,19 @@ def __set_stream_mapping(self): self.audio_stream_count += 1 continue else: - self.__copy_stream_mapping('a', self.audio_stream_count) - self.audio_stream_count += 1 + if self.settings.get_setting('mode') == 'advanced': + amaps = self.settings.get_setting('custom_options').split() + self.logger.debug("Advanced Mode Video Settings with custom audio encoding: '%s'", amaps) + if '-c:a' not in amaps: + self.logger.debug("-c:a not detected in custom mappings: '%s'", amaps) + self.__copy_stream_mapping('a', self.audio_stream_count) + else: + self.logger.debug("-c:a detected in custom mappings: '%s'", amaps) + self.stream_mapping += ['-map', '0:{}:{}'.format('a', self.audio_stream_count)] + self.audio_stream_count += 1 + else: + self.__copy_stream_mapping('a', self.audio_stream_count) + self.audio_stream_count += 1 continue # If this is a subtitle stream? @@ -255,8 +266,19 @@ def __set_stream_mapping(self): self.subtitle_stream_count += 1 continue else: - self.__copy_stream_mapping('s', self.subtitle_stream_count) - self.subtitle_stream_count += 1 + if self.settings.get_setting('mode') == 'advanced': + submaps = self.settings.get_setting('custom_options').split() + self.logger.debug("Advanced Mode Video Settings with custom subtitle encoding: '%s'", submaps) + if '-c:s' not in submaps: + self.logger.debug("-c:s not detected in custom mappings: '%s'", submaps) + self.__copy_stream_mapping('s', self.subtitle_stream_count) + else: + self.logger.debug("-c:s detected in custom mappings: '%s'", submaps) + self.stream_mapping += ['-map', '0:{}:{}'.format('s', self.subtitle_stream_count)] + self.subtitle_stream_count += 1 + else: + self.__copy_stream_mapping('s', self.subtitle_stream_count) + self.subtitle_stream_count += 1 continue # If this is a data stream? @@ -367,13 +389,6 @@ def set_output_file(self, path): def set_output_null(self): """Set the output container to NULL for the FFmpeg args""" self.output_file = '-' - if os.name == "nt": - # Windows uses NUL instead - self.output_file = 'NUL' - main_options = { - "-f": 'null', - } - self.__build_args(self.main_options, **main_options) def set_ffmpeg_generic_options(self, *args, **kwargs): """ @@ -455,15 +470,15 @@ def get_ffmpeg_args(self): # Add generic options first args += self.generic_options + # Add other main options + args += self.main_options + # Add the input file # This class requires at least one input file specified with the input_file attribute if not self.input_file: raise Exception("Input file has not been set") args += ['-i', self.input_file] - # Add other main options - args += self.main_options - # Add advanced options. This includes the stream mapping and the encoding args args += self.advanced_options args += self.stream_mapping @@ -474,6 +489,10 @@ def get_ffmpeg_args(self): if not self.output_file: raise Exception("Output file has not been set") elif self.output_file == '-': + if os.name == "nt": + # Windows uses NUL instead + self.output_file = 'NUL' + args += ['-f', 'null'] args += [self.output_file] else: args += ['-y', self.output_file] diff --git a/source/audio_transcoder/lib/ffmpeg/tools.py b/source/audio_transcoder/lib/ffmpeg/tools.py new file mode 100644 index 000000000..c4d9c5475 --- /dev/null +++ b/source/audio_transcoder/lib/ffmpeg/tools.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" + plugins.tools.py + + Written by: Josh.5 + Date: 17 Feb 2023, (12:07 PM) + + Copyright: + Copyright (C) 2021 Josh Sunnex + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU General + Public License as published by the Free Software Foundation, version 3. + + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the + implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + for more details. + + You should have received a copy of the GNU General Public License along with this program. + If not, see . + +""" + +image_video_codecs = [ + 'alias_pix', + 'apng', + 'brender_pix', + 'dds', + 'dpx', + 'exr', + 'fits', + 'gif', + 'mjpeg', + 'mjpegb', + 'pam', + 'pbm', + 'pcx', + 'pfm', + 'pgm', + 'pgmyuv', + 'pgx', + 'photocd', + 'pictor', + 'pixlet', + 'png', + 'ppm', + 'ptx', + 'sgi', + 'sunrast', + 'tiff', + 'vc1image', + 'wmv3image', + 'xbm', + 'xface', + 'xpm', + 'xwd', +] + +resolution_map = { + '480p_sdtv': { + 'width': 854, + 'height': 480, + 'label': "480p (SDTV)", + }, + '576p_sdtv': { + 'width': 1024, + 'height': 576, + 'label': "576p (SDTV)", + }, + '720p_hdtv': { + 'width': 1280, + 'height': 720, + 'label': "720p (HDTV)", + }, + '1080p_hdtv': { + 'width': 1920, + 'height': 1080, + 'label': "1080p (HDTV)", + }, + 'dci_2k_hdtv': { + 'width': 2048, + 'height': 1080, + 'label': "DCI 2K (HDTV)", + }, + '1440p': { + 'width': 2560, + 'height': 1440, + 'label': "1440p (WQHD)", + }, + '4k_uhd': { + 'width': 3840, + 'height': 2160, + 'label': "4K (UHD)", + }, + 'dci_4k': { + 'width': 4096, + 'height': 2160, + 'label': "DCI 4K", + }, + '8k_uhd': { + 'width': 8192, + 'height': 4608, + 'label': "8k (UHD)", + }, +} + + +def get_video_stream_resolution(streams: list) -> object: + """ + Given a list of streams from a video file, returns the first video + stream's resolution and index. + + :param streams: The list of streams for the video file. + :type streams: list + :return: A tuple of the (width, height, stream_index,) + :rtype: object + """ + width = 0 + height = 0 + video_stream_index = 0 + + for stream in streams: + if stream.get('codec_type', '') == 'video': + width = stream.get('width', stream.get('coded_width', 0)) + height = stream.get('height', stream.get('coded_height', 0)) + video_stream_index = stream.get('index') + break + + return width, height, video_stream_index diff --git a/source/audio_transcoder/lib/global_settings.py b/source/audio_transcoder/lib/global_settings.py index c1ad3a016..f1e60c8a2 100644 --- a/source/audio_transcoder/lib/global_settings.py +++ b/source/audio_transcoder/lib/global_settings.py @@ -1,16 +1,5 @@ #!/usr/bin/env python3 # -*- coding:utf-8 -*- -### -# File: global_settings.py -# Project: lib -# File Created: Friday, 26th August 2022 5:06:41 pm -# Author: Josh.5 (jsunnex@gmail.com) -# ----- -# Last Modified: Monday, 29th August 2022 10:34:26 pm -# Modified By: Josh.5 (jsunnex@gmail.com) -### -# !/usr/bin/env python3 -# -*- coding: utf-8 -*- """ plugins.global_settings.py @@ -33,33 +22,89 @@ """ +from audio_transcoder.lib import tools + +supported_codecs = { + "aac": { + "label": "AAC", + "max_channels": 8, + "warning": "Although FFmpeg's native AAC encoder can produce multichannel AAC up to 7.1, 7.1 AAC is a poor choice for home-theater movie audio and should generally be avoided.
" + "In practice, 8-channel AAC can be unstable across channel layouts, FFmpeg mapping can be error-prone, and consumer playback chains often handle multichannel AAC poorly over HDMI.
" + "Even 5.1 AAC for movies can be a compromise.
" + "These are FFmpeg/codec/playback ecosystem limitations, not an arbitrary plugin restriction.", + }, + "flac": { + "label": "FLAC", + "max_channels": 8, + "warning": "FLAC supports multichannel lossless output up to 7.1 with FFmpeg's FLAC encoder.
" + "However, container and playback support is much narrower than AAC or AC3.
" + "FLAC is a good fit for MKV and dedicated audio workflows, but it is a poor fit for many MP4/MOV/TS-style media workflows.", + }, + "opus": { + "label": "Opus", + "max_channels": 8, + "warning": "Opus supports multichannel output up to 7.1 with FFmpeg's libopus encoder.
" + "However, container and device compatibility is much narrower than AAC or AC3.
" + "Opus is best suited to MKV/WebM and modern playback environments, and is a poor fit for many traditional home-theater container workflows.", + }, + "ac3": { + "label": "AC3 (Dolby Digital)", + "max_channels": 6, + "warning": "FFmpeg's AC3 encoder is limited to 5.1.
" + "Selecting AC3 will cap the maximum output channel count at 5.1.
" + "This is an FFmpeg/encoder limitation, not an arbitrary plugin restriction.", + }, + "eac3": { + "label": "EAC3 (Dolby Digital Plus)", + "max_channels": 6, + "warning": "FFmpeg's EAC3 encoder is limited to 5.1 in this build.
" + "Selecting EAC3 will cap the maximum output channel count at 5.1.
" + "This is an FFmpeg/encoder limitation, not an arbitrary plugin restriction.", + }, + "mp3": { + "label": "MP3", + "max_channels": 2, + "warning": "FFmpeg's MP3 encoding path should be treated as stereo-only here.
" + "Using MP3 on multichannel movie audio will downmix channels and is likely to be a poor fit for video libraries.
" + "This comes from codec/encoder limitations, not a plugin-only rule.", + }, +} -class GlobalSettings: +class GlobalSettings: def __init__(self, settings): self.settings = settings @staticmethod def options(): - # Global and main config options return { - "main_options": { + "main_options": { "mode": "basic", + "minimum_input_channel_count": "any", }, - "encoder_selection": { - "audio_codec": "mp3", + "encoder_selection": { + "audio_codec": "aac", + "audio_codec_admonition": "", "force_transcode": False, - "audio_encoder": "libmp3lame", + "audio_encoder": "aac", }, "advanced_input_options": { - "main_options": "", - "advanced_options": "-strict -2\n", - "custom_options": "libmp3lame\n" - "-b:a 192k\n", + "main_options": "", + "advanced_options": "", + "custom_options": "aac\n-b:a 192k\n", }, - "output_settings": { + "output_settings": {}, + "filter_settings": { + "enable_smart_audio_filters": False, + "max_channel_count": "same_as_source", + "normalize_audio_volume": False, + "apply_custom_filters": False, + "custom_audio_filters": "", }, - "filter_settings": { + "smart_output_target": { + "enable_smart_output_target": True, + "smart_output_target": "balanced", + "reencode_matching_codecs_above_target": False, }, } @@ -76,13 +121,22 @@ def __set_default_option(self, select_options, key, default_option=None): available_options.append(option.get("value")) if not default_option: default_option = option.get("value") - if self.settings.get_setting(key) not in available_options: - self.settings.set_setting(key, default_option) + current_value = self.settings.get_setting(key) + if not getattr(self.settings, "apply_default_fallbacks", True): + return current_value + if current_value not in available_options: + self.settings.settings_configured[key] = default_option + return default_option + return current_value + + def __selected_codec_details(self): + selected_codec = self.settings.get_setting("audio_codec") + return supported_codecs.get(selected_codec, {}) def get_mode_form_settings(self): return { - "label": "Config mode", - "input_type": "select", + "label": "Config mode", + "input_type": "select", "select_options": [ { "value": "basic", @@ -99,90 +153,269 @@ def get_mode_form_settings(self): ], } + def get_minimum_input_channel_count_form_settings(self): + values = { + "label": "Only process streams with at least this many channels", + "description": "Use this to skip lower-channel-count streams such as derived stereo tracks while still processing surround streams in the same file.", + "input_type": "select", + "select_options": tools.build_minimum_channel_select_options(), + } + self.__set_default_option( + values["select_options"], + "minimum_input_channel_count", + default_option="any", + ) + if self.settings.get_setting("mode") not in ["basic", "standard", "advanced"]: + values["display"] = "hidden" + return values + def get_audio_codec_form_settings(self): values = { - "label": "Audio Codec", - "input_type": "select", - "select_options": [ - { - "value": "aac", - "label": "AAC", - }, + "label": "Audio Codec", + "input_type": "select", + "select_options": [], + } + for key in supported_codecs: + values["select_options"].append( { - "value": "mp3", - "label": "MP3", - }, - ], + "value": key, + "label": supported_codecs.get(key, {}).get("label"), + } + ) + selected_codec = self.__set_default_option( + values["select_options"], "audio_codec", default_option="aac" + ) + if getattr(self.settings, "apply_default_fallbacks", True): + current_value = self.settings.get_setting("audio_codec") + if selected_codec and selected_codec != current_value: + self.settings.set_setting("audio_codec", selected_codec) + if self.settings.get_setting("mode") not in ["basic", "standard", "advanced"]: + values["display"] = "hidden" + return values + + def get_audio_codec_admonition_form_settings(self): + codec_details = self.__selected_codec_details() + codec_max_channels = codec_details.get("max_channels") + warning = codec_details.get("warning") + + values = { + "label": "Warning", + "input_type": "section_admonition", } - if self.settings.get_setting('mode') not in ['basic', 'standard', 'advanced']: - values["display"] = 'hidden' + + description_parts = [] + if codec_max_channels and codec_max_channels < 8: + channel_label = tools.CHANNEL_LAYOUT_LABELS.get( + codec_max_channels, codec_max_channels + ) + description_parts.append( + "Selected codec supports up to {} channels ({}).".format( + codec_max_channels, + channel_label, + ) + ) + if warning: + description_parts.append(warning) + + if description_parts: + values["description"] = "
".join(description_parts) + else: + values["display"] = "hidden" + + if self.settings.get_setting("mode") not in ["basic", "standard", "advanced"]: + values["display"] = "hidden" return values def get_force_transcode_form_settings(self): values = { - "label": "Force transcoding even if the file is already using the desired audio codec", - "description": "Will force a transcode of the audio file even if it matches the selected audio codec.\n" - "A file will only be forced to be transcoded once.\n" - "After that it is flagged to prevent it being added to the pending tasks list in a loop.", + "label": "Force transcoding even if the file is already using the desired audio codec", + "description": "Will force a transcode of the audio stream even if it matches the selected audio codec.\n" + "A file will only be forced to be transcoded once.\n" + "After that it is flagged to prevent it being added to the pending tasks list in a loop.", "sub_setting": True, } - if self.settings.get_setting('mode') not in ['basic', 'standard', 'advanced']: - values["display"] = 'hidden' + if self.settings.get_setting("mode") not in ["basic", "standard", "advanced"]: + values["display"] = "hidden" return values def get_audio_encoder_form_settings(self): values = { - "label": "Audio Encoder", - "input_type": "select", + "label": "Audio Encoder", + "input_type": "select", "select_options": [], } - if self.settings.get_setting('audio_codec') == 'aac': - # TODO: Add libfdk_aac encoder - # { - # "value": "libfdk_aac", - # "label": "Fraunhofer FDK AAC", - # }, - values['select_options'] = [ + encoder_libs = tools.available_encoders(settings=self.settings) + for encoder_name, encoder_lib in encoder_libs.items(): + encoder_details = encoder_lib.encoder_details(encoder_name) + if encoder_details.get("codec") != self.settings.get_setting("audio_codec"): + continue + values["select_options"].append( + { + "value": encoder_name, + "label": encoder_details.get("label"), + } + ) + selected_encoder = self.__set_default_option( + values["select_options"], "audio_encoder" + ) + if getattr(self.settings, "apply_default_fallbacks", True): + current_encoder = self.settings.get_setting("audio_encoder") + if selected_encoder and selected_encoder != current_encoder: + self.settings.set_setting("audio_encoder", selected_encoder) + if self.settings.get_setting("mode") not in ["basic", "standard"]: + values["display"] = "hidden" + return values + + def get_enable_smart_output_target_form_settings(self): + values = { + "label": "Enable smart output target", + "description": "Automatically detect an audio bitrate target from the source file for Basic mode transcodes.", + } + if self.settings.get_setting("mode") not in ["basic"]: + values["display"] = "hidden" + return values + + def get_smart_output_target_form_settings(self): + values = { + "label": "Smart output target", + "description": "Choose how Basic mode balances quality retention against compression when selecting the target bitrate.", + "sub_setting": True, + "input_type": "select", + "select_options": [ { - "value": "aac", - "label": "Native FFmpeg AAC encoder", + "value": "prefer_quality", + "label": "Prefer Quality", }, - ] - elif self.settings.get_setting('audio_codec') == 'mp3': - values['select_options'] = [ { - "value": "libmp3lame", - "label": "LAME - libmp3lame", + "value": "balanced", + "label": "Balanced", }, - ] - self.__set_default_option(values['select_options'], 'audio_encoder') - if self.settings.get_setting('mode') not in ['basic', 'standard']: - values["display"] = 'hidden' + { + "value": "prefer_compression", + "label": "Prefer Compression", + }, + ], + } + self.__set_default_option( + values["select_options"], "smart_output_target", default_option="balanced" + ) + if not self.settings.get_setting("enable_smart_output_target"): + values["display"] = "hidden" + if self.settings.get_setting("mode") not in ["basic"]: + values["display"] = "hidden" + return values + + def get_reencode_matching_codecs_above_target_form_settings(self): + values = { + "label": "Re-encode matching codecs above target", + "description": "Also queue files that already use the selected codec when their bitrate is significantly above the smart output target window.", + "sub_setting": True, + } + if not self.settings.get_setting("enable_smart_output_target"): + values["display"] = "hidden" + if self.settings.get_setting("mode") not in ["basic"]: + values["display"] = "hidden" + return values + + def get_enable_smart_audio_filters_form_settings(self): + values = { + "label": "Enable plugin's smart audio filters", + "description": "Apply audio-aware output shaping such as channel-count limiting and loudness normalization.", + } + if self.settings.get_setting("mode") not in ["basic", "standard"]: + values["display"] = "hidden" + return values + + def get_max_channel_count_form_settings(self): + codec_details = self.__selected_codec_details() + codec_max_channels = codec_details.get("max_channels") + values = { + "label": "Set maximum channel count", + "sub_setting": True, + "input_type": "select", + "select_options": tools.build_channel_select_options(codec_max_channels), + } + forced_channel_limit = tools.get_channel_option_limit(codec_max_channels) + if forced_channel_limit: + values["description"] = ( + "Selected codec supports up to {} channels ({}).".format( + forced_channel_limit, + tools.CHANNEL_LAYOUT_LABELS.get( + forced_channel_limit, forced_channel_limit + ), + ) + ) + default_option = "same_as_source" + current_value = self.settings.get_setting("max_channel_count") + if forced_channel_limit and current_value not in [None, "", "same_as_source"]: + parsed_current = tools.parse_max_channel_count(current_value) + if parsed_current and parsed_current > forced_channel_limit: + default_option = str(forced_channel_limit) + self.__set_default_option( + values["select_options"], "max_channel_count", default_option=default_option + ) + if not self.settings.get_setting("enable_smart_audio_filters"): + values["display"] = "hidden" + if self.settings.get_setting("mode") not in ["basic", "standard"]: + values["display"] = "hidden" + return values + + def get_normalize_audio_volume_form_settings(self): + values = { + "label": "Normalise audio volume levels", + "description": "Apply FFmpeg loudness normalization to the output audio stream.", + "sub_setting": True, + } + if not self.settings.get_setting("enable_smart_audio_filters"): + values["display"] = "hidden" + if self.settings.get_setting("mode") not in ["basic", "standard"]: + values["display"] = "hidden" + return values + + def get_apply_custom_filters_form_settings(self): + values = { + "label": "Apply custom audio filters", + "description": "Append one FFmpeg audio filter per line to the generated filtergraph.", + } + if self.settings.get_setting("mode") not in ["standard"]: + values["display"] = "hidden" + return values + + def get_custom_audio_filters_form_settings(self): + values = { + "label": "Custom audio filters", + "input_type": "textarea", + "sub_setting": True, + } + if not self.settings.get_setting("apply_custom_filters"): + values["display"] = "hidden" + if self.settings.get_setting("mode") not in ["standard"]: + values["display"] = "hidden" return values def get_main_options_form_settings(self): values = { - "label": "Write your own custom main options", + "label": "Write your own custom main options", "input_type": "textarea", } - if self.settings.get_setting('mode') not in ['advanced']: - values["display"] = 'hidden' + if self.settings.get_setting("mode") not in ["advanced"]: + values["display"] = "hidden" return values def get_advanced_options_form_settings(self): values = { - "label": "Write your own custom advanced options", + "label": "Write your own custom advanced options", "input_type": "textarea", } - if self.settings.get_setting('mode') not in ['advanced']: - values["display"] = 'hidden' + if self.settings.get_setting("mode") not in ["advanced"]: + values["display"] = "hidden" return values def get_custom_options_form_settings(self): values = { - "label": "Write your own custom audio options (starting with the encoder to use)", + "label": "Write your own custom audio options (starting with the encoder to use)", "input_type": "textarea", } - if self.settings.get_setting('mode') not in ['advanced']: - values["display"] = 'hidden' + if self.settings.get_setting("mode") not in ["advanced"]: + values["display"] = "hidden" return values diff --git a/source/audio_transcoder/lib/plugin_stream_mapper.py b/source/audio_transcoder/lib/plugin_stream_mapper.py index 45523a4e8..c8bd3bb83 100644 --- a/source/audio_transcoder/lib/plugin_stream_mapper.py +++ b/source/audio_transcoder/lib/plugin_stream_mapper.py @@ -24,124 +24,366 @@ import logging import os -from audio_transcoder.lib.encoders.aac import AacEncoder -from audio_transcoder.lib.encoders.lame import LameEncoder +from audio_transcoder.lib import tools from audio_transcoder.lib.ffmpeg import StreamMapper +from audio_transcoder.lib.smart_audio_bitrate import SmartAudioBitrateHelper logger = logging.getLogger("Unmanic.Plugin.audio_transcoder") class PluginStreamMapper(StreamMapper): - def __init__(self): + def __init__(self, worker_log=None): super(PluginStreamMapper, self).__init__(logger, ['audio']) + self.worker_log = worker_log if isinstance(worker_log, list) else None self.abspath = None self.settings = None + self.media_file_mode = 'audio_file' self.complex_audio_filters = {} - self.crop_value = None + self.filter_complex_parts = [] self.forced_encode = False + self.execution_stage = False + self.stream_recommendations = {} + + def _minimum_input_channel_count(self): + return tools.parse_minimum_channel_count( + self.settings.get_setting('minimum_input_channel_count') + ) @staticmethod - def get_encoders(settings): - """ - Return a dictionary of encoders and their controller classes - :return: - """ - return { - "libmp3lame": LameEncoder(settings), - "aac": AacEncoder(settings), - } + def _stream_channels(stream_info): + try: + return int(stream_info.get('channels')) + except (TypeError, ValueError): + return None + + def _get_target_channel_count(self, stream_info): + source_channels = stream_info.get('channels') + encoder_name = self.settings.get_setting('audio_encoder') + encoder_lib = tools.available_encoders(settings=self.settings, probe=self.probe).get(encoder_name) + encoder_details = encoder_lib.encoder_details(encoder_name) if encoder_lib else {} + codec_max_channels = encoder_details.get('max_channels') + + max_channel_count = None + if self.settings.get_setting('enable_smart_audio_filters'): + max_channel_count = tools.parse_max_channel_count(self.settings.get_setting('max_channel_count')) + + try: + source_channels = int(source_channels) + except (TypeError, ValueError): + source_channels = None + + target_channels = source_channels + if max_channel_count: + if target_channels is None: + target_channels = max_channel_count + else: + target_channels = min(target_channels, max_channel_count) + + if codec_max_channels: + if target_channels is None: + target_channels = codec_max_channels + else: + target_channels = min(target_channels, codec_max_channels) + + return target_channels def set_default_values(self, settings, abspath, probe): - """ - Configure the stream mapper with defaults - - :param settings: - :param abspath: - :param probe: - :return: - """ + self.execution_stage = False self.abspath = abspath - # Set the file probe data self.set_probe(probe) - # Set the input file self.set_input_file(abspath) - # Configure settings self.settings = settings + self.media_file_mode = tools.get_media_file_mode(abspath) or 'audio_file' + self.filter_complex_parts = [] + tools.append_worker_log( + self.worker_log, + "Stream mapper configured (mode='{}', media_mode='{}', encoder='{}')".format( + self.settings.get_setting('mode'), + self.media_file_mode, + self.settings.get_setting('audio_encoder'), + ) + ) - # Set default Advanced options - self.advanced_options = [ - '-strict', '-2', - ] - - # Build default options of advanced mode if self.settings.get_setting('mode') == 'advanced': - # If any main options are provided, overwrite them main_options = settings.get_setting('main_options').split() if main_options: - # Overwrite all main options self.main_options = main_options advanced_options = settings.get_setting('advanced_options').split() if advanced_options: - # Overwrite all advanced options self.advanced_options = advanced_options - # Don't apply any other settings return - # Build default options of standard mode - if self.settings.get_setting('mode') == 'standard': - # No standard mode defaults yet exist - pass + encoder_name = self.settings.get_setting('audio_encoder') + encoder_lib = tools.available_encoders(settings=self.settings, probe=self.probe).get(encoder_name) + if encoder_lib: + generic_kwargs, advanced_kwargs = encoder_lib.generate_default_args() + self.set_ffmpeg_generic_options(**generic_kwargs) + self.set_ffmpeg_advanced_options(**advanced_kwargs) + + def enable_execution_stage(self): + self.execution_stage = True + tools.append_worker_log(self.worker_log, "Stream mapper entering execution stage") + self.stream_mapping = [] + self.stream_encoding = [] + self.complex_audio_filters = {} + self.filter_complex_parts = [] + self.stream_recommendations = {} + + def _append_filter_complex(self, filter_complex): + if not filter_complex: + return + self.filter_complex_parts.append(filter_complex) + self.set_ffmpeg_advanced_options( + **{"-filter_complex": ';'.join(self.filter_complex_parts)} + ) + + def streams_need_processing(self): + tools.append_worker_log( + self.worker_log, + "Stream mapper building stream mapping (stage='{}')".format( + "execution" if self.execution_stage else "analysis" + ) + ) + needs_processing = super(PluginStreamMapper, self).streams_need_processing() + tools.append_worker_log( + self.worker_log, + "Stream mapper stream summary (video={}, audio={}, subtitle={}, data={}, attachment={})".format( + self.video_stream_count, + self.audio_stream_count, + self.subtitle_stream_count, + self.data_stream_count, + self.attachment_stream_count, + ) + ) + return needs_processing + + def build_filter_chain(self, stream_info, stream_id): + tools.append_worker_log(self.worker_log, "Stream mapper building filter chain for audio stream {}".format(stream_id)) + filter_args = [] + source_channels = stream_info.get('channels') + source_layout = stream_info.get('channel_layout') + source_sample_rate = stream_info.get('sample_rate') + target_channels = self._get_target_channel_count(stream_info) + filter_state = { + "source_channels": source_channels, + "target_channels": target_channels, + "source_layout": source_layout, + "target_layout": source_layout, + "source_sample_rate": source_sample_rate, + "target_sample_rate": source_sample_rate, + "normalization_applied": False, + "downmix_applied": False, + "resample_applied": False, + "execution_stage": self.execution_stage, + } + + encoder_name = self.settings.get_setting('audio_encoder') + encoder_lib = tools.available_encoders(settings=self.settings, probe=self.probe).get(encoder_name) + + smart_filters = [] + if self.settings.get_setting('enable_smart_audio_filters'): + if target_channels and source_channels and int(target_channels) < int(source_channels): + filter_state["downmix_applied"] = True + if int(target_channels) == 1: + filter_state["target_layout"] = "mono" + elif int(target_channels) == 2: + filter_state["target_layout"] = "stereo" + elif int(target_channels) == 6: + filter_state["target_layout"] = "5.1" + elif int(target_channels) == 8: + filter_state["target_layout"] = "7.1" - # Build encoder specific args based on configured encoder - # Note: these are not applied to advanced mode - advanced mode was returned above - encoder = self.get_encoders(self.settings).get(self.settings.get_setting('audio_encoder')) - generic_kwargs, advanced_kwargs = encoder.generate_default_args(self.settings) - self.set_ffmpeg_generic_options(**generic_kwargs) - self.set_ffmpeg_advanced_options(**advanced_kwargs) + if self.settings.get_setting('normalize_audio_volume'): + smart_filters.append({ + "loudnorm": { + "filter": "loudnorm=I=-16:TP=-1.5:LRA=11" + } + }) + filter_state["normalization_applied"] = True + + filtergraph_config = {} + if encoder_lib: + filtergraph_config = encoder_lib.generate_filtergraphs( + filter_args, + smart_filters, + encoder_name, + ) + + smart_filters = filtergraph_config.get('smart_filters', smart_filters) + for smart_filter in smart_filters: + for _, filter_data in smart_filter.items(): + filter_args.append(filter_data.get('filter')) + + if self.settings.get_setting('mode') == 'standard' and self.settings.get_setting('apply_custom_filters'): + for audio_filter in self.settings.get_setting('custom_audio_filters').splitlines(): + if audio_filter.strip(): + filter_args.append(audio_filter.strip()) + + self.set_ffmpeg_generic_options(**filtergraph_config.get('generic_kwargs', {})) + self.set_ffmpeg_advanced_options(**filtergraph_config.get('advanced_kwargs', {})) + + start_filter_args = filtergraph_config.get('start_filter_args', []) + end_filter_args = filtergraph_config.get('end_filter_args', []) + filter_args = start_filter_args + filter_args + end_filter_args + + if not filter_args: + self.complex_audio_filters[stream_id] = filter_state + return None, None, filter_state + + filter_id = '0:a:{}'.format(stream_id) + filter_id, filtergraph = tools.join_filtergraph(filter_id, filter_args, stream_id) + + self.complex_audio_filters[stream_id] = filter_state + return filter_id, filtergraph, filter_state def test_stream_needs_processing(self, stream_info: dict): - """ - Tests if the command will need to transcode the audio stream - - Return false if the stream should just be copied - - Return true to transcode this stream (configured by the 'custom_stream_mapping' method) - - :param stream_info: - :return: - """ - # If the stream is an audio, add a final check if the codec is already the correct format - # (Ignore checks if force transcode is set) - if stream_info.get('codec_type', '').lower() in ['audio'] and stream_info.get( - 'codec_name').lower() == self.settings.get_setting('audio_codec'): - if not self.settings.get_setting('force_transcode'): + codec_type = stream_info.get('codec_type', '').lower() + codec_name = stream_info.get('codec_name', '').lower() + + if codec_type in ['audio']: + if tools.stream_marked_to_be_ignored( + stream_info, + plugin_id=tools.UNMANIC_STREAM_IGNORE_PLUGIN_ID, + ): + tools.append_worker_log( + self.worker_log, + "Audio stream marked to be ignored by Unmanic metadata - skipping" + ) return False - else: + + minimum_channels = self._minimum_input_channel_count() + source_channels = self._stream_channels(stream_info) + if minimum_channels and source_channels and source_channels < minimum_channels: + tools.append_worker_log( + self.worker_log, + "Audio stream channel count below configured processing threshold (stream={}, minimum={}) - skipping".format( + source_channels, + minimum_channels, + ) + ) + return False + + if ( + self.settings.get_setting('enable_smart_audio_filters') and ( + ( + self.settings.get_setting('normalize_audio_volume') + ) or ( + self._get_target_channel_count(stream_info) and + int(self._get_target_channel_count(stream_info)) < int(stream_info.get('channels') or 0) + ) + ) + ): + return True + if ( + self.settings.get_setting('mode') == 'standard' and + self.settings.get_setting('apply_custom_filters') and + self.settings.get_setting('custom_audio_filters').strip() + ): + return True + if codec_name == self.settings.get_setting('audio_codec'): + if ( + self.settings.get_setting('mode') in ['basic'] and + self.settings.get_setting('enable_smart_output_target') and + self.settings.get_setting('reencode_matching_codecs_above_target') + ): + helper = SmartAudioBitrateHelper(self.probe) + recommendation = helper.recommend_params( + stream_info, + self.settings.get_setting('audio_codec'), + self.settings.get_setting('audio_encoder'), + self.settings.get_setting('smart_output_target'), + target_channels=self._get_target_channel_count(stream_info), + ) + self.stream_recommendations[stream_info.get('index', len(self.stream_recommendations))] = recommendation + if recommendation.get('should_transcode_for_bitrate'): + tools.append_worker_log( + self.worker_log, + "Audio stream bitrate exceeds smart target window (source={}k, target={}k, max_fit={}k)".format( + recommendation.get('source_bitrate_kbps'), + recommendation.get('recommended_target_kbps'), + recommendation.get('max_fit_kbps'), + ) + ) + return True + if not self.settings.get_setting('force_transcode'): + return False self.forced_encode = True - # All other streams should be custom mapped return True def custom_stream_mapping(self, stream_info: dict, stream_id: int): - """ - Generate the custom stream mapping and encoding args for the given stream based on the configured settings - - :param stream_info: - :param stream_id: - :return: - """ codec_type = stream_info.get('codec_type', '').lower() stream_specifier = '{}:{}'.format(self.stream_type_idents.get(codec_type), stream_id) map_identifier = '0:{}'.format(stream_specifier) - if self.settings.get_setting('mode') == 'advanced': - stream_encoding = ['-c:{}'.format(stream_specifier)] - stream_encoding += self.settings.get_setting('custom_options').split() - else: - stream_encoding = [ - '-c:{}'.format(stream_specifier), self.settings.get_setting('audio_encoder'), - ] + encoder_name = self.settings.get_setting('audio_encoder') - # Add encoder args - encoder = self.get_encoders(self.settings).get(self.settings.get_setting('audio_encoder')) - stream_encoding += encoder.args(stream_id) + if codec_type in ['audio']: + tools.append_worker_log( + self.worker_log, + "Stream mapper mapping audio stream {} for encoding (encoder='{}')".format(stream_id, encoder_name) + ) + if self.settings.get_setting('mode') == 'advanced': + stream_encoding = ['-c:{}'.format(stream_specifier)] + stream_encoding += self.settings.get_setting('custom_options').split() + else: + filter_id, filter_complex, filter_state = self.build_filter_chain(stream_info, stream_id) + if filter_complex: + map_identifier = '[{}]'.format(filter_id) + self._append_filter_complex(filter_complex) + else: + filter_state = self.complex_audio_filters.get(stream_id, { + "source_channels": stream_info.get('channels'), + "target_channels": stream_info.get('channels'), + "source_layout": stream_info.get('channel_layout'), + "target_layout": stream_info.get('channel_layout'), + "source_sample_rate": stream_info.get('sample_rate'), + "target_sample_rate": stream_info.get('sample_rate'), + "normalization_applied": False, + "downmix_applied": False, + "resample_applied": False, + "execution_stage": self.execution_stage, + }) + + stream_encoding = [ + '-c:{}'.format(stream_specifier), encoder_name, + ] + encoder_lib = tools.available_encoders(settings=self.settings, probe=self.probe).get(encoder_name) + if encoder_lib: + recommendation = None + if self.settings.get_setting('mode') in ['basic'] and self.settings.get_setting('enable_smart_output_target'): + recommendation = self.stream_recommendations.get(stream_id) + if recommendation is None: + helper = SmartAudioBitrateHelper(self.probe) + recommendation = helper.recommend_params( + stream_info, + self.settings.get_setting('audio_codec'), + encoder_name, + self.settings.get_setting('smart_output_target'), + target_channels=filter_state.get('target_channels'), + ) + self.stream_recommendations[stream_id] = recommendation + tools.append_worker_log( + self.worker_log, + "Smart bitrate target selected for audio stream {} (goal='{}', source={}k, target={}k, confidence='{}')".format( + stream_id, + recommendation.get('goal'), + recommendation.get('source_bitrate_kbps'), + recommendation.get('recommended_target_kbps'), + recommendation.get('confidence'), + ) + ) + stream_args = encoder_lib.stream_args( + stream_info, + stream_id, + encoder_name, + filter_state=filter_state, + ) + stream_encoding += stream_args.get("encoder_args", []) + stream_encoding += stream_args.get("stream_args", []) + self.set_ffmpeg_generic_options(**stream_args.get("generic_kwargs", {})) + self.set_ffmpeg_advanced_options(**stream_args.get("advanced_kwargs", {})) + else: + raise Exception("Unsupported codec type {}".format(codec_type)) return { 'stream_mapping': ['-map', map_identifier], @@ -149,19 +391,14 @@ def custom_stream_mapping(self, stream_info: dict, stream_id: int): } def set_output_file(self, path): - """ - Set the output file for the FFmpeg args based on the configured codec - - :param path: - :return: - """ - # Get the container extension - encoder = self.get_encoders(self.settings).get(self.settings.get_setting('audio_encoder')) - container_extension = encoder.get_output_file_extension(self.settings.get_setting('audio_encoder')) - # Remove the extension from the current file out path and replace with the encoder extension + if self.media_file_mode == 'video_file': + self.output_file = os.path.abspath(path) + return self.output_file + + encoder_name = self.settings.get_setting('audio_encoder') + encoder = tools.available_encoders(settings=self.settings, probe=self.probe).get(encoder_name) + container_extension = encoder.get_output_file_extension(encoder_name) split_file_out = os.path.splitext(path) new_file_out = "{}.{}".format(split_file_out[0], container_extension) - # Set the output file path self.output_file = os.path.abspath(new_file_out) - # Return the output file path return self.output_file diff --git a/source/audio_transcoder/lib/smart_audio_bitrate.py b/source/audio_transcoder/lib/smart_audio_bitrate.py new file mode 100644 index 000000000..b1d09585e --- /dev/null +++ b/source/audio_transcoder/lib/smart_audio_bitrate.py @@ -0,0 +1,510 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" + plugins.smart_audio_bitrate.py + + Written by: Josh.5 + Date: 14 Jun 2026 + + Copyright: + Copyright (C) 2021 Josh Sunnex + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU General + Public License as published by the Free Software Foundation, version 3. + + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the + implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + for more details. + + You should have received a copy of the GNU General Public License along with this program. + If not, see . + +""" + +import logging +import os + + +logger = logging.getLogger("Unmanic.Plugin.audio_transcoder") + + +class SmartAudioBitrateHelper: + GOAL_PREFER_QUALITY = "prefer_quality" + GOAL_BALANCED = "balanced" + GOAL_PREFER_COMPRESSION = "prefer_compression" + + _GOALS = { + GOAL_PREFER_QUALITY, + GOAL_BALANCED, + GOAL_PREFER_COMPRESSION, + } + + _BITRATE_LADDERS = { + "aac": [24, 32, 40, 48, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 448, 512, 576], + "ac3": [96, 128, 160, 192, 224, 256, 320, 384, 448, 512, 576, 640], + "eac3": [96, 128, 160, 192, 224, 256, 320, 384, 448, 512, 576, 640, 768, 896, 1024], + "mp3": [32, 40, 48, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320], + "opus": [32, 48, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 448, 512], + } + + _TARGETS = { + "aac": { + GOAL_PREFER_QUALITY: { + "mono": 96, + "stereo": 192, + "surround": 384, + }, + GOAL_BALANCED: { + "mono": 80, + "stereo": 160, + "surround": 320, + }, + GOAL_PREFER_COMPRESSION: { + "mono": 64, + "stereo": 128, + "surround": 256, + }, + }, + "mp3": { + GOAL_PREFER_QUALITY: { + "mono": 128, + "stereo": 224, + "surround": 320, + }, + GOAL_BALANCED: { + "mono": 96, + "stereo": 192, + "surround": 256, + }, + GOAL_PREFER_COMPRESSION: { + "mono": 64, + "stereo": 160, + "surround": 224, + }, + }, + "ac3": { + GOAL_PREFER_QUALITY: { + "mono": 160, + "stereo": 256, + "surround": 640, + }, + GOAL_BALANCED: { + "mono": 128, + "stereo": 224, + "surround": 448, + }, + GOAL_PREFER_COMPRESSION: { + "mono": 96, + "stereo": 192, + "surround": 384, + }, + }, + "eac3": { + GOAL_PREFER_QUALITY: { + "mono": 128, + "stereo": 224, + "surround": 512, + }, + GOAL_BALANCED: { + "mono": 96, + "stereo": 192, + "surround": 384, + }, + GOAL_PREFER_COMPRESSION: { + "mono": 64, + "stereo": 160, + "surround": 320, + }, + }, + "opus": { + GOAL_PREFER_QUALITY: { + "mono": 80, + "stereo": 160, + "surround": 320, + }, + GOAL_BALANCED: { + "mono": 64, + "stereo": 128, + "surround": 256, + }, + GOAL_PREFER_COMPRESSION: { + "mono": 48, + "stereo": 96, + "surround": 192, + }, + }, + } + + _MINIMUMS = { + "aac": { + "mono": 48, + "stereo": 96, + "surround": 192, + }, + "mp3": { + "mono": 64, + "stereo": 96, + "surround": 160, + }, + "ac3": { + "mono": 96, + "stereo": 192, + "surround": 384, + }, + "eac3": { + "mono": 64, + "stereo": 128, + "surround": 256, + }, + "opus": { + "mono": 48, + "stereo": 96, + "surround": 192, + }, + } + + _LOSSY_SOURCE_RETAIN = { + GOAL_PREFER_QUALITY: 1.00, + GOAL_BALANCED: 0.92, + GOAL_PREFER_COMPRESSION: 0.82, + } + + _FIT_UPPER_FACTORS = { + GOAL_PREFER_QUALITY: 1.35, + GOAL_BALANCED: 1.20, + GOAL_PREFER_COMPRESSION: 1.10, + } + + _LOSSY_CODECS = { + "aac", + "ac3", + "amr_nb", + "amr_wb", + "dts", + "eac3", + "mp2", + "mp3", + "opus", + "vorbis", + "wma", + } + + _LOSSLESS_CODECS = { + "alac", + "ape", + "flac", + "pcm_alaw", + "pcm_f32be", + "pcm_f32le", + "pcm_f64be", + "pcm_f64le", + "pcm_mulaw", + "pcm_s16be", + "pcm_s16le", + "pcm_s24be", + "pcm_s24le", + "pcm_s32be", + "pcm_s32le", + "pcm_u16be", + "pcm_u16le", + "pcm_u24be", + "pcm_u24le", + "pcm_u32be", + "pcm_u32le", + "truehd", + "tta", + "wavpack", + } + + _LOSSLESS_TARGET_CODECS = { + "flac", + } + + def __init__(self, probe): + self.probe = probe + + @staticmethod + def _safe_int(value): + try: + return int(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _safe_float(value): + try: + return float(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _parse_duration_hhmmss(duration_tag): + if not duration_tag or not isinstance(duration_tag, str) or ":" not in duration_tag: + return None + try: + h, m, s = duration_tag.split(":") + return float(h) * 3600 + float(m) * 60 + float(s) + except (TypeError, ValueError): + return None + + @staticmethod + def _get_tag_value(tags, key): + if not isinstance(tags, dict): + return None + candidates = ( + key, + key.upper(), + key.lower(), + "{}-eng".format(key), + "{}-eng".format(key.upper()), + "{}-eng".format(key.lower()), + ) + for candidate in candidates: + if candidate in tags: + return tags.get(candidate) + key_lower = str(key).lower() + for tag_key, tag_value in tags.items(): + if str(tag_key).lower() in (key_lower, "{}-eng".format(key_lower)): + return tag_value + return None + + def _duration_seconds(self, probe_dict, stream_info): + format_info = probe_dict.get("format") or {} + duration = self._safe_float(format_info.get("duration")) + if duration and duration > 0: + return duration + + duration = self._safe_float(stream_info.get("duration")) + if duration and duration > 0: + return duration + + for tags in (stream_info.get("tags") or {}, format_info.get("tags") or {}): + duration_tag = self._get_tag_value(tags, "DURATION") + duration = self._parse_duration_hhmmss(duration_tag) + if duration and duration > 0: + return duration + + return None + + def _channel_bucket(self, channels): + if not channels or channels <= 1: + return "mono" + if channels <= 2: + return "stereo" + return "surround" + + def _source_bitrate_from_tags(self, stream_info): + tags = stream_info.get("tags") or {} + + bps = self._safe_int(self._get_tag_value(tags, "BPS")) + if bps and bps > 0: + return bps + + number_of_bytes = self._safe_int(self._get_tag_value(tags, "NUMBER_OF_BYTES")) + duration_seconds = self._parse_duration_hhmmss(self._get_tag_value(tags, "DURATION")) + if number_of_bytes and number_of_bytes > 0 and duration_seconds and duration_seconds > 0: + return int((number_of_bytes * 8) / duration_seconds) + + return None + + def _derive_source_bitrate(self, stream_info): + probe_dict = self.probe.get_probe() or {} + stream_bitrate = self._safe_int(stream_info.get("bit_rate")) + tag_bitrate = None if stream_bitrate else self._source_bitrate_from_tags(stream_info) + container_bitrate = self._safe_int((probe_dict.get("format") or {}).get("bit_rate")) + + file_path = (probe_dict.get("format") or {}).get("filename") + duration = self._duration_seconds(probe_dict, stream_info) + filesize_bits = None + if file_path and os.path.exists(file_path): + try: + filesize_bits = os.path.getsize(file_path) * 8 + except OSError: + filesize_bits = None + + derived_bitrate = None + if stream_bitrate: + derived_bitrate = stream_bitrate + elif tag_bitrate: + derived_bitrate = tag_bitrate + elif container_bitrate: + derived_bitrate = container_bitrate + elif filesize_bits and duration and duration > 0: + derived_bitrate = int(filesize_bits / duration) + + confidence = "high" + notes = [] + if derived_bitrate is None: + confidence = "low" + notes.append("missing_bitrate") + elif stream_bitrate is None: + confidence = "medium" + notes.append("bitrate_estimated") + + if container_bitrate is None: + notes.append("missing_container_bitrate") + + return { + "stream_bitrate": stream_bitrate, + "container_bitrate": container_bitrate, + "derived_bitrate": derived_bitrate, + "duration": duration, + "confidence": confidence, + "notes": notes, + } + + def _sample_rate_factor(self, sample_rate, goal): + sample_rate = self._safe_int(sample_rate) or 0 + if sample_rate and sample_rate <= 24000: + return 0.75 + if sample_rate and sample_rate <= 32000: + return 0.85 + if sample_rate and sample_rate >= 88200: + if goal == self.GOAL_PREFER_QUALITY: + return 1.10 + if goal == self.GOAL_BALANCED: + return 1.05 + return 1.0 + + def _target_bitrate_kbps(self, target_codec, goal, channels, sample_rate, source_codec, source_bitrate_kbps): + codec_table = self._TARGETS.get(target_codec) or self._TARGETS.get("aac") + goal = goal if goal in self._GOALS else self.GOAL_BALANCED + channel_bucket = self._channel_bucket(channels) + + base_target = codec_table.get(goal, {}).get(channel_bucket) + if not base_target: + base_target = codec_table.get(self.GOAL_BALANCED, {}).get(channel_bucket, 128) + + target = float(base_target) * self._sample_rate_factor(sample_rate, goal) + minimum = self._MINIMUMS.get(target_codec, {}).get(channel_bucket, 64) + + source_codec = (source_codec or "").lower() + if source_bitrate_kbps and source_codec in self._LOSSY_CODECS: + retain_factor = self._LOSSY_SOURCE_RETAIN.get(goal, 0.92) + target = min(target, float(source_bitrate_kbps) * retain_factor) + + target = max(target, float(minimum)) + return self._round_bitrate(target_codec, int(round(target))) + + def _round_bitrate(self, target_codec, bitrate_kbps): + ladder = self._BITRATE_LADDERS.get(target_codec) or self._BITRATE_LADDERS.get("aac") + if not ladder: + return bitrate_kbps + for value in ladder: + if value >= bitrate_kbps: + return int(value) + return int(ladder[-1]) + + def _fits_target(self, goal, estimated_source_kbps, recommended_target_kbps): + if not estimated_source_kbps or not recommended_target_kbps: + return False, None + upper_factor = self._FIT_UPPER_FACTORS.get(goal, 1.20) + max_fit = int(round(recommended_target_kbps * upper_factor)) + return estimated_source_kbps <= max_fit, max_fit + + def recommend_params(self, stream_info, target_codec, target_encoder, goal, target_channels=None): + target_codec = (target_codec or "aac").lower() + requested_goal = goal or self.GOAL_BALANCED + goal = requested_goal if requested_goal in self._GOALS else self.GOAL_BALANCED + + source_codec = (stream_info.get("codec_name") or "").lower() + source_channels = self._safe_int(stream_info.get("channels")) or 2 + channels = self._safe_int(target_channels) or source_channels + sample_rate = self._safe_int(stream_info.get("sample_rate")) or 44100 + bitrate_data = self._derive_source_bitrate(stream_info) + derived_bitrate = bitrate_data.get("derived_bitrate") + source_bitrate_kbps = int(round(derived_bitrate / 1000.0)) if derived_bitrate else None + + if target_codec in self._LOSSLESS_TARGET_CODECS: + notes = list(bitrate_data.get("notes") or []) + notes.append("lossless_target") + recommendation = { + "goal": goal, + "target_codec": target_codec, + "target_encoder": target_encoder, + "source_codec": source_codec, + "source_channels": source_channels, + "target_channels": channels, + "source_sample_rate": sample_rate, + "source_bitrate_kbps": source_bitrate_kbps, + "recommended_target_kbps": None, + "max_fit_kbps": None, + "quality_mode": "lossless", + "confidence": bitrate_data.get("confidence"), + "should_transcode_for_bitrate": False, + "notes": notes, + } + logger.debug( + "[SmartAudioBitrateHelper] recommendation goal=%s target_codec=%s source_codec=%s source_kbps=%s target_kbps=%s max_fit_kbps=%s confidence=%s notes=%s", + recommendation.get("goal"), + recommendation.get("target_codec"), + recommendation.get("source_codec"), + recommendation.get("source_bitrate_kbps"), + recommendation.get("recommended_target_kbps"), + recommendation.get("max_fit_kbps"), + recommendation.get("confidence"), + ",".join(recommendation.get("notes") or []), + ) + return recommendation + + recommended_target_kbps = self._target_bitrate_kbps( + target_codec, + goal, + channels, + sample_rate, + source_codec, + source_bitrate_kbps, + ) + fits_target, max_fit_kbps = self._fits_target(goal, source_bitrate_kbps, recommended_target_kbps) + + should_transcode_for_bitrate = bool( + source_codec == target_codec and + source_bitrate_kbps and + max_fit_kbps and + source_bitrate_kbps > max_fit_kbps + ) + + notes = list(bitrate_data.get("notes") or []) + if source_codec in self._LOSSLESS_CODECS: + notes.append("lossless_source") + elif source_codec in self._LOSSY_CODECS: + notes.append("lossy_source") + + if should_transcode_for_bitrate: + notes.append("above_target_window") + elif fits_target: + notes.append("within_target_window") + elif source_bitrate_kbps and source_bitrate_kbps < recommended_target_kbps: + notes.append("below_target_no_upbitrate") + + recommendation = { + "goal": goal, + "target_codec": target_codec, + "target_encoder": target_encoder, + "source_codec": source_codec, + "source_channels": source_channels, + "target_channels": channels, + "source_sample_rate": sample_rate, + "source_bitrate_kbps": source_bitrate_kbps, + "recommended_target_kbps": recommended_target_kbps, + "max_fit_kbps": max_fit_kbps, + "quality_mode": "target_bitrate", + "confidence": bitrate_data.get("confidence"), + "should_transcode_for_bitrate": should_transcode_for_bitrate, + "notes": notes, + } + + logger.debug( + "[SmartAudioBitrateHelper] recommendation goal=%s target_codec=%s source_codec=%s source_kbps=%s target_kbps=%s max_fit_kbps=%s confidence=%s notes=%s", + recommendation.get("goal"), + recommendation.get("target_codec"), + recommendation.get("source_codec"), + recommendation.get("source_bitrate_kbps"), + recommendation.get("recommended_target_kbps"), + recommendation.get("max_fit_kbps"), + recommendation.get("confidence"), + ",".join(recommendation.get("notes") or []), + ) + + return recommendation diff --git a/source/audio_transcoder/lib/tools.py b/source/audio_transcoder/lib/tools.py new file mode 100644 index 000000000..eec52eaaa --- /dev/null +++ b/source/audio_transcoder/lib/tools.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" + plugins.tools.py + + Written by: Josh.5 + Date: 14 June 2026 + + Copyright: + Copyright (C) 2021 Josh Sunnex + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU General + Public License as published by the Free Software Foundation, version 3. + + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the + implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + for more details. + + You should have received a copy of the GNU General Public License along with this program. + If not, see . + +""" + +import mimetypes + +from audio_transcoder.lib.encoders.ac3 import Ac3Encoder +from audio_transcoder.lib.encoders.aac import AacEncoder +from audio_transcoder.lib.encoders.eac3 import Eac3Encoder +from audio_transcoder.lib.encoders.flac import FlacEncoder +from audio_transcoder.lib.encoders.lame import LameEncoder +from audio_transcoder.lib.encoders.opus import OpusEncoder + +CHANNEL_LAYOUT_LABELS = { + 1: "1.0", + 2: "2.0", + 6: "5.1", + 8: "7.1", +} +UNMANIC_STREAM_IGNORE_PLUGIN_ID = "audio_transcoder" + + +def append_worker_log(worker_log, line: str): + if worker_log is None or not isinstance(worker_log, list): + return + try: + worker_log.append(str(line)) + except Exception: + return + + +def available_encoders(settings=None, probe=None): + return_encoders = {} + encoder_libs = [ + LameEncoder, + AacEncoder, + FlacEncoder, + OpusEncoder, + Ac3Encoder, + Eac3Encoder, + ] + for encoder_class in encoder_libs: + encoder_lib = encoder_class(settings=settings, probe=probe) + for encoder in encoder_lib.provides(): + return_encoders[encoder] = encoder_lib + return return_encoders + + +def join_filtergraph(filter_id, filter_args, stream_id): + filtergraph = '' + count = 1 + for filter_string in filter_args: + if filtergraph: + filtergraph += ';' + filtergraph += '[{}]'.format(filter_id) + filtergraph += '{}'.format(filter_string) + filter_id = '0:af:{}-{}'.format(stream_id, count) + filtergraph += '[{}]'.format(filter_id) + count += 1 + return filter_id, filtergraph + + +def get_media_file_mode(path: str): + file_type = mimetypes.guess_type(path)[0] + if not file_type: + return None + + file_type_category = file_type.split('/')[0] + if file_type_category == 'video': + return 'video_file' + if file_type_category == 'audio': + return 'audio_file' + return None + + +def parse_max_channel_count(value): + if value in [None, '', 'same_as_source']: + return None + try: + parsed = int(value) + if parsed > 0: + return parsed + except (TypeError, ValueError): + return None + return None + + +def get_channel_option_limit(max_channels): + if not max_channels: + return None + valid_options = [ + channel_count for channel_count in sorted(CHANNEL_LAYOUT_LABELS.keys()) + if channel_count <= max_channels + ] + if not valid_options: + return None + return valid_options[-1] + + +def build_channel_select_options(max_channels=None): + options = [ + { + "value": "same_as_source", + "label": "Same as source", + } + ] + for channel_count in sorted(CHANNEL_LAYOUT_LABELS.keys(), reverse=True): + if max_channels and channel_count > max_channels: + continue + options.append( + { + "value": str(channel_count), + "label": CHANNEL_LAYOUT_LABELS[channel_count], + } + ) + return options + + +def build_minimum_channel_select_options(): + options = [ + { + "value": "any", + "label": "Any channel count", + } + ] + for channel_count in sorted(CHANNEL_LAYOUT_LABELS.keys()): + options.append( + { + "value": str(channel_count), + "label": "{} and above".format(CHANNEL_LAYOUT_LABELS[channel_count]), + } + ) + return options + + +def parse_minimum_channel_count(value): + if value in [None, '', 'any']: + return None + return parse_max_channel_count(value) + + +def _parse_boolish(value): + if value is None: + return False + return str(value).strip().lower() in ['1', 'true', 'yes', 'on'] + + +def _parse_unmanic_kv_string(value): + parsed = {} + if not value or not isinstance(value, str): + return parsed + for part in value.split(';'): + if '=' not in part: + continue + key, raw_value = part.split('=', 1) + key = key.strip().lower() + raw_value = raw_value.strip() + if key: + parsed[key] = raw_value + return parsed + + +def get_unmanic_stream_marker_data(stream_info): + tags = stream_info.get('tags') or {} + parsed = {} + + for tag_key, tag_value in tags.items(): + normalized_key = str(tag_key).strip().lower() + if normalized_key in [ + 'unmanic.ignore', + 'unmanic.ignore_for', + 'unmanic.source_plugin', + 'unmanic.role', + 'unmanic_ignore', + 'unmanic_ignore_for', + ]: + parsed[normalized_key] = str(tag_value).strip() + + for field in ['comment', 'description']: + for candidate in [field, field.upper(), field.capitalize()]: + if candidate in tags: + parsed.update(_parse_unmanic_kv_string(tags.get(candidate))) + + return parsed + + +def stream_marked_to_be_ignored(stream_info, plugin_id=UNMANIC_STREAM_IGNORE_PLUGIN_ID): + marker_data = get_unmanic_stream_marker_data(stream_info) + + if _parse_boolish(marker_data.get('unmanic.ignore')): + return True + if _parse_boolish(marker_data.get('unmanic_ignore')): + return True + + ignore_for = marker_data.get('unmanic.ignore_for') or marker_data.get('unmanic_ignore_for') + if ignore_for: + ignored_targets = [value.strip().lower() for value in str(ignore_for).split(',') if value.strip()] + if 'all' in ignored_targets or str(plugin_id).lower() in ignored_targets: + return True + + return False diff --git a/source/audio_transcoder/plugin.py b/source/audio_transcoder/plugin.py index abbe093c1..c4b3b35cf 100644 --- a/source/audio_transcoder/plugin.py +++ b/source/audio_transcoder/plugin.py @@ -21,85 +21,67 @@ If not, see . """ + import logging import os -from configparser import NoSectionError, NoOptionError +from configparser import NoOptionError, NoSectionError -from audio_transcoder.lib import plugin_stream_mapper +from audio_transcoder.lib import plugin_stream_mapper, tools +from audio_transcoder.lib.encoders.ac3 import Ac3Encoder from audio_transcoder.lib.encoders.aac import AacEncoder -from audio_transcoder.lib.ffmpeg import Probe, Parser -from audio_transcoder.lib.global_settings import GlobalSettings +from audio_transcoder.lib.encoders.eac3 import Eac3Encoder +from audio_transcoder.lib.encoders.flac import FlacEncoder from audio_transcoder.lib.encoders.lame import LameEncoder - -from unmanic.libs.unplugins.settings import PluginSettings +from audio_transcoder.lib.encoders.opus import OpusEncoder +from audio_transcoder.lib.ffmpeg import Parser, Probe +from audio_transcoder.lib.global_settings import GlobalSettings from unmanic.libs.directoryinfo import UnmanicDirectoryInfo +from unmanic.libs.unplugins.settings import PluginSettings # Configure plugin logger logger = logging.getLogger("Unmanic.Plugin.audio_transcoder") -""" -TODO: - - Add FLAC - - Add OPUS - - Add advanced options like the video transcoder plugin -""" - - class Settings(PluginSettings): - def __init__(self, *args, **kwargs): + self.apply_default_fallbacks = kwargs.pop('apply_default_fallbacks', True) super(Settings, self).__init__(*args, **kwargs) self.settings = self.__build_settings_object() - self.encoders = plugin_stream_mapper.PluginStreamMapper.get_encoders(self) self.global_settings = GlobalSettings(self) self.form_settings = self.__build_form_settings_object() def __build_form_settings_object(self): - """ - Build a form input config for all the plugin settings - This input changes dynamically based on the encoder selected - - :return: - """ return_values = {} for setting in self.settings: - # Fetch currently configured encoder - # This should be done every loop as some settings my change this value - selected_encoder = self.encoders.get(self.get_setting('audio_encoder')) - # Disable form by default + selected_encoder = tools.available_encoders(settings=self).get(self.get_setting('audio_encoder')) setting_form_settings = { "display": "hidden" } - # First check if selected_encoder object has form settings method if hasattr(selected_encoder, 'get_{}_form_settings'.format(setting)): getter = getattr(selected_encoder, 'get_{}_form_settings'.format(setting)) if callable(getter): setting_form_settings = getter() - # Next check if global_settings object has form settings method elif hasattr(self.global_settings, 'get_{}_form_settings'.format(setting)): getter = getattr(self.global_settings, 'get_{}_form_settings'.format(setting)) if callable(getter): setting_form_settings = getter() - # Apply form settings return_values[setting] = setting_form_settings return return_values def __encoder_settings_object(self): - """ - Returns a list of encoder settings for FFmpeg - - :return: - """ - # Fetch all encoder settings from encoder libs - lame_options = LameEncoder.options() - aac_options = AacEncoder.options() - return { - **lame_options, - **aac_options, - } + encoder_settings = {} + encoder_libs = [ + LameEncoder(self), + AacEncoder(self), + FlacEncoder(self), + OpusEncoder(self), + Ac3Encoder(self), + Eac3Encoder(self), + ] + for encoder_lib in encoder_libs: + encoder_settings.update(encoder_lib.options()) + return encoder_settings def __build_settings_object(self): - # Global and main config options global_settings = GlobalSettings.options() main_options = global_settings.get('main_options') encoder_selection = global_settings.get('encoder_selection') @@ -107,6 +89,7 @@ def __build_settings_object(self): advanced_input_options = global_settings.get('advanced_input_options') output_settings = global_settings.get('output_settings') filter_settings = global_settings.get('filter_settings') + smart_output_target = global_settings.get('smart_output_target') return { **main_options, **encoder_selection, @@ -114,26 +97,32 @@ def __build_settings_object(self): **advanced_input_options, **output_settings, **filter_settings, + **smart_output_target, } -def file_marked_as_force_transcoded(path): +def file_marked_as_force_transcoded(path, file_metadata=None): + if file_metadata: + try: + metadata = file_metadata.get() + if metadata.get('force_transcoded') is True or metadata.get('status') == 'force_transcoded': + return True + except Exception as e: + logger.debug("Unable to read UnmanicFileMetadata for '%s': %s", path, e) + directory_info = UnmanicDirectoryInfo(os.path.dirname(path)) try: - has_been_force_transcoded = directory_info.get('video_transcoder', os.path.basename(path)) - except NoSectionError as e: + has_been_force_transcoded = directory_info.get('audio_transcoder', os.path.basename(path)) + except NoSectionError: has_been_force_transcoded = '' - except NoOptionError as e: + except NoOptionError: has_been_force_transcoded = '' except Exception as e: logger.debug("Unknown exception %s.", e) has_been_force_transcoded = '' if has_been_force_transcoded == 'force_transcoded': - # This file has already been force transcoded return True - - # Default to... return False @@ -151,32 +140,23 @@ def on_library_management_file_test(data, task_data_store=None, file_metadata=No :param data: :return: - """ - # Get settings - settings = Settings(library_id=data.get('library_id')) - - # Get the path to the file + settings = Settings(library_id=data.get('library_id'), apply_default_fallbacks=False) abspath = data.get('path') - # Get file probe - probe = Probe.init_probe(data, logger, allowed_mimetypes=['audio']) - if not probe.file(abspath): - # File probe failed, skip the rest of this test - return data + probe = Probe.init_probe(data, logger, allowed_mimetypes=['audio', 'video']) + if not probe or not probe.file(abspath): + return - # Get stream mapper mapper = plugin_stream_mapper.PluginStreamMapper() mapper.set_default_values(settings, abspath, probe) - # Check if this file needs to be processed if mapper.streams_need_processing(): - if file_marked_as_force_transcoded(abspath) and mapper.forced_encode: + if file_marked_as_force_transcoded(abspath, file_metadata=file_metadata) and mapper.forced_encode: logger.debug( "File '%s' has been previously marked as forced transcoded. Plugin found streams require processing, but will ignore this file.", abspath) return - # Mark this file to be added to the pending tasks data['add_file_to_pending_tasks'] = True logger.debug("File '%s' should be added to task list. Plugin found streams require processing.", abspath) else: @@ -188,9 +168,11 @@ def on_worker_process(data, task_data_store=None, file_metadata=None): Runner function - enables additional configured processing jobs during the worker stages of a task. The 'data' object argument includes: + task_id - Integer, unique identifier of the task. worker_log - Array, the log lines that are being tailed by the frontend. Can be left empty. library_id - Number, the library that the current task is associated with. exec_command - Array, a subprocess command that Unmanic should execute. Can be empty. + current_command - Array, shared list for updating the worker's "current command" text in the UI (last entry wins). command_progress_parser - Function, a function that Unmanic can use to parse the STDOUT of the command to collect progress stats. Can be empty. file_in - String, the source file to be processed by the command. file_out - String, the destination that the command should output (may be the same as the file_in if necessary). @@ -199,45 +181,41 @@ def on_worker_process(data, task_data_store=None, file_metadata=None): :param data: :return: - """ - # Default to no FFMPEG command required. This prevents the FFMPEG command from running if it is not required data['exec_command'] = [] data['repeat'] = False - # Get settings - settings = Settings(library_id=data.get('library_id')) - - # Get the path to the file + worker_log = data.get('worker_log') + settings = Settings(library_id=data.get('library_id'), apply_default_fallbacks=False) abspath = data.get('file_in') - # Get file probe - probe = Probe(logger, allowed_mimetypes=['audio']) + tools.append_worker_log(worker_log, "Probing file: {}".format(abspath)) + probe = Probe(logger, allowed_mimetypes=['audio', 'video']) if not probe.file(abspath): - # File probe failed, skip the rest of this test + tools.append_worker_log(worker_log, "Probe failed - skipping file") return - # Get stream mapper - mapper = plugin_stream_mapper.PluginStreamMapper() + mapper = plugin_stream_mapper.PluginStreamMapper(worker_log=worker_log) mapper.set_default_values(settings, abspath, probe) - # Check if this file needs to be processed - if mapper.streams_need_processing(): - if file_marked_as_force_transcoded(abspath) and mapper.forced_encode: - # Do not process this file, it has been force transcoded once before + tools.append_worker_log(worker_log, "Checking what streams need processing...") + needs_processing = mapper.streams_need_processing() + if needs_processing: + if file_marked_as_force_transcoded(abspath, file_metadata=file_metadata) and mapper.forced_encode: + tools.append_worker_log(worker_log, "File previously force transcoded - skipping") return - # Set the output file based on codec data['file_out'] = mapper.set_output_file(data.get('file_out')) + tools.append_worker_log(worker_log, "Output file resolved to: {}".format(data.get('file_out'))) - # Get generated ffmpeg args + tools.append_worker_log(worker_log, "Generating FFmpeg command...") + mapper.enable_execution_stage() + mapper.streams_need_processing() ffmpeg_args = mapper.get_ffmpeg_args() - # Apply ffmpeg args to command data['exec_command'] = ['ffmpeg'] data['exec_command'] += ffmpeg_args - # Set the parser parser = Parser(logger) parser.set_probe(probe) data['command_progress_parser'] = parser.parse_progress @@ -246,8 +224,10 @@ def on_worker_process(data, task_data_store=None, file_metadata=None): cache_directory = os.path.dirname(data.get('file_out')) if not os.path.exists(cache_directory): os.makedirs(cache_directory) - with open(os.path.join(cache_directory, '.audio_transcoder_force_transcode'), 'w') as f: + with open(os.path.join(cache_directory, '.force_transcode'), 'w') as f: f.write('') + else: + tools.append_worker_log(worker_log, "No streams require processing - no FFmpeg command generated") return @@ -257,32 +237,43 @@ def on_postprocessor_task_results(data, task_data_store=None, file_metadata=None Runner function - provides a means for additional postprocessor functions based on the task success. The 'data' object argument includes: - final_cache_path - The path to the final cache file that was then used as the source for all destination files. library_id - The library that the current task is associated with. + task_id - Integer, unique identifier of the task. + task_type - String, "local" or "remote". + final_cache_path - The path to the final cache file that was then used as the source for all destination files. task_processing_success - Boolean, did all task processes complete successfully. file_move_processes_success - Boolean, did all postprocessor movement tasks complete successfully. destination_files - List containing all file paths created by postprocessor file movements. source_data - Dictionary containing data pertaining to the original source file. + start_time - Float, UNIX timestamp when the task began. + finish_time - Float, UNIX timestamp when the task completed. :param data: :return: - """ - # Get settings - settings = Settings(library_id=data.get('library_id')) + settings = Settings(library_id=data.get('library_id'), apply_default_fallbacks=False) - # Get the original file's absolute path original_source_path = data.get('source_data', {}).get('abspath') if not original_source_path: logger.error("Provided 'source_data' is missing the source file abspath data.") return - # Mark the source file to be ignored on subsequent scans if 'force_transcode' was enabled + if not data.get('destination_files'): + logger.error("No destination files found.") + return + + transcoded_file_path = data['destination_files'][0] + if settings.get_setting('force_transcode'): - cache_directory = data.get('final_cache_path') - cache_directory = "/tmp/unmanic/unmanic_file_conversion-1616571944.7296784" - if os.path.exists(os.path.join(cache_directory, '.audio_transcoder_force_transcode')): - directory_info = UnmanicDirectoryInfo(os.path.dirname(original_source_path)) - directory_info.set('video_transcoder', os.path.basename(original_source_path), 'force_transcoded') - directory_info.save() - logger.debug("Ignore on next scan written for '%s'.", original_source_path) + cache_directory = os.path.dirname(data.get('final_cache_path')) + if os.path.exists(os.path.join(cache_directory, '.force_transcode')): + if file_metadata: + file_metadata.set({ + 'force_transcoded': True, + 'status': 'force_transcoded', + }) + else: + directory_info = UnmanicDirectoryInfo(os.path.dirname(transcoded_file_path)) + directory_info.set('audio_transcoder', os.path.basename(transcoded_file_path), 'force_transcoded') + directory_info.save() + logger.debug("Ignore on next scan written for '%s'.", transcoded_file_path)