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,
|
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
|
||||||
@@ -179,6 +179,99 @@ class dl:
|
|||||||
self.log.info(f" $ sudo apt install {package_cmd}")
|
self.log.info(f" $ sudo apt install {package_cmd}")
|
||||||
self.log.info(f" → Provides: {', '.join(fonts)}")
|
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(
|
@click.command(
|
||||||
short_help="Download, Decrypt, and Mux tracks for titles from a Service.",
|
short_help="Download, Decrypt, and Mux tracks for titles from a Service.",
|
||||||
cls=Services,
|
cls=Services,
|
||||||
@@ -204,9 +297,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 +338,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 +851,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 +889,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 +897,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 +1417,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 +1459,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
|
||||||
)
|
)
|
||||||
@@ -1596,6 +1719,25 @@ class dl:
|
|||||||
break
|
break
|
||||||
video_track_n += 1
|
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..."):
|
with console.status("Converting Subtitles..."):
|
||||||
for subtitle in title.tracks.subtitles:
|
for subtitle in title.tracks.subtitles:
|
||||||
if sub_format:
|
if sub_format:
|
||||||
@@ -1657,6 +1799,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 +1816,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 +1889,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 +1900,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 +1913,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(
|
||||||
@@ -1758,8 +1930,18 @@ class dl:
|
|||||||
delete=False,
|
delete=False,
|
||||||
audio_expected=audio_expected,
|
audio_expected=audio_expected,
|
||||||
title_language=title.language,
|
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_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 +1953,31 @@ 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()
|
# 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:
|
for track in title.tracks:
|
||||||
track.delete()
|
track.delete()
|
||||||
|
|
||||||
@@ -1786,6 +1991,8 @@ class dl:
|
|||||||
# Clean up temp fonts
|
# Clean up temp fonts
|
||||||
for temp_path in temp_font_files:
|
for temp_path in temp_font_files:
|
||||||
temp_path.unlink(missing_ok=True)
|
temp_path.unlink(missing_ok=True)
|
||||||
|
for temp_path in sidecar_original_paths.values():
|
||||||
|
temp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# dont mux
|
# dont mux
|
||||||
@@ -1847,6 +2054,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)
|
||||||
|
|||||||
@@ -314,6 +314,7 @@ class Tracks:
|
|||||||
progress: Optional[partial] = None,
|
progress: Optional[partial] = None,
|
||||||
audio_expected: bool = True,
|
audio_expected: bool = True,
|
||||||
title_language: Optional[Language] = None,
|
title_language: Optional[Language] = None,
|
||||||
|
skip_subtitles: bool = False,
|
||||||
) -> tuple[Path, int, list[str]]:
|
) -> tuple[Path, int, list[str]]:
|
||||||
"""
|
"""
|
||||||
Multiplex all the Tracks into a Matroska Container file.
|
Multiplex all the Tracks into a Matroska Container file.
|
||||||
@@ -328,6 +329,7 @@ class Tracks:
|
|||||||
if embedded audio metadata should be added.
|
if embedded audio metadata should be added.
|
||||||
title_language: The title's intended language. Used to select the best video track
|
title_language: The title's intended language. Used to select the best video track
|
||||||
for audio metadata when multiple video tracks exist.
|
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:
|
if self.videos and not self.audio and audio_expected:
|
||||||
video_track = None
|
video_track = None
|
||||||
@@ -439,34 +441,35 @@ class Tracks:
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
for st in self.subtitles:
|
if not skip_subtitles:
|
||||||
if not st.path or not st.path.exists():
|
for st in self.subtitles:
|
||||||
raise ValueError("Text Track must be downloaded before muxing...")
|
if not st.path or not st.path.exists():
|
||||||
events.emit(events.Types.TRACK_MULTIPLEX, track=st)
|
raise ValueError("Text Track must be downloaded before muxing...")
|
||||||
default = bool(self.audio and is_close_match(st.language, [self.audio[0].language]) and st.forced)
|
events.emit(events.Types.TRACK_MULTIPLEX, track=st)
|
||||||
cl.extend(
|
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 ''}",
|
"--track-name",
|
||||||
"--language",
|
f"0:{st.get_track_name() or ''}",
|
||||||
f"0:{st.language}",
|
"--language",
|
||||||
"--sub-charset",
|
f"0:{st.language}",
|
||||||
"0:UTF-8",
|
"--sub-charset",
|
||||||
"--forced-track",
|
"0:UTF-8",
|
||||||
f"0:{st.forced}",
|
"--forced-track",
|
||||||
"--default-track",
|
f"0:{st.forced}",
|
||||||
f"0:{default}",
|
"--default-track",
|
||||||
"--hearing-impaired-flag",
|
f"0:{default}",
|
||||||
f"0:{st.sdh}",
|
"--hearing-impaired-flag",
|
||||||
"--original-flag",
|
f"0:{st.sdh}",
|
||||||
f"0:{st.is_original_lang}",
|
"--original-flag",
|
||||||
"--compression",
|
f"0:{st.is_original_lang}",
|
||||||
"0:none", # disable extra compression (probably zlib)
|
"--compression",
|
||||||
"(",
|
"0:none", # disable extra compression (probably zlib)
|
||||||
str(st.path),
|
"(",
|
||||||
")",
|
str(st.path),
|
||||||
]
|
")",
|
||||||
)
|
]
|
||||||
|
)
|
||||||
|
|
||||||
if self.chapters:
|
if self.chapters:
|
||||||
chapters_path = config.directories.temp / config.filenames.chapters.format(
|
chapters_path = config.directories.temp / config.filenames.chapters.format(
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -373,6 +378,14 @@ subtitle:
|
|||||||
# When true, skips pycaption processing for WebVTT files to keep tags like <i>, <b>, positioning intact
|
# 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)
|
# Combined with no sub_format setting, ensures subtitles remain in their original format (default: true)
|
||||||
preserve_formatting: 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
|
# Configuration for pywidevine and pyplayready's serve functionality
|
||||||
serve:
|
serve:
|
||||||
|
|||||||
Reference in New Issue
Block a user