mirror of
https://github.com/vinefeeder/TwinVine.git
synced 2026-07-15 18:10:04 +02:00
V2.1.0 upstream
This commit is contained in:
@@ -35,6 +35,8 @@ pip install uv
|
|||||||
or
|
or
|
||||||
|
|
||||||
python3 -m pip install uv
|
python3 -m pip install uv
|
||||||
|
|
||||||
|
or use your system's package manager to install python-uv
|
||||||
```
|
```
|
||||||
|
|
||||||
Then install TwinVine; the following installs the latest version directly from the GitHub repository:
|
Then install TwinVine; the following installs the latest version directly from the GitHub repository:
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
**Updates**
|
**Updates**
|
||||||
|
**29 Nov 2025**
|
||||||
|
- Followed upstream changes to envied V2.1.0
|
||||||
|
- Updated some services
|
||||||
|
- removed incorrectly duplicated vaults folder within src/envied/
|
||||||
|
|
||||||
**13 Sept 2025:**
|
**13 Sept 2025:**
|
||||||
- Added selected service name to screen display with GUI use. Featured in the original but got lost!
|
- Added selected service name to screen display with GUI use. Featured in the original but got lost!
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
# git-cliff ~ default configuration file
|
|
||||||
# https://git-cliff.org/docs/configuration
|
|
||||||
|
|
||||||
[changelog]
|
|
||||||
header = """
|
|
||||||
# Changelog\n
|
|
||||||
All notable changes to this project will be documented in this file.
|
|
||||||
|
|
||||||
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
||||||
|
|
||||||
Versions [3.0.0] and older use a format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
||||||
but versions thereafter use a custom changelog format using [git-cliff](https://git-cliff.org).\n
|
|
||||||
"""
|
|
||||||
body = """
|
|
||||||
{% if version -%}
|
|
||||||
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
|
||||||
{% else -%}
|
|
||||||
## [Unreleased]
|
|
||||||
{% endif -%}
|
|
||||||
{% for group, commits in commits | group_by(attribute="group") %}
|
|
||||||
### {{ group | striptags | trim | upper_first }}
|
|
||||||
{% for commit in commits %}
|
|
||||||
- {% if commit.scope %}*{{ commit.scope }}*: {% endif %}\
|
|
||||||
{% if commit.breaking %}[**breaking**] {% endif %}\
|
|
||||||
{{ commit.message | upper_first }}\
|
|
||||||
{% endfor %}
|
|
||||||
{% endfor %}\n
|
|
||||||
"""
|
|
||||||
footer = """
|
|
||||||
{% for release in releases -%}
|
|
||||||
{% if release.version -%}
|
|
||||||
{% if release.previous.version -%}
|
|
||||||
[{{ release.version | trim_start_matches(pat="v") }}]: \
|
|
||||||
https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}\
|
|
||||||
/compare/{{ release.previous.version }}..{{ release.version }}
|
|
||||||
{% endif -%}
|
|
||||||
{% else -%}
|
|
||||||
[unreleased]: https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}\
|
|
||||||
/compare/{{ release.previous.version }}..HEAD
|
|
||||||
{% endif -%}
|
|
||||||
{% endfor %}
|
|
||||||
"""
|
|
||||||
trim = true
|
|
||||||
postprocessors = [
|
|
||||||
# { pattern = '<REPO>', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL
|
|
||||||
]
|
|
||||||
|
|
||||||
[git]
|
|
||||||
conventional_commits = true
|
|
||||||
filter_unconventional = true
|
|
||||||
split_commits = false
|
|
||||||
commit_preprocessors = []
|
|
||||||
commit_parsers = [
|
|
||||||
{ message = "^feat", group = "<!-- 0 -->Features" },
|
|
||||||
{ message = "^fix|revert", group = "<!-- 1 -->Bug Fixes" },
|
|
||||||
{ message = "^docs", group = "<!-- 2 -->Documentation" },
|
|
||||||
{ message = "^style", skip = true },
|
|
||||||
{ message = "^refactor", group = "<!-- 3 -->Changes" },
|
|
||||||
{ message = "^perf", group = "<!-- 4 -->Performance Improvements" },
|
|
||||||
{ message = "^test", skip = true },
|
|
||||||
{ message = "^build", group = "<!-- 5 -->Builds" },
|
|
||||||
{ message = "^ci", skip = true },
|
|
||||||
{ message = "^chore", skip = true },
|
|
||||||
]
|
|
||||||
protect_breaking_commits = false
|
|
||||||
filter_commits = false
|
|
||||||
# tag_pattern = "v[0-9].*"
|
|
||||||
# skip_tags = ""
|
|
||||||
# ignore_tags = ""
|
|
||||||
topo_order = false
|
|
||||||
sort_commits = "oldest"
|
|
||||||
@@ -31,15 +31,16 @@ dependencies = [
|
|||||||
"click>=8.1.8,<9",
|
"click>=8.1.8,<9",
|
||||||
"construct>=2.8.8,<3",
|
"construct>=2.8.8,<3",
|
||||||
"crccheck>=1.3.0,<2",
|
"crccheck>=1.3.0,<2",
|
||||||
|
"fonttools>=4.60.1,<5",
|
||||||
"jsonpickle>=3.0.4,<4.5",
|
"jsonpickle>=3.0.4,<4.5",
|
||||||
"langcodes>=3.4.0,<4",
|
"langcodes>=3.4.0,<4",
|
||||||
"lxml>=6.0.2",
|
"lxml>=6.0.2, <7",
|
||||||
"pproxy>=2.7.9,<3",
|
"pproxy>=2.7.9,<3",
|
||||||
"protobuf>=4.25.3,<7",
|
"protobuf>=4.25.3,<7",
|
||||||
"pycaption>=2.2.6,<3",
|
"pycaption>=2.2.6,<3",
|
||||||
"pycryptodomex>=3.20.0,<4",
|
"pycryptodomex>=3.20.0,<4",
|
||||||
"pyjwt>=2.8.0,<3",
|
"pyjwt>=2.8.0,<3",
|
||||||
"pymediainfo>=6.1.0,<7.5",
|
"pymediainfo>=6.1.0,<8",
|
||||||
"pymp4>=1.4.0,<2",
|
"pymp4>=1.4.0,<2",
|
||||||
"pymysql>=1.1.0,<2",
|
"pymysql>=1.1.0,<2",
|
||||||
"pywidevine[serve]>=1.8.0,<2",
|
"pywidevine[serve]>=1.8.0,<2",
|
||||||
@@ -53,10 +54,10 @@ dependencies = [
|
|||||||
"Unidecode>=1.3.8,<2",
|
"Unidecode>=1.3.8,<2",
|
||||||
"urllib3>=2.2.1,<3",
|
"urllib3>=2.2.1,<3",
|
||||||
"chardet>=5.2.0,<6",
|
"chardet>=5.2.0,<6",
|
||||||
"curl-cffi>=0.7.0b4,<0.8",
|
"curl-cffi>=0.7.0b4,<0.14",
|
||||||
"pyplayready>=0.6.3,<0.7",
|
"pyplayready>=0.6.3,<0.7",
|
||||||
"httpx>=0.28.1,<0.29",
|
"httpx>=0.28.1,<0.29",
|
||||||
"cryptography>=45.0.0",
|
"cryptography>=45.0.0,<47",
|
||||||
"subby",
|
"subby",
|
||||||
"webvtt-py>=0.5.1",
|
"webvtt-py>=0.5.1",
|
||||||
"isodate>=0.7.2",
|
"isodate>=0.7.2",
|
||||||
@@ -64,10 +65,10 @@ dependencies = [
|
|||||||
"scrapy>=2.13.3",
|
"scrapy>=2.13.3",
|
||||||
"mypy>=1.18.2,<2",
|
"mypy>=1.18.2,<2",
|
||||||
"pysubs2>=1.7.0,<2",
|
"pysubs2>=1.7.0,<2",
|
||||||
|
"PyExecJS>=1.5.1,<2",
|
||||||
"jwt>=1.4.0",
|
"jwt>=1.4.0",
|
||||||
"langcodes>=3.5.0",
|
"langcodes>=3.5.0",
|
||||||
"fonttools>=4.60.1",
|
"aiohttp-swagger3>=0.10.0, <1",
|
||||||
"aiohttp-swagger3>=0.10.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|||||||
@@ -65,10 +65,8 @@ from envied.core.utils.click_types import (LANGUAGE_RANGE, QUALITY_LIST, SEASON_
|
|||||||
from envied.core.utils.collections import merge_dict
|
from envied.core.utils.collections import merge_dict
|
||||||
from envied.core.utils.subprocess import ffprobe
|
from envied.core.utils.subprocess import ffprobe
|
||||||
from envied.core.vaults import Vaults
|
from envied.core.vaults import Vaults
|
||||||
|
|
||||||
from beaupy import select_multiple, Config
|
from beaupy import select_multiple, Config
|
||||||
|
|
||||||
|
|
||||||
class dl:
|
class dl:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def truncate_pssh_for_display(pssh_string: str, drm_type: str) -> str:
|
def truncate_pssh_for_display(pssh_string: str, drm_type: str) -> str:
|
||||||
@@ -256,6 +254,13 @@ class dl:
|
|||||||
default=False,
|
default=False,
|
||||||
help="Exclude Dolby Atmos audio tracks when selecting audio.",
|
help="Exclude Dolby Atmos audio tracks when selecting audio.",
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"--select-titles",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Interactively select downloads from a list. Only use with Series to select Episodes",
|
||||||
|
)
|
||||||
|
|
||||||
@click.option(
|
@click.option(
|
||||||
"-w",
|
"-w",
|
||||||
"--wanted",
|
"--wanted",
|
||||||
@@ -270,12 +275,6 @@ class dl:
|
|||||||
default=False,
|
default=False,
|
||||||
help="Download only the single most recent episode available.",
|
help="Download only the single most recent episode available.",
|
||||||
)
|
)
|
||||||
@click.option(
|
|
||||||
"--select-titles",
|
|
||||||
is_flag=True,
|
|
||||||
default=False,
|
|
||||||
help="Interactively select downloads from a list. Only use with Series to select Episodes",
|
|
||||||
)
|
|
||||||
@click.option(
|
@click.option(
|
||||||
"-l",
|
"-l",
|
||||||
"--lang",
|
"--lang",
|
||||||
@@ -311,7 +310,6 @@ class dl:
|
|||||||
default=False,
|
default=False,
|
||||||
help="Use exact language matching (no variants). With this flag, -l es-419 matches ONLY es-419, not es-ES or other variants.",
|
help="Use exact language matching (no variants). With this flag, -l es-419 matches ONLY es-419, not es-ES or other variants.",
|
||||||
)
|
)
|
||||||
|
|
||||||
@click.option(
|
@click.option(
|
||||||
"--proxy",
|
"--proxy",
|
||||||
type=str,
|
type=str,
|
||||||
@@ -733,9 +731,9 @@ class dl:
|
|||||||
range_: list[Video.Range],
|
range_: list[Video.Range],
|
||||||
channels: float,
|
channels: float,
|
||||||
no_atmos: bool,
|
no_atmos: bool,
|
||||||
|
select_titles: bool,
|
||||||
wanted: list[str],
|
wanted: list[str],
|
||||||
latest_episode: bool,
|
latest_episode: bool,
|
||||||
select_titles: bool,
|
|
||||||
lang: list[str],
|
lang: list[str],
|
||||||
v_lang: list[str],
|
v_lang: list[str],
|
||||||
a_lang: list[str],
|
a_lang: list[str],
|
||||||
@@ -908,7 +906,6 @@ class dl:
|
|||||||
console.print(Padding(titles.tree(verbose=list_titles), (0, 5)))
|
console.print(Padding(titles.tree(verbose=list_titles), (0, 5)))
|
||||||
if list_titles:
|
if list_titles:
|
||||||
return
|
return
|
||||||
|
|
||||||
# modification to enable beaupy module to list titles for download for manual selection
|
# modification to enable beaupy module to list titles for download for manual selection
|
||||||
# use --select-titles after dl in unshackle command
|
# use --select-titles after dl in unshackle command
|
||||||
|
|
||||||
@@ -942,8 +939,6 @@ class dl:
|
|||||||
if i not in keep:
|
if i not in keep:
|
||||||
del titles[i]
|
del titles[i]
|
||||||
# end modification
|
# end modification
|
||||||
|
|
||||||
|
|
||||||
# Determine the latest episode if --latest-episode is set
|
# Determine the latest episode if --latest-episode is set
|
||||||
latest_episode_id = None
|
latest_episode_id = None
|
||||||
if latest_episode and isinstance(titles, Series) and len(titles) > 0:
|
if latest_episode and isinstance(titles, Series) and len(titles) > 0:
|
||||||
@@ -1413,6 +1408,7 @@ class dl:
|
|||||||
kept_tracks.extend(title.tracks.subtitles)
|
kept_tracks.extend(title.tracks.subtitles)
|
||||||
if keep_chapters:
|
if keep_chapters:
|
||||||
kept_tracks.extend(title.tracks.chapters)
|
kept_tracks.extend(title.tracks.chapters)
|
||||||
|
kept_tracks.extend(title.tracks.attachments)
|
||||||
|
|
||||||
title.tracks = Tracks(kept_tracks)
|
title.tracks = Tracks(kept_tracks)
|
||||||
|
|
||||||
@@ -1499,6 +1495,7 @@ class dl:
|
|||||||
)
|
)
|
||||||
):
|
):
|
||||||
download.result()
|
download.result()
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
console.print(Padding(":x: Download Cancelled...", (0, 5, 1, 5)))
|
console.print(Padding(":x: Download Cancelled...", (0, 5, 1, 5)))
|
||||||
if self.debug_logger:
|
if self.debug_logger:
|
||||||
@@ -1891,25 +1888,32 @@ class dl:
|
|||||||
if not drm:
|
if not drm:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
track_quality = None
|
||||||
if isinstance(track, Video) and track.height:
|
if isinstance(track, Video) and track.height:
|
||||||
pass
|
track_quality = track.height
|
||||||
|
|
||||||
if isinstance(drm, Widevine):
|
if isinstance(drm, Widevine):
|
||||||
if not isinstance(self.cdm, (WidevineCdm, DecryptLabsRemoteCDM)) or (
|
if not isinstance(self.cdm, (WidevineCdm, DecryptLabsRemoteCDM)) or (
|
||||||
isinstance(self.cdm, DecryptLabsRemoteCDM) and self.cdm.is_playready
|
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:
|
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
|
self.cdm = widevine_cdm
|
||||||
|
|
||||||
elif isinstance(drm, PlayReady):
|
elif isinstance(drm, PlayReady):
|
||||||
if not isinstance(self.cdm, (PlayReadyCdm, DecryptLabsRemoteCDM)) or (
|
if not isinstance(self.cdm, (PlayReadyCdm, DecryptLabsRemoteCDM)) or (
|
||||||
isinstance(self.cdm, DecryptLabsRemoteCDM) and not self.cdm.is_playready
|
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:
|
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
|
self.cdm = playready_cdm
|
||||||
|
|
||||||
if isinstance(drm, Widevine):
|
if isinstance(drm, Widevine):
|
||||||
@@ -1929,7 +1933,7 @@ class dl:
|
|||||||
|
|
||||||
with self.DRM_TABLE_LOCK:
|
with self.DRM_TABLE_LOCK:
|
||||||
pssh_display = self.truncate_pssh_for_display(drm.pssh.dumps(), "Widevine")
|
pssh_display = self.truncate_pssh_for_display(drm.pssh.dumps(), "Widevine")
|
||||||
cek_tree = Tree(Text.assemble(("Widevine", "cyan"), (f"({pssh_display})", "text"), overflow="ellipses"))
|
cek_tree = Tree(Text.assemble(("Widevine", "cyan"), (f"({pssh_display})", "text"), overflow="fold"))
|
||||||
pre_existing_tree = next(
|
pre_existing_tree = next(
|
||||||
(x for x in table.columns[0].cells if isinstance(x, Tree) and x.label == cek_tree.label), None
|
(x for x in table.columns[0].cells if isinstance(x, Tree) and x.label == cek_tree.label), None
|
||||||
)
|
)
|
||||||
@@ -2073,12 +2077,21 @@ class dl:
|
|||||||
if export:
|
if export:
|
||||||
keys = {}
|
keys = {}
|
||||||
if export.is_file():
|
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:
|
if str(title) not in keys:
|
||||||
keys[str(title)] = {}
|
keys[str(title)] = {}
|
||||||
if str(track) not in keys[str(title)]:
|
if str(track) not in keys[str(title)]:
|
||||||
keys[str(title)][str(track)] = {}
|
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")
|
export.write_text(jsonpickle.dumps(keys, indent=4), encoding="utf8")
|
||||||
|
|
||||||
elif isinstance(drm, PlayReady):
|
elif isinstance(drm, PlayReady):
|
||||||
@@ -2218,12 +2231,21 @@ class dl:
|
|||||||
if export:
|
if export:
|
||||||
keys = {}
|
keys = {}
|
||||||
if export.is_file():
|
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:
|
if str(title) not in keys:
|
||||||
keys[str(title)] = {}
|
keys[str(title)] = {}
|
||||||
if str(track) not in keys[str(title)]:
|
if str(track) not in keys[str(title)]:
|
||||||
keys[str(title)][str(track)] = {}
|
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")
|
export.write_text(jsonpickle.dumps(keys, indent=4), encoding="utf8")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "2.0.0"
|
__version__ = "2.1.0"
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ def main(version: bool, debug: bool) -> None:
|
|||||||
expand=True,
|
expand=True,
|
||||||
),
|
),
|
||||||
justify="center",
|
justify="center",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@atexit.register
|
@atexit.register
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import zlib
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from os import stat_result
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional, Union
|
||||||
|
|
||||||
|
import jsonpickle
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
from envied.core.config import config
|
||||||
|
|
||||||
|
EXP_T = Union[datetime, str, int, float]
|
||||||
|
|
||||||
|
|
||||||
|
class Cacher:
|
||||||
|
"""Cacher for Services to get and set arbitrary data with expiration dates."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
service_tag: str,
|
||||||
|
key: Optional[str] = None,
|
||||||
|
version: Optional[int] = 1,
|
||||||
|
data: Optional[Any] = None,
|
||||||
|
expiration: Optional[datetime] = None,
|
||||||
|
) -> None:
|
||||||
|
self.service_tag = service_tag
|
||||||
|
self.key = key
|
||||||
|
self.version = version
|
||||||
|
self.data = data or {}
|
||||||
|
self.expiration = expiration
|
||||||
|
|
||||||
|
if self.expiration and self.expired:
|
||||||
|
# if its expired, remove the data for safety and delete cache file
|
||||||
|
self.data = None
|
||||||
|
self.path.unlink()
|
||||||
|
|
||||||
|
def __bool__(self) -> bool:
|
||||||
|
return bool(self.data)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def path(self) -> Path:
|
||||||
|
"""Get the path at which the cache will be read and written."""
|
||||||
|
return (config.directories.cache / self.service_tag / self.key).with_suffix(".json")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def expired(self) -> bool:
|
||||||
|
return self.expiration and self.expiration < datetime.now()
|
||||||
|
|
||||||
|
def get(self, key: str, version: int = 1) -> Cacher:
|
||||||
|
"""
|
||||||
|
Get Cached data for the Service by Key.
|
||||||
|
:param key: the filename to save the data to, should be url-safe.
|
||||||
|
:param version: the config data version you expect to use.
|
||||||
|
:returns: Cache object containing the cached data or None if the file does not exist.
|
||||||
|
"""
|
||||||
|
cache = Cacher(self.service_tag, key, version)
|
||||||
|
if cache.path.is_file():
|
||||||
|
data = jsonpickle.loads(cache.path.read_text(encoding="utf8"))
|
||||||
|
payload = data.copy()
|
||||||
|
del payload["crc32"]
|
||||||
|
checksum = data["crc32"]
|
||||||
|
calculated = zlib.crc32(jsonpickle.dumps(payload).encode("utf8"))
|
||||||
|
if calculated != checksum:
|
||||||
|
raise ValueError(
|
||||||
|
f"The checksum of the Cache payload mismatched. Checksum: {checksum} !== Calculated: {calculated}"
|
||||||
|
)
|
||||||
|
cache.data = data["data"]
|
||||||
|
cache.expiration = data["expiration"]
|
||||||
|
cache.version = data["version"]
|
||||||
|
if cache.version != version:
|
||||||
|
raise ValueError(
|
||||||
|
f"The version of your {self.service_tag} {key} cache is outdated. Please delete: {cache.path}"
|
||||||
|
)
|
||||||
|
return cache
|
||||||
|
|
||||||
|
def set(self, data: Any, expiration: Optional[EXP_T] = None) -> Any:
|
||||||
|
"""
|
||||||
|
Set Cached data for the Service by Key.
|
||||||
|
:param data: absolutely anything including None.
|
||||||
|
:param expiration: when the data expires, optional. Can be ISO 8601, seconds
|
||||||
|
til expiration, unix timestamp, or a datetime object.
|
||||||
|
:returns: the data provided for quick wrapping of functions or vars.
|
||||||
|
"""
|
||||||
|
self.data = data
|
||||||
|
|
||||||
|
if not expiration:
|
||||||
|
try:
|
||||||
|
expiration = jwt.decode(self.data, options={"verify_signature": False})["exp"]
|
||||||
|
except jwt.DecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.expiration = self._resolve_datetime(expiration) if expiration else None
|
||||||
|
|
||||||
|
payload = {"data": self.data, "expiration": self.expiration, "version": self.version}
|
||||||
|
payload["crc32"] = zlib.crc32(jsonpickle.dumps(payload).encode("utf8"))
|
||||||
|
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.path.write_text(jsonpickle.dumps(payload))
|
||||||
|
|
||||||
|
return self.data
|
||||||
|
|
||||||
|
def stat(self) -> stat_result:
|
||||||
|
"""
|
||||||
|
Get Cache file OS Stat data like Creation Time, Modified Time, and such.
|
||||||
|
:returns: an os.stat_result tuple
|
||||||
|
"""
|
||||||
|
return self.path.stat()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_datetime(timestamp: EXP_T) -> datetime:
|
||||||
|
"""
|
||||||
|
Resolve multiple formats of a Datetime or Timestamp to an absolute Datetime.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> now = datetime.now()
|
||||||
|
datetime.datetime(2022, 6, 27, 9, 49, 13, 657208)
|
||||||
|
>>> iso8601 = now.isoformat()
|
||||||
|
'2022-06-27T09:49:13.657208'
|
||||||
|
>>> Cacher._resolve_datetime(iso8601)
|
||||||
|
datetime.datetime(2022, 6, 27, 9, 49, 13, 657208)
|
||||||
|
>>> Cacher._resolve_datetime(iso8601 + "Z")
|
||||||
|
datetime.datetime(2022, 6, 27, 9, 49, 13, 657208)
|
||||||
|
>>> Cacher._resolve_datetime(3600)
|
||||||
|
datetime.datetime(2022, 6, 27, 10, 52, 50, 657208)
|
||||||
|
>>> Cacher._resolve_datetime('3600')
|
||||||
|
datetime.datetime(2022, 6, 27, 10, 52, 51, 657208)
|
||||||
|
>>> Cacher._resolve_datetime(7800.113)
|
||||||
|
datetime.datetime(2022, 6, 27, 11, 59, 13, 770208)
|
||||||
|
|
||||||
|
In the int/float examples you may notice that it did not return now + 3600 seconds
|
||||||
|
but rather something a bit more than that. This is because it did not resolve 3600
|
||||||
|
seconds from the `now` variable but from right now as the function was called.
|
||||||
|
"""
|
||||||
|
if isinstance(timestamp, datetime):
|
||||||
|
return timestamp
|
||||||
|
if isinstance(timestamp, str):
|
||||||
|
if timestamp.endswith("Z"):
|
||||||
|
# fromisoformat doesn't accept the final Z
|
||||||
|
timestamp = timestamp.split("Z")[0]
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(timestamp)
|
||||||
|
except ValueError:
|
||||||
|
timestamp = float(timestamp)
|
||||||
|
try:
|
||||||
|
if len(str(int(timestamp))) == 13: # JS-style timestamp
|
||||||
|
timestamp /= 1000
|
||||||
|
timestamp = datetime.fromtimestamp(timestamp)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Unrecognized Timestamp value {timestamp!r}")
|
||||||
|
if timestamp < datetime.now():
|
||||||
|
# timestamp is likely an amount of seconds til expiration
|
||||||
|
# or, it's an already expired timestamp which is unlikely
|
||||||
|
timestamp = timestamp + timedelta(seconds=datetime.now().timestamp())
|
||||||
|
return timestamp
|
||||||
@@ -297,8 +297,9 @@ class DASH:
|
|||||||
manifest_base_url = track.url
|
manifest_base_url = track.url
|
||||||
elif not re.match("^https?://", manifest_base_url, re.IGNORECASE):
|
elif not re.match("^https?://", manifest_base_url, re.IGNORECASE):
|
||||||
manifest_base_url = urljoin(track.url, f"./{manifest_base_url}")
|
manifest_base_url = urljoin(track.url, f"./{manifest_base_url}")
|
||||||
period_base_url = urljoin(manifest_base_url, period.findtext("BaseURL"))
|
period_base_url = urljoin(manifest_base_url, period.findtext("BaseURL") or "")
|
||||||
rep_base_url = urljoin(period_base_url, representation.findtext("BaseURL"))
|
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")
|
period_duration = period.get("duration") or manifest.get("mediaPresentationDuration")
|
||||||
init_data: Optional[bytes] = None
|
init_data: Optional[bytes] = None
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ class HLS:
|
|||||||
|
|
||||||
if segment.byterange:
|
if segment.byterange:
|
||||||
byte_range = HLS.calculate_byte_range(segment.byterange, range_offset)
|
byte_range = HLS.calculate_byte_range(segment.byterange, range_offset)
|
||||||
range_offset = byte_range.split("-")[0]
|
range_offset = int(byte_range.split("-")[0])
|
||||||
else:
|
else:
|
||||||
byte_range = None
|
byte_range = None
|
||||||
|
|
||||||
|
|||||||
@@ -44,8 +44,17 @@ class WindscribeVPN(Proxy):
|
|||||||
def get_proxy(self, query: str) -> Optional[str]:
|
def get_proxy(self, query: str) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Get an HTTPS proxy URI for a WindscribeVPN server.
|
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()
|
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:
|
if query in self.server_map:
|
||||||
hostname = self.server_map[query]
|
hostname = self.server_map[query]
|
||||||
@@ -58,6 +67,7 @@ class WindscribeVPN(Proxy):
|
|||||||
if not hostname:
|
if not hostname:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
hostname = hostname.split(':')[0]
|
||||||
return f"https://{self.username}:{self.password}@{hostname}:443"
|
return f"https://{self.username}:{self.password}@{hostname}:443"
|
||||||
|
|
||||||
def get_random_server(self, country_code: str) -> Optional[str]:
|
def get_random_server(self, country_code: str) -> Optional[str]:
|
||||||
|
|||||||
@@ -99,24 +99,42 @@ class Video(Track):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def from_cicp(primaries: int, transfer: int, matrix: int) -> Video.Range:
|
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:
|
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):
|
class Primaries(Enum):
|
||||||
Unspecified = 0
|
Reserved = 0
|
||||||
BT_709 = 1
|
BT_709 = 1
|
||||||
|
Unspecified = 2
|
||||||
BT_601_625 = 5
|
BT_601_625 = 5
|
||||||
BT_601_525 = 6
|
BT_601_525 = 6
|
||||||
BT_2020_and_2100 = 9
|
BT_2020_and_2100 = 9
|
||||||
SMPTE_ST_2113_and_EG_4321 = 12 # P3D65
|
SMPTE_ST_2113_and_EG_4321 = 12 # P3D65
|
||||||
|
|
||||||
class Transfer(Enum):
|
class Transfer(Enum):
|
||||||
Unspecified = 0
|
Reserved = 0
|
||||||
BT_709 = 1
|
BT_709 = 1
|
||||||
Unspecified_Image = 2
|
Unspecified = 2
|
||||||
BT_601 = 6
|
BT_601 = 6
|
||||||
BT_2020 = 14
|
BT_2020 = 14
|
||||||
BT_2100 = 15
|
BT_2100 = 15
|
||||||
@@ -143,7 +161,7 @@ class Video(Track):
|
|||||||
|
|
||||||
# primaries and matrix does not strictly correlate to a range
|
# 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
|
return Video.Range.SDR
|
||||||
elif primaries in (Primaries.BT_601_625, Primaries.BT_601_525):
|
elif primaries in (Primaries.BT_601_625, Primaries.BT_601_525):
|
||||||
return Video.Range.SDR
|
return Video.Range.SDR
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ def sanitize_filename(filename: str, spacer: str = ".") -> str:
|
|||||||
# remove or replace further characters as needed
|
# remove or replace further characters as needed
|
||||||
filename = "".join(c for c in filename if unicodedata.category(c) != "Mn") # hidden characters
|
filename = "".join(c for c in filename if unicodedata.category(c) != "Mn") # hidden characters
|
||||||
filename = filename.replace("/", " & ").replace(";", " & ") # e.g. multi-episode filenames
|
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"[:; ]", spacer, filename) # structural chars to (spacer)
|
||||||
filename = re.sub(r"[\\*!?¿,'\"" "()<>|$#~]", "", filename) # not filename safe chars
|
filename = re.sub(r"[\\*!?¿,'\"" "()<>|$#~]", "", filename) # not filename safe chars
|
||||||
filename = re.sub(rf"[{spacer}]{{2,}}", spacer, filename) # remove extra neighbouring (spacer)s
|
filename = re.sub(rf"[{spacer}]{{2,}}", spacer, filename) # remove extra neighbouring (spacer)s
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
@@ -12,14 +11,14 @@ import click
|
|||||||
from click import Context
|
from click import Context
|
||||||
from requests import Request
|
from requests import Request
|
||||||
|
|
||||||
#from envied.core.downloaders import n_m3u8dl_re
|
from unshackle.core.downloaders import n_m3u8dl_re
|
||||||
from envied.core.credential import Credential
|
from unshackle.core.credential import Credential
|
||||||
from envied.core.manifests import HLS
|
from unshackle.core.manifests import HLS
|
||||||
from envied.core.search_result import SearchResult
|
from unshackle.core.search_result import SearchResult
|
||||||
from envied.core.service import Service
|
from unshackle.core.service import Service
|
||||||
from envied.core.titles import Episode, Movie, Movies, Series
|
from unshackle.core.titles import Episode, Movie, Movies, Series
|
||||||
from envied.core.tracks import Chapter, Chapters, Tracks, Video
|
from unshackle.core.tracks import Chapters, Tracks, Video, Hybrid
|
||||||
from envied.core.utils.collections import as_list
|
from unshackle.core.utils.collections import as_list
|
||||||
|
|
||||||
from . import queries
|
from . import queries
|
||||||
|
|
||||||
@@ -57,30 +56,42 @@ class DSNP(Service):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
@click.command(name="DSNP", short_help="https://www.disneyplus.com", help=__doc__)
|
@click.command(name="DSNP", short_help="https://www.disneyplus.com", help=__doc__)
|
||||||
@click.argument("title", type=str)
|
@click.argument("title", type=str)
|
||||||
|
@click.option(
|
||||||
|
"-m", "--movie", is_flag=True, default=False, help="Title is a Movie."
|
||||||
|
)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def cli(ctx: Context, **kwargs: Any) -> DSNP:
|
def cli(ctx: Context, **kwargs: Any) -> DSNP:
|
||||||
return DSNP(ctx, **kwargs)
|
return DSNP(ctx, **kwargs)
|
||||||
|
|
||||||
def __init__(self, ctx: Context, title: str):
|
def __init__(self, ctx: Context, title: str, movie):
|
||||||
self.title = title
|
self.title = title
|
||||||
|
self.movie = movie
|
||||||
super().__init__(ctx)
|
super().__init__(ctx)
|
||||||
self.cdm = ctx.obj.cdm
|
self.cdm = ctx.obj.cdm
|
||||||
self.playback_data = {}
|
|
||||||
|
|
||||||
vcodec = ctx.parent.params.get("vcodec")
|
vcodec = ctx.parent.params.get("vcodec")
|
||||||
range = ctx.parent.params.get("range_")
|
range = ctx.parent.params.get("range_")
|
||||||
|
|
||||||
self.range = range[0].name if range else "SDR"
|
self.range = range[0].name if range else "SDR"
|
||||||
self.vcodec = "H265" if vcodec and vcodec == Video.Codec.HEVC else "H264"
|
self.vcodec = "H.265" if vcodec and vcodec == Video.Codec.HEVC else "H.264"
|
||||||
if self.range != "SDR" and self.vcodec != "H265":
|
if self.range != "SDR" and self.vcodec != "H.265":
|
||||||
self.vcodec = "H265"
|
self.vcodec = "H.265"
|
||||||
|
|
||||||
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
||||||
super().authenticate(cookies, credential)
|
super().authenticate(cookies, credential)
|
||||||
if not credential:
|
if not credential:
|
||||||
raise EnvironmentError("Service requires Credentials for Authentication.")
|
raise EnvironmentError("Service requires Credentials for Authentication.")
|
||||||
|
|
||||||
self.session.headers.update(self.config["HEADERS"])
|
# Use exact headers from working Vinetrimmer implementation to avoid geoblocking
|
||||||
|
self.session.headers.update({
|
||||||
|
"Accept-Language": "en-US,en;q=0.5",
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
|
||||||
|
"Origin": "https://www.disneyplus.com",
|
||||||
|
"x-bamsdk-platform": "javascript/windows/chrome",
|
||||||
|
"x-bamsdk-version": "28.0",
|
||||||
|
"x-bamsdk-client-id": "disney-svod",
|
||||||
|
"Accept-Encoding": "gzip",
|
||||||
|
})
|
||||||
self.session.headers.update({"x-bamsdk-transaction-id": str(uuid.uuid4())})
|
self.session.headers.update({"x-bamsdk-transaction-id": str(uuid.uuid4())})
|
||||||
self.prd_config = self.session.get(self.config["CONFIG_URL"]).json()
|
self.prd_config = self.session.get(self.config["CONFIG_URL"]).json()
|
||||||
|
|
||||||
@@ -155,13 +166,58 @@ class DSNP(Service):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_titles(self) -> Union[Movies, Series]:
|
def get_titles(self) -> Union[Movies, Series]:
|
||||||
if not self.title.startswith("entity"):
|
# Use Vinetrimmer logic - handle both entity IDs and other formats
|
||||||
raise ValueError("Invalid input - Use only entity IDs.")
|
if not "entity" in self.title:
|
||||||
|
# Convert to entity ID like Vinetrimmer does
|
||||||
|
try:
|
||||||
|
deeplinkId_response = self.session.get(
|
||||||
|
url='https://disney.api.edge.bamgrid.com/explore/v1.3/deeplink',
|
||||||
|
params={
|
||||||
|
'refId': self.title,
|
||||||
|
'refIdType': 'encodedFamilyId' # Try movie first
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if deeplinkId_response.status_code == 200:
|
||||||
|
deeplinkId = deeplinkId_response.json()
|
||||||
|
self.title = deeplinkId["data"]["deeplink"]["actions"][0]["deeplinkId"]
|
||||||
|
else:
|
||||||
|
# Try with encodedSeriesId
|
||||||
|
deeplinkId_response = self.session.get(
|
||||||
|
url='https://disney.api.edge.bamgrid.com/explore/v1.3/deeplink',
|
||||||
|
params={
|
||||||
|
'refId': self.title,
|
||||||
|
'refIdType': 'encodedSeriesId'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if deeplinkId_response.status_code == 200:
|
||||||
|
deeplinkId = deeplinkId_response.json()
|
||||||
|
self.title = deeplinkId["data"]["deeplink"]["actions"][0]["deeplinkId"]
|
||||||
|
except Exception:
|
||||||
|
# If all fails, assume it's already an entity ID
|
||||||
|
pass
|
||||||
|
|
||||||
content = self.get_deeplink(self.title)
|
content = self.get_deeplink(self.title)
|
||||||
_type = content["data"]["deeplink"]["actions"][0]["contentType"]
|
|
||||||
|
|
||||||
if _type == "movie":
|
# Handle deeplink response structure - determine if series or movie from infoBlock
|
||||||
|
if "data" in content and "deeplink" in content["data"]:
|
||||||
|
actions = content["data"]["deeplink"]["actions"]
|
||||||
|
if actions and len(actions) > 0:
|
||||||
|
action = actions[0]
|
||||||
|
# Check the infoBlock to determine content type like Vinetrimmer does
|
||||||
|
info_block = action.get("infoBlock", "")
|
||||||
|
if "urn:ds:cmp:eva:series" in info_block:
|
||||||
|
_type = "series"
|
||||||
|
elif "urn:ds:cmp:eva:movie" in info_block or "urn:ds:cmp:eva:film" in info_block:
|
||||||
|
_type = "movie"
|
||||||
|
else:
|
||||||
|
# Fallback: assume series for browse type
|
||||||
|
_type = "series" if action.get("type") == "browse" else "movie"
|
||||||
|
else:
|
||||||
|
raise ValueError("No actions found in deeplink response")
|
||||||
|
else:
|
||||||
|
raise ValueError("Invalid deeplink response structure")
|
||||||
|
|
||||||
|
if _type == "movie" or self.movie:
|
||||||
movie = self._movie(self.title)
|
movie = self._movie(self.title)
|
||||||
return Movies(movie)
|
return Movies(movie)
|
||||||
|
|
||||||
@@ -169,242 +225,170 @@ class DSNP(Service):
|
|||||||
episodes = self._show(self.title)
|
episodes = self._show(self.title)
|
||||||
return Series(episodes)
|
return Series(episodes)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown content type: {_type}")
|
||||||
|
|
||||||
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
|
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
|
||||||
resource_id = title.data.get("resourceId")
|
resource_id = title.data.get("resourceId")
|
||||||
content_id = title.data["partnerFeed"].get("dmcContentId")
|
|
||||||
content = self.get_video(content_id)
|
|
||||||
playback = content["video"]["mediaMetadata"]["playbackUrls"][0]["href"]
|
|
||||||
|
|
||||||
token = self._refresh()
|
# ===============================
|
||||||
|
# TOKEN REFRESH
|
||||||
|
# ===============================
|
||||||
|
if self._cache:
|
||||||
|
refresh_token = self._cache.data["token"]["refreshToken"]
|
||||||
|
fresh_token = self.refresh_token(refresh_token)
|
||||||
|
self._cache.set(fresh_token, expiration=fresh_token["token"]["expiresIn"] - 30)
|
||||||
|
|
||||||
headers = {
|
# Update session header
|
||||||
"accept": "application/vnd.media-service+json; version=5",
|
token = fresh_token["token"]["accessToken"]
|
||||||
"authorization": token,
|
self.session.headers.update({"Authorization": f"Bearer {token}"})
|
||||||
"x-dss-feature-filtering": "true",
|
|
||||||
}
|
|
||||||
|
|
||||||
payload = {
|
# ===============================
|
||||||
"playbackId": resource_id,
|
# INTERNAL MANIFEST FETCHER
|
||||||
"playback": {
|
# ===============================
|
||||||
"attributes": {
|
def get_manifest_for_scenario(scenario_name, quality=None):
|
||||||
"codecs": {
|
original_headers = dict(self.session.headers)
|
||||||
"supportsMultiCodecMaster": False,
|
|
||||||
|
# Vinetrimmer-style headers
|
||||||
|
self.session.headers.update({
|
||||||
|
'x-dss-feature-filtering': 'true',
|
||||||
|
'x-application-version': '1.1.2',
|
||||||
|
'x-bamsdk-client-id': 'disney-svod',
|
||||||
|
'x-bamsdk-platform': 'javascript/windows/chrome',
|
||||||
|
'x-bamsdk-version': '28.0'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Default resolution (Vinetrimmer-style)
|
||||||
|
if quality is None:
|
||||||
|
quality = '1280' if hasattr(self, 'cdm') and self.cdm.security_level == 3 else '1920'
|
||||||
|
|
||||||
|
json_data = {
|
||||||
|
'playback': {
|
||||||
|
'attributes': {
|
||||||
|
'resolution': {
|
||||||
|
'max': [quality],
|
||||||
|
},
|
||||||
|
'protocol': 'HTTPS',
|
||||||
|
'assetInsertionStrategy': 'SGAI',
|
||||||
|
'playbackInitiationContext': 'ONLINE',
|
||||||
|
'frameRates': [60],
|
||||||
},
|
},
|
||||||
"protocol": "HTTPS",
|
|
||||||
# "ads": "",
|
|
||||||
"frameRates": [60],
|
|
||||||
"assetInsertionStrategy": "SGAI",
|
|
||||||
"playbackInitializationContext": "ONLINE",
|
|
||||||
},
|
},
|
||||||
},
|
'playbackId': resource_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
video_ranges = []
|
try:
|
||||||
audio_types = []
|
res = self.session.post(
|
||||||
|
f'https://disney.playback.edge.bamgrid.com/v7/playback/{scenario_name}',
|
||||||
|
json=json_data
|
||||||
|
)
|
||||||
|
if res.status_code == 200:
|
||||||
|
data = res.json()
|
||||||
|
manifest_url = data["stream"]["sources"][0]['complete']['url']
|
||||||
|
return HLS.from_url(url=manifest_url, session=self.session).to_tracks(language="en-US")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
self.session.headers.clear()
|
||||||
|
self.session.headers.update(original_headers)
|
||||||
|
|
||||||
audio_types.append("ATMOS")
|
# ==========================================================
|
||||||
audio_types.append("DTS_X")
|
# =============== HYBRID RANGE (DV + HDR10) =================
|
||||||
|
# ==========================================================
|
||||||
|
if self.range == "HYBRID":
|
||||||
|
self.log.info("HYBRID mode — fetching HDR10 + DV manifests")
|
||||||
|
|
||||||
if not self.cdm.security_level == 3 and self.range == "DV":
|
all_tracks = Tracks()
|
||||||
video_ranges.append("DOLBY_VISION")
|
|
||||||
|
|
||||||
if not self.cdm.security_level == 3 and self.range == "HDR10":
|
# -------- Fetch HDR10 ------------
|
||||||
video_ranges.append("HDR10")
|
self.log.info("Fetching HDR10 tracks")
|
||||||
|
self.range = Video.Range.HDR10
|
||||||
|
hdr_scenario = 'tv-drm-cbcs-h265-hdr10'
|
||||||
|
hdr_tracks = get_manifest_for_scenario(hdr_scenario, '2160')
|
||||||
|
if hdr_tracks:
|
||||||
|
all_tracks.add(hdr_tracks, warn_only=True)
|
||||||
|
|
||||||
if self.vcodec == "H265":
|
# -------- Fetch DV ---------------
|
||||||
payload["playback"]["attributes"]["codecs"] = {"video": ["h264", "h265"]}
|
self.log.info("Fetching DV tracks")
|
||||||
|
self.range = Video.Range.DV
|
||||||
|
dv_scenario = 'tv-drm-cbcs-h265-dovi'
|
||||||
|
dv_tracks = get_manifest_for_scenario(dv_scenario, '2160')
|
||||||
|
if dv_tracks:
|
||||||
|
all_tracks.add(dv_tracks, warn_only=True)
|
||||||
|
|
||||||
if audio_types:
|
# Restore range
|
||||||
payload["playback"]["attributes"]["audioTypes"] = audio_types
|
self.range = "HYBRID"
|
||||||
|
|
||||||
if video_ranges:
|
tracks = all_tracks
|
||||||
payload["playback"]["attributes"]["videoRanges"] = video_ranges
|
self.log.info("HYBRID fetch complete — merge will occur after download")
|
||||||
|
|
||||||
if self.cdm.security_level == 3:
|
# ==========================================================
|
||||||
payload["playback"]["attributes"]["resolution"] = {"max": ["1280x720"]}
|
# =============== NON-HYBRID MODES =========================
|
||||||
|
# ==========================================================
|
||||||
|
else:
|
||||||
|
if self.vcodec == "H.265" and self.range == 'HDR10':
|
||||||
|
scenario = 'tv-drm-cbcs-h265-hdr10'
|
||||||
|
quality = '2160'
|
||||||
|
elif self.vcodec == "H.265" and self.range == 'DV':
|
||||||
|
scenario = 'tv-drm-cbcs-h265-dovi'
|
||||||
|
quality = '2160'
|
||||||
|
else:
|
||||||
|
scenario = 'tv-drm-cbcs'
|
||||||
|
quality = '1920'
|
||||||
|
|
||||||
scenario = "ctr-regular" if self.cdm.security_level == 3 else "ctr-high"
|
tracks = get_manifest_for_scenario(scenario, quality)
|
||||||
endpoint = playback.format(scenario=scenario)
|
if not tracks:
|
||||||
|
raise ValueError("Failed to fetch DSNP manifest")
|
||||||
|
|
||||||
res = self._request("POST", endpoint, payload=payload, headers=headers)
|
# =====================================================
|
||||||
self.playback_data[title.id] = self._request(
|
# Fetch ATMOS/H265 secondary manifest (like Vinetrimmer)
|
||||||
"POST", f"https://disney.playback.edge.bamgrid.com/v7/playback/{scenario}", payload=payload, headers=headers
|
# =====================================================
|
||||||
)
|
atmos_tracks = get_manifest_for_scenario('tv-drm-ctr-h265-atmos')
|
||||||
|
if atmos_tracks:
|
||||||
|
tracks.videos.extend(atmos_tracks.videos)
|
||||||
|
tracks.audio.extend(atmos_tracks.audio)
|
||||||
|
tracks.subtitles.extend(atmos_tracks.subtitles)
|
||||||
|
|
||||||
manifest = res["stream"]["complete"][0]["url"]
|
# =====================================================
|
||||||
|
# AUDIO BITRATE FIX
|
||||||
tracks = HLS.from_url(url=manifest, session=self.session).to_tracks(language=title.language)
|
# =====================================================
|
||||||
for audio in tracks.audio:
|
for audio in tracks.audio:
|
||||||
bitrate = re.search(
|
bitrate = re.search(
|
||||||
r"(?<=r/composite_)\d+|\d+(?=_complete.m3u8)",
|
r"(?<=r/composite_)\d+|\d+(?=_complete.m3u8)",
|
||||||
as_list(audio.url)[0],
|
as_list(audio.url)[0],
|
||||||
)
|
)
|
||||||
audio.bitrate = int(bitrate.group()) * 1000
|
audio.bitrate = int(bitrate.group()) * 1000
|
||||||
if audio.bitrate == 1000_000:
|
if audio.bitrate == 1000_000: # DSNP lies about Atmos
|
||||||
# DSNP lies about the Atmos bitrate
|
|
||||||
audio.bitrate = 768_000
|
audio.bitrate = 768_000
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# FINAL CONFIG
|
||||||
|
# =====================================================
|
||||||
for track in tracks:
|
for track in tracks:
|
||||||
if track not in tracks.attachments:
|
if track not in tracks.attachments:
|
||||||
track.downloader = "N_m3u8DL-RE"
|
track.downloader = n_m3u8dl_re
|
||||||
track.needs_repack = True
|
track.needs_repack = True
|
||||||
|
|
||||||
return tracks
|
return tracks
|
||||||
|
|
||||||
def get_chapters(self, title: Union[Movie, Episode]) -> Chapters:
|
def get_chapters(self, title: Union[Movie, Episode]) -> Chapters:
|
||||||
"""
|
return Chapters()
|
||||||
Extract chapter information from the title data if available.
|
|
||||||
Returns chapter markers for intro, credits, and scenes.
|
|
||||||
"""
|
|
||||||
chapters = Chapters()
|
|
||||||
|
|
||||||
try:
|
def get_widevine_service_certificate(self, **_: Any) -> str:
|
||||||
# First try to get chapters from the new API via playback data
|
return None
|
||||||
if title.id in self.playback_data and "stream" in self.playback_data[title.id]:
|
|
||||||
playback_res = self.playback_data[title.id]
|
|
||||||
|
|
||||||
# Check for editorial markers in playback data
|
def get_widevine_license(self, *, challenge: bytes, title, track) -> bytes:
|
||||||
if "editorial" in playback_res.get("stream", {}):
|
"""Get Widevine license for Disney+ content - Adapted from working Vinetrimmer implementation"""
|
||||||
editorial = playback_res["stream"]["editorial"]
|
# Force token refresh like Vinetrimmer does for license calls
|
||||||
|
if self._cache:
|
||||||
|
refresh_token = self._cache.data["token"]["refreshToken"]
|
||||||
|
fresh_token = self.refresh_token(refresh_token)
|
||||||
|
self._cache.set(fresh_token, expiration=fresh_token["token"]["expiresIn"] - 30)
|
||||||
|
token = fresh_token["token"]["accessToken"]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
# Add "Start" chapter if not already present
|
|
||||||
if not any(item.get("offsetMillis") == 0 for item in editorial):
|
|
||||||
chapters.add(Chapter(timestamp=0, name="Start"))
|
|
||||||
|
|
||||||
# Map editorial labels to chapter names
|
|
||||||
mapping = {
|
|
||||||
"recap_start": "Recap",
|
|
||||||
"FFER": "Recap", # First Frame Episode Recap
|
|
||||||
"recap_end": "Scene",
|
|
||||||
"LFER": "Scene", # Last Frame Episode Recap
|
|
||||||
"intro_start": "Title Sequence",
|
|
||||||
"intro_end": "Scene",
|
|
||||||
"FFEI": "Title Sequence", # First Frame Episode Intro
|
|
||||||
"LFEI": "Scene", # Last Frame Episode Intro
|
|
||||||
"FFCB": None, # First Frame Credits Bumper
|
|
||||||
"LFCB": "Scene", # Last Frame Credits Bumper
|
|
||||||
"FFEC": "End Credits", # First Frame End Credits
|
|
||||||
"LFEC": None, # Last Frame End Credits
|
|
||||||
"up_next": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Sort by timestamp to ensure proper scene numbering
|
|
||||||
editorial.sort(key=lambda x: x.get("offsetMillis", 0))
|
|
||||||
|
|
||||||
# Track chapters we've already added by timestamp to avoid duplicates
|
|
||||||
seen_timestamps = set()
|
|
||||||
scene_count = 0
|
|
||||||
|
|
||||||
for marker in editorial:
|
|
||||||
if "label" in marker and "offsetMillis" in marker:
|
|
||||||
timestamp = marker["offsetMillis"]
|
|
||||||
name = mapping.get(marker["label"])
|
|
||||||
|
|
||||||
# Skip if no mapping or already processed timestamp
|
|
||||||
if not name or timestamp in seen_timestamps:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Mark this timestamp as seen
|
|
||||||
seen_timestamps.add(timestamp)
|
|
||||||
|
|
||||||
if name == "Scene":
|
|
||||||
scene_count += 1
|
|
||||||
name = f"Scene {scene_count}"
|
|
||||||
|
|
||||||
chapters.add(Chapter(timestamp=timestamp, name=name))
|
|
||||||
|
|
||||||
# If we found chapters in the playback data, return them
|
|
||||||
if chapters:
|
|
||||||
return chapters
|
|
||||||
|
|
||||||
# If no chapters found in playback data, try the original method
|
|
||||||
content_id = title.data["partnerFeed"].get("dmcContentId")
|
|
||||||
content = self.get_video(content_id)
|
|
||||||
|
|
||||||
# Check for chapter/milestone data
|
|
||||||
video_info = content.get("video", {}).get("milestone", {})
|
|
||||||
|
|
||||||
if not video_info:
|
|
||||||
return chapters
|
|
||||||
|
|
||||||
# Mapping of milestone types to chapter names
|
|
||||||
mapping = {
|
|
||||||
"recap_start": "Recap",
|
|
||||||
"recap_end": "Scene",
|
|
||||||
"intro_start": "Title Sequence",
|
|
||||||
"intro_end": "Scene",
|
|
||||||
"FFEI": "Title Sequence", # First Frame Episode Intro
|
|
||||||
"LFEI": "Scene", # Last Frame Episode Intro
|
|
||||||
"FFCB": None, # First Frame Credits Bumper
|
|
||||||
"LFCB": "Scene", # Last Frame Credits Bumper
|
|
||||||
"FFEC": "End Credits", # First Frame End Credits
|
|
||||||
"LFEC": None, # Last Frame End Credits
|
|
||||||
"up_next": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Flatten the milestone data and sort by start time
|
|
||||||
flattened = []
|
|
||||||
for chapter_type, items in video_info.items():
|
|
||||||
for entry in items:
|
|
||||||
if "milestoneTime" in entry and entry["milestoneTime"]:
|
|
||||||
start = entry["milestoneTime"][0]["startMillis"]
|
|
||||||
flattened.append({"type": chapter_type, "start": start})
|
|
||||||
|
|
||||||
flattened.sort(key=lambda x: x["start"])
|
|
||||||
|
|
||||||
# Create chapters
|
|
||||||
chapter_list = []
|
|
||||||
scene_count = 0
|
|
||||||
for f in flattened:
|
|
||||||
name = mapping.get(f["type"])
|
|
||||||
if not name:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if name == "Scene":
|
|
||||||
scene_count += 1
|
|
||||||
name = f"Scene {scene_count}"
|
|
||||||
|
|
||||||
chapter_list.append(Chapter(timestamp=f["start"], name=name))
|
|
||||||
|
|
||||||
# Add a "Start" chapter at 0 if we have end credits
|
|
||||||
if "FFEC" in video_info and not any(ch.timestamp == 0 for ch in chapter_list):
|
|
||||||
chapter_list.insert(0, Chapter(timestamp=0, name="Start"))
|
|
||||||
|
|
||||||
# Remove duplicates (same time and name)
|
|
||||||
prev_time, prev_name = None, None
|
|
||||||
|
|
||||||
for ch in chapter_list:
|
|
||||||
# Convert timestamp to milliseconds for comparison
|
|
||||||
if isinstance(ch.timestamp, str):
|
|
||||||
ts_parts = ch.timestamp.split(":")
|
|
||||||
hour, minute, second = int(ts_parts[0]), int(ts_parts[1]), float(ts_parts[2])
|
|
||||||
ts_ms = (hour * 3600 + minute * 60 + second) * 1000
|
|
||||||
else:
|
|
||||||
ts_ms = ch.timestamp
|
|
||||||
|
|
||||||
if prev_time is None or (ts_ms != prev_time and ch.name != prev_name):
|
|
||||||
chapters.add(ch)
|
|
||||||
prev_time, prev_name = ts_ms, ch.name
|
|
||||||
|
|
||||||
return chapters
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.log.warning(f"Failed to extract chapters: {e}")
|
|
||||||
return chapters
|
|
||||||
|
|
||||||
def get_playready_license(self, *, challenge: bytes, title, track) -> bytes:
|
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {self._cache.data['token']['accessToken']}",
|
"Authorization": f'Bearer {token}',
|
||||||
"Content-Type": "application/octet-stream",
|
|
||||||
}
|
|
||||||
r = self.session.post(url=self.config["PLAYREADY_LICENSE"], headers=headers, data=challenge)
|
|
||||||
if r.status_code != 200:
|
|
||||||
raise ConnectionError(r.text)
|
|
||||||
return r.content
|
|
||||||
|
|
||||||
def get_widevine_license(self, *, challenge: bytes, title, track) -> None:
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Bearer {self._cache.data['token']['accessToken']}",
|
|
||||||
"Content-Type": "application/octet-stream",
|
"Content-Type": "application/octet-stream",
|
||||||
}
|
}
|
||||||
r = self.session.post(url=self.config["LICENSE"], headers=headers, data=challenge)
|
r = self.session.post(url=self.config["LICENSE"], headers=headers, data=challenge)
|
||||||
@@ -412,6 +396,23 @@ class DSNP(Service):
|
|||||||
raise ConnectionError(r.text)
|
raise ConnectionError(r.text)
|
||||||
return r.content
|
return r.content
|
||||||
|
|
||||||
|
def get_playready_license(self, *, challenge: bytes, title, track) -> Optional[bytes]:
|
||||||
|
"""Get PlayReady license for Disney+ content - Adapted from working Vinetrimmer implementation"""
|
||||||
|
# Refresh token if needed
|
||||||
|
token = self._cache.data["token"]["accessToken"] if self._cache else None
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
r = self.session.post(
|
||||||
|
url='https://disney.playback.edge.bamgrid.com/playready/v1/obtain-license.asmx',
|
||||||
|
headers={'authorization': f'Bearer {token}'},
|
||||||
|
data=challenge
|
||||||
|
)
|
||||||
|
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise ConnectionError(r.text)
|
||||||
|
return r.text.encode()
|
||||||
|
|
||||||
# Service specific functions
|
# Service specific functions
|
||||||
|
|
||||||
def _show(self, title: str) -> Episode:
|
def _show(self, title: str) -> Episode:
|
||||||
@@ -421,13 +422,16 @@ class DSNP(Service):
|
|||||||
|
|
||||||
episodes = []
|
episodes = []
|
||||||
for season in season_ids:
|
for season in season_ids:
|
||||||
endpoint = self.href(
|
# Use direct Disney+ API URL like Vinetrimmer does
|
||||||
self.prd_config["services"]["explore"]["client"]["endpoints"]["getSeason"]["href"],
|
endpoint = f'https://disney.api.edge.bamgrid.com/explore/v1.3/season/{season}'
|
||||||
version=self.config["EXPLORE_VERSION"],
|
params = {'limit': 80}
|
||||||
seasonId=season,
|
|
||||||
)
|
response = self.session.get(endpoint, params=params)
|
||||||
data = self.session.get(endpoint, params={'limit': 999}).json()["data"]["season"]["items"]
|
if response.status_code == 200:
|
||||||
episodes.extend(data)
|
data = response.json()["data"]["season"]["items"]
|
||||||
|
episodes.extend(data)
|
||||||
|
else:
|
||||||
|
self.log.warning(f"Failed to get season {season}: {response.status_code}")
|
||||||
|
|
||||||
return [
|
return [
|
||||||
Episode(
|
Episode(
|
||||||
@@ -438,7 +442,6 @@ class DSNP(Service):
|
|||||||
season=int(episode["visuals"].get("seasonNumber", 0)),
|
season=int(episode["visuals"].get("seasonNumber", 0)),
|
||||||
number=int(episode["visuals"].get("episodeNumber", 0)),
|
number=int(episode["visuals"].get("episodeNumber", 0)),
|
||||||
name=episode["visuals"].get("episodeTitle"),
|
name=episode["visuals"].get("episodeTitle"),
|
||||||
language=self.get_original_lang(next(x for x in episode["actions"] if x.get("type") == "playback").get("availId")),
|
|
||||||
data=next(x for x in episode["actions"] if x.get("type") == "playback"),
|
data=next(x for x in episode["actions"] if x.get("type") == "playback"),
|
||||||
)
|
)
|
||||||
for episode in episodes
|
for episode in episodes
|
||||||
@@ -448,28 +451,16 @@ class DSNP(Service):
|
|||||||
def _movie(self, title: str) -> Movie:
|
def _movie(self, title: str) -> Movie:
|
||||||
movie = self.get_page(title)
|
movie = self.get_page(title)
|
||||||
|
|
||||||
playback_action = next(x for x in movie["actions"] if x.get("type") == "playback")
|
|
||||||
original_lang = self.get_original_lang(playback_action.get("availId"))
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
Movie(
|
Movie(
|
||||||
id_=movie.get("id"),
|
id_=movie.get("id"),
|
||||||
service=self.__class__,
|
service=self.__class__,
|
||||||
name=movie["visuals"].get("title"),
|
name=movie["visuals"].get("title"),
|
||||||
year=movie["visuals"]["metastringParts"].get("releaseYearRange", {}).get("startYear"),
|
year=movie["visuals"]["metastringParts"].get("releaseYearRange", {}).get("startYear"),
|
||||||
language=original_lang,
|
data=next(x for x in movie["actions"] if x.get("type") == "playback"),
|
||||||
data=playback_action,
|
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_original_lang(self, availId):
|
|
||||||
try:
|
|
||||||
title_lang = self.session.get(f'https://disney.api.edge.bamgrid.com/explore/v1.6/playerExperience/{availId}').json()
|
|
||||||
original_lang = title_lang["data"]["playerExperience"]["targetLanguage"]
|
|
||||||
except Exception:
|
|
||||||
original_lang = "en"
|
|
||||||
return original_lang
|
|
||||||
|
|
||||||
def _request(
|
def _request(
|
||||||
self,
|
self,
|
||||||
method: str,
|
method: str,
|
||||||
@@ -478,14 +469,18 @@ class DSNP(Service):
|
|||||||
headers: dict = None,
|
headers: dict = None,
|
||||||
payload: dict = None,
|
payload: dict = None,
|
||||||
) -> Any[dict | str]:
|
) -> Any[dict | str]:
|
||||||
_headers = {**self.session.headers, **(headers or {})}
|
_headers = headers if headers else self.session.headers
|
||||||
|
|
||||||
prep = self.session.prepare_request(Request(method, endpoint, headers=_headers, params=params, json=payload))
|
prep = self.session.prepare_request(Request(method, endpoint, headers=_headers, params=params, json=payload))
|
||||||
response = self.session.send(prep)
|
response = self.session.send(prep)
|
||||||
|
|
||||||
|
# Check for geoblocking
|
||||||
|
if response.status_code == 404 and 'x-dss-edge' in response.headers:
|
||||||
|
edge_error = response.headers.get('x-dss-edge', '')
|
||||||
|
if 'location.invalid' in edge_error:
|
||||||
|
raise ConnectionError("Disney+ content is geoblocked in your region. Please use a VPN to US/supported region.")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
if data.get("errors"):
|
if data.get("errors"):
|
||||||
code = data["errors"][0]["extensions"].get("code")
|
code = data["errors"][0]["extensions"].get("code")
|
||||||
|
|
||||||
@@ -495,38 +490,111 @@ class DSNP(Service):
|
|||||||
raise ConnectionError("Bad Credentials: " + code)
|
raise ConnectionError("Bad Credentials: " + code)
|
||||||
else:
|
else:
|
||||||
raise ConnectionError(data["errors"])
|
raise ConnectionError(data["errors"])
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
except Exception:
|
except Exception as e:
|
||||||
raise ConnectionError("Request failed: {}".format(response.content))
|
if response.status_code == 404:
|
||||||
|
raise ConnectionError(f"Disney+ content not found or geoblocked. Status: {response.status_code}")
|
||||||
|
raise ConnectionError(f"Request failed. Status: {response.status_code}, Content: {response.content}")
|
||||||
|
|
||||||
def get_page(self, title):
|
def get_page(self, title):
|
||||||
|
# Use direct Disney+ API URL like Vinetrimmer does - no need for SDK endpoints
|
||||||
params = {
|
params = {
|
||||||
"disableSmartFocus": "true",
|
"disableSmartFocus": True,
|
||||||
"limit": 999,
|
"enhancedContainersLimit": 12,
|
||||||
"enhancedContainersLimit": 0,
|
"limit": 24,
|
||||||
}
|
}
|
||||||
endpoint = self.href(
|
|
||||||
self.prd_config["services"]["explore"]["client"]["endpoints"]["getPage"]["href"],
|
# Use exact Vinetrimmer URL pattern
|
||||||
version=self.config["EXPLORE_VERSION"],
|
endpoint = f'https://disney.api.edge.bamgrid.com/explore/v1.4/page/{title}'
|
||||||
pageId=title,
|
|
||||||
)
|
response = self.session.get(endpoint, params=params)
|
||||||
return self._request("GET", endpoint, params=params)["data"]["page"]
|
if response.status_code == 200:
|
||||||
|
return response.json()["data"]["page"]
|
||||||
|
else:
|
||||||
|
raise ConnectionError(f"Failed to get page data. Status: {response.status_code}")
|
||||||
|
|
||||||
def get_video(self, content_id: str) -> dict:
|
def get_video(self, content_id: str) -> dict:
|
||||||
endpoint = self.href(
|
# Use Vinetrimmer's approach - get manifest directly using playback API
|
||||||
self.prd_config["services"]["content"]["client"]["endpoints"]["getDmcVideo"]["href"], contentId=content_id
|
# Add special headers like Vinetrimmer does to avoid geoblocking
|
||||||
)
|
original_headers = dict(self.session.headers)
|
||||||
return self._request("GET", endpoint)["data"]["DmcVideo"]
|
self.session.headers.update({
|
||||||
|
'x-dss-feature-filtering': 'true',
|
||||||
|
'x-application-version': '1.1.2',
|
||||||
|
'x-bamsdk-client-id': 'disney-svod',
|
||||||
|
'x-bamsdk-platform': 'javascript/windows/chrome',
|
||||||
|
'x-bamsdk-version': '28.0'
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use playback API like Vinetrimmer with exact configuration
|
||||||
|
# Use exact Vinetrimmer configuration - L3 CAN get 1080p if done correctly
|
||||||
|
json_data = {
|
||||||
|
'playback': {
|
||||||
|
'attributes': {
|
||||||
|
'resolution': {
|
||||||
|
'max': ['1920'], # Same as Vinetrimmer - no artificial L3 limitation
|
||||||
|
},
|
||||||
|
'protocol': 'HTTPS',
|
||||||
|
'assetInsertionStrategy': 'SGAI',
|
||||||
|
'playbackInitiationContext': 'ONLINE',
|
||||||
|
'frameRates': [60],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'playbackId': content_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Use exact Vinetrimmer playback URL and scenario
|
||||||
|
manifest_response = self.session.post(
|
||||||
|
'https://disney.playback.edge.bamgrid.com/v7/playback/tv-drm-ctr',
|
||||||
|
json=json_data
|
||||||
|
)
|
||||||
|
|
||||||
|
if manifest_response.status_code == 200:
|
||||||
|
manifest_data = manifest_response.json()
|
||||||
|
# Return in expected format
|
||||||
|
return {
|
||||||
|
"video": {
|
||||||
|
"mediaMetadata": {
|
||||||
|
"playbackUrls": [{"url": manifest_data["stream"]["sources"][0]['complete']['url']}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Fallback: try original method but with new headers
|
||||||
|
endpoint = self.href(
|
||||||
|
self.prd_config["services"]["content"]["client"]["endpoints"]["getDmcVideo"]["href"],
|
||||||
|
contentId=content_id
|
||||||
|
)
|
||||||
|
data = self._request("GET", endpoint)["data"]["DmcVideo"]
|
||||||
|
return data
|
||||||
|
except Exception as e:
|
||||||
|
# Final fallback
|
||||||
|
endpoint = self.href(
|
||||||
|
self.prd_config["services"]["content"]["client"]["endpoints"]["getDmcVideo"]["href"],
|
||||||
|
contentId=content_id
|
||||||
|
)
|
||||||
|
data = self._request("GET", endpoint)["data"]["DmcVideo"]
|
||||||
|
return data
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Restore original headers
|
||||||
|
self.session.headers.clear()
|
||||||
|
self.session.headers.update(original_headers)
|
||||||
|
|
||||||
def get_deeplink(self, ref_id: str) -> str:
|
def get_deeplink(self, ref_id: str) -> str:
|
||||||
|
# Use direct Disney+ API URL like Vinetrimmer does
|
||||||
params = {
|
params = {
|
||||||
"refId": ref_id,
|
"refId": ref_id,
|
||||||
"refIdType": "deeplinkId",
|
"refIdType": "deeplinkId",
|
||||||
}
|
}
|
||||||
endpoint = "https://disney.content.edge.bamgrid.com/explore/v1.0/deeplink"
|
endpoint = "https://disney.api.edge.bamgrid.com/explore/v1.3/deeplink"
|
||||||
return self._request("GET", endpoint, params=params)
|
|
||||||
|
response = self.session.get(endpoint, params=params)
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json()
|
||||||
|
else:
|
||||||
|
raise ConnectionError(f"Failed to get deeplink. Status: {response.status_code}")
|
||||||
|
|
||||||
def series_bundle(self, series_id: str) -> dict:
|
def series_bundle(self, series_id: str) -> dict:
|
||||||
endpoint = self.href(
|
endpoint = self.href(
|
||||||
@@ -563,14 +631,14 @@ class DSNP(Service):
|
|||||||
payload = {
|
payload = {
|
||||||
"variables": {
|
"variables": {
|
||||||
"registerDevice": {
|
"registerDevice": {
|
||||||
"applicationRuntime": self.config["APPLICATION_RUNTIME"],
|
"applicationRuntime": self.config.get("BAM_APPLICATION_RUNTIME", "android"),
|
||||||
"attributes": {
|
"attributes": {
|
||||||
"operatingSystem": "Android",
|
"operatingSystem": "Android",
|
||||||
"operatingSystemVersion": "8.1.0",
|
"operatingSystemVersion": "8.1.0",
|
||||||
},
|
},
|
||||||
"deviceFamily": self.config["DEVICE_FAMILY"],
|
"deviceFamily": self.config.get("BAM_FAMILY", "browser"), # Use Vinetrimmer family
|
||||||
"deviceLanguage": "en",
|
"deviceLanguage": "en",
|
||||||
"deviceProfile": self.config["DEVICE_PROFILE"],
|
"deviceProfile": self.config.get("BAM_PROFILE", "tv"),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"query": queries.REGISTER_DEVICE,
|
"query": queries.REGISTER_DEVICE,
|
||||||
@@ -655,3 +723,5 @@ class DSNP(Service):
|
|||||||
endpoint = self.prd_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"]
|
endpoint = self.prd_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"]
|
||||||
data = self._request("POST", endpoint, payload=payload)
|
data = self._request("POST", endpoint, payload=payload)
|
||||||
return data["extensions"]["sdk"]
|
return data["extensions"]["sdk"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,29 @@
|
|||||||
|
# Use exact configuration from working Vinetrimmer implementation
|
||||||
CLIENT_ID: disney-svod-3d9324fc
|
CLIENT_ID: disney-svod-3d9324fc
|
||||||
CLIENT_VERSION: "9.10.0"
|
CLIENT_VERSION: "6.0.4"
|
||||||
|
|
||||||
API_KEY: "ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA"
|
API_KEY: "ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA"
|
||||||
CONFIG_URL: https://bam-sdk-configs.bamgrid.com/bam-sdk/v5.0/disney-svod-3d9324fc/android/v9.10.0/google/tv/prod.json
|
CONFIG_URL: https://bam-sdk-configs.bamgrid.com/bam-sdk/v3.0/disney-svod-3d9324fc/android/v6.0.0/google/tv/prod.json
|
||||||
|
|
||||||
PAGE_SIZE_SETS: 15
|
PAGE_SIZE_SETS: 15
|
||||||
PAGE_SIZE_CONTENT: 30
|
PAGE_SIZE_CONTENT: 30
|
||||||
SEARCH_QUERY_TYPE: ge
|
SEARCH_QUERY_TYPE: ge
|
||||||
BAM_PARTNER: disney
|
BAM_PARTNER: disney
|
||||||
EXPLORE_VERSION: v1.3
|
EXPLORE_VERSION: v1.3
|
||||||
DEVICE_FAMILY: browser
|
|
||||||
DEVICE_PROFILE: tv
|
# Use Vinetrimmer family/platform combination that avoids geoblocking
|
||||||
APPLICATION_RUNTIME: android
|
BAM_FAMILY: browser
|
||||||
|
BAM_PLATFORM: android-tv
|
||||||
|
BAM_PROFILE: tv
|
||||||
|
BAM_APPLICATION_RUNTIME: android
|
||||||
|
|
||||||
HEADERS:
|
HEADERS:
|
||||||
User-Agent: BAMSDK/v9.10.0 (disney-svod-3d9324fc 2.26.2-rc1.0; v5.0/v9.10.0; android; tv)
|
User-Agent: BAMSDK/v6.0.4 (disney-svod-3d9324fc 1.15.0.0; v3.0/v6.0.0; android; tv) google Nexus Player (OPR2.170623.027; Linux; 8.0.0; API 26)
|
||||||
x-application-version: google
|
x-application-version: google
|
||||||
x-bamsdk-platform-id: android-tv
|
x-bamsdk-platform-id: android-tv
|
||||||
x-bamsdk-client-id: disney-svod-3d9324fc
|
x-bamsdk-client-id: disney-svod-3d9324fc
|
||||||
x-bamsdk-platform: android-tv
|
x-bamsdk-platform: android-tv
|
||||||
x-bamsdk-version: "9.10.0"
|
x-bamsdk-version: "6.0.4"
|
||||||
Accept-Encoding: gzip
|
Accept-Encoding: gzip
|
||||||
Origin: https://www.disneyplus.com
|
|
||||||
|
|
||||||
LICENSE: https://disney.playback.edge.bamgrid.com/widevine/v1/obtain-license
|
LICENSE: https://disney.playback.edge.bamgrid.com/widevine/v1/obtain-license
|
||||||
PLAYREADY_LICENSE: https://disney.playback.edge.bamgrid.com/playready/v1/obtain-license.asmx
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,504 @@
|
|||||||
|
# API key for The Movie Database (TMDB)
|
||||||
|
tmdb_api_key: ""
|
||||||
|
|
||||||
|
# Client ID for SIMKL API (optional, improves metadata matching)
|
||||||
|
# Get your free client ID at: https://simkl.com/settings/developer/
|
||||||
|
simkl_client_id: ""
|
||||||
|
|
||||||
|
# Group or Username to postfix to the end of all download filenames following a dash
|
||||||
|
tag: user_tag
|
||||||
|
|
||||||
|
# Enable/disable tagging with group name (default: true)
|
||||||
|
tag_group_name: true
|
||||||
|
|
||||||
|
# Enable/disable tagging with IMDB/TMDB/TVDB details (default: true)
|
||||||
|
tag_imdb_tmdb: true
|
||||||
|
|
||||||
|
# Set terminal background color (custom option not in CONFIG.md)
|
||||||
|
set_terminal_bg: false
|
||||||
|
|
||||||
|
# Set file naming convention
|
||||||
|
# true for style - Prime.Suspect.S07E01.The.Final.Act.Part.One.1080p.ITV.WEB-DL.AAC2.0.H.264
|
||||||
|
# false for style - Prime Suspect S07E01 The Final Act - Part One
|
||||||
|
scene_naming: true
|
||||||
|
|
||||||
|
# Whether to include the year in series names for episodes and folders (default: true)
|
||||||
|
# true for style - Show Name (2023) S01E01 Episode Name
|
||||||
|
# false for style - Show Name S01E01 Episode Name
|
||||||
|
series_year: true
|
||||||
|
|
||||||
|
# Check for updates from GitHub repository on startup (default: true)
|
||||||
|
update_checks: true
|
||||||
|
|
||||||
|
# How often to check for updates, in hours (default: 24)
|
||||||
|
update_check_interval: 24
|
||||||
|
|
||||||
|
# Title caching configuration
|
||||||
|
# Cache title metadata to reduce redundant API calls
|
||||||
|
title_cache_enabled: true # Enable/disable title caching globally (default: true)
|
||||||
|
title_cache_time: 1800 # Cache duration in seconds (default: 1800 = 30 minutes)
|
||||||
|
title_cache_max_retention: 86400 # Maximum cache retention for fallback when API fails (default: 86400 = 24 hours)
|
||||||
|
|
||||||
|
# Debug logging configuration
|
||||||
|
# Comprehensive JSON-based debug logging for troubleshooting and service development
|
||||||
|
debug:
|
||||||
|
false # Enable structured JSON debug logging (default: false)
|
||||||
|
# When enabled with --debug flag or set to true:
|
||||||
|
# - Creates JSON Lines (.jsonl) log files with complete debugging context
|
||||||
|
# - Logs: session info, CLI params, service config, CDM details, authentication,
|
||||||
|
# titles, tracks metadata, DRM operations, vault queries, errors with stack traces
|
||||||
|
# - File location: logs/unshackle_debug_{service}_{timestamp}.jsonl
|
||||||
|
# - Also creates text log: logs/unshackle_root_{timestamp}.log
|
||||||
|
|
||||||
|
debug_keys:
|
||||||
|
false # Log decryption keys in debug logs (default: false)
|
||||||
|
# Set to true to include actual decryption keys in logs
|
||||||
|
# Useful for debugging key retrieval and decryption issues
|
||||||
|
# SECURITY NOTE: Passwords, tokens, cookies, and session tokens
|
||||||
|
# are ALWAYS redacted regardless of this setting
|
||||||
|
# Only affects: content_key, key fields (the actual CEKs)
|
||||||
|
# Never affects: kid, keys_count, key_id (metadata is always logged)
|
||||||
|
|
||||||
|
# Muxing configuration
|
||||||
|
muxing:
|
||||||
|
set_title: false
|
||||||
|
|
||||||
|
# Login credentials for each Service
|
||||||
|
credentials:
|
||||||
|
# Direct credentials (no profile support)
|
||||||
|
EXAMPLE: email@example.com:password
|
||||||
|
|
||||||
|
# Per-profile credentials with default fallback
|
||||||
|
SERVICE_NAME:
|
||||||
|
default: default@email.com:password # Used when no -p/--profile is specified
|
||||||
|
profile1: user1@email.com:password1
|
||||||
|
profile2: user2@email.com:password2
|
||||||
|
|
||||||
|
# Per-profile credentials without default (requires -p/--profile)
|
||||||
|
SERVICE_NAME2:
|
||||||
|
john: john@example.com:johnspassword
|
||||||
|
jane: jane@example.com:janespassword
|
||||||
|
|
||||||
|
# You can also use list format for passwords with special characters
|
||||||
|
SERVICE_NAME3:
|
||||||
|
default: ["user@email.com", ":PasswordWith:Colons"]
|
||||||
|
|
||||||
|
# Override default directories used across unshackle
|
||||||
|
directories:
|
||||||
|
cache: Cache
|
||||||
|
cookies: Cookies
|
||||||
|
dcsl: DCSL # Device Certificate Status List
|
||||||
|
downloads: Downloads
|
||||||
|
logs: Logs
|
||||||
|
temp: Temp
|
||||||
|
wvds: WVDs
|
||||||
|
prds: PRDs
|
||||||
|
# Additional directories that can be configured:
|
||||||
|
# commands: Commands
|
||||||
|
services:
|
||||||
|
- /path/to/services
|
||||||
|
- /other/path/to/services
|
||||||
|
# vaults: Vaults
|
||||||
|
# fonts: Fonts
|
||||||
|
|
||||||
|
# Pre-define which Widevine or PlayReady device to use for each Service
|
||||||
|
cdm:
|
||||||
|
# Global default CDM device (fallback for all services/profiles)
|
||||||
|
default: WVD_1
|
||||||
|
|
||||||
|
# Direct service-specific CDM
|
||||||
|
DIFFERENT_EXAMPLE: PRD_1
|
||||||
|
|
||||||
|
# Per-profile CDM configuration
|
||||||
|
EXAMPLE:
|
||||||
|
john_sd: chromecdm_903_l3 # Profile 'john_sd' uses Chrome CDM L3
|
||||||
|
jane_uhd: nexus_5_l1 # Profile 'jane_uhd' uses Nexus 5 L1
|
||||||
|
default: generic_android_l3 # Default CDM for this service
|
||||||
|
|
||||||
|
# NEW: Quality-based CDM selection
|
||||||
|
# Use different CDMs based on video resolution
|
||||||
|
# Supports operators: >=, >, <=, <, or exact match
|
||||||
|
EXAMPLE_QUALITY:
|
||||||
|
"<=1080": generic_android_l3 # Use L3 for 1080p and below
|
||||||
|
">1080": nexus_5_l1 # Use L1 for above 1080p (1440p, 2160p)
|
||||||
|
default: generic_android_l3 # Optional: fallback if no quality match
|
||||||
|
|
||||||
|
# You can mix profiles and quality thresholds in the same service
|
||||||
|
NETFLIX:
|
||||||
|
# Profile-based selection (existing functionality)
|
||||||
|
john: netflix_l3_profile
|
||||||
|
jane: netflix_l1_profile
|
||||||
|
# Quality-based selection (new functionality)
|
||||||
|
"<=720": netflix_mobile_l3
|
||||||
|
"1080": netflix_standard_l3
|
||||||
|
">=1440": netflix_premium_l1
|
||||||
|
# Fallback
|
||||||
|
default: netflix_standard_l3
|
||||||
|
|
||||||
|
# Use pywidevine Serve-compliant Remote CDMs
|
||||||
|
|
||||||
|
# Example: Custom CDM API Configuration
|
||||||
|
# This demonstrates the highly configurable custom_api type that can adapt to any CDM API format
|
||||||
|
# - name: "chrome"
|
||||||
|
# type: "custom_api"
|
||||||
|
# host: "http://remotecdm.test/"
|
||||||
|
# timeout: 30
|
||||||
|
# device:
|
||||||
|
# name: "ChromeCDM"
|
||||||
|
# type: "CHROME"
|
||||||
|
# system_id: 34312
|
||||||
|
# security_level: 3
|
||||||
|
# auth:
|
||||||
|
# type: "header"
|
||||||
|
# header_name: "x-api-key"
|
||||||
|
# key: "YOUR_API_KEY_HERE"
|
||||||
|
# custom_headers:
|
||||||
|
# User-Agent: "Unshackle/2.0.0"
|
||||||
|
# endpoints:
|
||||||
|
# get_request:
|
||||||
|
# path: "/get-challenge"
|
||||||
|
# method: "POST"
|
||||||
|
# timeout: 30
|
||||||
|
# decrypt_response:
|
||||||
|
# path: "/get-keys"
|
||||||
|
# method: "POST"
|
||||||
|
# timeout: 30
|
||||||
|
# request_mapping:
|
||||||
|
# get_request:
|
||||||
|
# param_names:
|
||||||
|
# scheme: "device"
|
||||||
|
# init_data: "init_data"
|
||||||
|
# static_params:
|
||||||
|
# scheme: "Widevine"
|
||||||
|
# decrypt_response:
|
||||||
|
# param_names:
|
||||||
|
# scheme: "device"
|
||||||
|
# license_request: "license_request"
|
||||||
|
# license_response: "license_response"
|
||||||
|
# static_params:
|
||||||
|
# scheme: "Widevine"
|
||||||
|
# response_mapping:
|
||||||
|
# get_request:
|
||||||
|
# fields:
|
||||||
|
# challenge: "challenge"
|
||||||
|
# session_id: "session_id"
|
||||||
|
# message: "message"
|
||||||
|
# message_type: "message_type"
|
||||||
|
# response_types:
|
||||||
|
# - condition: "message_type == 'license-request'"
|
||||||
|
# type: "license_request"
|
||||||
|
# success_conditions:
|
||||||
|
# - "message == 'success'"
|
||||||
|
# decrypt_response:
|
||||||
|
# fields:
|
||||||
|
# keys: "keys"
|
||||||
|
# message: "message"
|
||||||
|
# key_fields:
|
||||||
|
# kid: "kid"
|
||||||
|
# key: "key"
|
||||||
|
# type: "type"
|
||||||
|
# success_conditions:
|
||||||
|
# - "message == 'success'"
|
||||||
|
# caching:
|
||||||
|
# enabled: true
|
||||||
|
# use_vaults: true
|
||||||
|
# check_cached_first: true
|
||||||
|
|
||||||
|
remote_cdm:
|
||||||
|
- name: "chrome"
|
||||||
|
device_name: chrome
|
||||||
|
device_type: CHROME
|
||||||
|
system_id: 27175
|
||||||
|
security_level: 3
|
||||||
|
host: https://domain.com/api
|
||||||
|
secret: secret_key
|
||||||
|
- name: "chrome-2"
|
||||||
|
device_name: chrome
|
||||||
|
device_type: CHROME
|
||||||
|
system_id: 26830
|
||||||
|
security_level: 3
|
||||||
|
host: https://domain-2.com/api
|
||||||
|
secret: secret_key
|
||||||
|
|
||||||
|
- name: "decrypt_labs_chrome"
|
||||||
|
type: "decrypt_labs" # Required to identify as DecryptLabs CDM
|
||||||
|
device_name: "ChromeCDM" # Scheme identifier - must match exactly
|
||||||
|
device_type: CHROME
|
||||||
|
system_id: 4464 # Doesn't matter
|
||||||
|
security_level: 3
|
||||||
|
host: "https://keyxtractor.decryptlabs.com"
|
||||||
|
secret: "your_decrypt_labs_api_key_here" # Replace with your API key
|
||||||
|
- name: "decrypt_labs_l1"
|
||||||
|
type: "decrypt_labs"
|
||||||
|
device_name: "L1" # Scheme identifier - must match exactly
|
||||||
|
device_type: ANDROID
|
||||||
|
system_id: 4464
|
||||||
|
security_level: 1
|
||||||
|
host: "https://keyxtractor.decryptlabs.com"
|
||||||
|
secret: "your_decrypt_labs_api_key_here"
|
||||||
|
|
||||||
|
- name: "decrypt_labs_l2"
|
||||||
|
type: "decrypt_labs"
|
||||||
|
device_name: "L2" # Scheme identifier - must match exactly
|
||||||
|
device_type: ANDROID
|
||||||
|
system_id: 4464
|
||||||
|
security_level: 2
|
||||||
|
host: "https://keyxtractor.decryptlabs.com"
|
||||||
|
secret: "your_decrypt_labs_api_key_here"
|
||||||
|
|
||||||
|
- name: "decrypt_labs_playready_sl2"
|
||||||
|
type: "decrypt_labs"
|
||||||
|
device_name: "SL2" # Scheme identifier - must match exactly
|
||||||
|
device_type: PLAYREADY
|
||||||
|
system_id: 0
|
||||||
|
security_level: 2000
|
||||||
|
host: "https://keyxtractor.decryptlabs.com"
|
||||||
|
secret: "your_decrypt_labs_api_key_here"
|
||||||
|
|
||||||
|
- name: "decrypt_labs_playready_sl3"
|
||||||
|
type: "decrypt_labs"
|
||||||
|
device_name: "SL3" # Scheme identifier - must match exactly
|
||||||
|
device_type: PLAYREADY
|
||||||
|
system_id: 0
|
||||||
|
security_level: 3000
|
||||||
|
host: "https://keyxtractor.decryptlabs.com"
|
||||||
|
secret: "your_decrypt_labs_api_key_here"
|
||||||
|
|
||||||
|
# Key Vaults store your obtained Content Encryption Keys (CEKs)
|
||||||
|
# Use 'no_push: true' to prevent a vault from receiving pushed keys
|
||||||
|
# while still allowing it to provide keys when requested
|
||||||
|
key_vaults:
|
||||||
|
- type: SQLite
|
||||||
|
name: Local
|
||||||
|
path: key_store.db
|
||||||
|
# Additional vault types:
|
||||||
|
# - type: API
|
||||||
|
# name: "Remote Vault"
|
||||||
|
# uri: "https://key-vault.example.com"
|
||||||
|
# token: "secret_token"
|
||||||
|
# no_push: true # This vault will only provide keys, not receive them
|
||||||
|
# - type: MySQL
|
||||||
|
# name: "MySQL Vault"
|
||||||
|
# host: "127.0.0.1"
|
||||||
|
# port: 3306
|
||||||
|
# database: vault
|
||||||
|
# username: user
|
||||||
|
# password: pass
|
||||||
|
# no_push: false # Default behavior - vault both provides and receives keys
|
||||||
|
|
||||||
|
# Choose what software to use to download data
|
||||||
|
downloader: aria2c
|
||||||
|
# Options: requests | aria2c | curl_impersonate | n_m3u8dl_re
|
||||||
|
# Can also be a mapping:
|
||||||
|
# downloader:
|
||||||
|
# NF: requests
|
||||||
|
# AMZN: n_m3u8dl_re
|
||||||
|
# DSNP: n_m3u8dl_re
|
||||||
|
# default: requests
|
||||||
|
|
||||||
|
# aria2c downloader configuration
|
||||||
|
aria2c:
|
||||||
|
max_concurrent_downloads: 4
|
||||||
|
max_connection_per_server: 3
|
||||||
|
split: 5
|
||||||
|
file_allocation: falloc # none | prealloc | falloc | trunc
|
||||||
|
|
||||||
|
# N_m3u8DL-RE downloader configuration
|
||||||
|
n_m3u8dl_re:
|
||||||
|
thread_count: 16
|
||||||
|
ad_keyword: "advertisement"
|
||||||
|
use_proxy: true
|
||||||
|
|
||||||
|
# curl_impersonate downloader configuration
|
||||||
|
curl_impersonate:
|
||||||
|
browser: chrome120
|
||||||
|
|
||||||
|
# Pre-define default options and switches of the dl command
|
||||||
|
dl:
|
||||||
|
sub_format: srt
|
||||||
|
downloads: 4
|
||||||
|
workers: 16
|
||||||
|
lang:
|
||||||
|
- en
|
||||||
|
- fr
|
||||||
|
EXAMPLE:
|
||||||
|
bitrate: CBR
|
||||||
|
|
||||||
|
# Chapter Name to use when exporting a Chapter without a Name
|
||||||
|
chapter_fallback_name: "Chapter {j:02}"
|
||||||
|
|
||||||
|
# Case-Insensitive dictionary of headers for all Services
|
||||||
|
headers:
|
||||||
|
Accept-Language: "en-US,en;q=0.8"
|
||||||
|
User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
|
||||||
|
|
||||||
|
# Override default filenames used across unshackle
|
||||||
|
filenames:
|
||||||
|
debug_log: "unshackle_debug_{service}_{time}.jsonl" # JSON Lines debug log file
|
||||||
|
config: "config.yaml"
|
||||||
|
root_config: "envied.yaml"
|
||||||
|
chapters: "Chapters_{title}_{random}.txt"
|
||||||
|
subtitle: "Subtitle_{id}_{language}.srt"
|
||||||
|
|
||||||
|
# conversion_method:
|
||||||
|
# - auto (default): Smart routing - subby for WebVTT/SAMI, pycaption for others
|
||||||
|
# - subby: Always use subby with advanced processing
|
||||||
|
# - pycaption: Use only pycaption library (no SubtitleEdit, no subby)
|
||||||
|
# - subtitleedit: Prefer SubtitleEdit when available, fall back to pycaption
|
||||||
|
# - pysubs2: Use pysubs2 library (supports SRT/SSA/ASS/WebVTT/TTML/SAMI/MicroDVD/MPL2/TMP)
|
||||||
|
subtitle:
|
||||||
|
conversion_method: auto
|
||||||
|
# sdh_method: Method to use for SDH (hearing impaired) stripping
|
||||||
|
# - auto (default): Try subby (SRT only), then SubtitleEdit (if available), then subtitle-filter
|
||||||
|
# - subby: Use subby library (SRT only)
|
||||||
|
# - subtitleedit: Use SubtitleEdit tool (Windows only, falls back to subtitle-filter)
|
||||||
|
# - filter-subs: Use subtitle-filter library directly
|
||||||
|
sdh_method: auto
|
||||||
|
# strip_sdh: Automatically create stripped (non-SDH) versions of SDH subtitles
|
||||||
|
# Set to false to disable automatic SDH stripping entirely (default: true)
|
||||||
|
strip_sdh: true
|
||||||
|
# convert_before_strip: Auto-convert VTT/other formats to SRT before using subtitle-filter
|
||||||
|
# This ensures compatibility when subtitle-filter is used as fallback (default: true)
|
||||||
|
convert_before_strip: true
|
||||||
|
# preserve_formatting: Preserve original subtitle formatting (tags, positioning, styling)
|
||||||
|
# When true, skips pycaption processing for WebVTT files to keep tags like <i>, <b>, positioning intact
|
||||||
|
# Combined with no sub_format setting, ensures subtitles remain in their original format (default: true)
|
||||||
|
preserve_formatting: true
|
||||||
|
|
||||||
|
# Configuration for pywidevine's serve functionality
|
||||||
|
serve:
|
||||||
|
api_secret: "your-secret-key-here"
|
||||||
|
users:
|
||||||
|
secret_key_for_user:
|
||||||
|
devices:
|
||||||
|
- generic_nexus_4464_l3
|
||||||
|
username: user
|
||||||
|
# devices:
|
||||||
|
# - '/path/to/device.wvd'
|
||||||
|
|
||||||
|
# Configuration data for each Service
|
||||||
|
services:
|
||||||
|
# Service-specific configuration goes here
|
||||||
|
# Profile-specific configurations can be nested under service names
|
||||||
|
|
||||||
|
# You can override ANY global configuration option on a per-service basis
|
||||||
|
# This allows fine-tuned control for services with special requirements
|
||||||
|
# Supported overrides: dl, aria2c, n_m3u8dl_re, curl_impersonate, subtitle, muxing, headers, etc.
|
||||||
|
|
||||||
|
# Example: Comprehensive service configuration showing all features
|
||||||
|
EXAMPLE:
|
||||||
|
# Standard service config
|
||||||
|
api_key: "service_api_key"
|
||||||
|
|
||||||
|
# Service certificate for Widevine L1/L2 (base64 encoded)
|
||||||
|
# This certificate is automatically used when L1/L2 schemes are selected
|
||||||
|
# Services obtain this from their DRM provider or license server
|
||||||
|
certificate: |
|
||||||
|
CAUSwwUKvQIIAxIQ5US6QAvBDzfTtjb4tU/7QxiH8c+TBSKOAjCCAQoCggEBAObzvlu2hZRsapAPx4Aa4GUZj4/GjxgXUtBH4THSkM40x63wQeyVxlEEo
|
||||||
|
# ... (full base64 certificate here)
|
||||||
|
|
||||||
|
# Profile-specific device configurations
|
||||||
|
profiles:
|
||||||
|
john_sd:
|
||||||
|
device:
|
||||||
|
app_name: "AIV"
|
||||||
|
device_model: "SHIELD Android TV"
|
||||||
|
jane_uhd:
|
||||||
|
device:
|
||||||
|
app_name: "AIV"
|
||||||
|
device_model: "Fire TV Stick 4K"
|
||||||
|
|
||||||
|
# NEW: Configuration overrides (can be combined with profiles and certificates)
|
||||||
|
# Override dl command defaults for this service
|
||||||
|
dl:
|
||||||
|
downloads: 4 # Limit concurrent track downloads (global default: 6)
|
||||||
|
workers: 8 # Reduce workers per track (global default: 16)
|
||||||
|
lang: ["en", "es-419"] # Different language priority for this service
|
||||||
|
sub_format: srt # Force SRT subtitle format
|
||||||
|
|
||||||
|
# Override n_m3u8dl_re downloader settings
|
||||||
|
n_m3u8dl_re:
|
||||||
|
thread_count: 8 # Lower thread count for rate-limited service (global default: 16)
|
||||||
|
use_proxy: true # Force proxy usage for this service
|
||||||
|
retry_count: 10 # More retries for unstable connections
|
||||||
|
ad_keyword: "advertisement" # Service-specific ad filtering
|
||||||
|
|
||||||
|
# Override aria2c downloader settings
|
||||||
|
aria2c:
|
||||||
|
max_concurrent_downloads: 2 # Limit concurrent downloads (global default: 4)
|
||||||
|
max_connection_per_server: 1 # Single connection per server
|
||||||
|
split: 3 # Fewer splits (global default: 5)
|
||||||
|
file_allocation: none # Faster allocation for this service
|
||||||
|
|
||||||
|
# Override subtitle processing for this service
|
||||||
|
subtitle:
|
||||||
|
conversion_method: pycaption # Use specific subtitle converter
|
||||||
|
sdh_method: auto
|
||||||
|
|
||||||
|
# Service-specific headers
|
||||||
|
headers:
|
||||||
|
User-Agent: "Service-specific user agent string"
|
||||||
|
Accept-Language: "en-US,en;q=0.9"
|
||||||
|
|
||||||
|
# Override muxing options
|
||||||
|
muxing:
|
||||||
|
set_title: true
|
||||||
|
|
||||||
|
# Example: Service with different regions per profile
|
||||||
|
SERVICE_NAME:
|
||||||
|
profiles:
|
||||||
|
us_account:
|
||||||
|
region: "US"
|
||||||
|
api_endpoint: "https://api.us.service.com"
|
||||||
|
uk_account:
|
||||||
|
region: "GB"
|
||||||
|
api_endpoint: "https://api.uk.service.com"
|
||||||
|
|
||||||
|
# Example: Rate-limited service
|
||||||
|
RATE_LIMITED_SERVICE:
|
||||||
|
dl:
|
||||||
|
downloads: 2 # Limit concurrent downloads
|
||||||
|
workers: 4 # Reduce workers to avoid rate limits
|
||||||
|
n_m3u8dl_re:
|
||||||
|
thread_count: 4 # Very low thread count
|
||||||
|
retry_count: 20 # More retries for flaky service
|
||||||
|
aria2c:
|
||||||
|
max_concurrent_downloads: 1 # Download tracks one at a time
|
||||||
|
max_connection_per_server: 1 # Single connection only
|
||||||
|
|
||||||
|
# Notes on service-specific overrides:
|
||||||
|
# - Overrides are merged with global config, not replaced
|
||||||
|
# - Only specified keys are overridden, others use global defaults
|
||||||
|
# - Reserved keys (profiles, api_key, certificate, etc.) are NOT treated as overrides
|
||||||
|
# - Any dict-type config option can be overridden (dl, aria2c, n_m3u8dl_re, subtitle, etc.)
|
||||||
|
# - CLI arguments always take priority over service-specific config
|
||||||
|
|
||||||
|
# External proxy provider services
|
||||||
|
proxy_providers:
|
||||||
|
nordvpn:
|
||||||
|
username: username_from_service_credentials
|
||||||
|
password: password_from_service_credentials
|
||||||
|
server_map:
|
||||||
|
us: 12 # force US server #12 for US proxies
|
||||||
|
surfsharkvpn:
|
||||||
|
username: your_surfshark_service_username # Service credentials from https://my.surfshark.com/vpn/manual-setup/main/openvpn
|
||||||
|
password: your_surfshark_service_password # Service credentials (not your login password)
|
||||||
|
server_map:
|
||||||
|
us: 3844 # force US server #3844 for US proxies
|
||||||
|
gb: 2697 # force GB server #2697 for GB proxies
|
||||||
|
au: 4621 # force AU server #4621 for AU proxies
|
||||||
|
windscribevpn:
|
||||||
|
username: your_windscribe_username # Service credentials from https://windscribe.com/getconfig/openvpn
|
||||||
|
password: your_windscribe_password # Service credentials (not your login password)
|
||||||
|
server_map:
|
||||||
|
us: "us-central-096.totallyacdn.com" # force US server
|
||||||
|
gb: "uk-london-055.totallyacdn.com" # force GB server
|
||||||
|
basic:
|
||||||
|
GB:
|
||||||
|
- "socks5://username:password@bhx.socks.ipvanish.com:1080" # 1 (Birmingham)
|
||||||
|
- "socks5://username:password@gla.socks.ipvanish.com:1080" # 2 (Glasgow)
|
||||||
|
AU:
|
||||||
|
- "socks5://username:password@syd.socks.ipvanish.com:1080" # 1 (Sydney)
|
||||||
|
- "https://username:password@au-syd.prod.surfshark.com" # 2 (Sydney)
|
||||||
|
- "https://username:password@au-bne.prod.surfshark.com" # 3 (Brisbane)
|
||||||
|
BG: "https://username:password@bg-sof.prod.surfshark.com"
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# Group or Username to postfix to the end of all download fienames following a dash
|
||||||
|
#tag: ''
|
||||||
|
|
||||||
|
# Set terminal background color (custom option not in CONFIG.md)
|
||||||
|
set_terminal_bg: false
|
||||||
|
|
||||||
|
# Muxing configuration
|
||||||
|
muxing:
|
||||||
|
set_title: false
|
||||||
|
|
||||||
|
#
|
||||||
|
decryption: mp4decrypt
|
||||||
|
|
||||||
|
scene_naming: false
|
||||||
|
series_year: false
|
||||||
|
|
||||||
|
# Widevine pssh display; fold, crop or ellipsis
|
||||||
|
pssh_display: ellipsis
|
||||||
|
|
||||||
|
# Login credentials for each Service
|
||||||
|
credentials:
|
||||||
|
ALL4: angela.slaney@gmail.com:Nood13sh
|
||||||
|
ROKU: moretonplumber@yandex.com:$WPJ~Mdh2:X@EtB
|
||||||
|
TVNZ: moretonplumber@yandex.com:penumbra
|
||||||
|
TPTV: moretonplumber@yandex.com:penumbra1234
|
||||||
|
CBC: moretonplumber@yandex.com:penumbra
|
||||||
|
# Override default directories used across unshackle
|
||||||
|
directories:
|
||||||
|
cache: /home/angela/.local/tmp/devine
|
||||||
|
cookies: /home/angela/.local/share/devine/cookies
|
||||||
|
dcsl: DCSL # Device Certificate Status List
|
||||||
|
downloads: /home/angela/Downloads/devine
|
||||||
|
logs: /home/angela/.local/tmp/devine/
|
||||||
|
temp: /home/angela/.local/tmp/devine/
|
||||||
|
wvds: /home/angela/.local/share/devine/WVDs/
|
||||||
|
prds: PRDs
|
||||||
|
User_Configs: /home/angela/.local/share/devine/
|
||||||
|
# Additional directories that can be configured:
|
||||||
|
# commands: Commands
|
||||||
|
services:
|
||||||
|
- /home/angela/Programming/gits/production/envied/unshackle/services/
|
||||||
|
vaults: /home/angela/.local/share/devine/vaults/
|
||||||
|
# fonts: Fonts
|
||||||
|
|
||||||
|
# Pre-define which Widevine or PlayReady device to use for each Service
|
||||||
|
cdm:
|
||||||
|
default: device
|
||||||
|
|
||||||
|
# Use pywidevine Serve-compliant Remote CDMs
|
||||||
|
#remote_cdm:
|
||||||
|
# - name: "CDRM_Project_API"
|
||||||
|
# device_type: ANDROID
|
||||||
|
# system_id: 22590
|
||||||
|
# security_level: 3
|
||||||
|
# host: "https://cdrm-project.com/remotecdm/widevine"
|
||||||
|
# secret: "CDRM"
|
||||||
|
# device_name: "public"
|
||||||
|
|
||||||
|
|
||||||
|
# Key Vaults store your obtained Content Encryption Keys (CEKs)
|
||||||
|
key_vaults:
|
||||||
|
- type: SQLite
|
||||||
|
name: Local vault
|
||||||
|
path: /home/angela/Programming/gits/devine-services/key_store.db
|
||||||
|
|
||||||
|
- type: HTTPAPI
|
||||||
|
name: drmlab
|
||||||
|
host: http://api.drmlab.io/vault/
|
||||||
|
password: gEX75q7I5YVkvgF5SUkcNd41IbGrDtTT
|
||||||
|
#- type: API
|
||||||
|
# name: "Online Vault"
|
||||||
|
# uri: "https://cdrm-project.com/api/cache"
|
||||||
|
# token: "CDRM"
|
||||||
|
|
||||||
|
|
||||||
|
# Choose what software to use to download data
|
||||||
|
downloader: n_m3u8dl_re
|
||||||
|
# Options: requests | aria2c | curl_impersonate | n_m3u8dl_re
|
||||||
|
# Can also be a mapping:
|
||||||
|
# downloader:
|
||||||
|
# NF: requests
|
||||||
|
# AMZN: n_m3u8dl_re
|
||||||
|
# DSNP: n_m3u8dl_re
|
||||||
|
# default: requests
|
||||||
|
|
||||||
|
# aria2c downloader configuration
|
||||||
|
aria2c:
|
||||||
|
max_concurrent_downloads: 4
|
||||||
|
max_connection_per_server: 3
|
||||||
|
split: 5
|
||||||
|
file_allocation: falloc # none | prealloc | falloc | trunc
|
||||||
|
|
||||||
|
# N_m3u8DL-RE downloader configuration
|
||||||
|
n_m3u8dl_re:
|
||||||
|
thread_count: 16
|
||||||
|
ad_keyword: "advertisement"
|
||||||
|
use_proxy: true
|
||||||
|
|
||||||
|
# curl_impersonate downloader configuration
|
||||||
|
curl_impersonate:
|
||||||
|
browser: chrome120
|
||||||
|
|
||||||
|
# Pre-define default options and switches of the dl command
|
||||||
|
dl:
|
||||||
|
best: true
|
||||||
|
sub_format: srt
|
||||||
|
downloads: 4
|
||||||
|
workers: 16
|
||||||
|
|
||||||
|
# Chapter Name to use when exporting a Chapter without a Name
|
||||||
|
chapter_fallback_name: "Chapter {j:02}"
|
||||||
|
|
||||||
|
# Case-Insensitive dictionary of headers for all Services
|
||||||
|
headers:
|
||||||
|
Accept-Language: "en-US,en;q=0.8"
|
||||||
|
User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.75 Safari/537.36"
|
||||||
|
|
||||||
|
# Override default filenames used across unshackle
|
||||||
|
filenames:
|
||||||
|
log: "unshackle_{name}_{time}.log"
|
||||||
|
config: "config.yaml"
|
||||||
|
root_config: "envied.yaml"
|
||||||
|
chapters: "Chapters_{title}_{random}.txt"
|
||||||
|
subtitle: "Subtitle_{id}_{language}.srt"
|
||||||
|
|
||||||
|
# API key for The Movie Database (TMDB)
|
||||||
|
tmdb_api_key: ""
|
||||||
|
|
||||||
|
|
||||||
|
# conversion_method:
|
||||||
|
# - auto (default): Smart routing - subby for WebVTT/SAMI, standard for others
|
||||||
|
# - subby: Always use subby with advanced processing
|
||||||
|
# - pycaption: Use only pycaption library (no SubtitleEdit, no subby)
|
||||||
|
# - subtitleedit: Prefer SubtitleEdit when available, fall back to pycaption
|
||||||
|
subtitle:
|
||||||
|
conversion_method: auto
|
||||||
|
sdh_method: auto
|
||||||
|
|
||||||
|
|
||||||
|
# Configuration data for each Service
|
||||||
|
services:
|
||||||
|
# Service-specific configuration goes here
|
||||||
|
# EXAMPLE:
|
||||||
|
# api_key: "service_specific_key"
|
||||||
|
|
||||||
|
# Legacy NordVPN configuration (use proxy_providers instead)
|
||||||
|
proxy_providers:
|
||||||
|
nordvpn:
|
||||||
|
username: gBM9ejjmY1J15jccH12VT7Qy
|
||||||
|
password: 1kmuCrRXnT55uAjiUTd8twv9
|
||||||
|
server_map:
|
||||||
|
us: 6918
|
||||||
|
uk: 2613
|
||||||
|
nz: 100
|
||||||
|
|
||||||
@@ -576,6 +576,7 @@ dependencies = [
|
|||||||
{ name = "protobuf" },
|
{ name = "protobuf" },
|
||||||
{ name = "pycaption" },
|
{ name = "pycaption" },
|
||||||
{ name = "pycryptodomex" },
|
{ name = "pycryptodomex" },
|
||||||
|
{ name = "pyexecjs" },
|
||||||
{ name = "pyjwt" },
|
{ name = "pyjwt" },
|
||||||
{ name = "pymediainfo" },
|
{ name = "pymediainfo" },
|
||||||
{ name = "pymp4" },
|
{ name = "pymp4" },
|
||||||
@@ -611,7 +612,7 @@ dev = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "aiohttp-swagger3", specifier = ">=0.10.0" },
|
{ name = "aiohttp-swagger3", specifier = ">=0.10.0,<1" },
|
||||||
{ name = "appdirs", specifier = ">=1.4.4,<2" },
|
{ name = "appdirs", specifier = ">=1.4.4,<2" },
|
||||||
{ name = "beaupy", specifier = ">=3.10.2" },
|
{ name = "beaupy", specifier = ">=3.10.2" },
|
||||||
{ name = "brotli", specifier = ">=1.1.0,<2" },
|
{ name = "brotli", specifier = ">=1.1.0,<2" },
|
||||||
@@ -619,23 +620,24 @@ requires-dist = [
|
|||||||
{ name = "click", specifier = ">=8.1.8,<9" },
|
{ name = "click", specifier = ">=8.1.8,<9" },
|
||||||
{ name = "construct", specifier = ">=2.8.8,<3" },
|
{ name = "construct", specifier = ">=2.8.8,<3" },
|
||||||
{ name = "crccheck", specifier = ">=1.3.0,<2" },
|
{ name = "crccheck", specifier = ">=1.3.0,<2" },
|
||||||
{ name = "cryptography", specifier = ">=45.0.0" },
|
{ name = "cryptography", specifier = ">=45.0.0,<47" },
|
||||||
{ name = "curl-cffi", specifier = ">=0.7.0b4,<0.8" },
|
{ name = "curl-cffi", specifier = ">=0.7.0b4,<0.14" },
|
||||||
{ name = "fonttools", specifier = ">=4.60.1" },
|
{ name = "fonttools", specifier = ">=4.60.1,<5" },
|
||||||
{ name = "httpx", specifier = ">=0.28.1,<0.29" },
|
{ name = "httpx", specifier = ">=0.28.1,<0.29" },
|
||||||
{ name = "isodate", specifier = ">=0.7.2" },
|
{ name = "isodate", specifier = ">=0.7.2" },
|
||||||
{ name = "jsonpickle", specifier = ">=3.0.4,<4.5" },
|
{ name = "jsonpickle", specifier = ">=3.0.4,<4.5" },
|
||||||
{ name = "jwt", specifier = ">=1.4.0" },
|
{ name = "jwt", specifier = ">=1.4.0" },
|
||||||
{ name = "langcodes", specifier = ">=3.4.0,<4" },
|
{ name = "langcodes", specifier = ">=3.4.0,<4" },
|
||||||
{ name = "langcodes", specifier = ">=3.5.0" },
|
{ name = "langcodes", specifier = ">=3.5.0" },
|
||||||
{ name = "lxml", specifier = ">=6.0.2" },
|
{ name = "lxml", specifier = ">=6.0.2,<7" },
|
||||||
{ name = "mypy", specifier = ">=1.18.2,<2" },
|
{ name = "mypy", specifier = ">=1.18.2,<2" },
|
||||||
{ name = "pproxy", specifier = ">=2.7.9,<3" },
|
{ name = "pproxy", specifier = ">=2.7.9,<3" },
|
||||||
{ name = "protobuf", specifier = ">=4.25.3,<7" },
|
{ name = "protobuf", specifier = ">=4.25.3,<7" },
|
||||||
{ name = "pycaption", specifier = ">=2.2.6,<3" },
|
{ name = "pycaption", specifier = ">=2.2.6,<3" },
|
||||||
{ name = "pycryptodomex", specifier = ">=3.20.0,<4" },
|
{ name = "pycryptodomex", specifier = ">=3.20.0,<4" },
|
||||||
|
{ name = "pyexecjs", specifier = ">=1.5.1,<2" },
|
||||||
{ name = "pyjwt", specifier = ">=2.8.0,<3" },
|
{ name = "pyjwt", specifier = ">=2.8.0,<3" },
|
||||||
{ name = "pymediainfo", specifier = ">=6.1.0,<7.5" },
|
{ name = "pymediainfo", specifier = ">=6.1.0,<8" },
|
||||||
{ name = "pymp4", specifier = ">=1.4.0,<2" },
|
{ name = "pymp4", specifier = ">=1.4.0,<2" },
|
||||||
{ name = "pymysql", specifier = ">=1.1.0,<2" },
|
{ name = "pymysql", specifier = ">=1.1.0,<2" },
|
||||||
{ name = "pyplayready", specifier = ">=0.6.3,<0.7" },
|
{ name = "pyplayready", specifier = ">=0.6.3,<0.7" },
|
||||||
@@ -1502,6 +1504,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/66/0e/9ee7bc0b48ec45d93b302fa2d787830dca4dc454d31a237faa5815995988/PyDispatcher-2.0.7-py3-none-any.whl", hash = "sha256:96543bea04115ffde08f851e1d45cacbfd1ee866ac42127d9b476dc5aefa7de0", size = 12040, upload-time = "2023-02-17T20:11:11.991Z" },
|
{ url = "https://files.pythonhosted.org/packages/66/0e/9ee7bc0b48ec45d93b302fa2d787830dca4dc454d31a237faa5815995988/PyDispatcher-2.0.7-py3-none-any.whl", hash = "sha256:96543bea04115ffde08f851e1d45cacbfd1ee866ac42127d9b476dc5aefa7de0", size = 12040, upload-time = "2023-02-17T20:11:11.991Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyexecjs"
|
||||||
|
version = "1.5.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ba/8e/aedef81641c8dca6fd0fb7294de5bed9c45f3397d67fddf755c1042c2642/PyExecJS-1.5.1.tar.gz", hash = "sha256:34cc1d070976918183ff7bdc0ad71f8157a891c92708c00c5fbbff7a769f505c", size = 13344, upload-time = "2018-01-18T04:33:55.126Z" }
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pygments"
|
name = "pygments"
|
||||||
version = "2.19.2"
|
version = "2.19.2"
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ class API(Vault):
|
|||||||
super().__init__(name, no_push)
|
super().__init__(name, no_push)
|
||||||
self.uri = uri.rstrip("/")
|
self.uri = uri.rstrip("/")
|
||||||
self.session = Session()
|
self.session = Session()
|
||||||
self.session.headers.update({"User-Agent": f"envied v{__version__}"})
|
self.session.headers.update({"User-Agent": f"unshackle v{__version__}"})
|
||||||
self.session.headers.update({"Authorization": f"Bearer {token}"})
|
self.session.headers.update({"Authorization": f"Bearer {token}"})
|
||||||
|
|
||||||
def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]:
|
def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]:
|
||||||
|
|||||||
+84
-11
@@ -16,13 +16,21 @@ class InsertResult(Enum):
|
|||||||
|
|
||||||
|
|
||||||
class HTTP(Vault):
|
class HTTP(Vault):
|
||||||
"""Key Vault using HTTP API with support for both query parameters and JSON payloads."""
|
"""
|
||||||
|
Key Vault using HTTP API with support for multiple API modes.
|
||||||
|
|
||||||
|
Supported modes:
|
||||||
|
- query: Uses GET requests with query parameters
|
||||||
|
- json: Uses POST requests with JSON payloads
|
||||||
|
- decrypt_labs: Uses DecryptLabs API format (read-only)
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
host: str,
|
host: str,
|
||||||
password: str,
|
password: Optional[str] = None,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
username: Optional[str] = None,
|
username: Optional[str] = None,
|
||||||
api_mode: str = "query",
|
api_mode: str = "query",
|
||||||
no_push: bool = False,
|
no_push: bool = False,
|
||||||
@@ -34,25 +42,33 @@ class HTTP(Vault):
|
|||||||
name: Vault name
|
name: Vault name
|
||||||
host: Host URL
|
host: Host URL
|
||||||
password: Password for query mode or API token for json mode
|
password: Password for query mode or API token for json mode
|
||||||
username: Username (required for query mode, ignored for json mode)
|
api_key: API key (alternative to password, used for decrypt_labs mode)
|
||||||
api_mode: "query" for query parameters or "json" for JSON API
|
username: Username (required for query mode, ignored for json/decrypt_labs mode)
|
||||||
|
api_mode: "query" for query parameters, "json" for JSON API, or "decrypt_labs" for DecryptLabs API
|
||||||
no_push: If True, this vault will not receive pushed keys
|
no_push: If True, this vault will not receive pushed keys
|
||||||
"""
|
"""
|
||||||
super().__init__(name, no_push)
|
super().__init__(name, no_push)
|
||||||
self.url = host
|
self.url = host
|
||||||
self.password = password
|
self.password = api_key or password
|
||||||
|
if not self.password:
|
||||||
|
raise ValueError("Either password or api_key is required")
|
||||||
|
|
||||||
self.username = username
|
self.username = username
|
||||||
self.api_mode = api_mode.lower()
|
self.api_mode = api_mode.lower()
|
||||||
self.current_title = None
|
self.current_title = None
|
||||||
self.session = Session()
|
self.session = Session()
|
||||||
self.session.headers.update({"User-Agent": f"envied v{__version__}"})
|
self.session.headers.update({"User-Agent": f"unshackle v{__version__}"})
|
||||||
self.api_session_id = None
|
self.api_session_id = None
|
||||||
|
|
||||||
|
if self.api_mode == "decrypt_labs":
|
||||||
|
self.session.headers.update({"decrypt-labs-api-key": self.password})
|
||||||
|
self.no_push = True
|
||||||
|
|
||||||
# Validate configuration based on mode
|
# Validate configuration based on mode
|
||||||
if self.api_mode == "query" and not self.username:
|
if self.api_mode == "query" and not self.username:
|
||||||
raise ValueError("Username is required for query mode")
|
raise ValueError("Username is required for query mode")
|
||||||
elif self.api_mode not in ["query", "json"]:
|
elif self.api_mode not in ["query", "json", "decrypt_labs"]:
|
||||||
raise ValueError("api_mode must be either 'query' or 'json'")
|
raise ValueError("api_mode must be either 'query', 'json', or 'decrypt_labs'")
|
||||||
|
|
||||||
def request(self, method: str, params: dict = None) -> dict:
|
def request(self, method: str, params: dict = None) -> dict:
|
||||||
"""Make a request to the JSON API vault."""
|
"""Make a request to the JSON API vault."""
|
||||||
@@ -95,7 +111,51 @@ class HTTP(Vault):
|
|||||||
if isinstance(kid, UUID):
|
if isinstance(kid, UUID):
|
||||||
kid = kid.hex
|
kid = kid.hex
|
||||||
|
|
||||||
if self.api_mode == "json":
|
if self.api_mode == "decrypt_labs":
|
||||||
|
try:
|
||||||
|
request_payload = {"service": service.lower(), "kid": kid}
|
||||||
|
|
||||||
|
response = self.session.post(self.url, json=request_payload)
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("message") != "success":
|
||||||
|
return None
|
||||||
|
|
||||||
|
cached_keys = data.get("cached_keys")
|
||||||
|
if not cached_keys:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(cached_keys, str):
|
||||||
|
try:
|
||||||
|
cached_keys = json.loads(cached_keys)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return cached_keys
|
||||||
|
|
||||||
|
if isinstance(cached_keys, dict):
|
||||||
|
if cached_keys.get("kid") == kid:
|
||||||
|
return cached_keys.get("key")
|
||||||
|
if kid in cached_keys:
|
||||||
|
return cached_keys[kid]
|
||||||
|
elif isinstance(cached_keys, list):
|
||||||
|
for entry in cached_keys:
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
if entry.get("kid") == kid:
|
||||||
|
return entry.get("key")
|
||||||
|
elif isinstance(entry, str) and ":" in entry:
|
||||||
|
entry_kid, entry_key = entry.split(":", 1)
|
||||||
|
if entry_kid == kid:
|
||||||
|
return entry_key
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to get key from DecryptLabs ({e.__class__.__name__}: {e})")
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
elif self.api_mode == "json":
|
||||||
try:
|
try:
|
||||||
params = {
|
params = {
|
||||||
"kid": kid,
|
"kid": kid,
|
||||||
@@ -132,7 +192,9 @@ class HTTP(Vault):
|
|||||||
return data["keys"][0]["key"]
|
return data["keys"][0]["key"]
|
||||||
|
|
||||||
def get_keys(self, service: str) -> Iterator[tuple[str, str]]:
|
def get_keys(self, service: str) -> Iterator[tuple[str, str]]:
|
||||||
if self.api_mode == "json":
|
if self.api_mode == "decrypt_labs":
|
||||||
|
return iter([])
|
||||||
|
elif self.api_mode == "json":
|
||||||
# JSON API doesn't support getting all keys, so return empty iterator
|
# JSON API doesn't support getting all keys, so return empty iterator
|
||||||
# This will cause the copy command to rely on the API's internal duplicate handling
|
# This will cause the copy command to rely on the API's internal duplicate handling
|
||||||
return iter([])
|
return iter([])
|
||||||
@@ -153,6 +215,9 @@ class HTTP(Vault):
|
|||||||
if not key or key.count("0") == len(key):
|
if not key or key.count("0") == len(key):
|
||||||
raise ValueError("You cannot add a NULL Content Key to a Vault.")
|
raise ValueError("You cannot add a NULL Content Key to a Vault.")
|
||||||
|
|
||||||
|
if self.api_mode == "decrypt_labs":
|
||||||
|
return False
|
||||||
|
|
||||||
if isinstance(kid, UUID):
|
if isinstance(kid, UUID):
|
||||||
kid = kid.hex
|
kid = kid.hex
|
||||||
|
|
||||||
@@ -192,6 +257,9 @@ class HTTP(Vault):
|
|||||||
return data.get("status_code") == 200
|
return data.get("status_code") == 200
|
||||||
|
|
||||||
def add_keys(self, service: str, kid_keys: dict[Union[UUID, str], str]) -> int:
|
def add_keys(self, service: str, kid_keys: dict[Union[UUID, str], str]) -> int:
|
||||||
|
if self.api_mode == "decrypt_labs":
|
||||||
|
return 0
|
||||||
|
|
||||||
for kid, key in kid_keys.items():
|
for kid, key in kid_keys.items():
|
||||||
if not key or key.count("0") == len(key):
|
if not key or key.count("0") == len(key):
|
||||||
raise ValueError("You cannot add a NULL Content Key to a Vault.")
|
raise ValueError("You cannot add a NULL Content Key to a Vault.")
|
||||||
@@ -243,7 +311,9 @@ class HTTP(Vault):
|
|||||||
return inserted_count
|
return inserted_count
|
||||||
|
|
||||||
def get_services(self) -> Iterator[str]:
|
def get_services(self) -> Iterator[str]:
|
||||||
if self.api_mode == "json":
|
if self.api_mode == "decrypt_labs":
|
||||||
|
return iter([])
|
||||||
|
elif self.api_mode == "json":
|
||||||
try:
|
try:
|
||||||
response = self.request("GetServices")
|
response = self.request("GetServices")
|
||||||
services = response.get("services", [])
|
services = response.get("services", [])
|
||||||
@@ -283,6 +353,9 @@ class HTTP(Vault):
|
|||||||
if not key or key.count("0") == len(key):
|
if not key or key.count("0") == len(key):
|
||||||
raise ValueError("You cannot add a NULL Content Key to a Vault.")
|
raise ValueError("You cannot add a NULL Content Key to a Vault.")
|
||||||
|
|
||||||
|
if self.api_mode == "decrypt_labs":
|
||||||
|
return InsertResult.FAILURE
|
||||||
|
|
||||||
if isinstance(kid, UUID):
|
if isinstance(kid, UUID):
|
||||||
kid = kid.hex
|
kid = kid.hex
|
||||||
|
|
||||||
|
|||||||
+35
-17
@@ -28,26 +28,33 @@ class MySQL(Vault):
|
|||||||
raise PermissionError(f"MySQL vault {self.slug} has no SELECT permission.")
|
raise PermissionError(f"MySQL vault {self.slug} has no SELECT permission.")
|
||||||
|
|
||||||
def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]:
|
def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]:
|
||||||
if not self.has_table(service):
|
|
||||||
# no table, no key, simple
|
|
||||||
return None
|
|
||||||
|
|
||||||
if isinstance(kid, UUID):
|
if isinstance(kid, UUID):
|
||||||
kid = kid.hex
|
kid = kid.hex
|
||||||
|
|
||||||
|
service_variants = [service]
|
||||||
|
if service != service.lower():
|
||||||
|
service_variants.append(service.lower())
|
||||||
|
if service != service.upper():
|
||||||
|
service_variants.append(service.upper())
|
||||||
|
|
||||||
conn = self.conn_factory.get()
|
conn = self.conn_factory.get()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cursor.execute(
|
for service_name in service_variants:
|
||||||
# TODO: SQL injection risk
|
if not self.has_table(service_name):
|
||||||
f"SELECT `id`, `key_` FROM `{service}` WHERE `kid`=%s AND `key_`!=%s",
|
continue
|
||||||
(kid, "0" * 32),
|
|
||||||
)
|
cursor.execute(
|
||||||
cek = cursor.fetchone()
|
# TODO: SQL injection risk
|
||||||
if not cek:
|
f"SELECT `id`, `key_` FROM `{service_name}` WHERE `kid`=%s AND `key_`!=%s",
|
||||||
return None
|
(kid, "0" * 32),
|
||||||
return cek["key_"]
|
)
|
||||||
|
cek = cursor.fetchone()
|
||||||
|
if cek:
|
||||||
|
return cek["key_"]
|
||||||
|
|
||||||
|
return None
|
||||||
finally:
|
finally:
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|
||||||
@@ -131,16 +138,27 @@ class MySQL(Vault):
|
|||||||
if any(isinstance(kid, UUID) for kid, key_ in kid_keys.items()):
|
if any(isinstance(kid, UUID) for kid, key_ in kid_keys.items()):
|
||||||
kid_keys = {kid.hex if isinstance(kid, UUID) else kid: key_ for kid, key_ in kid_keys.items()}
|
kid_keys = {kid.hex if isinstance(kid, UUID) else kid: key_ for kid, key_ in kid_keys.items()}
|
||||||
|
|
||||||
|
if not kid_keys:
|
||||||
|
return 0
|
||||||
|
|
||||||
conn = self.conn_factory.get()
|
conn = self.conn_factory.get()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
placeholders = ",".join(["%s"] * len(kid_keys))
|
||||||
|
cursor.execute(f"SELECT kid FROM `{service}` WHERE kid IN ({placeholders})", list(kid_keys.keys()))
|
||||||
|
existing_kids = {row["kid"] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
new_keys = {kid: key for kid, key in kid_keys.items() if kid not in existing_kids}
|
||||||
|
|
||||||
|
if not new_keys:
|
||||||
|
return 0
|
||||||
|
|
||||||
cursor.executemany(
|
cursor.executemany(
|
||||||
# TODO: SQL injection risk
|
f"INSERT INTO `{service}` (kid, key_) VALUES (%s, %s)",
|
||||||
f"INSERT IGNORE INTO `{service}` (kid, key_) VALUES (%s, %s)",
|
new_keys.items(),
|
||||||
kid_keys.items(),
|
|
||||||
)
|
)
|
||||||
return cursor.rowcount
|
return len(new_keys)
|
||||||
finally:
|
finally:
|
||||||
conn.commit()
|
conn.commit()
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|||||||
+34
-13
@@ -19,22 +19,32 @@ class SQLite(Vault):
|
|||||||
self.conn_factory = ConnectionFactory(self.path)
|
self.conn_factory = ConnectionFactory(self.path)
|
||||||
|
|
||||||
def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]:
|
def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]:
|
||||||
if not self.has_table(service):
|
|
||||||
# no table, no key, simple
|
|
||||||
return None
|
|
||||||
|
|
||||||
if isinstance(kid, UUID):
|
if isinstance(kid, UUID):
|
||||||
kid = kid.hex
|
kid = kid.hex
|
||||||
|
|
||||||
conn = self.conn_factory.get()
|
conn = self.conn_factory.get()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Try both the original service name and lowercase version to handle case sensitivity issues
|
||||||
|
service_variants = [service]
|
||||||
|
if service != service.lower():
|
||||||
|
service_variants.append(service.lower())
|
||||||
|
if service != service.upper():
|
||||||
|
service_variants.append(service.upper())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cursor.execute(f"SELECT `id`, `key_` FROM `{service}` WHERE `kid`=? AND `key_`!=?", (kid, "0" * 32))
|
for service_name in service_variants:
|
||||||
cek = cursor.fetchone()
|
if not self.has_table(service_name):
|
||||||
if not cek:
|
continue
|
||||||
return None
|
|
||||||
return cek[1]
|
cursor.execute(
|
||||||
|
f"SELECT `id`, `key_` FROM `{service_name}` WHERE `kid`=? AND `key_`!=?", (kid, "0" * 32)
|
||||||
|
)
|
||||||
|
cek = cursor.fetchone()
|
||||||
|
if cek:
|
||||||
|
return cek[1]
|
||||||
|
|
||||||
|
return None
|
||||||
finally:
|
finally:
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|
||||||
@@ -102,16 +112,27 @@ class SQLite(Vault):
|
|||||||
if any(isinstance(kid, UUID) for kid, key_ in kid_keys.items()):
|
if any(isinstance(kid, UUID) for kid, key_ in kid_keys.items()):
|
||||||
kid_keys = {kid.hex if isinstance(kid, UUID) else kid: key_ for kid, key_ in kid_keys.items()}
|
kid_keys = {kid.hex if isinstance(kid, UUID) else kid: key_ for kid, key_ in kid_keys.items()}
|
||||||
|
|
||||||
|
if not kid_keys:
|
||||||
|
return 0
|
||||||
|
|
||||||
conn = self.conn_factory.get()
|
conn = self.conn_factory.get()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
placeholders = ",".join(["?"] * len(kid_keys))
|
||||||
|
cursor.execute(f"SELECT kid FROM `{service}` WHERE kid IN ({placeholders})", list(kid_keys.keys()))
|
||||||
|
existing_kids = {row[0] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
new_keys = {kid: key for kid, key in kid_keys.items() if kid not in existing_kids}
|
||||||
|
|
||||||
|
if not new_keys:
|
||||||
|
return 0
|
||||||
|
|
||||||
cursor.executemany(
|
cursor.executemany(
|
||||||
# TODO: SQL injection risk
|
f"INSERT INTO `{service}` (kid, key_) VALUES (?, ?)",
|
||||||
f"INSERT OR IGNORE INTO `{service}` (kid, key_) VALUES (?, ?)",
|
new_keys.items(),
|
||||||
kid_keys.items(),
|
|
||||||
)
|
)
|
||||||
return cursor.rowcount
|
return len(new_keys)
|
||||||
finally:
|
finally:
|
||||||
conn.commit()
|
conn.commit()
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user