mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-03-10 08:29:00 +00:00
Merge branch 'quiet-sleeping-crane' into dev
This commit is contained in:
@@ -61,8 +61,8 @@ from unshackle.core.tracks.hybrid import Hybrid
|
|||||||
from unshackle.core.utilities import (find_font_with_fallbacks, get_debug_logger, get_system_fonts, init_debug_logger,
|
from unshackle.core.utilities import (find_font_with_fallbacks, get_debug_logger, get_system_fonts, init_debug_logger,
|
||||||
is_close_match, suggest_font_packages, time_elapsed_since)
|
is_close_match, suggest_font_packages, time_elapsed_since)
|
||||||
from unshackle.core.utils import tags
|
from unshackle.core.utils import tags
|
||||||
from unshackle.core.utils.click_types import (LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE, ContextData, MultipleChoice,
|
from unshackle.core.utils.click_types import (AUDIO_CODEC_LIST, LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE,
|
||||||
SubtitleCodecChoice, VideoCodecChoice)
|
ContextData, MultipleChoice, SubtitleCodecChoice, VideoCodecChoice)
|
||||||
from unshackle.core.utils.collections import merge_dict
|
from unshackle.core.utils.collections import merge_dict
|
||||||
from unshackle.core.utils.subprocess import ffprobe
|
from unshackle.core.utils.subprocess import ffprobe
|
||||||
from unshackle.core.vaults import Vaults
|
from unshackle.core.vaults import Vaults
|
||||||
@@ -204,9 +204,9 @@ class dl:
|
|||||||
@click.option(
|
@click.option(
|
||||||
"-a",
|
"-a",
|
||||||
"--acodec",
|
"--acodec",
|
||||||
type=click.Choice(Audio.Codec, case_sensitive=False),
|
type=AUDIO_CODEC_LIST,
|
||||||
default=None,
|
default=[],
|
||||||
help="Audio Codec to download, defaults to any codec.",
|
help="Audio Codec(s) to download (comma-separated), e.g., 'AAC,EC3'. Defaults to any.",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"-vb",
|
"-vb",
|
||||||
@@ -245,6 +245,13 @@ class dl:
|
|||||||
default=False,
|
default=False,
|
||||||
help="Exclude Dolby Atmos audio tracks when selecting audio.",
|
help="Exclude Dolby Atmos audio tracks when selecting audio.",
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"--split-audio",
|
||||||
|
"split_audio",
|
||||||
|
is_flag=True,
|
||||||
|
default=None,
|
||||||
|
help="Create separate output files per audio codec instead of merging all audio.",
|
||||||
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"-w",
|
"-w",
|
||||||
"--wanted",
|
"--wanted",
|
||||||
@@ -751,7 +758,7 @@ class dl:
|
|||||||
service: Service,
|
service: Service,
|
||||||
quality: list[int],
|
quality: list[int],
|
||||||
vcodec: Optional[Video.Codec],
|
vcodec: Optional[Video.Codec],
|
||||||
acodec: Optional[Audio.Codec],
|
acodec: list[Audio.Codec],
|
||||||
vbitrate: int,
|
vbitrate: int,
|
||||||
abitrate: int,
|
abitrate: int,
|
||||||
range_: list[Video.Range],
|
range_: list[Video.Range],
|
||||||
@@ -789,6 +796,7 @@ class dl:
|
|||||||
workers: Optional[int],
|
workers: Optional[int],
|
||||||
downloads: int,
|
downloads: int,
|
||||||
best_available: bool,
|
best_available: bool,
|
||||||
|
split_audio: Optional[bool] = None,
|
||||||
*_: Any,
|
*_: Any,
|
||||||
**__: Any,
|
**__: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -796,6 +804,15 @@ class dl:
|
|||||||
self.search_source = None
|
self.search_source = None
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
|
if not acodec:
|
||||||
|
acodec = []
|
||||||
|
elif isinstance(acodec, Audio.Codec):
|
||||||
|
acodec = [acodec]
|
||||||
|
elif isinstance(acodec, str) or (
|
||||||
|
isinstance(acodec, list) and not all(isinstance(v, Audio.Codec) for v in acodec)
|
||||||
|
):
|
||||||
|
acodec = AUDIO_CODEC_LIST.convert(acodec)
|
||||||
|
|
||||||
if require_subs and s_lang != ["all"]:
|
if require_subs and s_lang != ["all"]:
|
||||||
self.log.error("--require-subs and --s-lang cannot be used together")
|
self.log.error("--require-subs and --s-lang cannot be used together")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -1307,9 +1324,10 @@ class dl:
|
|||||||
if not audio_description:
|
if not audio_description:
|
||||||
title.tracks.select_audio(lambda x: not x.descriptive) # exclude descriptive audio
|
title.tracks.select_audio(lambda x: not x.descriptive) # exclude descriptive audio
|
||||||
if acodec:
|
if acodec:
|
||||||
title.tracks.select_audio(lambda x: x.codec == acodec)
|
title.tracks.select_audio(lambda x: x.codec in acodec)
|
||||||
if not title.tracks.audio:
|
if not title.tracks.audio:
|
||||||
self.log.error(f"There's no {acodec.name} Audio Tracks...")
|
codec_names = ", ".join(c.name for c in acodec)
|
||||||
|
self.log.error(f"No audio tracks matching codecs: {codec_names}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if channels:
|
if channels:
|
||||||
title.tracks.select_audio(lambda x: math.ceil(x.channels) == math.ceil(channels))
|
title.tracks.select_audio(lambda x: math.ceil(x.channels) == math.ceil(channels))
|
||||||
@@ -1348,15 +1366,27 @@ class dl:
|
|||||||
if "best" in processed_lang:
|
if "best" in processed_lang:
|
||||||
unique_languages = {track.language for track in title.tracks.audio}
|
unique_languages = {track.language for track in title.tracks.audio}
|
||||||
selected_audio = []
|
selected_audio = []
|
||||||
for language in unique_languages:
|
if acodec and len(acodec) > 1:
|
||||||
highest_quality = max(
|
for language in unique_languages:
|
||||||
(track for track in title.tracks.audio if track.language == language),
|
for codec in acodec:
|
||||||
key=lambda x: x.bitrate or 0,
|
candidates = [
|
||||||
)
|
track
|
||||||
selected_audio.append(highest_quality)
|
for track in title.tracks.audio
|
||||||
|
if track.language == language and track.codec == codec
|
||||||
|
]
|
||||||
|
if not candidates:
|
||||||
|
continue
|
||||||
|
selected_audio.append(max(candidates, key=lambda x: x.bitrate or 0))
|
||||||
|
else:
|
||||||
|
for language in unique_languages:
|
||||||
|
highest_quality = max(
|
||||||
|
(track for track in title.tracks.audio if track.language == language),
|
||||||
|
key=lambda x: x.bitrate or 0,
|
||||||
|
)
|
||||||
|
selected_audio.append(highest_quality)
|
||||||
title.tracks.audio = selected_audio
|
title.tracks.audio = selected_audio
|
||||||
elif "all" not in processed_lang:
|
elif "all" not in processed_lang:
|
||||||
per_language = 1
|
per_language = 0 if acodec and len(acodec) > 1 else 1
|
||||||
title.tracks.audio = title.tracks.by_language(
|
title.tracks.audio = title.tracks.by_language(
|
||||||
title.tracks.audio, processed_lang, per_language=per_language, exact_match=exact_lang
|
title.tracks.audio, processed_lang, per_language=per_language, exact_match=exact_lang
|
||||||
)
|
)
|
||||||
@@ -1657,6 +1687,7 @@ class dl:
|
|||||||
self.log.info("Repacked one or more tracks with FFMPEG")
|
self.log.info("Repacked one or more tracks with FFMPEG")
|
||||||
|
|
||||||
muxed_paths = []
|
muxed_paths = []
|
||||||
|
muxed_audio_codecs: dict[Path, Optional[Audio.Codec]] = {}
|
||||||
|
|
||||||
if no_mux:
|
if no_mux:
|
||||||
# Skip muxing, handle individual track files
|
# Skip muxing, handle individual track files
|
||||||
@@ -1673,7 +1704,40 @@ class dl:
|
|||||||
console=console,
|
console=console,
|
||||||
)
|
)
|
||||||
|
|
||||||
multiplex_tasks: list[tuple[TaskID, Tracks]] = []
|
if split_audio is not None:
|
||||||
|
merge_audio = not split_audio
|
||||||
|
else:
|
||||||
|
merge_audio = config.muxing.get("merge_audio", True)
|
||||||
|
|
||||||
|
multiplex_tasks: list[tuple[TaskID, Tracks, Optional[Audio.Codec]]] = []
|
||||||
|
|
||||||
|
def clone_tracks_for_audio(base_tracks: Tracks, audio_tracks: list[Audio]) -> Tracks:
|
||||||
|
task_tracks = Tracks()
|
||||||
|
task_tracks.videos = list(base_tracks.videos)
|
||||||
|
task_tracks.audio = audio_tracks
|
||||||
|
task_tracks.subtitles = list(base_tracks.subtitles)
|
||||||
|
task_tracks.chapters = base_tracks.chapters
|
||||||
|
task_tracks.attachments = list(base_tracks.attachments)
|
||||||
|
return task_tracks
|
||||||
|
|
||||||
|
def enqueue_mux_tasks(task_description: str, base_tracks: Tracks) -> None:
|
||||||
|
if merge_audio or not base_tracks.audio:
|
||||||
|
task_id = progress.add_task(f"{task_description}...", total=None, start=False)
|
||||||
|
multiplex_tasks.append((task_id, base_tracks, None))
|
||||||
|
return
|
||||||
|
|
||||||
|
audio_by_codec: dict[Optional[Audio.Codec], list[Audio]] = {}
|
||||||
|
for audio_track in base_tracks.audio:
|
||||||
|
audio_by_codec.setdefault(audio_track.codec, []).append(audio_track)
|
||||||
|
|
||||||
|
for audio_codec, codec_audio_tracks in audio_by_codec.items():
|
||||||
|
description = task_description
|
||||||
|
if audio_codec:
|
||||||
|
description = f"{task_description} {audio_codec.name}"
|
||||||
|
|
||||||
|
task_id = progress.add_task(f"{description}...", total=None, start=False)
|
||||||
|
task_tracks = clone_tracks_for_audio(base_tracks, codec_audio_tracks)
|
||||||
|
multiplex_tasks.append((task_id, task_tracks, audio_codec))
|
||||||
|
|
||||||
# Check if we're in hybrid mode
|
# Check if we're in hybrid mode
|
||||||
if any(r == Video.Range.HYBRID for r in range_) and title.tracks.videos:
|
if any(r == Video.Range.HYBRID for r in range_) and title.tracks.videos:
|
||||||
@@ -1713,11 +1777,8 @@ class dl:
|
|||||||
if default_output.exists():
|
if default_output.exists():
|
||||||
shutil.move(str(default_output), str(hybrid_output_path))
|
shutil.move(str(default_output), str(hybrid_output_path))
|
||||||
|
|
||||||
# Create a mux task for this resolution
|
|
||||||
task_description = f"Multiplexing Hybrid HDR10+DV {resolution}p"
|
|
||||||
task_id = progress.add_task(f"{task_description}...", total=None, start=False)
|
|
||||||
|
|
||||||
# Create tracks with the hybrid video output for this resolution
|
# Create tracks with the hybrid video output for this resolution
|
||||||
|
task_description = f"Multiplexing Hybrid HDR10+DV {resolution}p"
|
||||||
task_tracks = Tracks(title.tracks) + title.tracks.chapters + title.tracks.attachments
|
task_tracks = Tracks(title.tracks) + title.tracks.chapters + title.tracks.attachments
|
||||||
|
|
||||||
# Create a new video track for the hybrid output
|
# Create a new video track for the hybrid output
|
||||||
@@ -1727,7 +1788,7 @@ class dl:
|
|||||||
hybrid_track.needs_duration_fix = True
|
hybrid_track.needs_duration_fix = True
|
||||||
task_tracks.videos = [hybrid_track]
|
task_tracks.videos = [hybrid_track]
|
||||||
|
|
||||||
multiplex_tasks.append((task_id, task_tracks))
|
enqueue_mux_tasks(task_description, task_tracks)
|
||||||
|
|
||||||
console.print()
|
console.print()
|
||||||
else:
|
else:
|
||||||
@@ -1740,16 +1801,15 @@ class dl:
|
|||||||
if len(range_) > 1:
|
if len(range_) > 1:
|
||||||
task_description += f" {video_track.range.name}"
|
task_description += f" {video_track.range.name}"
|
||||||
|
|
||||||
task_id = progress.add_task(f"{task_description}...", total=None, start=False)
|
|
||||||
|
|
||||||
task_tracks = Tracks(title.tracks) + title.tracks.chapters + title.tracks.attachments
|
task_tracks = Tracks(title.tracks) + title.tracks.chapters + title.tracks.attachments
|
||||||
if video_track:
|
if video_track:
|
||||||
task_tracks.videos = [video_track]
|
task_tracks.videos = [video_track]
|
||||||
|
|
||||||
multiplex_tasks.append((task_id, task_tracks))
|
enqueue_mux_tasks(task_description, task_tracks)
|
||||||
|
|
||||||
with Live(Padding(progress, (0, 5, 1, 5)), console=console):
|
with Live(Padding(progress, (0, 5, 1, 5)), console=console):
|
||||||
for task_id, task_tracks in multiplex_tasks:
|
mux_index = 0
|
||||||
|
for task_id, task_tracks, audio_codec in multiplex_tasks:
|
||||||
progress.start_task(task_id) # TODO: Needed?
|
progress.start_task(task_id) # TODO: Needed?
|
||||||
audio_expected = not video_only and not no_audio
|
audio_expected = not video_only and not no_audio
|
||||||
muxed_path, return_code, errors = task_tracks.mux(
|
muxed_path, return_code, errors = task_tracks.mux(
|
||||||
@@ -1759,7 +1819,16 @@ class dl:
|
|||||||
audio_expected=audio_expected,
|
audio_expected=audio_expected,
|
||||||
title_language=title.language,
|
title_language=title.language,
|
||||||
)
|
)
|
||||||
|
if muxed_path.exists():
|
||||||
|
mux_index += 1
|
||||||
|
unique_path = muxed_path.with_name(
|
||||||
|
f"{muxed_path.stem}.{mux_index}{muxed_path.suffix}"
|
||||||
|
)
|
||||||
|
if unique_path != muxed_path:
|
||||||
|
shutil.move(muxed_path, unique_path)
|
||||||
|
muxed_path = unique_path
|
||||||
muxed_paths.append(muxed_path)
|
muxed_paths.append(muxed_path)
|
||||||
|
muxed_audio_codecs[muxed_path] = audio_codec
|
||||||
if return_code >= 2:
|
if return_code >= 2:
|
||||||
self.log.error(f"Failed to Mux video to Matroska file ({return_code}):")
|
self.log.error(f"Failed to Mux video to Matroska file ({return_code}):")
|
||||||
elif return_code == 1 or errors:
|
elif return_code == 1 or errors:
|
||||||
@@ -1771,8 +1840,6 @@ class dl:
|
|||||||
self.log.warning(line)
|
self.log.warning(line)
|
||||||
if return_code >= 2:
|
if return_code >= 2:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
for video_track in task_tracks.videos:
|
|
||||||
video_track.delete()
|
|
||||||
for track in title.tracks:
|
for track in title.tracks:
|
||||||
track.delete()
|
track.delete()
|
||||||
|
|
||||||
@@ -1847,6 +1914,9 @@ class dl:
|
|||||||
media_info = MediaInfo.parse(muxed_path)
|
media_info = MediaInfo.parse(muxed_path)
|
||||||
final_dir = config.directories.downloads
|
final_dir = config.directories.downloads
|
||||||
final_filename = title.get_filename(media_info, show_service=not no_source)
|
final_filename = title.get_filename(media_info, show_service=not no_source)
|
||||||
|
audio_codec_suffix = muxed_audio_codecs.get(muxed_path)
|
||||||
|
if audio_codec_suffix:
|
||||||
|
final_filename = f"{final_filename}.{audio_codec_suffix.name}"
|
||||||
|
|
||||||
if not no_folder and isinstance(title, (Episode, Song)):
|
if not no_folder and isinstance(title, (Episode, Song)):
|
||||||
final_dir /= title.get_filename(media_info, show_service=not no_source, folder=True)
|
final_dir /= title.get_filename(media_info, show_service=not no_source, folder=True)
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ def _perform_download(
|
|||||||
range_=params.get("range", ["SDR"]),
|
range_=params.get("range", ["SDR"]),
|
||||||
channels=params.get("channels"),
|
channels=params.get("channels"),
|
||||||
no_atmos=params.get("no_atmos", False),
|
no_atmos=params.get("no_atmos", False),
|
||||||
|
split_audio=params.get("split_audio"),
|
||||||
wanted=params.get("wanted", []),
|
wanted=params.get("wanted", []),
|
||||||
latest_episode=params.get("latest_episode", False),
|
latest_episode=params.get("latest_episode", False),
|
||||||
lang=params.get("lang", ["orig"]),
|
lang=params.get("lang", ["orig"]),
|
||||||
|
|||||||
@@ -748,9 +748,17 @@ def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]:
|
|||||||
return f"Invalid vcodec: {data['vcodec']}. Must be one of: {', '.join(valid_vcodecs)}"
|
return f"Invalid vcodec: {data['vcodec']}. Must be one of: {', '.join(valid_vcodecs)}"
|
||||||
|
|
||||||
if "acodec" in data and data["acodec"]:
|
if "acodec" in data and data["acodec"]:
|
||||||
valid_acodecs = ["AAC", "AC3", "EAC3", "OPUS", "FLAC", "ALAC", "VORBIS", "DTS"]
|
valid_acodecs = ["AAC", "AC3", "EC3", "EAC3", "DD", "DD+", "AC4", "OPUS", "FLAC", "ALAC", "VORBIS", "OGG", "DTS"]
|
||||||
if data["acodec"].upper() not in valid_acodecs:
|
if isinstance(data["acodec"], str):
|
||||||
return f"Invalid acodec: {data['acodec']}. Must be one of: {', '.join(valid_acodecs)}"
|
acodec_values = [v.strip() for v in data["acodec"].split(",") if v.strip()]
|
||||||
|
elif isinstance(data["acodec"], list):
|
||||||
|
acodec_values = [str(v).strip() for v in data["acodec"] if str(v).strip()]
|
||||||
|
else:
|
||||||
|
return "acodec must be a string or list"
|
||||||
|
|
||||||
|
invalid = [value for value in acodec_values if value.upper() not in valid_acodecs]
|
||||||
|
if invalid:
|
||||||
|
return f"Invalid acodec: {', '.join(invalid)}. Must be one of: {', '.join(valid_acodecs)}"
|
||||||
|
|
||||||
if "sub_format" in data and data["sub_format"]:
|
if "sub_format" in data and data["sub_format"]:
|
||||||
valid_sub_formats = ["SRT", "VTT", "ASS", "SSA"]
|
valid_sub_formats = ["SRT", "VTT", "ASS", "SSA"]
|
||||||
|
|||||||
@@ -416,7 +416,7 @@ async def download(request: web.Request) -> web.Response:
|
|||||||
description: Video codec to download (e.g., H264, H265, VP9, AV1) (default - None)
|
description: Video codec to download (e.g., H264, H265, VP9, AV1) (default - None)
|
||||||
acodec:
|
acodec:
|
||||||
type: string
|
type: string
|
||||||
description: Audio codec to download (e.g., AAC, AC3, EAC3) (default - None)
|
description: Audio codec(s) to download (e.g., AAC or AAC,EC3) (default - None)
|
||||||
vbitrate:
|
vbitrate:
|
||||||
type: integer
|
type: integer
|
||||||
description: Video bitrate in kbps (default - None)
|
description: Video bitrate in kbps (default - None)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import click
|
|||||||
from click.shell_completion import CompletionItem
|
from click.shell_completion import CompletionItem
|
||||||
from pywidevine.cdm import Cdm as WidevineCdm
|
from pywidevine.cdm import Cdm as WidevineCdm
|
||||||
|
|
||||||
|
from unshackle.core.tracks.audio import Audio
|
||||||
|
|
||||||
|
|
||||||
class VideoCodecChoice(click.Choice):
|
class VideoCodecChoice(click.Choice):
|
||||||
"""
|
"""
|
||||||
@@ -241,6 +243,52 @@ class QualityList(click.ParamType):
|
|||||||
return sorted(resolutions, reverse=True)
|
return sorted(resolutions, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AudioCodecList(click.ParamType):
|
||||||
|
"""Parses comma-separated audio codecs like 'AAC,EC3'."""
|
||||||
|
|
||||||
|
name = "audio_codec_list"
|
||||||
|
|
||||||
|
def __init__(self, codec_enum):
|
||||||
|
self.codec_enum = codec_enum
|
||||||
|
self._name_to_codec: dict[str, Audio.Codec] = {}
|
||||||
|
for codec in codec_enum:
|
||||||
|
self._name_to_codec[codec.name.lower()] = codec
|
||||||
|
self._name_to_codec[codec.value.lower()] = codec
|
||||||
|
|
||||||
|
aliases = {
|
||||||
|
"eac3": "EC3",
|
||||||
|
"ddp": "EC3",
|
||||||
|
"vorbis": "OGG",
|
||||||
|
}
|
||||||
|
for alias, target in aliases.items():
|
||||||
|
if target in codec_enum.__members__:
|
||||||
|
self._name_to_codec[alias] = codec_enum[target]
|
||||||
|
|
||||||
|
def convert(self, value: Any, param: Optional[click.Parameter] = None, ctx: Optional[click.Context] = None) -> list:
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
if isinstance(value, self.codec_enum):
|
||||||
|
return [value]
|
||||||
|
if isinstance(value, list):
|
||||||
|
if all(isinstance(v, self.codec_enum) for v in value):
|
||||||
|
return value
|
||||||
|
values = [str(v).strip() for v in value]
|
||||||
|
else:
|
||||||
|
values = [v.strip() for v in str(value).split(",")]
|
||||||
|
|
||||||
|
codecs = []
|
||||||
|
for val in values:
|
||||||
|
if not val:
|
||||||
|
continue
|
||||||
|
key = val.lower()
|
||||||
|
if key in self._name_to_codec:
|
||||||
|
codecs.append(self._name_to_codec[key])
|
||||||
|
else:
|
||||||
|
valid = sorted(set(self._name_to_codec.keys()))
|
||||||
|
self.fail(f"'{val}' is not valid. Choices: {', '.join(valid)}", param, ctx)
|
||||||
|
return list(dict.fromkeys(codecs)) # Remove duplicates, preserve order
|
||||||
|
|
||||||
|
|
||||||
class MultipleChoice(click.Choice):
|
class MultipleChoice(click.Choice):
|
||||||
"""
|
"""
|
||||||
The multiple choice type allows multiple values to be checked against
|
The multiple choice type allows multiple values to be checked against
|
||||||
@@ -288,5 +336,6 @@ class MultipleChoice(click.Choice):
|
|||||||
SEASON_RANGE = SeasonRange()
|
SEASON_RANGE = SeasonRange()
|
||||||
LANGUAGE_RANGE = LanguageRange()
|
LANGUAGE_RANGE = LanguageRange()
|
||||||
QUALITY_LIST = QualityList()
|
QUALITY_LIST = QualityList()
|
||||||
|
AUDIO_CODEC_LIST = AudioCodecList(Audio.Codec)
|
||||||
|
|
||||||
# VIDEO_CODEC_CHOICE will be created dynamically when imported
|
# VIDEO_CODEC_CHOICE will be created dynamically when imported
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ debug_keys:
|
|||||||
# Muxing configuration
|
# Muxing configuration
|
||||||
muxing:
|
muxing:
|
||||||
set_title: false
|
set_title: false
|
||||||
|
# merge_audio: Merge all audio tracks into each output file
|
||||||
|
# true (default): All selected audio in one MKV per quality
|
||||||
|
# false: Separate MKV per (quality, audio_codec) combination
|
||||||
|
# Example: Title.1080p.AAC.mkv, Title.1080p.EC3.mkv
|
||||||
|
merge_audio: true
|
||||||
|
|
||||||
# Login credentials for each Service
|
# Login credentials for each Service
|
||||||
credentials:
|
credentials:
|
||||||
|
|||||||
Reference in New Issue
Block a user