From ba69bc7d61e5190ff327f871d77cd25b6e58388b Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Wed, 17 Jun 2026 13:31:21 -0600 Subject: [PATCH] feat(dl): add --merge-video to merge video language variants Group selected videos by (resolution, range, codec) and mux each group into one MKV; only language is collapsed, so ranges/codecs/resolutions stay in separate files. Adds --merge-video flag + muxing.merge_video config (global and per-service), docs, and tests. --- docs/OUTPUT_CONFIG.md | 17 ++++ tests/orchestration/test_merge_video.py | 129 ++++++++++++++++++++++++ unshackle/commands/dl.py | 76 +++++++++----- unshackle/unshackle-example.yaml | 11 +- uv.lock | 2 +- 5 files changed, 207 insertions(+), 28 deletions(-) create mode 100644 tests/orchestration/test_merge_video.py diff --git a/docs/OUTPUT_CONFIG.md b/docs/OUTPUT_CONFIG.md index 1d21b3d..7485618 100644 --- a/docs/OUTPUT_CONFIG.md +++ b/docs/OUTPUT_CONFIG.md @@ -204,6 +204,23 @@ Enable/disable tagging downloaded files with IMDB/TMDB/TVDB identifiers (when av Note: The `--split-audio` CLI flag overrides this setting. When `--split-audio` is passed, `merge_audio` is effectively set to `false` for that run. +- `merge_video` + Merge video **language variants** into one file. Default: `false` + - `false`: One MKV per video track (the default behaviour). + - `true`: Group the selected video tracks by `(resolution, range, codec)` and merge + each group into one MKV, so only language varies within a file. The player switches + between the language tracks. No re-encode, no concatenation. + + Only the language dimension is collapsed. Different **resolutions**, **ranges** + (SDR/HDR10/HDR10+/DV/HYBRID) and **codecs** (H264/H265) always stay in separate files. + For example, `-r HYBRID,DV,HDR10,SDR --merge-video` produces one file per range (never a + single combined file), while a title offering English + French video of the same + resolution/range/codec produces one file containing both video tracks. + + Note: The `--merge-video` CLI flag overrides this setting. Can be set per service under + `services..muxing.merge_video`. Change `group_videos_by_variant` in + `unshackle/commands/dl.py` to adjust the grouping. + - `default_language` (dict) Override which track is flagged as the default in the muxed MKV, regardless of the title's original language. Useful when you always want your player to diff --git a/tests/orchestration/test_merge_video.py b/tests/orchestration/test_merge_video.py new file mode 100644 index 0000000..aaa6c6b --- /dev/null +++ b/tests/orchestration/test_merge_video.py @@ -0,0 +1,129 @@ +"""Tests for ``--merge-video`` track grouping. + +``group_videos_by_variant`` (``unshackle/commands/dl.py``) decides which selected video +tracks share one MKV when merge mode is on. The rule: group by ``(resolution, range, +codec)`` so only language varies within a file; resolutions, ranges and codecs stay +separate. With ``merge=False`` every track is its own group (one file per track). + +These lock the pure grouping unit; the surrounding mux loop is Click-command orchestration. +""" + +from __future__ import annotations + +from unshackle.commands.dl import group_videos_by_variant +from unshackle.core.tracks import Video + + +def make_video( + track_id: str, + *, + range_: Video.Range, + height: int, + codec: Video.Codec, + language: str = "en", +) -> Video: + return Video( + id_=track_id, + url=f"https://example.test/{track_id}.m3u8", + language=language, + codec=codec, + range_=range_, + width=int(height * 16 / 9), + height=height, + bitrate=1_000_000, + ) + + +HEVC = Video.Codec.HEVC +AVC = Video.Codec.AVC +SDR = Video.Range.SDR +HDR10 = Video.Range.HDR10 +DV = Video.Range.DV + + +def test_merge_collapses_language_only() -> None: + """Same (height, range, codec), different language → one group.""" + videos = [ + make_video("en", range_=SDR, height=1080, codec=HEVC, language="en"), + make_video("fr", range_=SDR, height=1080, codec=HEVC, language="fr"), + ] + groups = group_videos_by_variant(videos, merge=True) + assert len(groups) == 1 + assert [v.id for v in groups[0]] == ["en", "fr"] + + +def test_merge_splits_on_codec() -> None: + """H264 vs H265 of the same resolution+range → separate groups.""" + videos = [ + make_video("hevc", range_=SDR, height=1080, codec=HEVC), + make_video("avc", range_=SDR, height=1080, codec=AVC), + ] + groups = group_videos_by_variant(videos, merge=True) + assert len(groups) == 2 + assert all(len(g) == 1 for g in groups) + + +def test_merge_splits_on_range() -> None: + """SDR vs HDR10 of the same resolution+codec → separate groups.""" + videos = [ + make_video("sdr", range_=SDR, height=1080, codec=HEVC), + make_video("hdr10", range_=HDR10, height=1080, codec=HEVC), + ] + groups = group_videos_by_variant(videos, merge=True) + assert len(groups) == 2 + + +def test_merge_splits_on_resolution() -> None: + """1080p vs 2160p of the same range+codec → separate groups.""" + videos = [ + make_video("1080", range_=SDR, height=1080, codec=HEVC), + make_video("2160", range_=SDR, height=2160, codec=HEVC), + ] + groups = group_videos_by_variant(videos, merge=True) + assert len(groups) == 2 + + +def test_merge_multi_range_yields_one_group_per_range() -> None: + """Regression guard: -r HYBRID,DV,HDR10,SDR must never collapse into one file. + + HYBRID is resolved upstream into a DV deliverable plus the requested standalone + ranges; here the four selected single-range tracks must stay in four groups. + """ + videos = [ + make_video("sdr", range_=SDR, height=2160, codec=HEVC), + make_video("hdr10", range_=HDR10, height=2160, codec=HEVC), + make_video("dv", range_=DV, height=2160, codec=HEVC), + make_video("dv-hybrid", range_=DV, height=1080, codec=HEVC), # different height + ] + groups = group_videos_by_variant(videos, merge=True) + assert len(groups) == 4 + + +def test_no_merge_yields_one_group_per_track() -> None: + """merge=False reproduces today's per-track behaviour exactly.""" + videos = [ + make_video("en", range_=SDR, height=1080, codec=HEVC, language="en"), + make_video("fr", range_=SDR, height=1080, codec=HEVC, language="fr"), + make_video("avc", range_=SDR, height=1080, codec=AVC), + ] + groups = group_videos_by_variant(videos, merge=False) + assert len(groups) == 3 + assert all(len(g) == 1 for g in groups) + + +def test_merge_preserves_first_seen_order() -> None: + """Group order follows first-seen track order, for stable output filenames.""" + videos = [ + make_video("hevc-en", range_=SDR, height=1080, codec=HEVC, language="en"), + make_video("avc-en", range_=SDR, height=1080, codec=AVC, language="en"), + make_video("hevc-fr", range_=SDR, height=1080, codec=HEVC, language="fr"), + ] + groups = group_videos_by_variant(videos, merge=True) + # HEVC group seen first (and gathers both languages), AVC group second. + assert [v.id for v in groups[0]] == ["hevc-en", "hevc-fr"] + assert [v.id for v in groups[1]] == ["avc-en"] + + +def test_empty_input_returns_empty() -> None: + assert group_videos_by_variant([], merge=True) == [] + assert group_videos_by_variant([], merge=False) == [] diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 8cc0dc6..af61d51 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -48,16 +48,8 @@ from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, from unshackle.core.credential import Credential from unshackle.core.drm import DRM_T, ClearKeyCENC, MonaLisa, PlayReady, Widevine from unshackle.core.events import events -from unshackle.core.music import ( - MusicAudioIntegrityError, - MusicMetadataResult, - MusicPlanner, - MusicRenderer, - file_md5, - verify_music_audio, - write_music_manifest, - write_music_metadata, -) +from unshackle.core.music import (MusicAudioIntegrityError, MusicMetadataResult, MusicPlanner, MusicRenderer, + file_md5, verify_music_audio, write_music_manifest, write_music_metadata) from unshackle.core.proxies import Basic, Gluetun, Hola, NordVPN, SurfsharkVPN, WindscribeVPN from unshackle.core.service import Service from unshackle.core.services import Services @@ -100,6 +92,22 @@ def normalize_dl_config(dl_config: dict[str, Any]) -> dict[str, Any]: return {DL_OPTION_ALIASES.get(key, key): value for key, value in dl_config.items()} +def group_videos_by_variant(videos: list[Video], *, merge: bool) -> list[list[Video]]: + """Group video tracks for muxing. + + When ``merge`` is True, tracks sharing ``(height, range, codec)`` are grouped into one + file so only language varies within a group; different resolutions, ranges and codecs + stay in separate groups (separate files). When False, each track is its own group + (one file per track, the default behaviour). Group order follows first-seen track order. + """ + if not merge: + return [[video] for video in videos] + groups: dict[tuple[Any, ...], list[Video]] = {} + for video in videos: + groups.setdefault((video.height, video.range, video.codec), []).append(video) + return list(groups.values()) + + def apply_service_dl_overrides(ctx: click.Context, service_dl_config: dict[str, Any], log: logging.Logger) -> None: """Apply ``services..dl`` config onto ``ctx.params``. Explicit CLI/env values win; defaults and global ``dl:`` default_map values are replaced.""" @@ -459,6 +467,13 @@ class dl: default=None, help="Create separate output files per audio codec instead of merging all audio.", ) + @click.option( + "--merge-video", + "merge_video", + is_flag=True, + default=None, + help="Mux all selected video tracks into a single file instead of one file per track.", + ) @click.option( "--select-titles", is_flag=True, @@ -1173,6 +1188,7 @@ class dl: worst: bool, best_available: bool, split_audio: Optional[bool] = None, + merge_video: Optional[bool] = None, real_video_bitrate: bool = False, real_audio_bitrate: bool = False, progress_sink: Optional[Callable[[dict[str, Any]], None]] = None, @@ -3062,6 +3078,9 @@ class dl: # When we split audio (merge_audio=False), multiple outputs may exist per title, so suffix codec. append_audio_codec_suffix = not merge_audio + # Mux all selected video tracks into one file instead of one file per track. + merge_video = merge_video if merge_video is not None else config.muxing.get("merge_video", False) + multiplex_tasks: list[tuple[TaskID, Tracks, Optional[Audio.Codec]]] = [] # Track hybrid-processing outputs explicitly so we can always clean them up, # even if muxing fails early (e.g. SystemExit) before the normal delete loop. @@ -3097,22 +3116,25 @@ class dl: task_tracks = clone_tracks_for_audio(base_tracks, codec_audio_tracks) multiplex_tasks.append((task_id, task_tracks, audio_codec)) - def mux_video_standalone(video_track: Optional[Video]) -> None: - if video_track and video_track.dv_compatible_bitstream: - apply_dv_fixup(video_track) + def mux_video_group(video_tracks: list[Optional[Video]]) -> None: + for video_track in video_tracks: + if video_track and video_track.dv_compatible_bitstream: + apply_dv_fixup(video_track) task_description = "Multiplexing" - if video_track: + # All tracks in a merged group share height/range/codec, so describe from the first. + head = next((v for v in video_tracks if v), None) + if head: if len(quality) > 1: - task_description += f" {video_track.height}p" + task_description += f" {head.height}p" if len(range_) > 1: - task_description += f" {video_track.range.name}" + task_description += f" {head.range.name}" if len(vcodec) > 1: - task_description += f" {video_track.codec.name}" + task_description += f" {head.codec.name}" task_tracks = Tracks(title.tracks) + title.tracks.chapters + title.tracks.attachments - if video_track: - task_tracks.videos = [video_track] + if head: + task_tracks.videos = [v for v in video_tracks if v] enqueue_mux_tasks(task_description, task_tracks) @@ -3172,16 +3194,18 @@ class dl: enqueue_mux_tasks(task_description, task_tracks) # Mux every requested range standalone, skipping the ingredient-only DV. - for video_track in original_videos: - if video_track.hybrid_base_only: - continue - mux_video_standalone(video_track) + # merge_video collapses only language variants (same height/range/codec). + standalone_videos = [v for v in original_videos if not v.hybrid_base_only] + for group in group_videos_by_variant(standalone_videos, merge=merge_video): + mux_video_group(group) console.print() else: - # Normal mode: process each video track separately - for video_track in title.tracks.videos or [None]: - mux_video_standalone(video_track) + # Normal mode: one file per video track, unless merge_video groups + # same-(height, range, codec) language variants into one file. + groups = group_videos_by_variant(title.tracks.videos, merge=merge_video) + for group in groups or [[None]]: + mux_video_group(group) if progress_sink: progress_sink( diff --git a/unshackle/unshackle-example.yaml b/unshackle/unshackle-example.yaml index 49484de..39781e3 100644 --- a/unshackle/unshackle-example.yaml +++ b/unshackle/unshackle-example.yaml @@ -158,6 +158,14 @@ muxing: # false: Separate MKV per (quality, audio_codec) combination # Example: Title.1080p.AAC.mkv, Title.1080p.EC3.mkv merge_audio: true + # merge_video: Merge video language variants into one file + # false (default): One file per video track + # true: Group videos by (resolution, range, codec) and merge each group into one + # MKV - so only language varies within a file. Different resolutions, ranges + # (SDR/HDR10/DV/...) and codecs (H264/H265) still produce separate files. + # Example: -r HYBRID,DV,HDR10,SDR yields one file per range, not one mega-file. + # The --merge-video CLI flag overrides this. + merge_video: false # default_language: Override which track is flagged as the default in the muxed MKV. # audio: BCP-47 tag of the preferred default audio track (e.g. pl, en, pt-BR). # Wins over the title's original_language. Falls back to is_original_lang @@ -675,9 +683,10 @@ services: User-Agent: "Service-specific user agent string" Accept-Language: "en-US,en;q=0.9" - # Override muxing options + # Override muxing options (always merge this service's video tracks into one file) muxing: set_title: true + merge_video: true # Remap service-provided titles before naming/output # Keyed by the exact title the service returns -> desired output title. diff --git a/uv.lock b/uv.lock index 7971ec4..40e47b0 100644 --- a/uv.lock +++ b/uv.lock @@ -1779,7 +1779,7 @@ wheels = [ [[package]] name = "unshackle" -version = "5.1.0" +version = "5.2.0" source = { editable = "." } dependencies = [ { name = "aiohttp" },