mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-03-12 17:39:01 +00:00
Compare commits
11 Commits
6975f4f9f4
...
2.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc9823cd28 | ||
|
|
6fa3554b70 | ||
|
|
2d4bf140fa | ||
|
|
d0816787ce | ||
|
|
3d384b8e3e | ||
|
|
e0a666ada6 | ||
|
|
26c81779fa | ||
|
|
3b32462251 | ||
|
|
a7a8c882d8 | ||
|
|
7cc4af207e | ||
|
|
492134b8ff |
41
CHANGELOG.md
41
CHANGELOG.md
@@ -5,6 +5,47 @@ 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).
|
||||
|
||||
## [2.1.0] - 2025-11-27
|
||||
|
||||
### Added
|
||||
|
||||
- **Per-Track Quality-Based CDM Selection**: Dynamic CDM switching during runtime DRM operations
|
||||
- Enables quality-based CDM selection during runtime DRM switching
|
||||
- Different CDMs can be used for different video quality levels within the same download session
|
||||
- Example: Use Widevine L3 for SD/HD and PlayReady SL3 for 4K content
|
||||
- **Enhanced Track Export**: Improved export functionality with additional metadata
|
||||
- Added URL field to track export for easier identification
|
||||
- Added descriptor information in export output
|
||||
- Keys now exported in hex-formatted strings
|
||||
|
||||
### Changed
|
||||
|
||||
- **Dependencies**: Upgraded to latest compatible versions
|
||||
- Updated various dependencies to their latest versions
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Attachment Preservation**: Fixed attachments being dropped during track filtering
|
||||
- Attachments (screenshots, fonts) were being lost when track list was rebuilt
|
||||
- Fixes image files remaining in temp directory after muxing
|
||||
- **DASH BaseURL Resolution**: Added AdaptationSet-level BaseURL support per DASH spec
|
||||
- URL resolution chain now properly follows: MPD → Period → AdaptationSet → Representation
|
||||
- **WindscribeVPN Region Support**: Restricted to supported regions with proper error handling
|
||||
- Added error handling for unsupported regions in get_proxy method
|
||||
- Prevents cryptic errors when using unsupported region codes
|
||||
- **Filename Sanitization**: Fixed space-hyphen-space handling in filenames
|
||||
- Pre-process space-hyphen-space patterns (e.g., "Title - Episode") before other replacements
|
||||
- Made space-hyphen-space handling conditional on scene_naming setting
|
||||
- Addresses PR #44 by fixing the root cause
|
||||
- **CICP Enum Values**: Corrected values to match ITU-T H.273 specification
|
||||
- Added Primaries.Unspecified (value 2) per H.273 spec
|
||||
- Renamed Primaries/Transfer value 0 from Unspecified to Reserved for spec accuracy
|
||||
- Simplified Transfer value 2 from Unspecified_Image to Unspecified
|
||||
- Verified against ITU-T H.273, ISO/IEC 23091-2, H.264/H.265 specs, and FFmpeg enums
|
||||
- **HLS Byte Range Parsing**: Fixed TypeError in range_offset conversion
|
||||
- Converted range_offset to int to prevent TypeError in calculate_byte_range
|
||||
- **pyplayready Compatibility**: Pinned to <0.7 to avoid KID extraction bug
|
||||
|
||||
## [2.0.0] - 2025-11-10
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "unshackle"
|
||||
version = "2.0.0"
|
||||
version = "2.1.0"
|
||||
description = "Modular Movie, TV, and Music Archival Software."
|
||||
authors = [{ name = "unshackle team" }]
|
||||
requires-python = ">=3.10,<3.13"
|
||||
|
||||
@@ -1368,6 +1368,7 @@ class dl:
|
||||
kept_tracks.extend(title.tracks.subtitles)
|
||||
if keep_chapters:
|
||||
kept_tracks.extend(title.tracks.chapters)
|
||||
kept_tracks.extend(title.tracks.attachments)
|
||||
|
||||
title.tracks = Tracks(kept_tracks)
|
||||
|
||||
@@ -1454,6 +1455,7 @@ class dl:
|
||||
)
|
||||
):
|
||||
download.result()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
console.print(Padding(":x: Download Cancelled...", (0, 5, 1, 5)))
|
||||
if self.debug_logger:
|
||||
@@ -1846,25 +1848,32 @@ class dl:
|
||||
if not drm:
|
||||
return
|
||||
|
||||
track_quality = None
|
||||
if isinstance(track, Video) and track.height:
|
||||
pass
|
||||
track_quality = track.height
|
||||
|
||||
if isinstance(drm, Widevine):
|
||||
if not isinstance(self.cdm, (WidevineCdm, DecryptLabsRemoteCDM)) or (
|
||||
isinstance(self.cdm, DecryptLabsRemoteCDM) and self.cdm.is_playready
|
||||
):
|
||||
widevine_cdm = self.get_cdm(self.service, self.profile, drm="widevine")
|
||||
widevine_cdm = self.get_cdm(self.service, self.profile, drm="widevine", quality=track_quality)
|
||||
if widevine_cdm:
|
||||
self.log.info("Switching to Widevine CDM for Widevine content")
|
||||
if track_quality:
|
||||
self.log.info(f"Switching to Widevine CDM for Widevine {track_quality}p content")
|
||||
else:
|
||||
self.log.info("Switching to Widevine CDM for Widevine content")
|
||||
self.cdm = widevine_cdm
|
||||
|
||||
elif isinstance(drm, PlayReady):
|
||||
if not isinstance(self.cdm, (PlayReadyCdm, DecryptLabsRemoteCDM)) or (
|
||||
isinstance(self.cdm, DecryptLabsRemoteCDM) and not self.cdm.is_playready
|
||||
):
|
||||
playready_cdm = self.get_cdm(self.service, self.profile, drm="playready")
|
||||
playready_cdm = self.get_cdm(self.service, self.profile, drm="playready", quality=track_quality)
|
||||
if playready_cdm:
|
||||
self.log.info("Switching to PlayReady CDM for PlayReady content")
|
||||
if track_quality:
|
||||
self.log.info(f"Switching to PlayReady CDM for PlayReady {track_quality}p content")
|
||||
else:
|
||||
self.log.info("Switching to PlayReady CDM for PlayReady content")
|
||||
self.cdm = playready_cdm
|
||||
|
||||
if isinstance(drm, Widevine):
|
||||
@@ -2028,12 +2037,21 @@ class dl:
|
||||
if export:
|
||||
keys = {}
|
||||
if export.is_file():
|
||||
keys = jsonpickle.loads(export.read_text(encoding="utf8"))
|
||||
keys = jsonpickle.loads(export.read_text(encoding="utf8")) or {}
|
||||
if str(title) not in keys:
|
||||
keys[str(title)] = {}
|
||||
if str(track) not in keys[str(title)]:
|
||||
keys[str(title)][str(track)] = {}
|
||||
keys[str(title)][str(track)].update(drm.content_keys)
|
||||
|
||||
track_data = keys[str(title)][str(track)]
|
||||
track_data["url"] = track.url
|
||||
track_data["descriptor"] = track.descriptor.name
|
||||
|
||||
if "keys" not in track_data:
|
||||
track_data["keys"] = {}
|
||||
for kid, key in drm.content_keys.items():
|
||||
track_data["keys"][kid.hex] = key
|
||||
|
||||
export.write_text(jsonpickle.dumps(keys, indent=4), encoding="utf8")
|
||||
|
||||
elif isinstance(drm, PlayReady):
|
||||
@@ -2173,12 +2191,21 @@ class dl:
|
||||
if export:
|
||||
keys = {}
|
||||
if export.is_file():
|
||||
keys = jsonpickle.loads(export.read_text(encoding="utf8"))
|
||||
keys = jsonpickle.loads(export.read_text(encoding="utf8")) or {}
|
||||
if str(title) not in keys:
|
||||
keys[str(title)] = {}
|
||||
if str(track) not in keys[str(title)]:
|
||||
keys[str(title)][str(track)] = {}
|
||||
keys[str(title)][str(track)].update(drm.content_keys)
|
||||
|
||||
track_data = keys[str(title)][str(track)]
|
||||
track_data["url"] = track.url
|
||||
track_data["descriptor"] = track.descriptor.name
|
||||
|
||||
if "keys" not in track_data:
|
||||
track_data["keys"] = {}
|
||||
for kid, key in drm.content_keys.items():
|
||||
track_data["keys"][kid.hex] = key
|
||||
|
||||
export.write_text(jsonpickle.dumps(keys, indent=4), encoding="utf8")
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.0.0"
|
||||
__version__ = "2.1.0"
|
||||
|
||||
@@ -297,8 +297,9 @@ class DASH:
|
||||
manifest_base_url = track.url
|
||||
elif not re.match("^https?://", manifest_base_url, re.IGNORECASE):
|
||||
manifest_base_url = urljoin(track.url, f"./{manifest_base_url}")
|
||||
period_base_url = urljoin(manifest_base_url, period.findtext("BaseURL"))
|
||||
rep_base_url = urljoin(period_base_url, representation.findtext("BaseURL"))
|
||||
period_base_url = urljoin(manifest_base_url, period.findtext("BaseURL") or "")
|
||||
adaptation_set_base_url = urljoin(period_base_url, adaptation_set.findtext("BaseURL") or "")
|
||||
rep_base_url = urljoin(adaptation_set_base_url, representation.findtext("BaseURL") or "")
|
||||
|
||||
period_duration = period.get("duration") or manifest.get("mediaPresentationDuration")
|
||||
init_data: Optional[bytes] = None
|
||||
|
||||
@@ -313,7 +313,7 @@ class HLS:
|
||||
|
||||
if segment.byterange:
|
||||
byte_range = HLS.calculate_byte_range(segment.byterange, range_offset)
|
||||
range_offset = byte_range.split("-")[0]
|
||||
range_offset = int(byte_range.split("-")[0])
|
||||
else:
|
||||
byte_range = None
|
||||
|
||||
|
||||
@@ -44,8 +44,17 @@ class WindscribeVPN(Proxy):
|
||||
def get_proxy(self, query: str) -> Optional[str]:
|
||||
"""
|
||||
Get an HTTPS proxy URI for a WindscribeVPN server.
|
||||
|
||||
Note: Windscribe's static OpenVPN credentials work reliably on US, AU, and NZ servers.
|
||||
"""
|
||||
query = query.lower()
|
||||
supported_regions = {"us", "au", "nz"}
|
||||
|
||||
if query not in supported_regions and query not in self.server_map:
|
||||
raise ValueError(
|
||||
f"Windscribe proxy does not currently support the '{query.upper()}' region. "
|
||||
f"Supported regions with reliable credentials: {', '.join(sorted(supported_regions))}. "
|
||||
)
|
||||
|
||||
if query in self.server_map:
|
||||
hostname = self.server_map[query]
|
||||
@@ -58,6 +67,7 @@ class WindscribeVPN(Proxy):
|
||||
if not hostname:
|
||||
return None
|
||||
|
||||
hostname = hostname.split(':')[0]
|
||||
return f"https://{self.username}:{self.password}@{hostname}:443"
|
||||
|
||||
def get_random_server(self, country_code: str) -> Optional[str]:
|
||||
|
||||
@@ -99,24 +99,42 @@ class Video(Track):
|
||||
@staticmethod
|
||||
def from_cicp(primaries: int, transfer: int, matrix: int) -> Video.Range:
|
||||
"""
|
||||
ISO/IEC 23001-8 Coding-independent code points to Video Range.
|
||||
Convert CICP (Coding-Independent Code Points) values to Video Range.
|
||||
|
||||
CICP is defined in ITU-T H.273 and ISO/IEC 23091-2 for signaling video
|
||||
color properties independently of the compression codec. These values are
|
||||
used across AVC (H.264), HEVC (H.265), VVC, AV1, and other modern codecs.
|
||||
|
||||
The enum values (Primaries, Transfer, Matrix) match the official specifications:
|
||||
- ITU-T H.273: Coding-independent code points for video signal type identification
|
||||
- ISO/IEC 23091-2: Information technology — Coding-independent code points — Part 2: Video
|
||||
- H.264 Table E-3 (Colour Primaries) and Table E-4 (Transfer Characteristics)
|
||||
- H.265 Table E.3 and E.4 (identical to H.264)
|
||||
|
||||
Note: Value 0 = "Reserved" and Value 2 = "Unspecified" per specification.
|
||||
While both effectively mean "unknown" in practice, the distinction matters for
|
||||
spec compliance. Value 2 was added based on user feedback (GitHub issue) and
|
||||
verified against FFmpeg's AVColorPrimaries/AVColorTransferCharacteristic enums.
|
||||
|
||||
Sources:
|
||||
https://www.itu.int/rec/T-REC-H.Sup19-202104-I
|
||||
- https://www.itu.int/rec/T-REC-H.273
|
||||
- https://www.itu.int/rec/T-REC-H.Sup19-202104-I
|
||||
- https://github.com/FFmpeg/FFmpeg/blob/master/libavutil/pixfmt.h
|
||||
"""
|
||||
|
||||
class Primaries(Enum):
|
||||
Unspecified = 0
|
||||
Reserved = 0
|
||||
BT_709 = 1
|
||||
Unspecified = 2
|
||||
BT_601_625 = 5
|
||||
BT_601_525 = 6
|
||||
BT_2020_and_2100 = 9
|
||||
SMPTE_ST_2113_and_EG_4321 = 12 # P3D65
|
||||
|
||||
class Transfer(Enum):
|
||||
Unspecified = 0
|
||||
Reserved = 0
|
||||
BT_709 = 1
|
||||
Unspecified_Image = 2
|
||||
Unspecified = 2
|
||||
BT_601 = 6
|
||||
BT_2020 = 14
|
||||
BT_2100 = 15
|
||||
@@ -143,7 +161,7 @@ class Video(Track):
|
||||
|
||||
# primaries and matrix does not strictly correlate to a range
|
||||
|
||||
if (primaries, transfer, matrix) == (0, 0, 0):
|
||||
if (primaries, transfer, matrix) == (Primaries.Reserved, Transfer.Reserved, Matrix.RGB):
|
||||
return Video.Range.SDR
|
||||
elif primaries in (Primaries.BT_601_625, Primaries.BT_601_525):
|
||||
return Video.Range.SDR
|
||||
|
||||
@@ -127,6 +127,8 @@ def sanitize_filename(filename: str, spacer: str = ".") -> str:
|
||||
# remove or replace further characters as needed
|
||||
filename = "".join(c for c in filename if unicodedata.category(c) != "Mn") # hidden characters
|
||||
filename = filename.replace("/", " & ").replace(";", " & ") # e.g. multi-episode filenames
|
||||
if spacer == ".":
|
||||
filename = re.sub(r" - ", spacer, filename) # title separators to spacer (avoids .-. pattern)
|
||||
filename = re.sub(r"[:; ]", spacer, filename) # structural chars to (spacer)
|
||||
filename = re.sub(r"[\\*!?¿,'\"" "()<>|$#~]", "", filename) # not filename safe chars
|
||||
filename = re.sub(rf"[{spacer}]{{2,}}", spacer, filename) # remove extra neighbouring (spacer)s
|
||||
|
||||
Reference in New Issue
Block a user