mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-03-12 17:39:01 +00:00
Compare commits
3 Commits
1cde8964c1
...
cacb695093
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cacb695093 | ||
|
|
64875e8371 | ||
|
|
5b9be075de |
@@ -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,
|
||||
is_close_match, suggest_font_packages, time_elapsed_since)
|
||||
from unshackle.core.utils import tags
|
||||
from unshackle.core.utils.click_types import (LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE, ContextData, MultipleChoice,
|
||||
SubtitleCodecChoice, VideoCodecChoice)
|
||||
from unshackle.core.utils.click_types import (AUDIO_CODEC_LIST, LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE,
|
||||
ContextData, MultipleChoice, SubtitleCodecChoice, VideoCodecChoice)
|
||||
from unshackle.core.utils.collections import merge_dict
|
||||
from unshackle.core.utils.subprocess import ffprobe
|
||||
from unshackle.core.vaults import Vaults
|
||||
@@ -179,6 +179,99 @@ class dl:
|
||||
self.log.info(f" $ sudo apt install {package_cmd}")
|
||||
self.log.info(f" → Provides: {', '.join(fonts)}")
|
||||
|
||||
def generate_sidecar_subtitle_path(
|
||||
self,
|
||||
subtitle: Subtitle,
|
||||
base_filename: str,
|
||||
output_dir: Path,
|
||||
target_codec: Optional[Subtitle.Codec] = None,
|
||||
source_path: Optional[Path] = None,
|
||||
) -> Path:
|
||||
"""Generate sidecar path: {base}.{lang}[.forced][.sdh].{ext}"""
|
||||
lang_suffix = str(subtitle.language) if subtitle.language else "und"
|
||||
forced_suffix = ".forced" if subtitle.forced else ""
|
||||
sdh_suffix = ".sdh" if (subtitle.sdh or subtitle.cc) else ""
|
||||
|
||||
extension = (target_codec or subtitle.codec or Subtitle.Codec.SubRip).extension
|
||||
if (
|
||||
not target_codec
|
||||
and not subtitle.codec
|
||||
and source_path
|
||||
and source_path.suffix
|
||||
):
|
||||
extension = source_path.suffix.lstrip(".")
|
||||
|
||||
filename = f"{base_filename}.{lang_suffix}{forced_suffix}{sdh_suffix}.{extension}"
|
||||
return output_dir / filename
|
||||
|
||||
def output_subtitle_sidecars(
|
||||
self,
|
||||
subtitles: list[Subtitle],
|
||||
base_filename: str,
|
||||
output_dir: Path,
|
||||
sidecar_format: str,
|
||||
original_paths: Optional[dict[str, Path]] = None,
|
||||
) -> list[Path]:
|
||||
"""Output subtitles as sidecar files, converting if needed."""
|
||||
created_paths: list[Path] = []
|
||||
config.directories.temp.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for subtitle in subtitles:
|
||||
source_path = subtitle.path
|
||||
if sidecar_format == "original" and original_paths and subtitle.id in original_paths:
|
||||
source_path = original_paths[subtitle.id]
|
||||
|
||||
if not source_path or not source_path.exists():
|
||||
continue
|
||||
|
||||
# Determine target codec
|
||||
if sidecar_format == "original":
|
||||
target_codec = None
|
||||
if source_path.suffix:
|
||||
try:
|
||||
target_codec = Subtitle.Codec.from_mime(source_path.suffix.lstrip("."))
|
||||
except ValueError:
|
||||
target_codec = None
|
||||
else:
|
||||
target_codec = Subtitle.Codec.from_mime(sidecar_format)
|
||||
|
||||
sidecar_path = self.generate_sidecar_subtitle_path(
|
||||
subtitle, base_filename, output_dir, target_codec, source_path=source_path
|
||||
)
|
||||
|
||||
# Copy or convert
|
||||
if not target_codec or subtitle.codec == target_codec:
|
||||
shutil.copy2(source_path, sidecar_path)
|
||||
else:
|
||||
# Create temp copy for conversion to preserve original
|
||||
temp_path = config.directories.temp / f"sidecar_{subtitle.id}{source_path.suffix}"
|
||||
shutil.copy2(source_path, temp_path)
|
||||
|
||||
temp_sub = Subtitle(
|
||||
subtitle.url,
|
||||
subtitle.language,
|
||||
is_original_lang=subtitle.is_original_lang,
|
||||
descriptor=subtitle.descriptor,
|
||||
codec=subtitle.codec,
|
||||
forced=subtitle.forced,
|
||||
sdh=subtitle.sdh,
|
||||
cc=subtitle.cc,
|
||||
id_=f"{subtitle.id}_sc",
|
||||
)
|
||||
temp_sub.path = temp_path
|
||||
try:
|
||||
temp_sub.convert(target_codec)
|
||||
if temp_sub.path and temp_sub.path.exists():
|
||||
shutil.copy2(temp_sub.path, sidecar_path)
|
||||
finally:
|
||||
if temp_sub.path and temp_sub.path.exists():
|
||||
temp_sub.path.unlink(missing_ok=True)
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
created_paths.append(sidecar_path)
|
||||
|
||||
return created_paths
|
||||
|
||||
@click.command(
|
||||
short_help="Download, Decrypt, and Mux tracks for titles from a Service.",
|
||||
cls=Services,
|
||||
@@ -204,9 +297,9 @@ class dl:
|
||||
@click.option(
|
||||
"-a",
|
||||
"--acodec",
|
||||
type=click.Choice(Audio.Codec, case_sensitive=False),
|
||||
default=None,
|
||||
help="Audio Codec to download, defaults to any codec.",
|
||||
type=AUDIO_CODEC_LIST,
|
||||
default=[],
|
||||
help="Audio Codec(s) to download (comma-separated), e.g., 'AAC,EC3'. Defaults to any.",
|
||||
)
|
||||
@click.option(
|
||||
"-vb",
|
||||
@@ -245,6 +338,13 @@ class dl:
|
||||
default=False,
|
||||
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(
|
||||
"-w",
|
||||
"--wanted",
|
||||
@@ -751,7 +851,7 @@ class dl:
|
||||
service: Service,
|
||||
quality: list[int],
|
||||
vcodec: Optional[Video.Codec],
|
||||
acodec: Optional[Audio.Codec],
|
||||
acodec: list[Audio.Codec],
|
||||
vbitrate: int,
|
||||
abitrate: int,
|
||||
range_: list[Video.Range],
|
||||
@@ -789,6 +889,7 @@ class dl:
|
||||
workers: Optional[int],
|
||||
downloads: int,
|
||||
best_available: bool,
|
||||
split_audio: Optional[bool] = None,
|
||||
*_: Any,
|
||||
**__: Any,
|
||||
) -> None:
|
||||
@@ -796,6 +897,15 @@ class dl:
|
||||
self.search_source = None
|
||||
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"]:
|
||||
self.log.error("--require-subs and --s-lang cannot be used together")
|
||||
sys.exit(1)
|
||||
@@ -1307,9 +1417,10 @@ class dl:
|
||||
if not audio_description:
|
||||
title.tracks.select_audio(lambda x: not x.descriptive) # exclude descriptive audio
|
||||
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:
|
||||
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)
|
||||
if channels:
|
||||
title.tracks.select_audio(lambda x: math.ceil(x.channels) == math.ceil(channels))
|
||||
@@ -1348,15 +1459,27 @@ class dl:
|
||||
if "best" in processed_lang:
|
||||
unique_languages = {track.language for track in title.tracks.audio}
|
||||
selected_audio = []
|
||||
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)
|
||||
if acodec and len(acodec) > 1:
|
||||
for language in unique_languages:
|
||||
for codec in acodec:
|
||||
candidates = [
|
||||
track
|
||||
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
|
||||
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, processed_lang, per_language=per_language, exact_match=exact_lang
|
||||
)
|
||||
@@ -1596,6 +1719,25 @@ class dl:
|
||||
break
|
||||
video_track_n += 1
|
||||
|
||||
# Subtitle output mode configuration (for sidecar originals)
|
||||
subtitle_output_mode = config.subtitle.get("output_mode", "mux")
|
||||
sidecar_format = config.subtitle.get("sidecar_format", "srt")
|
||||
skip_subtitle_mux = (
|
||||
subtitle_output_mode == "sidecar" and (title.tracks.videos or title.tracks.audio)
|
||||
)
|
||||
sidecar_subtitles: list[Subtitle] = []
|
||||
sidecar_original_paths: dict[str, Path] = {}
|
||||
if subtitle_output_mode in ("sidecar", "both") and not no_mux:
|
||||
sidecar_subtitles = [s for s in title.tracks.subtitles if s.path and s.path.exists()]
|
||||
if sidecar_format == "original":
|
||||
config.directories.temp.mkdir(parents=True, exist_ok=True)
|
||||
for subtitle in sidecar_subtitles:
|
||||
original_path = (
|
||||
config.directories.temp / f"sidecar_original_{subtitle.id}{subtitle.path.suffix}"
|
||||
)
|
||||
shutil.copy2(subtitle.path, original_path)
|
||||
sidecar_original_paths[subtitle.id] = original_path
|
||||
|
||||
with console.status("Converting Subtitles..."):
|
||||
for subtitle in title.tracks.subtitles:
|
||||
if sub_format:
|
||||
@@ -1657,6 +1799,7 @@ class dl:
|
||||
self.log.info("Repacked one or more tracks with FFMPEG")
|
||||
|
||||
muxed_paths = []
|
||||
muxed_audio_codecs: dict[Path, Optional[Audio.Codec]] = {}
|
||||
|
||||
if no_mux:
|
||||
# Skip muxing, handle individual track files
|
||||
@@ -1673,7 +1816,40 @@ class dl:
|
||||
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
|
||||
if any(r == Video.Range.HYBRID for r in range_) and title.tracks.videos:
|
||||
@@ -1713,11 +1889,8 @@ class dl:
|
||||
if default_output.exists():
|
||||
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
|
||||
task_description = f"Multiplexing Hybrid HDR10+DV {resolution}p"
|
||||
task_tracks = Tracks(title.tracks) + title.tracks.chapters + title.tracks.attachments
|
||||
|
||||
# Create a new video track for the hybrid output
|
||||
@@ -1727,7 +1900,7 @@ class dl:
|
||||
hybrid_track.needs_duration_fix = True
|
||||
task_tracks.videos = [hybrid_track]
|
||||
|
||||
multiplex_tasks.append((task_id, task_tracks))
|
||||
enqueue_mux_tasks(task_description, task_tracks)
|
||||
|
||||
console.print()
|
||||
else:
|
||||
@@ -1740,16 +1913,15 @@ class dl:
|
||||
if len(range_) > 1:
|
||||
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
|
||||
if 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):
|
||||
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?
|
||||
audio_expected = not video_only and not no_audio
|
||||
muxed_path, return_code, errors = task_tracks.mux(
|
||||
@@ -1758,8 +1930,18 @@ class dl:
|
||||
delete=False,
|
||||
audio_expected=audio_expected,
|
||||
title_language=title.language,
|
||||
skip_subtitles=skip_subtitle_mux,
|
||||
)
|
||||
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_audio_codecs[muxed_path] = audio_codec
|
||||
if return_code >= 2:
|
||||
self.log.error(f"Failed to Mux video to Matroska file ({return_code}):")
|
||||
elif return_code == 1 or errors:
|
||||
@@ -1771,8 +1953,31 @@ class dl:
|
||||
self.log.warning(line)
|
||||
if return_code >= 2:
|
||||
sys.exit(1)
|
||||
for video_track in task_tracks.videos:
|
||||
video_track.delete()
|
||||
|
||||
# Output sidecar subtitles before deleting track files
|
||||
if sidecar_subtitles and not no_mux:
|
||||
media_info = MediaInfo.parse(muxed_paths[0]) if muxed_paths else None
|
||||
if media_info:
|
||||
base_filename = title.get_filename(media_info, show_service=not no_source)
|
||||
else:
|
||||
base_filename = str(title)
|
||||
|
||||
sidecar_dir = config.directories.downloads
|
||||
if not no_folder and isinstance(title, (Episode, Song)) and media_info:
|
||||
sidecar_dir /= title.get_filename(media_info, show_service=not no_source, folder=True)
|
||||
sidecar_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with console.status("Saving subtitle sidecar files..."):
|
||||
created = self.output_subtitle_sidecars(
|
||||
sidecar_subtitles,
|
||||
base_filename,
|
||||
sidecar_dir,
|
||||
sidecar_format,
|
||||
original_paths=sidecar_original_paths or None,
|
||||
)
|
||||
if created:
|
||||
self.log.info(f"Saved {len(created)} sidecar subtitle files")
|
||||
|
||||
for track in title.tracks:
|
||||
track.delete()
|
||||
|
||||
@@ -1786,6 +1991,8 @@ class dl:
|
||||
# Clean up temp fonts
|
||||
for temp_path in temp_font_files:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
for temp_path in sidecar_original_paths.values():
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
else:
|
||||
# dont mux
|
||||
@@ -1847,6 +2054,9 @@ class dl:
|
||||
media_info = MediaInfo.parse(muxed_path)
|
||||
final_dir = config.directories.downloads
|
||||
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)):
|
||||
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"]),
|
||||
channels=params.get("channels"),
|
||||
no_atmos=params.get("no_atmos", False),
|
||||
split_audio=params.get("split_audio"),
|
||||
wanted=params.get("wanted", []),
|
||||
latest_episode=params.get("latest_episode", False),
|
||||
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)}"
|
||||
|
||||
if "acodec" in data and data["acodec"]:
|
||||
valid_acodecs = ["AAC", "AC3", "EAC3", "OPUS", "FLAC", "ALAC", "VORBIS", "DTS"]
|
||||
if data["acodec"].upper() not in valid_acodecs:
|
||||
return f"Invalid acodec: {data['acodec']}. Must be one of: {', '.join(valid_acodecs)}"
|
||||
valid_acodecs = ["AAC", "AC3", "EC3", "EAC3", "DD", "DD+", "AC4", "OPUS", "FLAC", "ALAC", "VORBIS", "OGG", "DTS"]
|
||||
if isinstance(data["acodec"], str):
|
||||
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"]:
|
||||
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)
|
||||
acodec:
|
||||
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:
|
||||
type: integer
|
||||
description: Video bitrate in kbps (default - None)
|
||||
|
||||
@@ -314,6 +314,7 @@ class Tracks:
|
||||
progress: Optional[partial] = None,
|
||||
audio_expected: bool = True,
|
||||
title_language: Optional[Language] = None,
|
||||
skip_subtitles: bool = False,
|
||||
) -> tuple[Path, int, list[str]]:
|
||||
"""
|
||||
Multiplex all the Tracks into a Matroska Container file.
|
||||
@@ -328,6 +329,7 @@ class Tracks:
|
||||
if embedded audio metadata should be added.
|
||||
title_language: The title's intended language. Used to select the best video track
|
||||
for audio metadata when multiple video tracks exist.
|
||||
skip_subtitles: Skip muxing subtitle tracks into the container.
|
||||
"""
|
||||
if self.videos and not self.audio and audio_expected:
|
||||
video_track = None
|
||||
@@ -439,34 +441,35 @@ class Tracks:
|
||||
]
|
||||
)
|
||||
|
||||
for st in self.subtitles:
|
||||
if not st.path or not st.path.exists():
|
||||
raise ValueError("Text Track must be downloaded before muxing...")
|
||||
events.emit(events.Types.TRACK_MULTIPLEX, track=st)
|
||||
default = bool(self.audio and is_close_match(st.language, [self.audio[0].language]) and st.forced)
|
||||
cl.extend(
|
||||
[
|
||||
"--track-name",
|
||||
f"0:{st.get_track_name() or ''}",
|
||||
"--language",
|
||||
f"0:{st.language}",
|
||||
"--sub-charset",
|
||||
"0:UTF-8",
|
||||
"--forced-track",
|
||||
f"0:{st.forced}",
|
||||
"--default-track",
|
||||
f"0:{default}",
|
||||
"--hearing-impaired-flag",
|
||||
f"0:{st.sdh}",
|
||||
"--original-flag",
|
||||
f"0:{st.is_original_lang}",
|
||||
"--compression",
|
||||
"0:none", # disable extra compression (probably zlib)
|
||||
"(",
|
||||
str(st.path),
|
||||
")",
|
||||
]
|
||||
)
|
||||
if not skip_subtitles:
|
||||
for st in self.subtitles:
|
||||
if not st.path or not st.path.exists():
|
||||
raise ValueError("Text Track must be downloaded before muxing...")
|
||||
events.emit(events.Types.TRACK_MULTIPLEX, track=st)
|
||||
default = bool(self.audio and is_close_match(st.language, [self.audio[0].language]) and st.forced)
|
||||
cl.extend(
|
||||
[
|
||||
"--track-name",
|
||||
f"0:{st.get_track_name() or ''}",
|
||||
"--language",
|
||||
f"0:{st.language}",
|
||||
"--sub-charset",
|
||||
"0:UTF-8",
|
||||
"--forced-track",
|
||||
f"0:{st.forced}",
|
||||
"--default-track",
|
||||
f"0:{default}",
|
||||
"--hearing-impaired-flag",
|
||||
f"0:{st.sdh}",
|
||||
"--original-flag",
|
||||
f"0:{st.is_original_lang}",
|
||||
"--compression",
|
||||
"0:none", # disable extra compression (probably zlib)
|
||||
"(",
|
||||
str(st.path),
|
||||
")",
|
||||
]
|
||||
)
|
||||
|
||||
if self.chapters:
|
||||
chapters_path = config.directories.temp / config.filenames.chapters.format(
|
||||
|
||||
@@ -5,6 +5,8 @@ import click
|
||||
from click.shell_completion import CompletionItem
|
||||
from pywidevine.cdm import Cdm as WidevineCdm
|
||||
|
||||
from unshackle.core.tracks.audio import Audio
|
||||
|
||||
|
||||
class VideoCodecChoice(click.Choice):
|
||||
"""
|
||||
@@ -241,6 +243,52 @@ class QualityList(click.ParamType):
|
||||
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):
|
||||
"""
|
||||
The multiple choice type allows multiple values to be checked against
|
||||
@@ -288,5 +336,6 @@ class MultipleChoice(click.Choice):
|
||||
SEASON_RANGE = SeasonRange()
|
||||
LANGUAGE_RANGE = LanguageRange()
|
||||
QUALITY_LIST = QualityList()
|
||||
AUDIO_CODEC_LIST = AudioCodecList(Audio.Codec)
|
||||
|
||||
# VIDEO_CODEC_CHOICE will be created dynamically when imported
|
||||
|
||||
@@ -62,6 +62,11 @@ debug_keys:
|
||||
# Muxing configuration
|
||||
muxing:
|
||||
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
|
||||
credentials:
|
||||
@@ -373,6 +378,14 @@ subtitle:
|
||||
# When true, skips pycaption processing for WebVTT files to keep tags like <i>, <b>, positioning intact
|
||||
# Combined with no sub_format setting, ensures subtitles remain in their original format (default: true)
|
||||
preserve_formatting: true
|
||||
# output_mode: Output mode for subtitles
|
||||
# - mux: Embed subtitles in MKV container only (default)
|
||||
# - sidecar: Save subtitles as separate files only
|
||||
# - both: Embed in MKV AND save as sidecar files
|
||||
output_mode: mux
|
||||
# sidecar_format: Format for sidecar subtitle files
|
||||
# Options: srt, vtt, ass, original (keep current format)
|
||||
sidecar_format: srt
|
||||
|
||||
# Configuration for pywidevine and pyplayready's serve functionality
|
||||
serve:
|
||||
|
||||
Reference in New Issue
Block a user