Merge branch 'quiet-sleeping-crane' into dev

This commit is contained in:
Andy
2026-02-02 20:51:17 -07:00
6 changed files with 164 additions and 31 deletions

View File

@@ -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)

View File

@@ -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"]),

View File

@@ -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"]

View File

@@ -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)

View File

@@ -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

View File

@@ -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: