8 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
10 changed files with 270 additions and 131 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

@@ -541,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 = []
@@ -783,23 +788,26 @@ class dl:
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_videos = True keep_audio = False
keep_audio = True keep_subtitles = False
keep_subtitles = True keep_chapters = False
keep_chapters = True
# Handle exclusive flags (only keep one type) if video_only or audio_only or subs_only or chapters_only:
if video_only: if video_only:
keep_audio = keep_subtitles = keep_chapters = False keep_videos = True
elif audio_only: if audio_only:
keep_videos = keep_subtitles = keep_chapters = False keep_audio = True
elif subs_only: if subs_only:
keep_videos = keep_audio = keep_chapters = False keep_subtitles = True
elif chapters_only: if chapters_only:
keep_videos = keep_audio = keep_subtitles = False keep_chapters = True
else:
keep_videos = True
keep_audio = True
keep_subtitles = True
keep_chapters = True
# Handle exclusion flags (remove specific types)
if no_subs: if no_subs:
keep_subtitles = False keep_subtitles = False
if no_audio: if no_audio:
@@ -807,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)
@@ -904,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(

View File

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

View File

@@ -39,17 +39,23 @@ 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:
for header in pssh.wrm_headers: kids = self._extract_kids_from_pssh_b64(pssh_b64)
try: else:
signed_ids, _, _, _ = header.read_attributes() kids = []
except Exception:
continue # Extract KIDs using pyplayready's method (may miss some KIDs)
for signed_id in signed_ids: if not kids:
for header in pssh.wrm_headers:
try: try:
kids.append(UUID(bytes_le=base64.b64decode(signed_id.value))) signed_ids, _, _, _ = header.read_attributes()
except Exception: except Exception:
continue continue
for signed_id in signed_ids:
try:
kids.append(UUID(bytes_le=base64.b64decode(signed_id.value)))
except Exception:
continue
if kid: if kid:
if isinstance(kid, str): if isinstance(kid, str):
@@ -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:

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,38 +126,40 @@ 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:
output.unlink(missing_ok=True) output.unlink(missing_ok=True)
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)]
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",
]
extraction_args = [str(DoviTool)] rpu_extraction = subprocess.run(
if not untouched: extraction_args,
extraction_args += ["-m", "3"] stdout=subprocess.PIPE,
extraction_args += [ stderr=subprocess.PIPE,
"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: if rpu_extraction.returncode:
Path.unlink(config.directories.temp / f"{'RPU' if not untouched else 'RPU_UNT'}.bin") Path.unlink(config.directories.temp / f"{'RPU' if not untouched else 'RPU_UNT'}.bin")
@@ -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,26 +189,28 @@ 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),
"editor", "editor",
"-i", "-i",
config.directories.temp / self.rpu_file, config.directories.temp / self.rpu_file,
"-j", "-j",
config.directories.temp / "L6.json", config.directories.temp / "L6.json",
"-o", "-o",
config.directories.temp / "RPU_L6.bin", config.directories.temp / "RPU_L6.bin",
], ],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
) )
if level6.returncode: if level6.returncode:
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,35 +218,36 @@ 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 = [
str(DoviTool),
"inject-rpu",
"-i",
config.directories.temp / "HDR10.hevc",
"--rpu-in",
config.directories.temp / self.rpu_file,
]
inject_cmd = [ # If we converted from HDR10+, optionally remove HDR10+ metadata during injection
str(DoviTool), # Default to removing HDR10+ metadata since we're converting to DV
"inject-rpu", if self.hdr10plus_to_dv:
"-i", inject_cmd.append("--drop-hdr10plus")
config.directories.temp / "HDR10.hevc", self.log.info(" - Removing HDR10+ metadata during injection")
"--rpu-in",
config.directories.temp / self.rpu_file,
]
# If we converted from HDR10+, optionally remove HDR10+ metadata during injection inject_cmd.extend(["-o", config.directories.temp / self.hevc_file])
# Default to removing HDR10+ metadata since we're converting to DV
if self.hdr10plus_to_dv:
inject_cmd.append("--drop-hdr10plus")
self.log.info(" - Removing HDR10+ metadata during injection")
inject_cmd.extend(["-o", config.directories.temp / self.hevc_file]) inject = subprocess.run(
inject_cmd,
inject = subprocess.run( stdout=subprocess.PIPE,
inject_cmd, stderr=subprocess.PIPE,
stdout=subprocess.PIPE, )
stderr=subprocess.PIPE,
)
if inject.returncode: if inject.returncode:
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,20 +256,19 @@ 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( [
[ str(HDR10PlusTool),
str(HDR10PlusTool), "extract",
"extract", str(config.directories.temp / "HDR10.hevc"),
str(config.directories.temp / "HDR10.hevc"), "-o",
"-o", str(config.directories.temp / self.hdr10plus_file),
str(config.directories.temp / self.hdr10plus_file), ],
], stdout=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
stderr=subprocess.PIPE, )
)
if extraction.returncode: if extraction.returncode:
raise ValueError("Failed extracting HDR10+ metadata") raise ValueError("Failed extracting HDR10+ metadata")
@@ -271,47 +277,49 @@ 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
extra_metadata = {
"cm_version": "V29",
"length": 0, # dovi_tool will figure this out
"level6": {
"max_display_mastering_luminance": 1000,
"min_display_mastering_luminance": 1,
"max_content_light_level": 0,
"max_frame_average_light_level": 0,
},
}
# First create the extra metadata JSON for dovi_tool with open(config.directories.temp / "extra.json", "w") as f:
extra_metadata = { json.dump(extra_metadata, f, indent=2)
"cm_version": "V29",
"length": 0, # dovi_tool will figure this out
"level6": {
"max_display_mastering_luminance": 1000,
"min_display_mastering_luminance": 1,
"max_content_light_level": 0,
"max_frame_average_light_level": 0,
},
}
with open(config.directories.temp / "extra.json", "w") as f: # Generate DV RPU from HDR10+ metadata
json.dump(extra_metadata, f, indent=2) conversion = subprocess.run(
[
# Generate DV RPU from HDR10+ metadata str(DoviTool),
conversion = subprocess.run( "generate",
[ "-j",
str(DoviTool), str(config.directories.temp / "extra.json"),
"generate", "--hdr10plus-json",
"-j", str(config.directories.temp / self.hdr10plus_file),
str(config.directories.temp / "extra.json"), "-o",
"--hdr10plus-json", str(config.directories.temp / "RPU.bin"),
str(config.directories.temp / self.hdr10plus_file), ],
"-o", stdout=subprocess.PIPE,
str(config.directories.temp / "RPU.bin"), stderr=subprocess.PIPE,
], )
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
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

@@ -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" },