Merge pull request #1 from unshackle-dl/Hybrid-HDR

Hybrid HDR
This commit is contained in:
Sp5rky
2025-07-29 20:40:24 -04:00
committed by GitHub
16 changed files with 625 additions and 60 deletions

33
CHANGELOG.md Normal file
View File

@@ -0,0 +1,33 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.1.0] - 2025-07-29
### Added
- **HDR10+DV Hybrid Processing**: New `-r HYBRID` command for processing HDR10 and Dolby Vision tracks
- Support for hybrid HDR processing and injection using dovi_tool
- New hybrid track processing module for seamless HDR10/DV conversion
- Automatic detection and handling of HDR10 and DV metadata
- Support for HDR10 and DV tracks in hybrid mode for EXAMPLE service
- Binary availability check for dovi_tool in hybrid mode operations
- Enhanced track processing capabilities for HDR content
### Fixed
- Import order issues and missing json import in hybrid processing
- UV installation process and error handling improvements
- Binary search functionality updated to use `binaries.find`
### Changed
- Updated package version from 1.0.2 to 1.1.0
- Enhanced dl.py command processing for hybrid mode support
- Improved core titles (episode/movie) processing for HDR content
- Extended tracks module with hybrid processing capabilities

View File

@@ -14,6 +14,7 @@ unshackle is a fork of [Devine](https://github.com/devine-dl/devine/), a powerfu
- 🎥 **Multi-Media Support** - Movies, TV episodes, and music
- 🛠️ **Built-in Parsers** - DASH/HLS and ISM manifest support
- 🔒 **DRM Support** - Widevine and PlayReady integration
- 🌈 **HDR10+DV Hybrid** - Hybrid Dolby Vision injection via [dovi_tool](https://github.com/quietvoid/dovi_tool)
- 💾 **Flexible Storage** - Local and remote key vaults
- 👥 **Multi-Profile Auth** - Support for cookies and credentials
- 🤖 **Smart Naming** - Automatic P2P-style filename structure
@@ -87,7 +88,6 @@ docker run --rm unshackle env check
## Planned Features
- 🌈 **HDR10+DV Hybrid Support** - Allow support for hybrid HDR10+ and Dolby Vision.
- 🖥️ **Web UI Access & Control** - Manage and control unshackle from a modern web interface.
- 🔄 **Sonarr/Radarr Interactivity** - Direct integration for automated personal downloads.
- ⚙️ **Better ISM Support** - Improve on ISM support for multiple services

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "unshackle"
version = "1.0.1"
version = "1.1.0"
description = "Modular Movie, TV, and Music Archival Software."
authors = [{ name = "unshackle team" }]
requires-python = ">=3.10,<3.13"

View File

@@ -55,6 +55,7 @@ from unshackle.core.titles import Movie, Movies, Series, Song, Title_T
from unshackle.core.titles.episode import Episode
from unshackle.core.tracks import Audio, Subtitle, Tracks, Video
from unshackle.core.tracks.attachment import Attachment
from unshackle.core.tracks.hybrid import Hybrid
from unshackle.core.utilities import get_system_fonts, is_close_match, time_elapsed_since
from unshackle.core.utils import tags
from unshackle.core.utils.click_types import (LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE, ContextData, MultipleChoice,
@@ -399,6 +400,14 @@ class dl:
self.tmdb_searched = False
start_time = time.time()
# Check if dovi_tool is available when hybrid mode is requested
if any(r == Video.Range.HYBRID for r in range_):
from unshackle.core.binaries import DoviTool
if not DoviTool:
self.log.error("Unable to run hybrid mode: dovi_tool not detected")
self.log.error("Please install dovi_tool from https://github.com/quietvoid/dovi_tool")
sys.exit(1)
if cdm_only is None:
vaults_only = None
else:
@@ -539,10 +548,12 @@ class dl:
sys.exit(1)
if range_:
title.tracks.select_video(lambda x: x.range in range_)
missing_ranges = [r for r in range_ if not any(x.range == r for x in title.tracks.videos)]
for color_range in missing_ranges:
self.log.warning(f"Skipping {color_range.name} video tracks as none are available.")
# Special handling for HYBRID - don't filter, keep all HDR10 and DV tracks
if Video.Range.HYBRID not in range_:
title.tracks.select_video(lambda x: x.range in range_)
missing_ranges = [r for r in range_ if not any(x.range == r for x in title.tracks.videos)]
for color_range in missing_ranges:
self.log.warning(f"Skipping {color_range.name} video tracks as none are available.")
if vbitrate:
title.tracks.select_video(lambda x: x.bitrate and x.bitrate // 1000 == vbitrate)
@@ -559,38 +570,60 @@ class dl:
sys.exit(1)
if quality:
title.tracks.by_resolutions(quality)
missing_resolutions = []
for resolution in quality:
if any(video.height == resolution for video in title.tracks.videos):
continue
if any(int(video.width * (9 / 16)) == resolution for video in title.tracks.videos):
continue
missing_resolutions.append(resolution)
if any(r == Video.Range.HYBRID for r in range_):
title.tracks.select_video(title.tracks.select_hybrid(title.tracks.videos, quality))
else:
title.tracks.by_resolutions(quality)
for resolution in quality:
if any(v.height == resolution for v in title.tracks.videos):
continue
if any(int(v.width * 9 / 16) == resolution for v in title.tracks.videos):
continue
missing_resolutions.append(resolution)
if missing_resolutions:
res_list = ""
if len(missing_resolutions) > 1:
res_list = (", ".join([f"{x}p" for x in missing_resolutions[:-1]])) + " or "
res_list = ", ".join([f"{x}p" for x in missing_resolutions[:-1]]) + " or "
res_list = f"{res_list}{missing_resolutions[-1]}p"
plural = "s" if len(missing_resolutions) > 1 else ""
self.log.error(f"There's no {res_list} Video Track{plural}...")
sys.exit(1)
# choose best track by range and quality
selected_videos: list[Video] = []
for resolution, color_range in product(quality or [None], range_ or [None]):
match = next(
(
t
for t in title.tracks.videos
if (not resolution or t.height == resolution or int(t.width * (9 / 16)) == resolution)
and (not color_range or t.range == color_range)
),
None,
)
if match and match not in selected_videos:
selected_videos.append(match)
title.tracks.videos = selected_videos
if any(r == Video.Range.HYBRID for r in range_):
# For hybrid mode, always apply hybrid selection
# If no quality specified, use only the best (highest) resolution
if not quality:
# Get the highest resolution available
best_resolution = max((v.height for v in title.tracks.videos), default=None)
if best_resolution:
# Use the hybrid selection logic with only the best resolution
title.tracks.select_video(
title.tracks.select_hybrid(title.tracks.videos, [best_resolution])
)
# If quality was specified, hybrid selection was already applied above
else:
selected_videos: list[Video] = []
for resolution, color_range in product(quality or [None], range_ or [None]):
match = next(
(
t
for t in title.tracks.videos
if (
not resolution
or t.height == resolution
or int(t.width * (9 / 16)) == resolution
)
and (not color_range or t.range == color_range)
),
None,
)
if match and match not in selected_videos:
selected_videos.append(match)
title.tracks.videos = selected_videos
# filter subtitle tracks
if s_lang and "all" not in s_lang:
@@ -871,21 +904,52 @@ class dl:
)
multiplex_tasks: list[tuple[TaskID, Tracks]] = []
for video_track in title.tracks.videos or [None]:
task_description = "Multiplexing"
if video_track:
if len(quality) > 1:
task_description += f" {video_track.height}p"
if len(range_) > 1:
task_description += f" {video_track.range.name}"
# Check if we're in hybrid mode
if any(r == Video.Range.HYBRID for r in range_) and title.tracks.videos:
# Hybrid mode: process DV and HDR10 tracks together
self.log.info("Processing Hybrid HDR10+DV tracks...")
# Run the hybrid processing
Hybrid(title.tracks.videos, self.service)
# After hybrid processing, the output file should be in temp directory
hybrid_output_path = config.directories.temp / "HDR10-DV.hevc"
# Create a single mux task for the hybrid output
task_description = "Multiplexing Hybrid HDR10+DV"
task_id = progress.add_task(f"{task_description}...", total=None, start=False)
# Create tracks with the hybrid video output
task_tracks = Tracks(title.tracks) + title.tracks.chapters + title.tracks.attachments
if video_track:
task_tracks.videos = [video_track]
# Create a new video track for the hybrid output
# Use the HDR10 track as a template but update its path
hdr10_track = next((v for v in title.tracks.videos if v.range == Video.Range.HDR10), None)
if hdr10_track:
hybrid_track = deepcopy(hdr10_track)
hybrid_track.path = hybrid_output_path
hybrid_track.range = Video.Range.DV # It's now a DV track
task_tracks.videos = [hybrid_track]
multiplex_tasks.append((task_id, task_tracks))
else:
# Normal mode: process each video track separately
for video_track in title.tracks.videos or [None]:
task_description = "Multiplexing"
if video_track:
if len(quality) > 1:
task_description += f" {video_track.height}p"
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))
with Live(Padding(progress, (0, 5, 1, 5)), console=console):
for task_id, task_tracks in multiplex_tasks:

View File

@@ -35,7 +35,7 @@ def check() -> None:
dependencies = [
{"name": "FFMpeg", "binary": binaries.FFMPEG, "required": True},
{"name": "FFProbe", "binary": binaries.FFProbe, "required": True},
{"name": "Shaka-Packager", "binary": binaries.ShakaPackager, "required": True},
{"name": "shaka-packager", "binary": binaries.ShakaPackager, "required": True},
{"name": "MKVToolNix", "binary": binaries.MKVToolNix, "required": True},
{"name": "Mkvpropedit", "binary": binaries.Mkvpropedit, "required": True},
{"name": "CCExtractor", "binary": binaries.CCExtractor, "required": False},
@@ -46,6 +46,7 @@ def check() -> None:
{"name": "MPV", "binary": binaries.MPV, "required": False},
{"name": "Caddy", "binary": binaries.Caddy, "required": False},
{"name": "N_m3u8DL-RE", "binary": binaries.N_m3u8DL_RE, "required": False},
{"name": "dovi_tool", "binary": binaries.DoviTool, "required": False},
]
for dep in dependencies:

View File

@@ -1 +1 @@
__version__ = "1.0.1"
__version__ = "1.1.0"

View File

@@ -51,6 +51,7 @@ Caddy = find("caddy")
N_m3u8DL_RE = find("N_m3u8DL-RE", "n-m3u8dl-re")
MKVToolNix = find("mkvmerge")
Mkvpropedit = find("mkvpropedit")
DoviTool = find("dovi_tool")
__all__ = (
@@ -67,5 +68,6 @@ __all__ = (
"N_m3u8DL_RE",
"MKVToolNix",
"Mkvpropedit",
"DoviTool",
"find",
)

View File

@@ -7,7 +7,7 @@ DOWNLOAD_LICENCE_ONLY = Event()
DRM_SORT_MAP = ["ClearKey", "Widevine"]
LANGUAGE_MAX_DISTANCE = 5 # this is max to be considered "same", e.g., en, en-US, en-AU
VIDEO_CODEC_MAP = {"AVC": "H.264", "HEVC": "H.265"}
DYNAMIC_RANGE_MAP = {"HDR10": "HDR", "HDR10+": "HDR", "Dolby Vision": "DV"}
DYNAMIC_RANGE_MAP = {"HDR10": "HDR", "HDR10+": "HDR10P", "Dolby Vision": "DV", "HDR10 / HDR10+": "HDR10P", "HDR10 / HDR10": "HDR"}
AUDIO_CODEC_MAP = {"E-AC-3": "DDP", "AC-3": "DD"}
context_settings = dict(

View File

@@ -107,10 +107,6 @@ class Episode(Title):
name=self.name or "",
).strip()
# MULTi
if unique_audio_languages > 1:
name += " MULTi"
# Resolution
if primary_video_track:
resolution = primary_video_track.height
@@ -135,6 +131,14 @@ class Episode(Title):
# 'WEB-DL'
name += " WEB-DL"
# DUAL
if unique_audio_languages == 2:
name += " DUAL"
# MULTi
if unique_audio_languages > 2:
name += " MULTi"
# Audio Codec + Channels (+ feature)
if primary_audio_track:
codec = primary_audio_track.format
@@ -157,7 +161,11 @@ class Episode(Title):
trc = primary_video_track.transfer_characteristics or primary_video_track.transfer_characteristics_original
frame_rate = float(primary_video_track.frame_rate)
if hdr_format:
name += f" {DYNAMIC_RANGE_MAP.get(hdr_format)} "
if (primary_video_track.hdr_format or "").startswith("Dolby Vision"):
if (primary_video_track.hdr_format_commercial) != "Dolby Vision":
name += f" DV {DYNAMIC_RANGE_MAP.get(hdr_format)} "
else:
name += f" {DYNAMIC_RANGE_MAP.get(hdr_format)} "
elif trc and "HLG" in trc:
name += " HLG"
if frame_rate > 30:

View File

@@ -58,10 +58,6 @@ class Movie(Title):
# Name (Year)
name = str(self).replace("$", "S") # e.g., Arli$$
# MULTi
if unique_audio_languages > 1:
name += " MULTi"
# Resolution
if primary_video_track:
resolution = primary_video_track.height
@@ -86,6 +82,14 @@ class Movie(Title):
# 'WEB-DL'
name += " WEB-DL"
# DUAL
if unique_audio_languages == 2:
name += " DUAL"
# MULTi
if unique_audio_languages > 2:
name += " MULTi"
# Audio Codec + Channels (+ feature)
if primary_audio_track:
codec = primary_audio_track.format
@@ -108,7 +112,11 @@ class Movie(Title):
trc = primary_video_track.transfer_characteristics or primary_video_track.transfer_characteristics_original
frame_rate = float(primary_video_track.frame_rate)
if hdr_format:
name += f" {DYNAMIC_RANGE_MAP.get(hdr_format)} "
if (primary_video_track.hdr_format or "").startswith("Dolby Vision"):
if (primary_video_track.hdr_format_commercial) != "Dolby Vision":
name += f" DV {DYNAMIC_RANGE_MAP.get(hdr_format)} "
else:
name += f" {DYNAMIC_RANGE_MAP.get(hdr_format)} "
elif trc and "HLG" in trc:
name += " HLG"
if frame_rate > 30:

View File

@@ -2,9 +2,10 @@ from .attachment import Attachment
from .audio import Audio
from .chapter import Chapter
from .chapters import Chapters
from .hybrid import Hybrid
from .subtitle import Subtitle
from .track import Track
from .tracks import Tracks
from .video import Video
__all__ = ("Audio", "Attachment", "Chapter", "Chapters", "Subtitle", "Track", "Tracks", "Video")
__all__ = ("Audio", "Attachment", "Chapter", "Chapters", "Hybrid", "Subtitle", "Track", "Tracks", "Video")

View File

@@ -0,0 +1,369 @@
import json
import logging
import os
import subprocess
import sys
from pathlib import Path
from rich.padding import Padding
from rich.rule import Rule
from unshackle.core.binaries import DoviTool
from unshackle.core.config import config
from unshackle.core.console import console
class Hybrid:
def __init__(self, videos, source) -> None:
self.log = logging.getLogger("hybrid")
"""
Takes the Dolby Vision and HDR10(+) streams out of the VideoTracks.
It will then attempt to inject the Dolby Vision metadata layer to the HDR10(+) stream.
"""
global directories
from unshackle.core.tracks import Video
self.videos = videos
self.source = source
self.rpu_file = "RPU.bin"
self.hdr_type = "HDR10"
self.hevc_file = f"{self.hdr_type}-DV.hevc"
console.print(Padding(Rule("[rule.text]HDR10+DV Hybrid"), (1, 2)))
for video in self.videos:
if not video.path or not os.path.exists(video.path):
self.log.exit(f" - Video track {video.id} was not downloaded before injection.")
if not any(video.range == Video.Range.DV for video in self.videos) or not any(
video.range == Video.Range.HDR10 for video in self.videos
):
self.log.exit(" - Two VideoTracks available but one of them is not DV nor HDR10(+).")
if os.path.isfile(config.directories.temp / self.hevc_file):
self.log.info("✓ Already Injected")
return
for video in videos:
# Use the actual path from the video track
save_path = video.path
if not save_path or not os.path.exists(save_path):
self.log.exit(f" - Video track {video.id} was not downloaded or path not found: {save_path}")
if video.range == Video.Range.HDR10:
self.extract_stream(save_path, "HDR10")
elif video.range == Video.Range.DV:
self.extract_stream(save_path, "DV")
# self.extract_dv_stream(video, save_path)
self.extract_rpu([video for video in videos if video.range == Video.Range.DV][0])
if os.path.isfile(config.directories.temp / "RPU_UNT.bin"):
self.rpu_file = "RPU_UNT.bin"
self.level_6()
# Mode 3 conversion already done during extraction when not untouched
elif os.path.isfile(config.directories.temp / "RPU.bin"):
# RPU already extracted with mode 3
pass
self.injecting()
self.log.info("✓ Injection Completed")
if self.source == ("itunes" or "appletvplus"):
Path.unlink(config.directories.temp / "hdr10.mkv")
Path.unlink(config.directories.temp / "dv.mkv")
Path.unlink(config.directories.temp / "DV.hevc")
Path.unlink(config.directories.temp / "HDR10.hevc")
Path.unlink(config.directories.temp / f"{self.rpu_file}")
def ffmpeg_simple(self, save_path, output):
"""Simple ffmpeg execution without progress tracking"""
p = subprocess.run(
[
"ffmpeg",
"-nostdin",
"-i",
str(save_path),
"-c:v",
"copy",
str(output),
"-y", # overwrite output
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
return p.returncode
def extract_stream(self, save_path, type_):
output = Path(config.directories.temp / f"{type_}.hevc")
self.log.info(f"+ Extracting {type_} stream")
returncode = self.ffmpeg_simple(save_path, output)
if returncode:
output.unlink(missing_ok=True)
self.log.error(f"x Failed extracting {type_} stream")
sys.exit(1)
def ffmpeg_task(self, save_path, output, task_id):
p = subprocess.Popen(
[
"ffmpeg",
"-nostdin",
"-i",
str(save_path),
"-c:v",
"copy",
str(output),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=1,
universal_newlines=True,
)
self.progress.start_task(task_id)
for line in p.stderr:
if "frame=" in line:
self.progress.update(task_id, advance=0)
p.wait()
return p.returncode
def extract_hdr10_stream(self, video, save_path):
type_ = "HDR10"
if os.path.isfile(Path(config.directories.temp / f"{type_}.hevc")):
return
if self.source == "itunes" or self.source == "appletvplus":
self.log.info("+ Muxing HDR10 stream for fixing MP4 file")
subprocess.run(
[
"mkvmerge",
"-o",
Path(config.directories.temp / "hdr10.mkv"),
save_path,
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
self.log.info(f"+ Extracting {type_} stream")
extract_stream = subprocess.run(
[
"ffmpeg",
"-nostdin",
"-stats",
"-i",
Path(config.directories.temp / "hdr10.mkv"),
"-c:v",
"copy",
Path(config.directories.temp / f"{type_}.hevc"),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if extract_stream.returncode:
Path.unlink(Path(config.directories.temp / f"{type_}.hevc"))
self.log.error(f"x Failed extracting {type_} stream")
sys.exit(1)
else:
extract_stream = subprocess.run(
[
"ffmpeg",
"-nostdin",
"-stats",
"-i",
save_path,
"-c:v",
"copy",
Path(config.directories.temp / f"{type_}.hevc"),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if extract_stream.returncode:
Path.unlink(Path(config.directories.temp / f"{type_}.hevc"))
self.log.error(f"x Failed extracting {type_} stream")
sys.exit(1)
def extract_dv_stream(self, video, save_path):
type_ = "DV"
if os.path.isfile(Path(config.directories.temp / f"{type_}.hevc")):
return
if self.source == "itunes" or self.source == "appletvplus":
self.log.info("+ Muxing Dolby Vision stream for fixing MP4 file")
subprocess.run(
[
"mkvmerge",
"-o",
Path(config.directories.temp / "dv.mkv"),
save_path,
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
self.log.info("+ Extracting Dolby Vision stream")
extract_stream = subprocess.run(
[
"ffmpeg",
"-nostdin",
"-stats",
"-i",
Path(config.directories.temp / "dv.mkv"),
"-an",
"-c:v",
"copy",
"-f",
"hevc",
Path(config.directories.temp / "out_1.h265"),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if extract_stream.returncode:
Path.unlink(Path(config.directories.temp / f"{type_}.hevc"))
self.log.error(f"x Failed extracting {type_} stream")
sys.exit(1)
else:
extract_stream = subprocess.run(
[
"mp4demuxer",
"--input-file",
save_path,
"--output-folder",
Path(config.directories.temp),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if extract_stream.returncode:
Path.unlink(Path(config.directories.temp / f"{type_}.hevc"))
self.log.error(f"x Failed extracting {type_} stream")
sys.exit(1)
def extract_rpu(self, video, untouched=False):
if os.path.isfile(config.directories.temp / "RPU.bin") or os.path.isfile(
config.directories.temp / "RPU_UNT.bin"
):
return
self.log.info(f"+ Extracting{' untouched ' if untouched else ' '}RPU from Dolby Vision stream")
extraction_args = [str(DoviTool)]
if not untouched:
extraction_args += ["-m", "3"]
extraction_args += [
"extract-rpu",
config.directories.temp / "DV.hevc",
"-o",
config.directories.temp / f"{'RPU' if not untouched else 'RPU_UNT'}.bin",
]
rpu_extraction = subprocess.run(
extraction_args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if rpu_extraction.returncode:
Path.unlink(config.directories.temp / f"{'RPU' if not untouched else 'RPU_UNT'}.bin")
if b"MAX_PQ_LUMINANCE" in rpu_extraction.stderr:
self.extract_rpu(video, untouched=True)
elif b"Invalid PPS index" in rpu_extraction.stderr:
self.log.exit("x Dolby Vision VideoTrack seems to be corrupt")
else:
self.log.exit(f"x Failed extracting{' untouched ' if untouched else ' '}RPU from Dolby Vision stream")
def level_6(self):
"""Edit RPU Level 6 values"""
with open(config.directories.temp / "L6.json", "w+") as level6_file:
level6 = {
"cm_version": "V29",
"length": 0,
"level6": {
"max_display_mastering_luminance": 1000,
"min_display_mastering_luminance": 1,
"max_content_light_level": 0,
"max_frame_average_light_level": 0,
},
}
json.dump(level6, level6_file, indent=3)
if not os.path.isfile(config.directories.temp / "RPU_L6.bin"):
self.log.info("+ Editing RPU Level 6 values")
level6 = subprocess.run(
[
str(DoviTool),
"editor",
"-i",
config.directories.temp / self.rpu_file,
"-j",
config.directories.temp / "L6.json",
"-o",
config.directories.temp / "RPU_L6.bin",
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if level6.returncode:
Path.unlink(config.directories.temp / "RPU_L6.bin")
self.log.exit("x Failed editing RPU Level 6 values")
# Update rpu_file to use the edited version
self.rpu_file = "RPU_L6.bin"
def mode_3(self):
"""Convert RPU to Mode 3"""
with open(config.directories.temp / "M3.json", "w+") as mode3_file:
json.dump({"mode": 3}, mode3_file, indent=3)
if not os.path.isfile(config.directories.temp / "RPU_M3.bin"):
self.log.info("+ Converting RPU to Mode 3")
mode3 = subprocess.run(
[
str(DoviTool),
"editor",
"-i",
config.directories.temp / self.rpu_file,
"-j",
config.directories.temp / "M3.json",
"-o",
config.directories.temp / "RPU_M3.bin",
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if mode3.returncode:
Path.unlink(config.directories.temp / "RPU_M3.bin")
self.log.exit("x Failed converting RPU to Mode 3")
self.rpu_file = "RPU_M3.bin"
def injecting(self):
if os.path.isfile(config.directories.temp / self.hevc_file):
return
self.log.info(f"+ Injecting Dolby Vision metadata into {self.hdr_type} stream")
inject = subprocess.run(
[
str(DoviTool),
"inject-rpu",
"-i",
config.directories.temp / f"{self.hdr_type}.hevc",
"--rpu-in",
config.directories.temp / self.rpu_file,
"-o",
config.directories.temp / self.hevc_file,
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if inject.returncode:
Path.unlink(config.directories.temp / self.hevc_file)
self.log.exit("x Failed injecting Dolby Vision metadata into HDR10 stream")

View File

@@ -254,6 +254,31 @@ class Tracks:
def select_subtitles(self, x: Callable[[Subtitle], bool]) -> None:
self.subtitles = list(filter(x, self.subtitles))
def select_hybrid(self, tracks, quality):
hdr10_tracks = [
v
for v in tracks
if v.range == Video.Range.HDR10 and (v.height in quality or int(v.width * 9 / 16) in quality)
]
hdr10 = []
for res in quality:
candidates = [v for v in hdr10_tracks if v.height == res or int(v.width * 9 / 16) == res]
if candidates:
best = max(candidates, key=lambda v: v.bitrate) # assumes .bitrate exists
hdr10.append(best)
dv_tracks = [v for v in tracks if v.range == Video.Range.DV]
lowest_dv = min(dv_tracks, key=lambda v: v.height) if dv_tracks else None
def select(x):
if x in hdr10:
return True
if lowest_dv and x is lowest_dv:
return True
return False
return select
def by_resolutions(self, resolutions: list[int], per_resolution: int = 0) -> None:
# Note: Do not merge these list comprehensions. They must be done separately so the results
# from the 16:9 canvas check is only used if there's no exact height resolution match.

View File

@@ -94,6 +94,7 @@ class Video(Track):
HDR10 = "HDR10" # https://en.wikipedia.org/wiki/HDR10
HDR10P = "HDR10+" # https://en.wikipedia.org/wiki/HDR10%2B
DV = "DV" # https://en.wikipedia.org/wiki/Dolby_Vision
HYBRID = "HYBRID" # Selects both HDR10 and DV tracks for hybrid processing with DoviTool
@staticmethod
def from_cicp(primaries: int, transfer: int, matrix: int) -> Video.Range:

View File

@@ -16,7 +16,7 @@ from unshackle.core.manifests import DASH
from unshackle.core.search_result import SearchResult
from unshackle.core.service import Service
from unshackle.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
from unshackle.core.tracks import Chapter, Subtitle, Tracks
from unshackle.core.tracks import Chapter, Subtitle, Tracks, Video
class EXAMPLE(Service):
@@ -49,6 +49,11 @@ class EXAMPLE(Service):
self.title = title
self.movie = movie
self.device = device
self.cdm = ctx.obj.cdm
# Get range parameter for HDR support
range_param = ctx.parent.params.get("range_")
self.range = range_param[0].name if range_param else "SDR"
if self.config is None:
raise Exception("Config is missing!")
@@ -160,15 +165,54 @@ class EXAMPLE(Service):
return Series(episodes)
def get_tracks(self, title: Title_T) -> Tracks:
# Handle HYBRID mode by fetching both HDR10 and DV tracks separately
if self.range == "HYBRID" and self.cdm.security_level != 3:
tracks = Tracks()
# Get HDR10 tracks
hdr10_tracks = self._get_tracks_for_range(title, "HDR10")
tracks.add(hdr10_tracks, warn_only=True)
# Get DV tracks
dv_tracks = self._get_tracks_for_range(title, "DV")
tracks.add(dv_tracks, warn_only=True)
return tracks
else:
# Normal single-range behavior
return self._get_tracks_for_range(title, self.range)
def _get_tracks_for_range(self, title: Title_T, range_override: str = None) -> Tracks:
# Use range_override if provided, otherwise use self.range
current_range = range_override if range_override else self.range
# Build API request parameters
params = {
"token": self.token,
"guid": title.id,
}
data = {
"type": self.config["client"][self.device]["type"],
}
# Add range-specific parameters
if current_range == "HDR10":
data["video_format"] = "hdr10"
elif current_range == "DV":
data["video_format"] = "dolby_vision"
else:
data["video_format"] = "sdr"
# Only request high-quality HDR content with L1 CDM
if current_range in ("HDR10", "DV") and self.cdm.security_level == 3:
# L3 CDM - skip HDR content
return Tracks()
streams = self.session.post(
url=self.config["endpoints"]["streams"],
params={
"token": self.token,
"guid": title.id,
},
data={
"type": self.config["client"][self.device]["type"],
},
params=params,
data=data,
).json()["media"]
self.license = {
@@ -182,6 +226,15 @@ class EXAMPLE(Service):
self.log.debug(f"Manifest URL: {manifest_url}")
tracks = DASH.from_url(url=manifest_url, session=self.session).to_tracks(language=title.language)
# Set range attributes on video tracks
for video in tracks.videos:
if current_range == "HDR10":
video.range = Video.Range.HDR10
elif current_range == "DV":
video.range = Video.Range.DV
else:
video.range = Video.Range.SDR
# Remove DRM-free ("clear") audio tracks
tracks.audio = [
track for track in tracks.audio if "clear" not in track.data["dash"]["representation"].get("id")

2
uv.lock generated
View File

@@ -1505,7 +1505,7 @@ wheels = [
[[package]]
name = "unshackle"
version = "1.0.1"
version = "1.1.0"
source = { editable = "." }
dependencies = [
{ name = "appdirs" },