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)