From cacb6950939405e09e204ee112f57af7f3e66d84 Mon Sep 17 00:00:00 2001 From: Andy Date: Mon, 2 Feb 2026 21:43:27 -0700 Subject: [PATCH] fix(subtitles): preserve sidecar originals Use original subtitle files for sidecar output while keeping muxed conversion behavior. Fixes #59 --- unshackle/commands/dl.py | 140 +++++++++++++++++++++++++++++++ unshackle/core/tracks/tracks.py | 59 ++++++------- unshackle/unshackle-example.yaml | 8 ++ 3 files changed, 179 insertions(+), 28 deletions(-) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 85421d3..cde73cc 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -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, @@ -1626,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: @@ -1818,6 +1930,7 @@ class dl: delete=False, audio_expected=audio_expected, title_language=title.language, + skip_subtitles=skip_subtitle_mux, ) if muxed_path.exists(): mux_index += 1 @@ -1840,6 +1953,31 @@ class dl: self.log.warning(line) if return_code >= 2: sys.exit(1) + + # 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() @@ -1853,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 diff --git a/unshackle/core/tracks/tracks.py b/unshackle/core/tracks/tracks.py index fd9d78d..ce41d10 100644 --- a/unshackle/core/tracks/tracks.py +++ b/unshackle/core/tracks/tracks.py @@ -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( diff --git a/unshackle/unshackle-example.yaml b/unshackle/unshackle-example.yaml index 66617f6..281140b 100644 --- a/unshackle/unshackle-example.yaml +++ b/unshackle/unshackle-example.yaml @@ -378,6 +378,14 @@ subtitle: # When true, skips pycaption processing for WebVTT files to keep tags like , , 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: