12 Commits

Author SHA1 Message Date
Sp5rky
1d4e8bf9ec Update CHANGELOG.md 2025-08-05 17:43:57 -06:00
Andy
b4a1f2236e feat: Bump version to 1.4.0 and update changelog with new features and fixes 2025-08-05 23:37:45 +00:00
Andy
3277ab0d77 feat(playready): Enhance KID extraction from PSSH with base64 support and XML parsing 2025-08-05 23:28:30 +00:00
Andy
be0f7299f8 style(dl): Standardize quotation marks for service attribute checks 2025-08-05 23:27:59 +00:00
Andy
948ef30de7 feat(dl): Add support for services that do not support subtitle downloads 2025-08-05 20:22:08 +00:00
Andy
1bd63ddc91 feat(titles): Better detection of DV across all codecs in Episode and Movie classes dvhe.05.06 was not being detected correctly. 2025-08-05 18:33:51 +00:00
Andy
4dff597af2 feat(dl): Fix track selection to support combining -V, -A, -S flags
Previously, using multiple track selection flags like `-S -A` would not work
as expected. The flags were treated as mutually exclusive, resulting in only
one type of track being downloaded.

This change refactors the track selection logic to properly handle combinations:

- Multiple "only" flags now work together (e.g., `-S -A` downloads both)
- Exclusion flags (`--no-*`) continue to work and override selections
- Default behavior (no flags) remains unchanged

Fixes #10
2025-08-05 15:48:17 +00:00
Andy
8dbdde697d feat(hybrid): Enhance extraction and conversion processes with dymanic spinning bars to follow the rest of the codebase. 2025-08-05 14:57:51 +00:00
Andy
63c697f082 feat(series): Enhance tree representation with season breakdown 2025-08-04 19:30:27 +00:00
Andy
3e0835d9fb feat(dl): Improve DRM track decryption handling 2025-08-04 19:30:27 +00:00
Andy
c6c83ee43b feat(dl): Enhance language selection for video and audio tracks, including original language support 2025-08-04 19:30:27 +00:00
Andy
507690834b feat(tracks): Add support for HLG color transfer characteristics in video arguments 2025-08-04 19:28:11 +00:00
11 changed files with 364 additions and 150 deletions

View File

@@ -5,6 +5,60 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.4.0] - 2025-08-05
### Added
- **HLG Transfer Characteristics Preservation**: Enhanced video muxing to preserve HLG color metadata
- Added automatic detection of HLG video tracks during muxing process
- Implemented `--color-transfer-characteristics 0:18` argument for mkvmerge when processing HLG content
- Prevents incorrect conversion from HLG (18) to BT.2020 (14) transfer characteristics
- Ensures proper HLG playback support on compatible hardware without manual editing
- **Original Language Support**: Enhanced language selection with 'orig' keyword support
- Added support for 'orig' language selector for both video and audio tracks
- Automatically detects and uses the title's original language when 'orig' is specified
- Improved language processing logic with better duplicate handling
- Enhanced help text to document original language selection usage
- **Forced Subtitle Support**: Added option to include forced subtitle tracks
- New functionality to download and include forced subtitle tracks alongside regular subtitles
- **WebVTT Subtitle Filtering**: Enhanced subtitle processing capabilities
- Added filtering for unwanted cues in WebVTT subtitles
- Improved subtitle quality by removing unnecessary metadata
### Changed
- **DRM Track Decryption**: Improved DRM decryption track selection logic
- Enhanced `get_drm_for_cdm()` method usage for better DRM-CDM matching
- Added warning messages when no matching DRM is found for tracks
- Improved error handling and logging for DRM decryption failures
- **Series Tree Representation**: Enhanced episode tree display formatting
- Updated series tree to show season breakdown with episode counts
- Improved visual representation with "S{season}({count})" format
- Better organization of series information in console output
- **Hybrid Processing UI**: Enhanced extraction and conversion processes
- Added dynamic spinning bars to follow the rest of the codebase design
- Improved visual feedback during hybrid HDR processing operations
- **Track Selection Logic**: Enhanced multi-track selection capabilities
- Fixed track selection to support combining -V, -A, -S flags properly
- Improved flexibility in selecting multiple track types simultaneously
- **Service Subtitle Support**: Added configuration for services without subtitle support
- Services can now indicate if they don't support subtitle downloads
- Prevents unnecessary subtitle download attempts for unsupported services
- **Update Checker**: Enhanced update checking logic and cache handling
- Improved rate limiting and caching mechanisms for update checks
- Better performance and reduced API calls to GitHub
### Fixed
- **PlayReady KID Extraction**: Enhanced KID extraction from PSSH data
- Added base64 support and XML parsing for better KID detection
- Fixed issue where only one KID was being extracted for certain services
- Improved multi-KID support for PlayReady protected content
- **Dolby Vision Detection**: Improved DV codec detection across all formats
- Fixed detection of dvhe.05.06 codec which was not being recognized correctly
- Enhanced detection logic in Episode and Movie title classes
- Better support for various Dolby Vision codec variants
## [1.3.0] - 2025-08-03 ## [1.3.0] - 2025-08-03
### Added ### Added

View File

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

View File

@@ -139,7 +139,13 @@ class dl:
default=None, default=None,
help="Wanted episodes, e.g. `S01-S05,S07`, `S01E01-S02E03`, `S02-S02E03`, e.t.c, defaults to all.", help="Wanted episodes, e.g. `S01-S05,S07`, `S01E01-S02E03`, `S02-S02E03`, e.t.c, defaults to all.",
) )
@click.option("-l", "--lang", type=LANGUAGE_RANGE, default="en", help="Language wanted for Video and Audio.") @click.option(
"-l",
"--lang",
type=LANGUAGE_RANGE,
default="en",
help="Language wanted for Video and Audio. Use 'orig' to select the original language, e.g. 'orig,en' for both original and English.",
)
@click.option( @click.option(
"-vl", "-vl",
"--v-lang", "--v-lang",
@@ -535,7 +541,12 @@ class dl:
events.subscribe(events.Types.TRACK_REPACKED, service.on_track_repacked) events.subscribe(events.Types.TRACK_REPACKED, service.on_track_repacked)
events.subscribe(events.Types.TRACK_MULTIPLEX, service.on_track_multiplex) events.subscribe(events.Types.TRACK_MULTIPLEX, service.on_track_multiplex)
if no_subs: if hasattr(service, "NO_SUBTITLES") and service.NO_SUBTITLES:
console.log("Skipping subtitles - service does not support subtitle downloads")
no_subs = True
s_lang = None
title.tracks.subtitles = []
elif no_subs:
console.log("Skipped subtitles as --no-subs was used...") console.log("Skipped subtitles as --no-subs was used...")
s_lang = None s_lang = None
title.tracks.subtitles = [] title.tracks.subtitles = []
@@ -562,8 +573,31 @@ class dl:
) )
with console.status("Sorting tracks by language and bitrate...", spinner="dots"): with console.status("Sorting tracks by language and bitrate...", spinner="dots"):
title.tracks.sort_videos(by_language=v_lang or lang) video_sort_lang = v_lang or lang
title.tracks.sort_audio(by_language=lang) processed_video_sort_lang = []
for language in video_sort_lang:
if language == "orig":
if title.language:
orig_lang = str(title.language) if hasattr(title.language, "__str__") else title.language
if orig_lang not in processed_video_sort_lang:
processed_video_sort_lang.append(orig_lang)
else:
if language not in processed_video_sort_lang:
processed_video_sort_lang.append(language)
processed_audio_sort_lang = []
for language in lang:
if language == "orig":
if title.language:
orig_lang = str(title.language) if hasattr(title.language, "__str__") else title.language
if orig_lang not in processed_audio_sort_lang:
processed_audio_sort_lang.append(orig_lang)
else:
if language not in processed_audio_sort_lang:
processed_audio_sort_lang.append(language)
title.tracks.sort_videos(by_language=processed_video_sort_lang)
title.tracks.sort_audio(by_language=processed_audio_sort_lang)
title.tracks.sort_subtitles(by_language=s_lang) title.tracks.sort_subtitles(by_language=s_lang)
if list_: if list_:
@@ -594,12 +628,27 @@ class dl:
self.log.error(f"There's no {vbitrate}kbps Video Track...") self.log.error(f"There's no {vbitrate}kbps Video Track...")
sys.exit(1) sys.exit(1)
# Filter out "best" from the video languages list.
video_languages = [lang for lang in (v_lang or lang) if lang != "best"] video_languages = [lang for lang in (v_lang or lang) if lang != "best"]
if video_languages and "all" not in video_languages: if video_languages and "all" not in video_languages:
title.tracks.videos = title.tracks.by_language(title.tracks.videos, video_languages) processed_video_lang = []
for language in video_languages:
if language == "orig":
if title.language:
orig_lang = (
str(title.language) if hasattr(title.language, "__str__") else title.language
)
if orig_lang not in processed_video_lang:
processed_video_lang.append(orig_lang)
else:
self.log.warning(
"Original language not available for title, skipping 'orig' selection for video"
)
else:
if language not in processed_video_lang:
processed_video_lang.append(language)
title.tracks.videos = title.tracks.by_language(title.tracks.videos, processed_video_lang)
if not title.tracks.videos: if not title.tracks.videos:
self.log.error(f"There's no {video_languages} Video Track...") self.log.error(f"There's no {processed_video_lang} Video Track...")
sys.exit(1) sys.exit(1)
if quality: if quality:
@@ -702,8 +751,24 @@ class dl:
self.log.error(f"There's no {abitrate}kbps Audio Track...") self.log.error(f"There's no {abitrate}kbps Audio Track...")
sys.exit(1) sys.exit(1)
if lang: if lang:
if "best" in lang: processed_lang = []
# Get unique languages and select highest quality for each for language in lang:
if language == "orig":
if title.language:
orig_lang = (
str(title.language) if hasattr(title.language, "__str__") else title.language
)
if orig_lang not in processed_lang:
processed_lang.append(orig_lang)
else:
self.log.warning(
"Original language not available for title, skipping 'orig' selection"
)
else:
if language not in processed_lang:
processed_lang.append(language)
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: for language in unique_languages:
@@ -713,30 +778,36 @@ class dl:
) )
selected_audio.append(highest_quality) selected_audio.append(highest_quality)
title.tracks.audio = selected_audio title.tracks.audio = selected_audio
elif "all" not in lang: elif "all" not in processed_lang:
title.tracks.audio = title.tracks.by_language(title.tracks.audio, lang, per_language=1) per_language = 0 if len(processed_lang) > 1 else 1
title.tracks.audio = title.tracks.by_language(
title.tracks.audio, processed_lang, per_language=per_language
)
if not title.tracks.audio: if not title.tracks.audio:
self.log.error(f"There's no {lang} Audio Track, cannot continue...") self.log.error(f"There's no {processed_lang} Audio Track, cannot continue...")
sys.exit(1) sys.exit(1)
if video_only or audio_only or subs_only or chapters_only or no_subs or no_audio or no_chapters: if video_only or audio_only or subs_only or chapters_only or no_subs or no_audio or no_chapters:
# Determine which track types to keep based on the flags keep_videos = False
keep_audio = False
keep_subtitles = False
keep_chapters = False
if video_only or audio_only or subs_only or chapters_only:
if video_only:
keep_videos = True
if audio_only:
keep_audio = True
if subs_only:
keep_subtitles = True
if chapters_only:
keep_chapters = True
else:
keep_videos = True keep_videos = True
keep_audio = True keep_audio = True
keep_subtitles = True keep_subtitles = True
keep_chapters = True keep_chapters = True
# Handle exclusive flags (only keep one type)
if video_only:
keep_audio = keep_subtitles = keep_chapters = False
elif audio_only:
keep_videos = keep_subtitles = keep_chapters = False
elif subs_only:
keep_videos = keep_audio = keep_chapters = False
elif chapters_only:
keep_videos = keep_audio = keep_subtitles = False
# Handle exclusion flags (remove specific types)
if no_subs: if no_subs:
keep_subtitles = False keep_subtitles = False
if no_audio: if no_audio:
@@ -744,7 +815,6 @@ class dl:
if no_chapters: if no_chapters:
keep_chapters = False keep_chapters = False
# Build the kept_tracks list without duplicates
kept_tracks = [] kept_tracks = []
if keep_videos: if keep_videos:
kept_tracks.extend(title.tracks.videos) kept_tracks.extend(title.tracks.videos)
@@ -841,6 +911,7 @@ class dl:
while ( while (
not title.tracks.subtitles not title.tracks.subtitles
and not no_subs and not no_subs
and not (hasattr(service, "NO_SUBTITLES") and service.NO_SUBTITLES)
and not video_only and not video_only
and len(title.tracks.videos) > video_track_n and len(title.tracks.videos) > video_track_n
and any( and any(
@@ -929,12 +1000,15 @@ class dl:
with console.status(f"Decrypting tracks with {decrypt_tool}..."): with console.status(f"Decrypting tracks with {decrypt_tool}..."):
has_decrypted = False has_decrypted = False
for track in drm_tracks: for track in drm_tracks:
for drm in track.drm: drm = track.get_drm_for_cdm(self.cdm)
if hasattr(drm, "decrypt"): if drm and hasattr(drm, "decrypt"):
drm.decrypt(track.path, use_mp4decrypt=use_mp4decrypt) drm.decrypt(track.path, use_mp4decrypt=use_mp4decrypt)
has_decrypted = True has_decrypted = True
events.emit(events.Types.TRACK_REPACKED, track=track) events.emit(events.Types.TRACK_REPACKED, track=track)
break else:
self.log.warning(
f"No matching DRM found for track {track} with CDM type {type(self.cdm).__name__}"
)
if has_decrypted: if has_decrypted:
self.log.info(f"Decrypted tracks with {decrypt_tool}") self.log.info(f"Decrypted tracks with {decrypt_tool}")

View File

@@ -1 +1 @@
__version__ = "1.3.0" __version__ = "1.4.0"

View File

@@ -39,7 +39,13 @@ class PlayReady:
if not isinstance(pssh, PSSH): if not isinstance(pssh, PSSH):
raise TypeError(f"Expected pssh to be a {PSSH}, not {pssh!r}") raise TypeError(f"Expected pssh to be a {PSSH}, not {pssh!r}")
kids: list[UUID] = [] if pssh_b64:
kids = self._extract_kids_from_pssh_b64(pssh_b64)
else:
kids = []
# Extract KIDs using pyplayready's method (may miss some KIDs)
if not kids:
for header in pssh.wrm_headers: for header in pssh.wrm_headers:
try: try:
signed_ids, _, _, _ = header.read_attributes() signed_ids, _, _, _ = header.read_attributes()
@@ -72,6 +78,66 @@ class PlayReady:
if pssh_b64: if pssh_b64:
self.data.setdefault("pssh_b64", pssh_b64) self.data.setdefault("pssh_b64", pssh_b64)
def _extract_kids_from_pssh_b64(self, pssh_b64: str) -> list[UUID]:
"""Extract all KIDs from base64-encoded PSSH data."""
try:
import xml.etree.ElementTree as ET
# Decode the PSSH
pssh_bytes = base64.b64decode(pssh_b64)
# Try to find XML in the PSSH data
# PlayReady PSSH usually has XML embedded in it
pssh_str = pssh_bytes.decode("utf-16le", errors="ignore")
# Find WRMHEADER
xml_start = pssh_str.find("<WRMHEADER")
if xml_start == -1:
# Try UTF-8
pssh_str = pssh_bytes.decode("utf-8", errors="ignore")
xml_start = pssh_str.find("<WRMHEADER")
if xml_start != -1:
clean_xml = pssh_str[xml_start:]
xml_end = clean_xml.find("</WRMHEADER>") + len("</WRMHEADER>")
clean_xml = clean_xml[:xml_end]
root = ET.fromstring(clean_xml)
ns = {"pr": "http://schemas.microsoft.com/DRM/2007/03/PlayReadyHeader"}
kids = []
# Extract from CUSTOMATTRIBUTES/KIDS
kid_elements = root.findall(".//pr:CUSTOMATTRIBUTES/pr:KIDS/pr:KID", ns)
for kid_elem in kid_elements:
value = kid_elem.get("VALUE")
if value:
try:
kid_bytes = base64.b64decode(value + "==")
kid_uuid = UUID(bytes_le=kid_bytes)
kids.append(kid_uuid)
except Exception:
pass
# Also get individual KID
individual_kids = root.findall(".//pr:DATA/pr:KID", ns)
for kid_elem in individual_kids:
if kid_elem.text:
try:
kid_bytes = base64.b64decode(kid_elem.text.strip() + "==")
kid_uuid = UUID(bytes_le=kid_bytes)
if kid_uuid not in kids:
kids.append(kid_uuid)
except Exception:
pass
return kids
except Exception:
pass
return []
@classmethod @classmethod
def from_track(cls, track: AnyTrack, session: Optional[Session] = None) -> PlayReady: def from_track(cls, track: AnyTrack, session: Optional[Session] = None) -> PlayReady:
if not session: if not session:

View File

@@ -170,8 +170,9 @@ class Episode(Title):
frame_rate = float(primary_video_track.frame_rate) frame_rate = float(primary_video_track.frame_rate)
if hdr_format: if hdr_format:
if (primary_video_track.hdr_format or "").startswith("Dolby Vision"): if (primary_video_track.hdr_format or "").startswith("Dolby Vision"):
if (primary_video_track.hdr_format_commercial) != "Dolby Vision": name += " DV"
name += f" DV {DYNAMIC_RANGE_MAP.get(hdr_format)} " if DYNAMIC_RANGE_MAP.get(hdr_format) and DYNAMIC_RANGE_MAP.get(hdr_format) != "DV":
name += " HDR"
else: else:
name += f" {DYNAMIC_RANGE_MAP.get(hdr_format)} " name += f" {DYNAMIC_RANGE_MAP.get(hdr_format)} "
elif trc and "HLG" in trc: elif trc and "HLG" in trc:
@@ -201,9 +202,10 @@ class Series(SortedKeyList, ABC):
def tree(self, verbose: bool = False) -> Tree: def tree(self, verbose: bool = False) -> Tree:
seasons = Counter(x.season for x in self) seasons = Counter(x.season for x in self)
num_seasons = len(seasons) num_seasons = len(seasons)
num_episodes = sum(seasons.values()) sum(seasons.values())
season_breakdown = ", ".join(f"S{season}({count})" for season, count in sorted(seasons.items()))
tree = Tree( tree = Tree(
f"{num_seasons} Season{['s', ''][num_seasons == 1]}, {num_episodes} Episode{['s', ''][num_episodes == 1]}", f"{num_seasons} seasons, {season_breakdown}",
guide_style="bright_black", guide_style="bright_black",
) )
if verbose: if verbose:

View File

@@ -121,8 +121,9 @@ class Movie(Title):
frame_rate = float(primary_video_track.frame_rate) frame_rate = float(primary_video_track.frame_rate)
if hdr_format: if hdr_format:
if (primary_video_track.hdr_format or "").startswith("Dolby Vision"): if (primary_video_track.hdr_format or "").startswith("Dolby Vision"):
if (primary_video_track.hdr_format_commercial) != "Dolby Vision": name += " DV"
name += f" DV {DYNAMIC_RANGE_MAP.get(hdr_format)} " if DYNAMIC_RANGE_MAP.get(hdr_format) and DYNAMIC_RANGE_MAP.get(hdr_format) != "DV":
name += " HDR"
else: else:
name += f" {DYNAMIC_RANGE_MAP.get(hdr_format)} " name += f" {DYNAMIC_RANGE_MAP.get(hdr_format)} "
elif trc and "HLG" in trc: elif trc and "HLG" in trc:

View File

@@ -126,8 +126,7 @@ class Hybrid:
def extract_stream(self, save_path, type_): def extract_stream(self, save_path, type_):
output = Path(config.directories.temp / f"{type_}.hevc") output = Path(config.directories.temp / f"{type_}.hevc")
self.log.info(f"+ Extracting {type_} stream") with console.status(f"Extracting {type_} stream...", spinner="dots"):
returncode = self.ffmpeg_simple(save_path, output) returncode = self.ffmpeg_simple(save_path, output)
if returncode: if returncode:
@@ -135,14 +134,17 @@ class Hybrid:
self.log.error(f"x Failed extracting {type_} stream") self.log.error(f"x Failed extracting {type_} stream")
sys.exit(1) sys.exit(1)
self.log.info(f"Extracted {type_} stream")
def extract_rpu(self, video, untouched=False): def extract_rpu(self, video, untouched=False):
if os.path.isfile(config.directories.temp / "RPU.bin") or os.path.isfile( if os.path.isfile(config.directories.temp / "RPU.bin") or os.path.isfile(
config.directories.temp / "RPU_UNT.bin" config.directories.temp / "RPU_UNT.bin"
): ):
return return
self.log.info(f"+ Extracting{' untouched ' if untouched else ' '}RPU from Dolby Vision stream") with console.status(
f"Extracting{' untouched ' if untouched else ' '}RPU from Dolby Vision stream...", spinner="dots"
):
extraction_args = [str(DoviTool)] extraction_args = [str(DoviTool)]
if not untouched: if not untouched:
extraction_args += ["-m", "3"] extraction_args += ["-m", "3"]
@@ -168,6 +170,8 @@ class Hybrid:
else: else:
raise ValueError(f"Failed extracting{' untouched ' if untouched else ' '}RPU from Dolby Vision stream") raise ValueError(f"Failed extracting{' untouched ' if untouched else ' '}RPU from Dolby Vision stream")
self.log.info(f"Extracted{' untouched ' if untouched else ' '}RPU from Dolby Vision stream")
def level_6(self): def level_6(self):
"""Edit RPU Level 6 values""" """Edit RPU Level 6 values"""
with open(config.directories.temp / "L6.json", "w+") as level6_file: with open(config.directories.temp / "L6.json", "w+") as level6_file:
@@ -185,7 +189,7 @@ class Hybrid:
json.dump(level6, level6_file, indent=3) json.dump(level6, level6_file, indent=3)
if not os.path.isfile(config.directories.temp / "RPU_L6.bin"): if not os.path.isfile(config.directories.temp / "RPU_L6.bin"):
self.log.info("+ Editing RPU Level 6 values") with console.status("Editing RPU Level 6 values...", spinner="dots"):
level6 = subprocess.run( level6 = subprocess.run(
[ [
str(DoviTool), str(DoviTool),
@@ -205,6 +209,8 @@ class Hybrid:
Path.unlink(config.directories.temp / "RPU_L6.bin") Path.unlink(config.directories.temp / "RPU_L6.bin")
raise ValueError("Failed editing RPU Level 6 values") raise ValueError("Failed editing RPU Level 6 values")
self.log.info("Edited RPU Level 6 values")
# Update rpu_file to use the edited version # Update rpu_file to use the edited version
self.rpu_file = "RPU_L6.bin" self.rpu_file = "RPU_L6.bin"
@@ -212,8 +218,7 @@ class Hybrid:
if os.path.isfile(config.directories.temp / self.hevc_file): if os.path.isfile(config.directories.temp / self.hevc_file):
return return
self.log.info(f"+ Injecting Dolby Vision metadata into {self.hdr_type} stream") with console.status(f"Injecting Dolby Vision metadata into {self.hdr_type} stream...", spinner="dots"):
inject_cmd = [ inject_cmd = [
str(DoviTool), str(DoviTool),
"inject-rpu", "inject-rpu",
@@ -241,6 +246,8 @@ class Hybrid:
Path.unlink(config.directories.temp / self.hevc_file) Path.unlink(config.directories.temp / self.hevc_file)
raise ValueError("Failed injecting Dolby Vision metadata into HDR10 stream") raise ValueError("Failed injecting Dolby Vision metadata into HDR10 stream")
self.log.info(f"Injected Dolby Vision metadata into {self.hdr_type} stream")
def extract_hdr10plus(self, _video): def extract_hdr10plus(self, _video):
"""Extract HDR10+ metadata from the video stream""" """Extract HDR10+ metadata from the video stream"""
if os.path.isfile(config.directories.temp / self.hdr10plus_file): if os.path.isfile(config.directories.temp / self.hdr10plus_file):
@@ -249,8 +256,7 @@ class Hybrid:
if not HDR10PlusTool: if not HDR10PlusTool:
raise ValueError("HDR10Plus_tool not found. Please install it to use HDR10+ to DV conversion.") raise ValueError("HDR10Plus_tool not found. Please install it to use HDR10+ to DV conversion.")
self.log.info("+ Extracting HDR10+ metadata") with console.status("Extracting HDR10+ metadata...", spinner="dots"):
# HDR10Plus_tool needs raw HEVC stream # HDR10Plus_tool needs raw HEVC stream
extraction = subprocess.run( extraction = subprocess.run(
[ [
@@ -271,13 +277,14 @@ class Hybrid:
if os.path.getsize(config.directories.temp / self.hdr10plus_file) == 0: if os.path.getsize(config.directories.temp / self.hdr10plus_file) == 0:
raise ValueError("No HDR10+ metadata found in the stream") raise ValueError("No HDR10+ metadata found in the stream")
self.log.info("Extracted HDR10+ metadata")
def convert_hdr10plus_to_dv(self): def convert_hdr10plus_to_dv(self):
"""Convert HDR10+ metadata to Dolby Vision RPU""" """Convert HDR10+ metadata to Dolby Vision RPU"""
if os.path.isfile(config.directories.temp / "RPU.bin"): if os.path.isfile(config.directories.temp / "RPU.bin"):
return return
self.log.info("+ Converting HDR10+ metadata to Dolby Vision") with console.status("Converting HDR10+ metadata to Dolby Vision...", spinner="dots"):
# First create the extra metadata JSON for dovi_tool # First create the extra metadata JSON for dovi_tool
extra_metadata = { extra_metadata = {
"cm_version": "V29", "cm_version": "V29",
@@ -312,6 +319,7 @@ class Hybrid:
if conversion.returncode: if conversion.returncode:
raise ValueError("Failed converting HDR10+ to Dolby Vision") raise ValueError("Failed converting HDR10+ to Dolby Vision")
self.log.info("Converted HDR10+ metadata to Dolby Vision")
self.log.info("✓ HDR10+ successfully converted to Dolby Vision Profile 8") self.log.info("✓ HDR10+ successfully converted to Dolby Vision Profile 8")
# Clean up temporary files # Clean up temporary files

View File

@@ -355,6 +355,14 @@ class Tracks:
] ]
) )
if hasattr(vt, "range") and vt.range == Video.Range.HLG:
video_args.extend(
[
"--color-transfer-characteristics",
"0:18", # ARIB STD-B67 (HLG)
]
)
cl.extend(video_args + ["(", str(vt.path), ")"]) cl.extend(video_args + ["(", str(vt.path), ")"])
for i, at in enumerate(self.audio): for i, at in enumerate(self.audio):

View File

@@ -33,6 +33,7 @@ class EXAMPLE(Service):
TITLE_RE = r"^(?:https?://?domain\.com/details/)?(?P<title_id>[^/]+)" TITLE_RE = r"^(?:https?://?domain\.com/details/)?(?P<title_id>[^/]+)"
GEOFENCE = ("US", "UK") GEOFENCE = ("US", "UK")
NO_SUBTITLES = True
@staticmethod @staticmethod
@click.command(name="EXAMPLE", short_help="https://domain.com") @click.command(name="EXAMPLE", short_help="https://domain.com")

2
uv.lock generated
View File

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