mirror of
https://github.com/vinefeeder/TwinVine.git
synced 2026-09-14 06:11:51 +02:00
upstream-dev
This commit is contained in:
+6
-1
@@ -4,11 +4,16 @@ Downloads
|
||||
Temp/
|
||||
packages/envied/src/envied/logs/
|
||||
packages/envied/src/envied/envied.yaml
|
||||
packages/envied/src/envied/cache/TVNZ/local_storage.jsonv
|
||||
packages/envied/src/envied/cache/TVNZ/local_storage.json
|
||||
packages/envied/src/envied/services
|
||||
packages/envied/src/Cookies/*
|
||||
images/*
|
||||
tools/
|
||||
batch.txt
|
||||
*.json
|
||||
key_store.db-shm
|
||||
key_store.db-wal
|
||||
key_store.db
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[codz]
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
1785411859.1868708
|
||||
+1
-1
@@ -244,7 +244,7 @@ class ALL4(Service):
|
||||
tracks.videos[0].data = data
|
||||
|
||||
|
||||
# All4 video carries a stale encrypted sample entry beside the
|
||||
# All 4 video carries a stale encrypted sample entry beside the
|
||||
# decrypted one; repack so mkvmerge does not mux it as encrypted.
|
||||
for video in tracks.videos:
|
||||
video.needs_repack = True
|
||||
+897
@@ -0,0 +1,897 @@
|
||||
from __future__ import annotations
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from http.cookiejar import CookieJar
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import unquote, urlparse
|
||||
import click
|
||||
import requests
|
||||
from pyplayready.system.pssh import PSSH as PlayReadyPSSH
|
||||
from pywidevine.pssh import PSSH as WidevinePSSH
|
||||
from envied.core import binaries
|
||||
from envied.core.cdm.detect import is_playready_cdm
|
||||
from envied.core.config import config
|
||||
from envied.core.credential import Credential
|
||||
from envied.core.drm import PlayReady, Widevine
|
||||
from envied.core.music import MusicTrackOption
|
||||
from envied.core.service import Service
|
||||
from envied.core.titles import Music, Song, Titles_T
|
||||
from envied.core.tracks import Audio, Chapters, Tracks
|
||||
from envied.core.tracks.track import Track
|
||||
|
||||
_INVISIBLE = re.compile(r"[--]")
|
||||
|
||||
|
||||
class AMZM(Service):
|
||||
"""
|
||||
Service code for Amazon Music (https://music.amazon.com).
|
||||
www.nostalgic.cc
|
||||
Authorization: Credentials, Cookies
|
||||
Security: FLAC@L3/SL2K
|
||||
"""
|
||||
|
||||
ALIASES = ("AMZM", "amazonmusic", "amusic")
|
||||
GROUP_AUDIO_DOWNLOADS = True
|
||||
|
||||
TITLE_RE = r"^(?:https?://)?(?:music\.)?amazon\.(?:com|co\.uk|de|co\.jp|com\.mx|com\.br|fr)/.*?/(?:albums|tracks)/(?P<id>[A-Z0-9]{10,})"
|
||||
|
||||
@staticmethod
|
||||
@click.command(name="AMZM", short_help="https://music.amazon.com", help=__doc__)
|
||||
@click.argument("title", type=str)
|
||||
@click.option("-c", "--codec", "codec", default=None,
|
||||
type=click.Choice(["FLAC", "AAC", "EC3", "AC4", "OPUS", "MP3"], case_sensitive=False),
|
||||
help="Force an audio codec instead of picking the best available.")
|
||||
@click.option("-r", "--region", "region", default=None,
|
||||
help="Account region, one of the keys under 'regions' in config.yaml. "
|
||||
"Defaults to config 'region', else us.")
|
||||
@click.option("--single", is_flag=True, default=False,
|
||||
help="For a /tracks/ URL, get just that track instead of its whole album.")
|
||||
@click.pass_context
|
||||
def cli(ctx, **kwargs):
|
||||
return AMZM(ctx, **kwargs)
|
||||
|
||||
def __init__(self, ctx, title: str, codec: Optional[str], region: Optional[str], single: bool):
|
||||
super().__init__(ctx)
|
||||
self.title = title
|
||||
self.forced_codec = (codec or "").lower() or None
|
||||
self.single = single
|
||||
|
||||
if not self.config:
|
||||
self.log.error(" - Config is missing or empty.")
|
||||
raise SystemExit(1)
|
||||
|
||||
regions = self.config.get("regions") or {}
|
||||
region = self._resolve_region(region, regions)
|
||||
if region not in regions:
|
||||
self.log.error(f" - Unknown region {region!r}. Choose from: {', '.join(sorted(regions)) or 'none'}")
|
||||
raise SystemExit(1)
|
||||
self.region = region
|
||||
region_info = regions[region]
|
||||
self.base_url = region_info["base"]
|
||||
self.activation_url = region_info["activation"]
|
||||
self.api_location = region_info["location"]
|
||||
self.tvmesk_host = (self.config.get("tvmesk_hosts") or {})[self.api_location]
|
||||
self.marketplace_id = region_info["marketplace"]
|
||||
self.territory_id = region.upper()
|
||||
|
||||
self.device = self.config.get("device") or {}
|
||||
self.device_type_id = self.device["type_id"]
|
||||
self.timeout = self.config.get("request_timeout") or 30
|
||||
self.registration_timeout = self.config.get("registration_timeout") or 60
|
||||
self.codec_priority = self.config.get("codec_priority") or []
|
||||
self.device_id: Optional[str] = None
|
||||
self.customer_id: Optional[str] = None
|
||||
self.access_token: Optional[str] = None
|
||||
self.video_player_token: Optional[str] = None
|
||||
self.session_handoff_token: Optional[str] = None
|
||||
self.quality: str = ""
|
||||
self._mpd_cache: dict[str, str] = {}
|
||||
self.cdm = getattr(ctx.obj, "cdm", None)
|
||||
self.is_playready = is_playready_cdm(self.cdm) if self.cdm else False
|
||||
|
||||
def _resolve_region(self, flag: Optional[str], regions: dict) -> str:
|
||||
if flag:
|
||||
return flag.strip().lower()
|
||||
|
||||
by_domain = self.config.get("region_from_domain") or {}
|
||||
host = (urlparse(self.title if "//" in self.title else f"https://{self.title}").hostname or "").lower()
|
||||
from_url = by_domain.get(host.removeprefix("www."))
|
||||
configured = str(self.config.get("region") or "").strip().lower()
|
||||
|
||||
if from_url and configured and from_url != configured:
|
||||
self.log.info(f" + Using region {from_url.upper()} from the URL "
|
||||
f"(Config says {configured.upper()}). Pass -r to override.")
|
||||
return from_url or configured or "us"
|
||||
|
||||
def _endpoint(self, name: str, **extra: str) -> str:
|
||||
template = (self.config.get("endpoints") or {}).get(name)
|
||||
if not template:
|
||||
self.log.error(f" - config.yaml is missing endpoints.{name}")
|
||||
raise SystemExit(1)
|
||||
return template.format(base=self.base_url, location=self.api_location,
|
||||
tvmesk=self.tvmesk_host, activation=self.activation_url, **extra)
|
||||
|
||||
def _target(self, name: str, **extra: str) -> str:
|
||||
target = (self.config.get("amz_targets") or {}).get(name)
|
||||
if not target:
|
||||
self.log.error(f" - config.yaml is missing amz_targets.{name}")
|
||||
raise SystemExit(1)
|
||||
return target.format(**extra) if extra else target
|
||||
|
||||
def _music_agent(self, asin: str) -> str:
|
||||
agent = self.device.get("music_agent") or ""
|
||||
return agent.format(request_id=uuid.uuid4(), asin=asin)
|
||||
|
||||
@property
|
||||
def tokens_path(self):
|
||||
return self.cache_dir / f"tokens_{self.region}.json"
|
||||
|
||||
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
||||
super().authenticate(cookies, credential)
|
||||
self.session.headers.update(self.device.get("headers") or {})
|
||||
region_name = (self.config["regions"][self.region].get("name") or "").strip()
|
||||
self.log.info(f" + Region: {self.region.upper()} ({region_name})")
|
||||
|
||||
tokens = self._load_tokens()
|
||||
if not tokens:
|
||||
self.log.info(" + No cached tokens, registering a new device.")
|
||||
tokens = self._register_device()
|
||||
|
||||
if self._token_expired(tokens):
|
||||
self.log.info(" + Access token expired, refreshing.")
|
||||
if not self._refresh_token(tokens):
|
||||
self.log.warning(" - Refresh failed, re-registering device.")
|
||||
tokens = self._register_device()
|
||||
|
||||
self.device_id = tokens.get("device_id")
|
||||
self.access_token = tokens.get("x-amz-access-token")
|
||||
self.marketplace_id = tokens.get("marketplaceId") or self.marketplace_id
|
||||
self.territory_id = tokens.get("musicTerritory") or self.territory_id
|
||||
self.video_player_token = self._extract_video_player_token(tokens)
|
||||
|
||||
claims = self._player_token_claims(self.video_player_token)
|
||||
self.customer_id = claims.get("customerId")
|
||||
self.device_id = claims.get("deviceId") or self.device_id
|
||||
self.marketplace_id = claims.get("marketplaceId") or self.marketplace_id
|
||||
self.territory_id = claims.get("territoryId") or self.territory_id
|
||||
if claims.get("deviceTypeId") and claims["deviceTypeId"] != self.device_type_id:
|
||||
self.log.debug(f"Registered deviceTypeId is {claims['deviceTypeId']}, "
|
||||
f"config says {self.device_type_id}; using the registered one")
|
||||
self.device_type_id = claims["deviceTypeId"]
|
||||
|
||||
if not self.access_token:
|
||||
self.log.error(" - No Amazon Music access token."); raise SystemExit(1)
|
||||
|
||||
self.session.headers.update({
|
||||
"x-amz-access-token": self.access_token,
|
||||
"x-amzn-device-id": self.device_id or "",
|
||||
})
|
||||
if self.video_player_token:
|
||||
self.session.headers["x-amzn-video-player-token"] = self.video_player_token
|
||||
|
||||
self.log.info(f" + Authenticated with Amazon Music ({self.territory_id})")
|
||||
self.log.info(f" + DRM: {'PlayReady' if self.is_playready else 'Widevine'}")
|
||||
|
||||
_PLAYER_TOKEN_CLAIMS = ("customerId", "marketplaceId", "territoryId",
|
||||
"deviceId", "deviceTypeId")
|
||||
|
||||
@classmethod
|
||||
def _player_token_claims(cls, token: Optional[str]) -> dict:
|
||||
if not token or token.count(".") < 2:
|
||||
return {}
|
||||
payload = token.split(".")[1]
|
||||
payload += "=" * (-len(payload) % 4)
|
||||
try:
|
||||
decoded = base64.urlsafe_b64decode(payload.encode()).decode("latin-1", errors="ignore")
|
||||
except Exception:
|
||||
return {}
|
||||
claims = {}
|
||||
for name in cls._PLAYER_TOKEN_CLAIMS:
|
||||
match = re.search(rf'"{name}"\s*:\s*"([^"]+)"', decoded)
|
||||
if match:
|
||||
claims[name] = match.group(1)
|
||||
return claims
|
||||
|
||||
def _load_tokens(self) -> Optional[dict]:
|
||||
if not self.tokens_path.exists():
|
||||
return None
|
||||
try:
|
||||
tokens = json.loads(self.tokens_path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
self.log.warning(f" - Could not read cached tokens: {e}")
|
||||
return None
|
||||
if not tokens.get("service_token") or not tokens.get("device_id"):
|
||||
return None
|
||||
if isinstance(tokens["service_token"], dict):
|
||||
tokens["service_token"] = json.dumps(tokens["service_token"])
|
||||
return tokens
|
||||
|
||||
def _save_tokens(self, tokens: dict) -> None:
|
||||
try:
|
||||
self.tokens_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.tokens_path.write_text(json.dumps(tokens, indent=2), encoding="utf-8")
|
||||
except Exception as e:
|
||||
self.log.warning(f" - Could not cache tokens: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _token_expired(tokens: dict) -> bool:
|
||||
try:
|
||||
expires_at = int(tokens.get("expires_at") or 0)
|
||||
if not expires_at and tokens.get("service_token"):
|
||||
expires_at = int(json.loads(tokens["service_token"]).get("expiresAtMillis") or 0)
|
||||
except Exception:
|
||||
return True
|
||||
return not expires_at or expires_at <= int(time.time() * 1000) + 60_000
|
||||
|
||||
@staticmethod
|
||||
def _extract_video_player_token(tokens: dict) -> Optional[str]:
|
||||
header = tokens.get("x-amzn-video-player-token")
|
||||
if not header:
|
||||
return None
|
||||
if isinstance(header, str):
|
||||
try:
|
||||
return json.loads(header).get("token")
|
||||
except Exception:
|
||||
return header
|
||||
return None
|
||||
|
||||
def _tvmesk_headers(self, device_id: str, **extra: Optional[str]) -> dict:
|
||||
headers = {
|
||||
"x-amzn-request-id": str(uuid.uuid4()),
|
||||
"x-amzn-timestamp": str(int(time.time() * 1000)),
|
||||
"x-amzn-device-id": device_id,
|
||||
}
|
||||
headers.update({k: v for k, v in extra.items() if v})
|
||||
return headers
|
||||
|
||||
@staticmethod
|
||||
def _find_method(payload: dict, interface: str, key: str) -> Optional[str]:
|
||||
for item in payload.get("methods") or []:
|
||||
if item.get("interface") == interface and item.get(key):
|
||||
return item[key]
|
||||
return None
|
||||
|
||||
def _interface(self, name: str) -> str:
|
||||
interface = (self.config.get("interfaces") or {}).get(name)
|
||||
if not interface:
|
||||
self.log.error(f" - config.yaml is missing interfaces.{name}")
|
||||
raise SystemExit(1)
|
||||
return interface
|
||||
|
||||
def _store_service_token(self, tokens: dict, service_token: Any, video_token: Optional[str]) -> dict:
|
||||
if isinstance(service_token, str):
|
||||
token_data = json.loads(service_token)
|
||||
else:
|
||||
token_data = service_token
|
||||
service_token = json.dumps(service_token)
|
||||
|
||||
tokens["service_token"] = service_token
|
||||
tokens["x-amz-access-token"] = token_data.get("accessToken")
|
||||
if token_data.get("marketplaceId"):
|
||||
tokens["marketplaceId"] = token_data["marketplaceId"]
|
||||
if token_data.get("expiresAtMillis"):
|
||||
tokens["expires_at"] = token_data["expiresAtMillis"]
|
||||
if video_token:
|
||||
tokens["x-amzn-video-player-token"] = video_token
|
||||
return tokens
|
||||
|
||||
def _register_device(self) -> dict:
|
||||
device_id = secrets.token_hex(8)
|
||||
|
||||
try:
|
||||
code_res = self.session.post(
|
||||
self._endpoint("show_home"),
|
||||
json={"userHash": ""},
|
||||
headers=self._tvmesk_headers(device_id),
|
||||
timeout=self.registration_timeout,
|
||||
)
|
||||
code_res.raise_for_status()
|
||||
code_json = code_res.json()
|
||||
except requests.RequestException as e:
|
||||
self.log.error(f" - Could not reach Amazon to start device pairing: {e}")
|
||||
raise SystemExit(1)
|
||||
|
||||
template = ((code_json.get("methods") or [{}])[0]).get("template") or {}
|
||||
public_code = template.get("code")
|
||||
if not public_code:
|
||||
self.log.error(f" - No pairing code in response: {json.dumps(code_json)[:300]}")
|
||||
raise SystemExit(1)
|
||||
|
||||
register_code = public_code
|
||||
try:
|
||||
poll_url = template["onPollingIntervalElapsed"][0]["url"]
|
||||
if "code=" in poll_url:
|
||||
register_code = unquote(poll_url.rsplit("code=", 1)[-1])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.log.info("")
|
||||
self.log.info(" + DEVICE REGISTRATION")
|
||||
self.log.info(f" 1. Open: {self._endpoint('activation')}")
|
||||
self.log.info(f" 2. Enter: {public_code}")
|
||||
self.log.info(" 3. Press Enter")
|
||||
try:
|
||||
input()
|
||||
except EOFError:
|
||||
self.log.error(" - Registration needs an interactive terminal for the pairing step.")
|
||||
raise SystemExit(1)
|
||||
|
||||
reg_json = None
|
||||
for attempt in range(1, 4):
|
||||
try:
|
||||
reg_res = self.session.post(
|
||||
self._endpoint("show_home"),
|
||||
data=json.dumps({"code": register_code}, separators=(",", ":")),
|
||||
headers=self._tvmesk_headers(device_id),
|
||||
timeout=self.registration_timeout,
|
||||
)
|
||||
reg_res.raise_for_status()
|
||||
reg_json = reg_res.json()
|
||||
break
|
||||
except requests.RequestException as e:
|
||||
if attempt == 3:
|
||||
self.log.error(f" - Registration failed after {attempt} attempts: {e}")
|
||||
self.log.error(f" - Code {public_code} may still be valid.")
|
||||
raise SystemExit(1)
|
||||
self.log.warning(f" - Registration attempt {attempt} failed ({e}), retrying.")
|
||||
|
||||
service_token = self._find_method(reg_json, self._interface("authentication"), "authentication")
|
||||
if not service_token:
|
||||
self.log.error(" - Registration did not return a service token.")
|
||||
raise SystemExit(1)
|
||||
video_token = self._find_method(reg_json, self._interface("video_player"), "header")
|
||||
|
||||
tokens = self._store_service_token(
|
||||
{"device_id": device_id, "musicTerritory": self.territory_id}, service_token, video_token
|
||||
)
|
||||
self._save_tokens(tokens)
|
||||
self.log.info(" + Device registered")
|
||||
return tokens
|
||||
|
||||
def _refresh_token(self, tokens: dict) -> bool:
|
||||
device_id, service_token = tokens.get("device_id"), tokens.get("service_token")
|
||||
if not device_id or not service_token:
|
||||
return False
|
||||
try:
|
||||
res = self.session.post(
|
||||
self._endpoint("transfer_playback"),
|
||||
json={"showNowPlaying": "false", "newMediaRequired": "true", "userHash": ""},
|
||||
headers=self._tvmesk_headers(device_id, **{"x-amzn-authentication": service_token}),
|
||||
timeout=self.registration_timeout,
|
||||
)
|
||||
if res.status_code != 200:
|
||||
self.log.warning(f" - Token refresh returned HTTP {res.status_code}")
|
||||
return False
|
||||
data = res.json()
|
||||
new_token = self._find_method(data, self._interface("authentication"), "authentication")
|
||||
if not new_token:
|
||||
return False
|
||||
video_token = self._find_method(data, self._interface("video_player"), "header")
|
||||
self._save_tokens(self._store_service_token(tokens, new_token, video_token))
|
||||
return True
|
||||
except Exception as e:
|
||||
self.log.warning(f" - Token refresh failed: {e}")
|
||||
return False
|
||||
|
||||
def _muse(self, target: str, endpoint: str, payload: dict) -> Optional[dict]:
|
||||
res = self.session.post(
|
||||
self._endpoint("muse", operation=endpoint),
|
||||
json=payload,
|
||||
headers={
|
||||
"x-amzn-requestid": str(uuid.uuid4()),
|
||||
"X-Amz-Target": self._target("muse", operation=target),
|
||||
},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if res.status_code != 200:
|
||||
self.log.debug(f"muse/{endpoint} -> HTTP {res.status_code}: {res.text[:200]}")
|
||||
return None
|
||||
return res.json()
|
||||
|
||||
def _lookup_album(self, asin: str) -> Optional[dict]:
|
||||
data = self._muse("lookup", "lookup", {
|
||||
"asins": [asin],
|
||||
"features": ["popularity", "expandTracklist", "trackLibraryAvailability",
|
||||
"collectionLibraryAvailability"],
|
||||
"requestedContent": "MUSIC_SUBSCRIPTION",
|
||||
"musicTerritory": self.territory_id,
|
||||
"deviceId": self.device_id or "",
|
||||
"deviceType": self.device_type_id,
|
||||
})
|
||||
if not data:
|
||||
return None
|
||||
return (data.get("albumList") or [None])[0]
|
||||
|
||||
def get_titles(self) -> Titles_T:
|
||||
asin = self._extract_asin(self.title)
|
||||
if not asin:
|
||||
self.log.error(" - Could not find an ASIN in that URL."); raise SystemExit(1)
|
||||
|
||||
album = self._lookup_album(asin)
|
||||
if album and album.get("tracks") and not self.single:
|
||||
return self._album_titles(asin, album)
|
||||
|
||||
return self._single_title(asin)
|
||||
|
||||
def _album_titles(self, asin: str, album: dict) -> Music:
|
||||
album_title = self._clean(album.get("title") or album.get("name")) or asin
|
||||
album_artist = self._clean((album.get("artist") or {}).get("name")
|
||||
or album.get("artistName")) or "Unknown Artist"
|
||||
year = self._year(album)
|
||||
if not year:
|
||||
self.log.debug(f"No release year on album. Keys: {sorted(album)}")
|
||||
artwork = self._cover(album.get("image"))
|
||||
entries = [t for t in album.get("tracks") or [] if isinstance(t, dict) and t.get("asin")]
|
||||
|
||||
songs = []
|
||||
for index, track in enumerate(entries, 1):
|
||||
songs.append(self._build_song(track, album, album_title, album_artist, year,
|
||||
artwork, index, len(entries)))
|
||||
if not songs:
|
||||
self.log.error(" - Album has no playable tracks."); raise SystemExit(1)
|
||||
|
||||
return Music(songs, kind="album", title=album_title, artist=album_artist,
|
||||
year=year or None, total_tracks=len(songs), artwork_url=artwork)
|
||||
|
||||
def _single_title(self, asin: str) -> Music:
|
||||
data = self._muse("catalog", "catalog", {
|
||||
"asin": asin, "features": ["trackMetadata"], "musicTerritory": self.territory_id,
|
||||
})
|
||||
track = (data or {}).get("track")
|
||||
if not track:
|
||||
self.log.error(f" - Track {asin} not found in the {self.territory_id} catalogue.")
|
||||
raise SystemExit(1)
|
||||
|
||||
album_title = self._clean((track.get("album") or {}).get("name")) or self._clean(track.get("title"))
|
||||
artist = self._clean((track.get("artist") or {}).get("name") or track.get("artistName")
|
||||
or track.get("primaryArtistName")) or "Unknown Artist"
|
||||
year = self._year(track) or self._year(track.get("album") or {})
|
||||
artwork = self._cover(track.get("image") or (track.get("album") or {}).get("image"))
|
||||
|
||||
song = self._build_song(track, track.get("album") or {}, album_title, artist, year, artwork, 1, 1)
|
||||
return Music([song], kind="single", title=album_title, artist=artist,
|
||||
year=year or None, total_tracks=1, artwork_url=artwork)
|
||||
|
||||
def _build_song(self, track: dict, album: dict, album_title: str, album_artist: str,
|
||||
year: int, artwork: Optional[str], position: int, total: int) -> Song:
|
||||
title = self._clean(track.get("title") or track.get("name")) or "Unknown"
|
||||
artist = self._clean((track.get("artist") or {}).get("name")
|
||||
or track.get("artistName")) or album_artist
|
||||
genre = self._clean(track.get("primaryGenre") or album.get("primaryGenre")) or None
|
||||
isrc = track.get("isrc") or None
|
||||
label = self._clean(track.get("label") or album.get("label")) or None
|
||||
track_number = int(track.get("trackNum") or position)
|
||||
disc_number = int(track.get("discNum") or track.get("discNumber") or 1)
|
||||
asin = track["asin"]
|
||||
year = self._year(track) or self._year(album) or year or 1
|
||||
|
||||
data = {
|
||||
"service": self.ALIASES[0],
|
||||
"source": self.ALIASES[0],
|
||||
"track_id": asin,
|
||||
"track_url": f"{self.base_url}albums/{album.get('asin') or asin}",
|
||||
"title": title,
|
||||
"artist": artist,
|
||||
"performer": artist,
|
||||
"album": album_title,
|
||||
"album_artist": album_artist,
|
||||
"track_number": track_number,
|
||||
"total_tracks": total,
|
||||
"disc_number": disc_number,
|
||||
"genre": genre,
|
||||
"isrc": isrc,
|
||||
"label": label,
|
||||
"year": year,
|
||||
"copyright": self._clean(album.get("copyright")) or None,
|
||||
"artwork_url": artwork,
|
||||
"duration": int(track.get("duration") or 0) or None,
|
||||
"channels": 2,
|
||||
}
|
||||
if config.tag:
|
||||
data["comment"] = config.tag
|
||||
|
||||
return Song(
|
||||
id_=asin,
|
||||
service=self.__class__,
|
||||
name=title,
|
||||
artist=artist,
|
||||
album=album_title,
|
||||
track=track_number,
|
||||
disc=disc_number,
|
||||
year=year,
|
||||
album_artist=album_artist,
|
||||
release_type="album" if total > 1 else "single",
|
||||
total_tracks=total if total > 1 else None,
|
||||
genre=genre,
|
||||
isrc=isrc if isinstance(isrc, str) else None,
|
||||
label=label,
|
||||
artwork_url=artwork,
|
||||
data=data,
|
||||
)
|
||||
|
||||
def _get_mpd(self, asin: str) -> str:
|
||||
if asin in self._mpd_cache:
|
||||
return self._mpd_cache[asin]
|
||||
|
||||
manifest_cfg = self.config.get("manifest") or {}
|
||||
res = self.session.post(
|
||||
self._endpoint("dmls"),
|
||||
json={
|
||||
"deviceToken": {"deviceTypeId": self.device_type_id, "deviceId": self.device_id or ""},
|
||||
"appInfo": {"musicAgent": self._music_agent(asin)},
|
||||
**({"customerId": self.customer_id} if self.customer_id else {}),
|
||||
"contentIdList": [{"identifier": asin, "identifierType": "ASIN"}],
|
||||
"musicDashVersionList": manifest_cfg.get("dash_versions") or [],
|
||||
"contentProtectionList": manifest_cfg.get("content_protection") or [],
|
||||
"customerInfo": {"marketplaceId": self.marketplace_id, "territoryId": self.territory_id},
|
||||
"try3dAsinSubstitution": True,
|
||||
"tryAsinSubstitution": True,
|
||||
},
|
||||
headers={
|
||||
"X-Amz-RequestId": str(uuid.uuid4()),
|
||||
"X-Amz-Target": self._target("manifest"),
|
||||
"x-amz-access-token": self.access_token,
|
||||
"x-amzn-timestamp": str(int(time.time() * 1000)),
|
||||
"x-amzn-requestid": str(uuid.uuid4()),
|
||||
"Content-Encoding": "amz-1.0",
|
||||
},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if res.status_code != 200:
|
||||
if res.status_code == 403:
|
||||
self.log.error(" - Manifest denied (HTTP 403). Check your subscription.")
|
||||
else:
|
||||
self.log.error(f" - Manifest request failed: HTTP {res.status_code} {res.text[:200]}")
|
||||
return ""
|
||||
|
||||
try:
|
||||
data = res.json()
|
||||
except Exception as e:
|
||||
self.log.error(f" - Could not parse manifest response: {e}")
|
||||
return ""
|
||||
|
||||
if data.get("sessionHandoffToken"):
|
||||
self.session_handoff_token = data["sessionHandoffToken"]
|
||||
mpd = ((data.get("contentResponseList") or [{}])[0]).get("manifest") or ""
|
||||
self._mpd_cache[asin] = mpd
|
||||
return mpd
|
||||
|
||||
def _representations(self, mpd: str) -> list[dict]:
|
||||
reps = []
|
||||
for block in re.findall(r"<AdaptationSet\b[\s\S]*?</AdaptationSet>", mpd):
|
||||
kid = self._search(r'cenc:default_KID="([^"]+)"', block)
|
||||
psshs = self._content_protection(block)
|
||||
track_type = self._search(r'schemeIdUri="amz-music:trackType"\s+value="([^"]+)"', block) or "UNKNOWN"
|
||||
|
||||
for rep in re.findall(r"<Representation\b[\s\S]*?</Representation>", block):
|
||||
base_url = (self._search(r"<BaseURL>([\s\S]*?)</BaseURL>", rep) or "").strip()
|
||||
if not base_url:
|
||||
continue
|
||||
codec = self._search(r'codecs="([^"]+)"', rep) or "unknown"
|
||||
bandwidth = int(self._search(r'bandwidth="(\d+)"', rep) or 0)
|
||||
sample_rate = int(self._search(r'audioSamplingRate="(\d+)"', rep) or 0)
|
||||
bit_depth = int(self._search(r'schemeIdUri="amz-music:bitDepth"\s+value="(\d+)"', rep) or 0)
|
||||
reps.append({
|
||||
"url": base_url,
|
||||
"codec": codec,
|
||||
"family": self._codec_family(codec),
|
||||
"bandwidth": bandwidth,
|
||||
"sample_rate": sample_rate,
|
||||
"bit_depth": bit_depth,
|
||||
"kid": (kid or "").replace("-", ""),
|
||||
"pssh": psshs,
|
||||
"track_type": track_type,
|
||||
})
|
||||
reps.sort(key=self._rep_rank, reverse=True)
|
||||
return reps
|
||||
|
||||
def _rep_rank(self, rep: dict) -> tuple:
|
||||
try:
|
||||
codec_score = len(self.codec_priority) - self.codec_priority.index(rep["family"])
|
||||
except ValueError:
|
||||
codec_score = 0
|
||||
return (codec_score, rep["bit_depth"], rep["sample_rate"], rep["bandwidth"])
|
||||
|
||||
@staticmethod
|
||||
def _codec_family(codec: str) -> str:
|
||||
codec = codec.lower()
|
||||
if "flac" in codec:
|
||||
return "flac"
|
||||
if codec.startswith("ec-3") or "eac3" in codec:
|
||||
return "ec-3"
|
||||
if codec.startswith("ac-4"):
|
||||
return "ac-4"
|
||||
if codec.startswith("mp4a.40.34") or codec == "mp3":
|
||||
return "mp3"
|
||||
if codec.startswith("mp4a"):
|
||||
return "mp4a"
|
||||
if "opus" in codec:
|
||||
return "opus"
|
||||
return codec
|
||||
|
||||
_CODEC_ALIASES = {"aac": "mp4a", "ec3": "ec-3", "ac4": "ac-4",
|
||||
"flac": "flac", "opus": "opus", "mp3": "mp3"}
|
||||
|
||||
def _pick_representation(self, reps: list[dict]) -> Optional[dict]:
|
||||
if self.forced_codec:
|
||||
wanted = self._CODEC_ALIASES.get(self.forced_codec, self.forced_codec)
|
||||
matches = [r for r in reps if r["family"] == wanted]
|
||||
if not matches:
|
||||
available = ", ".join(sorted({r["family"] for r in reps})) or "none"
|
||||
self.log.error(f" - No {self.forced_codec.upper()} stream for this track. Available: {available}")
|
||||
return None
|
||||
return matches[0]
|
||||
return reps[0] if reps else None
|
||||
|
||||
def get_music_track_options(self, song: Song) -> list[MusicTrackOption]:
|
||||
reps = self._representations(self._get_mpd(str(song.id)))
|
||||
options = []
|
||||
for rep in reps:
|
||||
family = rep["family"]
|
||||
options.append(MusicTrackOption(
|
||||
codec={"flac": "FLAC", "mp4a": "AAC", "ec-3": "EC3",
|
||||
"ac-4": "AC4", "opus": "OPUS", "mp3": "MP3"}.get(family, family.upper()),
|
||||
bit_depth=rep["bit_depth"] or None,
|
||||
sample_rate=rep["sample_rate"] or None,
|
||||
bitrate=rep["bandwidth"] or None,
|
||||
channels=5.1 if family in ("ec-3", "ac-4") else 2.0,
|
||||
lossless=family == "flac",
|
||||
hires=family == "flac" and (rep["bit_depth"] > 16 or rep["sample_rate"] > 48000),
|
||||
duration=(song.data or {}).get("duration"),
|
||||
quality_label=self._quality_label(rep),
|
||||
))
|
||||
return options
|
||||
|
||||
def get_tracks(self, title: Song) -> Tracks:
|
||||
asin = str(title.id)
|
||||
mpd = self._get_mpd(asin)
|
||||
if not mpd:
|
||||
self.log.error(f" - No manifest for track {asin}."); raise SystemExit(1)
|
||||
|
||||
reps = self._representations(mpd)
|
||||
if not reps:
|
||||
self.log.error(f" - No audio in the manifest for {asin}."); raise SystemExit(1)
|
||||
|
||||
rep = self._pick_representation(reps)
|
||||
if not rep:
|
||||
raise SystemExit(1)
|
||||
|
||||
self.quality = self._quality_label(rep)
|
||||
self.log.debug(f" + Selected {rep['codec']} @ {rep['bandwidth']}bps "
|
||||
f"({rep['bit_depth'] or '?'}-bit/{rep['sample_rate'] or '?'}Hz)")
|
||||
|
||||
drm = self._drm_for(rep)
|
||||
family = rep["family"]
|
||||
|
||||
audio = Audio(
|
||||
rep["url"],
|
||||
language=title.language or "en",
|
||||
codec={"flac": Audio.Codec.FLAC, "mp4a": Audio.Codec.AAC, "ec-3": Audio.Codec.EC3,
|
||||
"ac-4": Audio.Codec.AC4, "opus": Audio.Codec.OPUS}.get(family),
|
||||
bitrate=rep["bandwidth"] or None,
|
||||
channels=6 if family in ("ec-3", "ac-4") else 2,
|
||||
descriptor=Track.Descriptor.URL,
|
||||
id_=asin,
|
||||
drm=[drm] if drm else None,
|
||||
data={"ext": "flac" if family == "flac" else "m4a", "rep": rep},
|
||||
)
|
||||
return Tracks([audio])
|
||||
|
||||
def get_chapters(self, title: Song) -> Chapters:
|
||||
return Chapters()
|
||||
|
||||
def _content_protection(self, block: str) -> dict:
|
||||
system_ids = ((self.config.get("drm") or {}).get("system_ids") or {})
|
||||
widevine_id = (system_ids.get("widevine") or "").lower()
|
||||
playready_id = (system_ids.get("playready") or "").lower()
|
||||
|
||||
found: dict[str, str] = {}
|
||||
for cp in re.findall(r"<ContentProtection\b[\s\S]*?(?:/>|</ContentProtection>)", block):
|
||||
scheme = (self._search(r'schemeIdUri="([^"]+)"', cp) or "").lower()
|
||||
pssh = self._search(r"<cenc:pssh[^>]*>([\s\S]*?)</cenc:pssh>", cp)
|
||||
if not pssh:
|
||||
continue
|
||||
if widevine_id and widevine_id in scheme:
|
||||
found["widevine"] = pssh.strip()
|
||||
elif (playready_id and playready_id in scheme) or "playready" in scheme:
|
||||
found["playready"] = pssh.strip()
|
||||
return found
|
||||
|
||||
def _drm_for(self, rep: dict):
|
||||
psshs = rep.get("pssh") or {}
|
||||
system = "playready" if self.is_playready else "widevine"
|
||||
pssh_b64 = psshs.get(system)
|
||||
|
||||
if pssh_b64:
|
||||
try:
|
||||
if self.is_playready:
|
||||
return PlayReady(pssh=PlayReadyPSSH(pssh_b64), pssh_b64=pssh_b64)
|
||||
return Widevine(pssh=WidevinePSSH(pssh_b64))
|
||||
except Exception as e:
|
||||
self.log.debug(f"Manifest {system} PSSH unusable ({e}), building one from the KID")
|
||||
|
||||
if not rep.get("kid"):
|
||||
self.log.error(" - Track is encrypted but the manifest exposed no KID or PSSH.")
|
||||
return None
|
||||
|
||||
if self.is_playready:
|
||||
pro = self._build_playready_object(rep["kid"])
|
||||
return PlayReady(pssh=PlayReadyPSSH(pro), pssh_b64=base64.b64encode(pro).decode())
|
||||
return Widevine(pssh=WidevinePSSH.new(
|
||||
system_id=WidevinePSSH.SystemId.Widevine, key_ids=[rep["kid"]], version=1
|
||||
))
|
||||
|
||||
@staticmethod
|
||||
def _build_playready_object(kid_hex: str) -> bytes:
|
||||
kid = uuid.UUID(hex=kid_hex)
|
||||
kid_b64 = base64.b64encode(kid.bytes_le).decode()
|
||||
xml = (
|
||||
'<WRMHEADER xmlns="http://schemas.microsoft.com/DRM/2007/03/PlayReadyHeader" '
|
||||
'version="4.0.0.0"><DATA><PROTECTINFO><KEYLEN>16</KEYLEN>'
|
||||
f"<ALGID>AESCTR</ALGID></PROTECTINFO><KID>{kid_b64}</KID></DATA></WRMHEADER>"
|
||||
)
|
||||
record = xml.encode("utf-16-le")
|
||||
body = struct.pack("<HH", 1, len(record)) + record
|
||||
return struct.pack("<IH", len(body) + 6, 1) + body
|
||||
|
||||
DENIAL_HINTS = {
|
||||
"BLOCKLISTED_DEVICE": "Amazon has revoked this CDM's device certificate.",
|
||||
}
|
||||
|
||||
def get_playready_license(self, *, challenge: Any, title: Song, track: Any = None,
|
||||
**_) -> Optional[bytes]:
|
||||
return self._request_license(challenge, title, "PLAYREADY")
|
||||
|
||||
def get_widevine_license(self, *, challenge: Any, title: Song, track: Any = None,
|
||||
**_) -> Optional[bytes]:
|
||||
return self._request_license(challenge, title, "WIDEVINE")
|
||||
|
||||
def _request_license(self, challenge: Any, title: Song, drm_type: str) -> Optional[bytes]:
|
||||
challenge_bytes = challenge if isinstance(challenge, bytes) else str(challenge).encode("utf-8")
|
||||
body = {
|
||||
"deviceToken": {"deviceTypeId": self.device_type_id, "deviceId": self.device_id or ""},
|
||||
"appInfo": {"musicAgent": self._music_agent(str(title.id))},
|
||||
"DrmType": drm_type,
|
||||
"licenseChallenge": base64.b64encode(challenge_bytes).decode(),
|
||||
}
|
||||
if self.customer_id:
|
||||
body["customerId"] = self.customer_id
|
||||
if self.session_handoff_token:
|
||||
body["sessionHandoffToken"] = self.session_handoff_token
|
||||
|
||||
res = self.session.post(
|
||||
self._endpoint("dmls"),
|
||||
json=body,
|
||||
headers={
|
||||
"x-amzn-requestid": str(uuid.uuid4()),
|
||||
"X-Amz-Target": self._target("license"),
|
||||
"x-amz-access-token": self.access_token,
|
||||
"x-amzn-timestamp": str(int(time.time() * 1000)),
|
||||
"Content-Encoding": "amz-1.0",
|
||||
},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
try:
|
||||
data = res.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
|
||||
denial = str(data.get("denialReason") or "")
|
||||
if denial or str(data.get("__type", "")).endswith("DrmLicenseDeniedException"):
|
||||
hint = self.DENIAL_HINTS.get(denial, "Check the subscription level and the CDM.")
|
||||
request_id = data.get("requestId") or "?"
|
||||
raise ValueError(f"{drm_type} licence denied by Amazon "
|
||||
f"[{denial or 'no reason given'}]. {hint} (requestId {request_id})")
|
||||
|
||||
if res.status_code != 200:
|
||||
raise ValueError(f"{drm_type} licence request failed: "
|
||||
f"HTTP {res.status_code} {res.text[:300]}")
|
||||
if not data.get("license"):
|
||||
raise ValueError(f"No licence in {drm_type} response: {json.dumps(data)[:300]}")
|
||||
|
||||
return base64.b64decode(data["license"])
|
||||
|
||||
def on_track_downloaded(self, track: Any) -> None:
|
||||
if getattr(track, "drm", None):
|
||||
return
|
||||
try:
|
||||
path = getattr(track, "path", None)
|
||||
tdata = getattr(track, "data", None)
|
||||
if not path or not path.exists() or not isinstance(tdata, dict):
|
||||
return
|
||||
ext = tdata.get("ext")
|
||||
if not ext or path.suffix.lower() == f".{ext}":
|
||||
return
|
||||
|
||||
new_path = path.with_suffix(f".{ext}")
|
||||
if new_path.exists():
|
||||
new_path.unlink()
|
||||
|
||||
if ext == "flac" and not self._remux(path, new_path):
|
||||
self.log.warning(" - Could not remux to FLAC.")
|
||||
return
|
||||
|
||||
if not new_path.exists():
|
||||
path.rename(new_path)
|
||||
elif path.exists():
|
||||
path.unlink()
|
||||
track.path = new_path
|
||||
except Exception as e:
|
||||
self.log.debug(f"Container fix-up skipped: {e}")
|
||||
|
||||
def _remux(self, src, dst) -> bool:
|
||||
if not binaries.FFMPEG:
|
||||
self.log.warning(" - ffmpeg not found, cannot remux.")
|
||||
return False
|
||||
proc = subprocess.run(
|
||||
[str(binaries.FFMPEG), "-nostdin", "-hide_banner", "-loglevel", "error",
|
||||
"-y", "-i", str(src), "-map", "0:a:0", "-c:a", "copy", str(dst)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if proc.returncode != 0 or not dst.exists() or dst.stat().st_size <= 3:
|
||||
self.log.debug(f"ffmpeg remux failed ({proc.returncode}): {proc.stderr[:300]}")
|
||||
if dst.exists():
|
||||
dst.unlink()
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _search(pattern: str, text: str) -> Optional[str]:
|
||||
match = re.search(pattern, text)
|
||||
return match.group(1) if match else None
|
||||
|
||||
@staticmethod
|
||||
def _extract_asin(url: str) -> Optional[str]:
|
||||
match = re.search(r"/(?:albums|tracks)/([A-Z0-9]{10,})", url, re.I)
|
||||
if match:
|
||||
return match.group(1).upper()
|
||||
return url.upper() if re.fullmatch(r"[A-Z0-9]{10,}", url, re.I) else None
|
||||
|
||||
@staticmethod
|
||||
def _clean(value: Any) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
return _INVISIBLE.sub("", str(value)).strip()
|
||||
|
||||
@staticmethod
|
||||
def _year(obj: dict) -> int:
|
||||
for key in ("releaseYear", "originalReleaseDate", "releaseDate",
|
||||
"albumReleaseDate", "publishDate"):
|
||||
value = obj.get(key)
|
||||
if not value:
|
||||
continue
|
||||
if isinstance(value, (int, float)):
|
||||
value = int(value)
|
||||
if 1000 <= value <= 2999:
|
||||
return value
|
||||
try:
|
||||
return int(time.gmtime(value / 1000 if value > 1e11 else value).tm_year)
|
||||
except Exception:
|
||||
continue
|
||||
match = re.search(r"(\d{4})", str(value))
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _cover(url: Optional[str]) -> Optional[str]:
|
||||
if not url:
|
||||
return None
|
||||
return re.sub(r"\._[A-Z0-9_,]+_\.", ".", url)
|
||||
|
||||
@staticmethod
|
||||
def _quality_label(rep: dict) -> str:
|
||||
family = rep["family"]
|
||||
if family == "flac":
|
||||
bits, rate = rep["bit_depth"] or 16, (rep["sample_rate"] or 44100) / 1000
|
||||
return f"FLAC {bits}-bit/{rate:g} kHz"
|
||||
name = {"mp4a": "AAC", "ec-3": "EC-3", "ac-4": "AC-4", "opus": "Opus", "mp3": "MP3"}.get(
|
||||
family, family.upper())
|
||||
kbps = (rep["bandwidth"] or 0) // 1000
|
||||
return f"{name} {kbps} kb/s" if kbps else name
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
region: 'us'
|
||||
|
||||
request_timeout: 30
|
||||
registration_timeout: 60
|
||||
|
||||
endpoints:
|
||||
dmls: "{base}{location}/api/dmls/"
|
||||
muse: "{base}{location}/api/muse/legacy/{operation}"
|
||||
show_home: "https://{tvmesk}/api/showHome"
|
||||
transfer_playback: "https://{tvmesk}/api/transferPlayback"
|
||||
activation: "{activation}"
|
||||
license_legacy: "{base}{location}/api/dmls/getLicenseForPlaybackV2"
|
||||
|
||||
web_endpoints:
|
||||
config_json: "{base}config.json?referrer={referrer}"
|
||||
force_sign_in: "{base}forceSignIn?useHorizonte=true"
|
||||
weblab: "{base}api/weblab"
|
||||
panda_token: "{base}pandaToken?profileId={customer_id}"
|
||||
|
||||
amz_targets:
|
||||
manifest: "com.amazon.digitalmusiclocator.DigitalMusicLocatorServiceExternal.getDashManifestsV2"
|
||||
license: "com.amazon.digitalmusiclocator.DigitalMusicLocatorServiceExternal.getLicenseForPlaybackV2"
|
||||
muse: "com.amazon.musicensembleservice.MusicEnsembleService.{operation}"
|
||||
|
||||
interfaces:
|
||||
authentication: "PlaybackAuthenticationInterface.v1_0.SetAuthenticationMethod"
|
||||
video_player: "VideoPlayerAuthenticationInterface.v1_0.SetVideoPlayerTokenMethod"
|
||||
|
||||
drm:
|
||||
system_ids:
|
||||
widevine: "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"
|
||||
playready: "9a04f079-9840-4286-ab92-e65be0885f95"
|
||||
|
||||
manifest:
|
||||
dash_versions: ["SIREN_KATANA"]
|
||||
content_protection: ["TRACK_PSSH"]
|
||||
|
||||
# A1I3OANZGDNGEE "Harley" Android TV (PlayReady + Widevine)
|
||||
# A16ZV8BU3SN1N3 WebCP Browser (Cookies) (Widevine)
|
||||
# A1KAXIG6VXSG8Y NVIDIA Shield
|
||||
# A2E0SNTXJVT7WK Fire TV
|
||||
# A2SNKIF736WF4T Generic TV
|
||||
# AOAGZA014O5RE Browser
|
||||
# A1MPSLFC7L5AFK Hardware Device
|
||||
|
||||
device:
|
||||
type_id: "A1I3OANZGDNGEE"
|
||||
app_version: "3.12.11.183"
|
||||
music_agent: "Harley/3.12.11.183 Harley/24.10.1 ({request_id} {asin})"
|
||||
headers:
|
||||
origin: "https://music.amazon.com"
|
||||
referer: "https://music.amazon.com/"
|
||||
user-agent: "Harley/3.12.11.183 A1I3OANZGDNGEE/24.10.1"
|
||||
x-amzn-device-type-id: "A1I3OANZGDNGEE"
|
||||
x-amzn-hardware-device-type-id: "A1MPSLFC7L5AFK"
|
||||
x-amzn-device-family: "AndroidTV"
|
||||
x-amzn-device-manufacturer: "NVIDIA"
|
||||
x-amzn-device-model: "A1I3OANZGDNGEE"
|
||||
x-amzn-device-language: "en_US"
|
||||
x-amzn-device-height: "1080"
|
||||
x-amzn-device-width: "1920"
|
||||
x-amzn-os-version: "11"
|
||||
x-amzn-application-version: "3.12.11.183"
|
||||
x-amzn-device-time-zone: "America/Detroit"
|
||||
x-amzn-user-agent: "Dalvik/2.1.0 (Linux; U; Android 9; Smart TV Build/PPR1.180610.011)"
|
||||
|
||||
codec_priority: ["flac", "ec-3", "ac-4", "mp4a", "opus", "mp3"]
|
||||
|
||||
regions:
|
||||
us:
|
||||
name: "United States"
|
||||
marketplace: "ATVPDKIKX0DER"
|
||||
location: "NA"
|
||||
base: "https://music.amazon.com/"
|
||||
activation: "https://www.amazon.com/code"
|
||||
gb:
|
||||
name: "United Kingdom"
|
||||
marketplace: "A1F83G8C2ARO7P"
|
||||
location: "EU"
|
||||
base: "https://music.amazon.co.uk/"
|
||||
activation: "https://www.amazon.co.uk/code"
|
||||
de:
|
||||
name: "Germany"
|
||||
marketplace: "A1PA6795UKMFR9"
|
||||
location: "EU"
|
||||
base: "https://music.amazon.de/"
|
||||
activation: "https://www.amazon.de/code"
|
||||
fr:
|
||||
name: "France"
|
||||
marketplace: "A13V1IB3VIYZZH"
|
||||
location: "EU"
|
||||
base: "https://music.amazon.fr/"
|
||||
activation: "https://www.amazon.fr/code"
|
||||
jp:
|
||||
name: "Japan"
|
||||
marketplace: "A1VC38T7YXB528"
|
||||
location: "FE"
|
||||
base: "https://music.amazon.co.jp/"
|
||||
activation: "https://www.amazon.co.jp/code"
|
||||
mx:
|
||||
name: "Mexico"
|
||||
marketplace: "A1AM78C64UM0Y8"
|
||||
location: "NA"
|
||||
base: "https://music.amazon.com.mx/"
|
||||
activation: "https://www.amazon.com.mx/code"
|
||||
br:
|
||||
name: "Brazil"
|
||||
marketplace: "A2Q3Y263D00KWC"
|
||||
location: "NA"
|
||||
base: "https://music.amazon.com.br/"
|
||||
activation: "https://www.amazon.com.br/code"
|
||||
|
||||
tvmesk_hosts:
|
||||
NA: "na.tvmesk.skill.music.a2z.com"
|
||||
EU: "eu.tvmesk.skill.music.a2z.com"
|
||||
FE: "fe.tvmesk.skill.music.a2z.com"
|
||||
|
||||
region_from_domain:
|
||||
music.amazon.com: "us"
|
||||
music.amazon.co.uk: "gb"
|
||||
music.amazon.de: "de"
|
||||
music.amazon.fr: "fr"
|
||||
music.amazon.co.jp: "jp"
|
||||
music.amazon.com.mx: "mx"
|
||||
music.amazon.com.br: "br"
|
||||
+2273
File diff suppressed because it is too large
Load Diff
+150
@@ -0,0 +1,150 @@
|
||||
certificate: |
|
||||
CAUSwgUKvAIIAxIQCuQRtZRasVgFt7DIvVtVHBi17OSpBSKOAjCCAQoCggEBAKU2UrYVOSDlcXajWhpEgGhqGraJtFdUPgu6plJGy9ViaRn5mhyXON5PXm
|
||||
w1krQdi0SLxf00FfIgnYFLpDfvNeItGn9rcx0RNPwP39PW7aW0Fbqi6VCaKWlR24kRpd7NQ4woyMXr7xlBWPwPNxK4xmR/6UuvKyYWEkroyeIjWHAqgCjC
|
||||
mpfIpVcPsyrnMuPFGl82MMVnAhTweTKnEPOqJpxQ1bdQvVNCvkba5gjOTbEnJ7aXegwhmCdRQzXjTeEV2dO8oo5YfxW6pRBovzF6wYBMQYpSCJIA24ptAP
|
||||
/2TkneyJuqm4hJNFvtF8fsBgTQQ4TIhnX4bZ9imuhivYLa6HsCAwEAAToPYW1hem9uLmNvbS1wcm9kEoADETQD6R0H/h9fyg0Hw7mj0M7T4s0bcBf4fMhA
|
||||
Rpwk2X4HpvB49bJ5Yvc4t41mAnXGe/wiXbzsddKMiMffkSE1QWK1CFPBgziU23y1PjQToGiIv/sJIFRKRJ4qMBxIl95xlvSEzKdt68n7wqGa442+uAgk7C
|
||||
XU3uTfVofYY76CrPBnEKQfad/CVqTh48geNTb4qRH1TX30NzCsB9NWlcdvg10pCnWSm8cSHu1d9yH+2yQgsGe52QoHHCqHNzG/wAxMYWTevXQW7EPTBeFy
|
||||
SPY0xUN+2F2FhCf5/A7uFUHywd0zNTswh0QJc93LBTh46clRLO+d4RKBiBSj3rah6Y5iXMw9N9o58tCRc9gFHrjfMNubopWHjDOO3ATUgqXrTp+fKVCmsG
|
||||
uGl1ComHxXV9i1AqHwzzY2JY2vFqo73jR3IElr6oChPIwcNokmNc0D4TXtjE0BoYkbWKJfHvJJihzMOvDicWUsemVHvua9/FBtpbHgpbgwijFPjtQF9Ldb
|
||||
8Swf
|
||||
|
||||
dtid_dict: [
|
||||
"A3EFHJ9BGBJ8L2", "A3VN4E5F7BBC7S", "A28RQHJKHM2A2W", "AFOQV1TK6EU6O","A1IJNVP3L4AY8B", "A2Z1NVLU6LCAUO",
|
||||
"A2E50Q8IVZ79QG", "A17PYKRA4ES6YB", "AFRP7VQQ7US69", "A2VZ790DVVI91K", "A2IK56KYGDHUVQ", "A1EXS4KWCX7GYC",
|
||||
"ANSTXZRNSPRXT", "A1KF4O3GA2MPZU", "A2XEBUI9EJ55OK", "A7YWW3KUHA54O", "A3DOA4P2WFIK9D", "ABJRG3JXAY5JL",
|
||||
"AI00TLC8V1PCT", "A2YWQEZX3UC3J1", "AM08S97P8ESGT", "A234HDVPYTUVNS", "AJ3B6LDE2HP5J", "A2GZIBBOG0DCV4",
|
||||
"A3MEKX9EL7SW8T", "A1GXILNJBV9CU7", "ANHPQG9GCMO4A", "A2CBAN119017AE", "A3FO6QR7E7PFQX", "A27OOP63XLO9TI",
|
||||
"A2EOVT31LL6KPV", "A1FNA83TYYU3QK", "A3JB7490MR9K86", "A2TIAYXTNQWU3T", "ADP5BND5THPTX", "A1H8RTR0E3Y362",
|
||||
"AUNIXHOL9EVMI", "AZKAGPPWORIRY", "A324X3KDTS7NYA", "A390CW53E1P0G4", "A2D0X18EHNKEOJ", "A3JN21B5ZOWUAN",
|
||||
"A1TG8VNKP4DSQR", "A2XZMRZUFPEDN4", "A2LJ4A527WOX9J", "A2RJLFEH0UEKI9", "AE5DW8GVLP9NX", "AIE8AMJ60B7OK",
|
||||
"A3ZKCWKG4097P", "A2H1I0AR67NWAC", "A3QXXOBP9MU5LY", "A25521KS9QCAMD", "AGQHFIWNI20PO", "A27XSKZJJPVQA4",
|
||||
"A1LCAPNEM1C36Z", "A31POKKHZJR1J4", "AOLDXB6WYN0UM", "A3SSWQ04XYPXBH", "A1BSQJM6E77NJE", "A2TX61L00VISA5",
|
||||
"AO4A5QLO9663Q", "A15MU3EQ4XZ3Y5", "A3IWJ2DYJQRA3T", "A1Q878J3NE8P81", "AAJB0R7QJO84W", "A2M1CHUCI6RHN8",
|
||||
"A6IUL9CVJZXRR", "A2RGJ95OVLR12U", "A1G2XVSR1VA5DI", "A1S15DUFSI8AUG", "ALYWZPYF4JAIT", "A2M4YX06LWP8WI",
|
||||
"A2O85NMVNLPKVN", "A3L0T0VL9A921N", "A1J16TEDOYCZTN", "A1Q7QCGNMXAKYW", "A38EHHIB10L47V", "A3R9S4ZZECZ6YL",
|
||||
"A1C66CX2XD756O", "A1ZB65LA390I4K", "AVU7CPPF2ZRAS", "ATNLRCEBX3W4P", "A2N49KXGVA18AR", "A271DR1789MXDS",
|
||||
"A1TD5Z1R8IWBHA", "A1DOD0Z74XEFYC", "A17AIVOKIKR4QQ", "A1S310WB67VFPY", "A2QCPPMSOLGVZE", "A1NPAGU1M4PA7Z",
|
||||
"A3ORLONYQTBTOZ", "A3M7PA8JXKE627", "A71I8788P1ZV8", "A2V9UEGZ82H4KZ", "A3URJAABOST7NW", "A2E0SNTXJVT7WK",
|
||||
"ADVBD696BHNV5", "A12GXV8XMS007S", "A2LWARUGJLBYEW", "A2GFL5ZMWNE0PX", "AKPGW064GI9HE", "A3HF4YRA2L7XGC",
|
||||
"AGHZIK8D6X7QR", "A1F8D55J0FWDTN", "A1P7E7V3FCZKU6", "A1NL4BVLQ4L3N3", "A10A33FOX2NUBK", "AWZZ5CVHX2CD",
|
||||
"A4ZP7ZC4PI6TO", "A1Z88NGR2BK6A2", "A2MDL5WTJIU8SZ", "AP4RS91ZQ0OOI", "AFF5OAL5E3DIU", "A2HYAJ0FEWP6N3",
|
||||
"A3SUJTTQGF9GNF", "A346DYAAR4WSNS", "A93SQJNJQLDSS", "A2JKHJ0PX4J3L3", "A2WJI2JG7UW2O1", "ARJHEDRXLP6DM",
|
||||
"A1AGU0A4YA5RF9", "A2WV8TTM37P2CB", "AN630UQPG2CA4", "A30OJ8LMIAF6GP", "A8MCGN45KMHDH", "A33S43L213VSHQ",
|
||||
"A2NYIDFQSJW39B", "A31DTMEEVDDOIV", "A2FDUYD6UQ1BQ", "A3MTL1JKF2IXY3", "AK6OCP5ZLUJI1", "A3JTVZS31ZJ340",
|
||||
"A43PXU4ZN2AL1", "A1OTX5GMM5144Z", "A2Z1NVLU6LCAUO", "A43PXU4ZN2AL1", "A1OTX5GMM5144Z", "A2Z1NVLU6LCAUO",
|
||||
"A2SNKIF736WF4T"
|
||||
]
|
||||
|
||||
device:
|
||||
default:
|
||||
manufacturer: WV
|
||||
device_chipset: MediaTek
|
||||
domain: Device
|
||||
app_name: AIV
|
||||
os_name: Android
|
||||
app_version: '3.19.5'
|
||||
device_model: WV_ELPTK514KT22_541264
|
||||
os_version: '9'
|
||||
device_serial: '95047855cf573479341d134104cfa312'
|
||||
device_type: A2SNKIF736WF4T
|
||||
device_name: "Zos Android TV"
|
||||
software_version: '248'
|
||||
firmware: 'Android 9'
|
||||
firmware_version: '9.0 | Android | TV | armeabi-v7a | CE CDM 14.0.0'
|
||||
user_agent: 'Dalvik/2.1.0 (Linux; U; Android 9; WV_ELPTK514KT22_541264 Build/PPR1.180610.011)'
|
||||
|
||||
device_types:
|
||||
browser: 'AOAGZA014O5RE'
|
||||
tv_generic: 'A2SNKIF736WF4T'
|
||||
pc_app: 'A1RTAM01W29CUP'
|
||||
mobile_app: 'A43PXU4ZN2AL1'
|
||||
echo: 'A7WXQPH584YP'
|
||||
echo_dot: 'A32DOYMUN6DTXA'
|
||||
echo_studio: 'A3RBAYBE7VM004'
|
||||
fire_7: 'A2M4YX06LWP8WI'
|
||||
fire_hd_8: 'A1C66CX2XD756O'
|
||||
fire_hd_8_plus_2020: 'AVU7CPPF2ZRAS'
|
||||
fire_hd_10: 'A1ZB65LA390I4K'
|
||||
fire_tv: 'A2E0SNTXJVT7WK'
|
||||
fire_tv_gen2: 'A12GXV8XMS007S'
|
||||
fire_tv_cube: 'A2JKHJ0PX4J3L3'
|
||||
fire_tv_stick_gen1: 'ADVBD696BHNV5'
|
||||
fire_tv_stick_gen2: 'A2LWARUGJLBYEW'
|
||||
fire_tv_stick_4k: 'A2GFL5ZMWNE0PX'
|
||||
fire_tv_stick_4k_gen3: 'AKPGW064GI9HE'
|
||||
nvidia_shield: 'A1KAXIG6VXSG8Y'
|
||||
|
||||
endpoints:
|
||||
configuration: '/acm/GetConfiguration/WebClient'
|
||||
details: '/gp/video/api/getDetailPage'
|
||||
getDetailWidgets: '/gp/video/api/getDetailWidgets'
|
||||
playback: '/playback/prs/GetVodPlaybackResources'
|
||||
metadata: '/api/enrichItemMetadata'
|
||||
refreshplayback: '/playback/tags/getRefreshedPlaybackEnvelope'
|
||||
license_wv: '/playback/drm-vod/GetWidevineLicense'
|
||||
license_pr: '/playback/drm-vod/GetPlayReadyLicense'
|
||||
opensession: '/cdp/playback/pes/StartSession'
|
||||
updatesession: '/cdp/playback/pes/UpdateSession'
|
||||
closesession: '/cdp/playback/pes/StopSession'
|
||||
xray: '/swift/page/xray'
|
||||
ontv: '/gp/video/ontv/code'
|
||||
devicelink: '/gp/video/api/codeBasedLinking'
|
||||
codepair: '/auth/create/codepair'
|
||||
register: '/auth/register'
|
||||
token: '/auth/token'
|
||||
|
||||
regions:
|
||||
us:
|
||||
base: 'www.amazon.com'
|
||||
base_api: 'api.amazon.com'
|
||||
base_manifest: 'atv-ps.amazon.com'
|
||||
marketplace_id: 'ATVPDKIKX0DER'
|
||||
|
||||
fr:
|
||||
base: 'www.amazon.fr'
|
||||
base_api: 'api.amazon.fr'
|
||||
base_manifest: 'atv-ps-eu.primevideo.com'
|
||||
marketplace_id: 'A13V1IB3VIYZZH'
|
||||
|
||||
gb:
|
||||
base: 'www.amazon.co.uk'
|
||||
base_api: 'api.amazon.co.uk'
|
||||
base_manifest: 'atv-ps-eu.amazon.co.uk'
|
||||
marketplace_id: 'A1F83G8C2ARO7P' # A2IR4J4NTCP2M5
|
||||
|
||||
es:
|
||||
base: 'www.amazon.es'
|
||||
base_api: 'api.amazon.es'
|
||||
base_manifest: 'atv-ps-eu.primevideo.com'
|
||||
marketplace_id: 'A1RKKUPIHCS9HS'
|
||||
|
||||
it:
|
||||
base: 'www.amazon.it'
|
||||
base_api: 'api.amazon.it'
|
||||
base_manifest: 'atv-ps-eu.primevideo.com'
|
||||
marketplace_id: 'A3K6Y4MI8GDYMT'
|
||||
|
||||
de:
|
||||
base: 'www.amazon.de'
|
||||
base_api: 'api.amazon.de'
|
||||
base_manifest: 'atv-ps-eu.amazon.de'
|
||||
marketplace_id: 'A1PA6795UKMFR9'
|
||||
|
||||
au:
|
||||
base: 'www.amazon.com.au'
|
||||
base_api: 'api.amazon.com.au'
|
||||
base_manifest: 'atv-ps-fe.amazon.com.au'
|
||||
marketplace_id: 'A3K6Y4MI8GDYMT'
|
||||
|
||||
jp:
|
||||
base: 'www.amazon.co.jp'
|
||||
base_api: 'api.amazon.co.jp'
|
||||
base_manifest: 'atv-ps-fe.amazon.co.jp'
|
||||
marketplace_id: 'A1VC38T7YXB528'
|
||||
|
||||
pl:
|
||||
base: 'www.amazon.com'
|
||||
base_api: 'api.amazon.com'
|
||||
base_manifest: 'atv-ps-eu.primevideo.com'
|
||||
marketplace_id: 'A3K6Y4MI8GDYMT'
|
||||
+503
@@ -0,0 +1,503 @@
|
||||
from __future__ import annotations
|
||||
import hashlib
|
||||
import re
|
||||
from http.cookiejar import CookieJar
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
import click
|
||||
from Cryptodome.Cipher import Blowfish
|
||||
from envied.core.credential import Credential
|
||||
from envied.core.music import MusicTrackOption
|
||||
from envied.core.service import Service
|
||||
from envied.core.titles import Music, Song, Titles_T
|
||||
from envied.core.tracks import Audio, Chapters, Tracks
|
||||
from envied.core.tracks.track import Track
|
||||
|
||||
|
||||
class DEZR(Service):
|
||||
"""
|
||||
Service code for Deezer (https://deezer.com)
|
||||
www.nostalgic.cc
|
||||
Authorization: Credentials, ARLs
|
||||
Security: None
|
||||
"""
|
||||
|
||||
ALIASES = ("DEZR", "deezer", "DEEZ")
|
||||
GROUP_AUDIO_DOWNLOADS = True
|
||||
|
||||
TITLE_RE = (
|
||||
r"^(?:https?://(?:www\.)?deezer\.com/(?:[a-z]{2}/)?(?P<type>track|album|playlist|artist)/)?"
|
||||
r"(?P<id>\d+)"
|
||||
)
|
||||
|
||||
GW_LIGHT = "https://www.deezer.com/ajax/gw-light.php"
|
||||
GET_URL = "https://media.deezer.com/v1/get_url"
|
||||
BLOWFISH_SECRET = b"g4el58wc0zvf9na1"
|
||||
FORMATS = {
|
||||
"FLAC": ("FLAC", "FLAC 16-bit/44.1kHz"),
|
||||
"MP3_320": ("MP3_320", "MP3 320 kb/s"),
|
||||
"MP3_128": ("MP3_128", "MP3 128 kb/s"),
|
||||
}
|
||||
QUALITY_MAP = {
|
||||
"FLAC": "FLAC", "LOSSLESS": "FLAC", "FUCK": "FLAC",
|
||||
"MP3_320": "MP3_320", "320": "MP3_320", "MP3": "MP3_320",
|
||||
"MP3_128": "MP3_128", "128": "MP3_128",
|
||||
}
|
||||
FALLBACK_ORDER = ["FLAC", "MP3_320", "MP3_128"]
|
||||
|
||||
@staticmethod
|
||||
@click.command(name="DEZR", short_help="https://deezer.com", help=__doc__)
|
||||
@click.argument("title", type=str)
|
||||
@click.option("-q", "--quality", "quality",
|
||||
type=click.Choice(["FLAC", "MP3_320", "MP3_128", "320", "128", "MP3", "LOSSLESS"],
|
||||
case_sensitive=False),
|
||||
default=None,
|
||||
help="Audio quality (default: config default_quality, or FLAC). "
|
||||
"FLAC needs a Deezer HiFi subscription.")
|
||||
@click.pass_context
|
||||
def cli(ctx, **kwargs):
|
||||
return DEZR(ctx, **kwargs)
|
||||
|
||||
def __init__(self, ctx, title: str, quality: Optional[str]):
|
||||
super().__init__(ctx)
|
||||
self.title = title
|
||||
|
||||
if quality:
|
||||
self.quality = self.QUALITY_MAP[quality.upper()]
|
||||
else:
|
||||
cfg_q = str(self.config.get("default_quality", "FLAC")).upper()
|
||||
self.quality = self.QUALITY_MAP.get(cfg_q, "FLAC")
|
||||
|
||||
self.arl: Optional[str] = None
|
||||
self.api_token: str = ""
|
||||
self.license_token: Optional[str] = None
|
||||
self.lossless_allowed: bool = False
|
||||
|
||||
m = re.search(self.TITLE_RE, self.title)
|
||||
if not m:
|
||||
self.log.error("Could not parse a Deezer track/album/playlist/artist ID from the input.")
|
||||
raise SystemExit(1)
|
||||
self.item_type = m.group("type") or "album"
|
||||
self.item_id = m.group("id")
|
||||
|
||||
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
||||
super().authenticate(cookies, credential)
|
||||
self.session.headers.update({
|
||||
"User-Agent": self.config.get(
|
||||
"user_agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
||||
),
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Content-Type": "application/json; charset=UTF-8",
|
||||
"Origin": "https://www.deezer.com",
|
||||
"Referer": "https://www.deezer.com/",
|
||||
})
|
||||
|
||||
self.arl = self._resolve_arl(cookies, credential)
|
||||
if not self.arl:
|
||||
self.log.error(
|
||||
"No Deezer ARL found. Set it in your unshackle config under "
|
||||
"services: DEZR: arl: \"YOUR_ARL\", or provide an 'arl' "
|
||||
"cookie, or credentials as 'arl:YOUR_ARL'."
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
self.session.cookies.set("arl", self.arl, domain=".deezer.com")
|
||||
|
||||
user = self._gw("deezer.getUserData")
|
||||
options = (user.get("USER") or {}).get("OPTIONS") or {}
|
||||
user_id = (user.get("USER") or {}).get("USER_ID")
|
||||
if not user_id or user_id == 0:
|
||||
self.log.error("Deezer ARL is invalid or expired. Refresh your ARL.")
|
||||
raise SystemExit(1)
|
||||
|
||||
self.api_token = user.get("checkForm") or ""
|
||||
self.license_token = options.get("license_token")
|
||||
wsq = options.get("web_sound_quality") or {}
|
||||
self.lossless_allowed = bool(wsq.get("lossless"))
|
||||
|
||||
if self.quality == "FLAC" and not self.lossless_allowed:
|
||||
self.log.warning("FLAC requested but this account has no HiFi/lossless plan.")
|
||||
self.log.info(
|
||||
f" + Authenticated with Deezer (Lossless {'available' if self.lossless_allowed else 'unavailable'})"
|
||||
)
|
||||
|
||||
def _resolve_arl(self, cookies: Optional[CookieJar], credential: Optional[Credential]) -> Optional[str]:
|
||||
if credential:
|
||||
user = (credential.username or "").strip()
|
||||
pw = (credential.password or "").strip()
|
||||
if user.lower() in ("arl", "token", "deezer") and pw:
|
||||
return pw
|
||||
if pw and not user:
|
||||
return pw
|
||||
if user and not pw:
|
||||
return user
|
||||
if cookies:
|
||||
for cookie in cookies:
|
||||
if cookie.name.lower() == "arl" and cookie.value:
|
||||
return cookie.value
|
||||
for key in ("arl", "token"):
|
||||
value = str(self.config.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
def _gw(self, method: str, payload: Optional[dict] = None) -> dict:
|
||||
resp = self.session.post(
|
||||
self.GW_LIGHT,
|
||||
params={"method": method, "input": "3", "api_version": "1.0", "api_token": self.api_token},
|
||||
json=payload or {},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
error = data.get("error")
|
||||
results = data.get("results")
|
||||
if not results:
|
||||
raise ValueError(f"Deezer gateway '{method}' failed: {error or 'empty response'}")
|
||||
return results
|
||||
|
||||
def get_titles(self) -> Titles_T:
|
||||
if self.item_type == "track":
|
||||
return self._titles_from_track(self.item_id)
|
||||
if self.item_type == "playlist":
|
||||
return self._titles_from_playlist(self.item_id)
|
||||
if self.item_type == "artist":
|
||||
return self._titles_from_artist(self.item_id)
|
||||
return self._titles_from_album(self.item_id)
|
||||
|
||||
def _titles_from_track(self, sng_id: str) -> Music:
|
||||
song_data = self._gw("song.getData", {"sng_id": sng_id})
|
||||
album_data = {}
|
||||
alb_id = song_data.get("ALB_ID")
|
||||
if alb_id:
|
||||
try:
|
||||
album_data = self._gw("album.getData", {"alb_id": alb_id})
|
||||
except Exception as e:
|
||||
self.log.debug(f"Album lookup for track {sng_id} failed: {e}")
|
||||
song = self._build_song(song_data, album_data)
|
||||
return Music(
|
||||
[song],
|
||||
kind="single",
|
||||
title=song.album,
|
||||
artist=song.album_artist or song.artist,
|
||||
year=song.year,
|
||||
total_tracks=1,
|
||||
artwork_url=song.artwork_url,
|
||||
)
|
||||
|
||||
def _titles_from_album(self, alb_id: str) -> Music:
|
||||
page = self._gw("deezer.pageAlbum", {"alb_id": alb_id, "lang": "en", "tab": 0})
|
||||
album_data = page.get("DATA") or {}
|
||||
songs_raw = (page.get("SONGS") or {}).get("data") or []
|
||||
songs = [self._build_song(s, album_data) for s in songs_raw]
|
||||
if not songs:
|
||||
self.log.error(f" - No tracks found for album {alb_id}."); raise SystemExit(1)
|
||||
return Music(
|
||||
songs,
|
||||
kind=self._release_kind(album_data, len(songs)),
|
||||
title=album_data.get("ALB_TITLE"),
|
||||
artist=album_data.get("ART_NAME"),
|
||||
year=self._year(album_data),
|
||||
total_tracks=len(songs),
|
||||
total_discs=max((s.disc for s in songs), default=1),
|
||||
artwork_url=self._cover_url(album_data.get("ALB_PICTURE")),
|
||||
total_duration=sum(int(s.data.get("duration") or 0) for s in songs) or None,
|
||||
)
|
||||
|
||||
def _titles_from_playlist(self, playlist_id: str) -> Music:
|
||||
page = self._gw("deezer.pagePlaylist", {
|
||||
"playlist_id": playlist_id, "lang": "en", "nb": 2000, "start": 0, "tab": 0, "header": True,
|
||||
})
|
||||
pl_data = page.get("DATA") or {}
|
||||
songs_raw = (page.get("SONGS") or {}).get("data") or []
|
||||
songs = []
|
||||
for position, s in enumerate(songs_raw, start=1):
|
||||
songs.append(self._build_song(s, {}, playlist_position=position))
|
||||
if not songs:
|
||||
self.log.error(f"No tracks found for playlist {playlist_id}."); raise SystemExit(1)
|
||||
return Music(
|
||||
songs,
|
||||
kind="playlist",
|
||||
title=pl_data.get("TITLE"),
|
||||
artist=(pl_data.get("PARENT_USERNAME") or None),
|
||||
total_tracks=len(songs),
|
||||
owner=(pl_data.get("PARENT_USERNAME") or None),
|
||||
artwork_url=self._cover_url(pl_data.get("PLAYLIST_PICTURE"), kind="playlist"),
|
||||
total_duration=int(pl_data.get("DURATION") or 0) or None,
|
||||
)
|
||||
|
||||
def _titles_from_artist(self, artist_id: str) -> Music:
|
||||
page = self._gw("artist.getTopTrack", {"art_id": artist_id, "nb": 100})
|
||||
songs_raw = page.get("data") or []
|
||||
songs = []
|
||||
for position, s in enumerate(songs_raw, start=1):
|
||||
songs.append(self._build_song(s, {}, playlist_position=position))
|
||||
if not songs:
|
||||
self.log.error(f"No top tracks found for artist {artist_id}."); raise SystemExit(1)
|
||||
artist_name = songs[0].artist
|
||||
return Music(
|
||||
songs,
|
||||
kind="playlist",
|
||||
title=f"{artist_name} - Top Tracks",
|
||||
artist=artist_name,
|
||||
total_tracks=len(songs),
|
||||
)
|
||||
|
||||
def _build_song(self, s: dict, album: dict, playlist_position: Optional[int] = None) -> Song:
|
||||
album = album or {}
|
||||
title = (s.get("SNG_TITLE") or "").strip() or "Unknown"
|
||||
version = (s.get("VERSION") or "").strip()
|
||||
if version:
|
||||
title = f"{title} {version}".strip()
|
||||
|
||||
artist = (s.get("ART_NAME") or "").strip() or "Unknown Artist"
|
||||
album_title = (s.get("ALB_TITLE") or album.get("ALB_TITLE") or "").strip() or "Unknown Album"
|
||||
album_artist = (album.get("ART_NAME") or s.get("ART_NAME") or artist).strip()
|
||||
year = self._year(album) or self._year(s) or 1
|
||||
cover_md5 = s.get("ALB_PICTURE") or album.get("ALB_PICTURE")
|
||||
artwork = self._cover_url(cover_md5)
|
||||
disc = self._to_int(s.get("DISK_NUMBER"), 1)
|
||||
track_num = playlist_position or self._to_int(s.get("TRACK_NUMBER"), 1)
|
||||
isrc = (s.get("ISRC") or "").strip() or None
|
||||
explicit = str(s.get("EXPLICIT_LYRICS", "0")) == "1"
|
||||
|
||||
data = {
|
||||
"service": self.ALIASES[0],
|
||||
"sng_id": str(s.get("SNG_ID")),
|
||||
"track_token": s.get("TRACK_TOKEN"),
|
||||
"album_id": str(album.get("ALB_ID") or s.get("ALB_ID") or "") or None,
|
||||
"title": title,
|
||||
"artist": artist,
|
||||
"album": album_title,
|
||||
"album_artist": album_artist,
|
||||
"duration": self._to_int(s.get("DURATION"), 0),
|
||||
"isrc": isrc,
|
||||
"artwork_url": artwork,
|
||||
"fallback_id": ((s.get("FALLBACK") or {}).get("SNG_ID") if isinstance(s.get("FALLBACK"), dict) else None),
|
||||
}
|
||||
|
||||
return Song(
|
||||
id_=str(s.get("SNG_ID")),
|
||||
service=self.__class__,
|
||||
name=title,
|
||||
artist=artist,
|
||||
album=album_title,
|
||||
track=int(track_num),
|
||||
disc=int(disc),
|
||||
year=int(year),
|
||||
album_artist=album_artist,
|
||||
release_type=self._release_kind(album, None),
|
||||
total_tracks=self._to_int(album.get("NUMBER_TRACK"), None),
|
||||
total_discs=self._to_int(album.get("NUMBER_DISK"), None),
|
||||
explicit=explicit,
|
||||
isrc=isrc,
|
||||
label=(album.get("LABEL_NAME") or None),
|
||||
artwork_url=artwork,
|
||||
data=data,
|
||||
)
|
||||
|
||||
def get_music_track_options(self, song: Song) -> list[MusicTrackOption]:
|
||||
fmt = self._effective_format()
|
||||
deezer_fmt = self.FORMATS[fmt][0]
|
||||
data = song.data if isinstance(song.data, dict) else {}
|
||||
if deezer_fmt == "FLAC":
|
||||
option = MusicTrackOption(
|
||||
codec="FLAC", bit_depth=16, sample_rate=44100, channels=2.0,
|
||||
lossless=True, hires=False,
|
||||
)
|
||||
else:
|
||||
option = MusicTrackOption(
|
||||
codec="MP3", bitrate=320000 if deezer_fmt == "MP3_320" else 128000,
|
||||
channels=2.0, lossless=False,
|
||||
)
|
||||
option.explicit = bool(data.get("explicit")) if "explicit" in data else song.explicit or False
|
||||
option.duration = int(data.get("duration")) if data.get("duration") else None
|
||||
option.quality_label = self.FORMATS[fmt][1]
|
||||
return [option]
|
||||
|
||||
def get_tracks(self, song: Song) -> Tracks:
|
||||
sng_id = str(song.id)
|
||||
try:
|
||||
fresh = self._gw("song.getData", {"sng_id": sng_id})
|
||||
track_token = fresh.get("TRACK_TOKEN") or (song.data or {}).get("track_token")
|
||||
fallback_id = (fresh.get("FALLBACK") or {}).get("SNG_ID") if isinstance(fresh.get("FALLBACK"), dict) else None
|
||||
except Exception as e:
|
||||
self.log.debug(f"Fresh token fetch failed for {sng_id}: {e}")
|
||||
track_token = (song.data or {}).get("track_token")
|
||||
fallback_id = (song.data or {}).get("fallback_id")
|
||||
|
||||
if not track_token:
|
||||
self.log.error(f"No track token for '{song.name}' (Not streamable?)."); raise SystemExit(1)
|
||||
|
||||
url, used_fmt, used_id = self._get_stream_url(sng_id, track_token, fallback_id)
|
||||
deezer_fmt = self.FORMATS[used_fmt][0]
|
||||
is_flac = deezer_fmt == "FLAC"
|
||||
|
||||
audio = Audio(
|
||||
url,
|
||||
language=song.language or "en",
|
||||
codec=Audio.Codec.FLAC if is_flac else None,
|
||||
bitrate=None if is_flac else (320000 if deezer_fmt == "MP3_320" else 128000),
|
||||
channels=2,
|
||||
descriptor=Track.Descriptor.URL,
|
||||
id_=sng_id,
|
||||
data={
|
||||
"dezr_sng_id": str(used_id),
|
||||
"dezr_ext": "flac" if is_flac else "mp3",
|
||||
"dezr_encrypted": True,
|
||||
},
|
||||
)
|
||||
return Tracks([audio])
|
||||
|
||||
def get_chapters(self, song: Song) -> Chapters:
|
||||
return Chapters()
|
||||
|
||||
def _effective_format(self) -> str:
|
||||
if self.quality == "FLAC" and not self.lossless_allowed:
|
||||
return "MP3_320"
|
||||
return self.quality
|
||||
|
||||
def _get_stream_url(self, sng_id: str, track_token: str, fallback_id: Optional[str]):
|
||||
start = self._effective_format()
|
||||
order = self.FALLBACK_ORDER[self.FALLBACK_ORDER.index(start):]
|
||||
|
||||
last_error = ""
|
||||
for fmt in order:
|
||||
url, err = self._request_url(track_token, self.FORMATS[fmt][0])
|
||||
if url:
|
||||
if fmt != self.quality:
|
||||
self.log.warning(f" - {self.quality} unavailable for this track. Using: {fmt}.")
|
||||
return url, fmt, sng_id
|
||||
last_error = err
|
||||
|
||||
if fallback_id and str(fallback_id) != str(sng_id):
|
||||
self.log.warning(f"Track unavailable ({last_error}). Trying fallback {fallback_id}.")
|
||||
try:
|
||||
fb = self._gw("song.getData", {"sng_id": str(fallback_id)})
|
||||
fb_token = fb.get("TRACK_TOKEN")
|
||||
if fb_token:
|
||||
return self._get_stream_url(str(fallback_id), fb_token, None)
|
||||
except Exception as e:
|
||||
self.log.debug(f"Fallback track fetch failed: {e}")
|
||||
|
||||
self.log.error(f"Could not get a stream URL for track {sng_id}. {last_error}")
|
||||
raise SystemExit(1)
|
||||
|
||||
def _request_url(self, track_token: str, deezer_fmt: str):
|
||||
try:
|
||||
resp = self.session.post(self.GET_URL, json={
|
||||
"license_token": self.license_token,
|
||||
"media": [{
|
||||
"type": "FULL",
|
||||
"formats": [{"cipher": "BF_CBC_STRIPE", "format": deezer_fmt}],
|
||||
}],
|
||||
"track_tokens": [track_token],
|
||||
})
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
return None, f"request failed: {e}"
|
||||
|
||||
entries = data.get("data") or []
|
||||
if not entries:
|
||||
return None, "no data in get_url response"
|
||||
entry = entries[0]
|
||||
if entry.get("errors"):
|
||||
msg = entry["errors"][0].get("message", "unknown error")
|
||||
return None, msg
|
||||
media = entry.get("media") or []
|
||||
if not media or not media[0].get("sources"):
|
||||
return None, f"no {deezer_fmt} media available"
|
||||
return media[0]["sources"][0]["url"], ""
|
||||
|
||||
def on_track_downloaded(self, track: Any) -> None:
|
||||
try:
|
||||
data = getattr(track, "data", None)
|
||||
path = getattr(track, "path", None)
|
||||
if not isinstance(data, dict) or not data.get("dezr_encrypted"):
|
||||
return
|
||||
if data.get("dezr_done"):
|
||||
return
|
||||
if not path or not Path(path).exists():
|
||||
return
|
||||
|
||||
path = Path(path)
|
||||
sng_id = str(data.get("dezr_sng_id"))
|
||||
key = self._blowfish_key(sng_id)
|
||||
if path.stat().st_size < 2048:
|
||||
head = path.read_bytes()[:1]
|
||||
if head in (b"{", b"["):
|
||||
self.log.error(
|
||||
f"Deezer returned an error instead of audio for track {sng_id} "
|
||||
"(token/geo/quality). File left in place for inspection."
|
||||
)
|
||||
data["dezr_done"] = True
|
||||
return
|
||||
|
||||
ext = data.get("dezr_ext") or "flac"
|
||||
out_path = path.with_suffix(f".{ext}")
|
||||
tmp_path = path.with_suffix(path.suffix + ".dec")
|
||||
|
||||
iv = bytes(range(8)) # 00 01 02 03 04 05 06 07
|
||||
block_size = 2048
|
||||
with path.open("rb") as fi, tmp_path.open("wb") as fo:
|
||||
index = 0
|
||||
while True:
|
||||
chunk = fi.read(block_size)
|
||||
if not chunk:
|
||||
break
|
||||
if index % 3 == 0 and len(chunk) == block_size:
|
||||
chunk = Blowfish.new(key, Blowfish.MODE_CBC, iv).decrypt(chunk)
|
||||
fo.write(chunk)
|
||||
index += 1
|
||||
|
||||
path.unlink()
|
||||
if out_path.exists():
|
||||
out_path.unlink()
|
||||
tmp_path.rename(out_path)
|
||||
track.path = out_path
|
||||
data["dezr_done"] = True
|
||||
except Exception as e:
|
||||
self.log.error(f"Failed to decrypt Deezer track: {e}")
|
||||
raise
|
||||
|
||||
def _blowfish_key(self, sng_id: str) -> bytes:
|
||||
md5_hex = hashlib.md5(sng_id.encode()).hexdigest()
|
||||
return bytes(
|
||||
ord(md5_hex[i]) ^ ord(md5_hex[i + 16]) ^ self.BLOWFISH_SECRET[i]
|
||||
for i in range(16)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _to_int(value: Any, default: Optional[int]) -> Optional[int]:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _year(obj: dict) -> int:
|
||||
for key in ("DIGITAL_RELEASE_DATE", "PHYSICAL_RELEASE_DATE", "DATE_ADD", "ORIGINAL_RELEASE_DATE"):
|
||||
value = obj.get(key)
|
||||
if value:
|
||||
match = re.match(r"(\d{4})", str(value))
|
||||
if match and int(match.group(1)) > 0:
|
||||
return int(match.group(1))
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _cover_url(md5: Optional[str], kind: str = "cover") -> Optional[str]:
|
||||
if not md5:
|
||||
return None
|
||||
return f"https://e-cdns-images.dzcdn.net/images/{kind}/{md5}/1400x0-000000-100-0-0.jpg"
|
||||
|
||||
@staticmethod
|
||||
def _release_kind(album: dict, track_count: Optional[int]) -> str:
|
||||
rt = str((album or {}).get("TYPE") or (album or {}).get("RECORD_TYPE") or "").lower()
|
||||
if rt in ("single", "ep", "compile", "compilation", "album"):
|
||||
return "compilation" if rt == "compile" else rt
|
||||
if track_count is not None and track_count <= 3:
|
||||
return "single"
|
||||
return "album"
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
|
||||
|
||||
arl: ''
|
||||
|
||||
default_quality: 'flac'
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
# DisneyPlus(디즈니플러스)
|
||||
|
||||
```
|
||||
uv run unshackle dl -vl all -al orig -sl ko,en,ja -q 1080,2160 -v h.264,h.265 -r SDR,HDR10,DV DSNP entity-4d12671a-f0ad-4c3f-8526-09ae6772390b
|
||||
```
|
||||
|
||||
## Information(정보)
|
||||
|
||||
- Authorization: Credentials, Web Token
|
||||
- Security: UHD@L1/SL3000, FHD@L1/SL3000, HD@L3/SL2000
|
||||
- Working Client Agent: AndroidTV
|
||||
- Support Codec
|
||||
- Video: H264, H265
|
||||
- Audio: AAC, AC3, ATMOS, DTS:X(P2:IMAX)
|
||||
- Range: SDR, HDR10, HDR10, DV
|
||||
|
||||
## Support Args(지원하는 명령어 인자)
|
||||
|
||||
- `-i`, `--imax`: Prefer IMAX Enhanced version if available.
|
||||
- `-r`, `--remastered-ar`: Prefer Remastered Aspect Ratio if available.
|
||||
- `-e`, `--extras`: Select a extras video if available.
|
||||
- `-tu`, `--tier-unlimits`: Remove stream quality restrictions for a specific account.
|
||||
|
||||
## Tips
|
||||
|
||||
- To enable the web refresh token-based login method, please comment out or delete the DSNP section under credentials in `envied.yaml`.
|
||||
웹 리프레시 토큰 기반 로그인 방식을 활성화하려면 `envied.yaml`의 credentials에서 DSNP부분을 주석 처리하거나 삭제하세요.
|
||||
|
||||
```
|
||||
credentials:
|
||||
...
|
||||
# DSNP: example@example.com:example
|
||||
```
|
||||
|
||||
- Configure user settings within the `envied.yaml` file.
|
||||
사용자 설정은 `envied.yaml`에서 다음과 같이 사용하세요.
|
||||
|
||||
```
|
||||
services:
|
||||
DSNP:
|
||||
## 사용자 환경설정
|
||||
## User configuration
|
||||
# 해당 설정값이 주석처리 되어 있는 경우에는 설정값들이 자동으로 선택됩니다.
|
||||
# If these settings are commented out, values will be selected automatically.
|
||||
preferences:
|
||||
# 사용할 프로필의 인덱스 번호를 지정합니다. (0 = 첫 번째 프로필, 1 = 두 번째 프로필 등)
|
||||
# Specifies the index of the profile to use. (0 = first profile, 1 = second profile, etc.)
|
||||
# 값이 설정되지 않은 경우에는 자동으로 PIN이 안 걸려 있고 키즈 모드가 아닌 프로필로 자동 선택됩니다.
|
||||
# If no value is set, a profile without a PIN and not in Kids Mode will be automatically selected.
|
||||
profile: 0
|
||||
|
||||
# 서비스 내에서 표시되는 메타데이터 언어를 선택합니다.
|
||||
# Selects the metadata language displayed within the service.
|
||||
# 언어 설정은 Disney+에서 지원하는 언어 코드(예: "ko", "en")만 사용 가능합니다.
|
||||
# Language settings are only available for language codes supported by Disney+ (e.g., "ko", "en").
|
||||
# 값이 설정되지 않은 경우에는 현재 프로필에 설정된 언어 설정을 사용합니다.
|
||||
# If no value is set, the language settings of the current profile will be used.
|
||||
# language: "ko"
|
||||
|
||||
# 매니페스트 로그 출력 레벨을 설정합니다.
|
||||
# Sets the manifest log output level.
|
||||
# 로그를 항상 표시해야 하는 경우 "info"를 사용하고, 그 외의 모든 경우에는 가급적 "debug"를 사용하십시오.
|
||||
# Use "info" if the log must always be displayed; otherwise, use "debug" whenever possible.
|
||||
# 값이 설정되지 않은 경우 기본값은 "debug"로 적용됩니다.
|
||||
# If no value is set, the default level is "debug".
|
||||
# manifest_log: "info"
|
||||
```
|
||||
|
||||
- To enable the tier_unlimits command by default, add the following to `envied.yaml`.
|
||||
tier_unlimits 명령을 기본값으로 활성화하려면 `envied.yaml`에 다음을 추가하세요.
|
||||
```
|
||||
dl:
|
||||
...
|
||||
DSNP:
|
||||
tier_unlimits: True
|
||||
```
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .dsnp import DSNP
|
||||
|
||||
"""
|
||||
Service code for Disney+ Streaming Service (https://disneyplus.com).\n
|
||||
Version: 26.06.14
|
||||
|
||||
Author: Made by CodeName393 and Improvement by sp4rk.y with Special Thanks to narakama, Hugov, Sam\n
|
||||
Authorization: Credentials, Web Token\n
|
||||
Security: UHD@L1/SL3000 FHD@L1/SL3000 HD@L3/SL2000
|
||||
"""
|
||||
__all__ = ("DSNP",)
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
## DO NOT EDIT THIS FILE
|
||||
# 해당 config 파일은 개발자 외에는 수정하지 마세요.
|
||||
# This configuration file should not be modified by anyone other than developers.
|
||||
# 사용자 환경설정은 반드시 "envied.yaml"에서만 사용하세요.
|
||||
# User configuration must be performed only in "envied.yaml".
|
||||
|
||||
certificate: |
|
||||
CAUSugUKtAIIAxIQbj3s4jO5oUyWjDWqjfr9WRjA2afZBSKOAjCCAQoCggEBALhKWfnyA+FGn5P3tl6ffDjoGq2Oq86hKGl6aZIaGaF7XHPO5mIk7Q35ml
|
||||
ZIgg1A458Udb4eXRws1n+kJFqtZXCY5S1yElLP0Om1WQsoEY2stpl+PZTGnVv/CsOJGKQ8K4KMr7rKjZem9lA9BrBoxgfXY3tbwlnSf3wTEohyANb5Qfpa
|
||||
xsU4v8tQDA8PcjzzV9ICodl6crcFZhAy4QMNXfbWOv/ZrGFx5blSXrzP1sMQ64IY8bjUYw4coZM34NDhu8aCA692g8k2mTz2494x7u3Is8v7RKC9ZNiETE
|
||||
K5/4oeVclXPpelNQokR4uvggnCD1L2EULG/pp6wnk1yWNNLxcCAwEAAToHYmFtdGVjaBKAA2FqHlqkE7EUmdOLiCi0hy5jRgBDJrU1CWNHfH6r2i6s5T5k
|
||||
6LK7ZfD65Tv6uyqq1k82PsDz4++kxbpfJDZaypFbae4XPc6lZxRCc5X0toX/x9TftOQQ4N82l5Hxoha569EPRkrnNy7rO7xrRILa3ZVj1alttEnEEjxEuw
|
||||
SV8usdlUg8/LvLA2C59T/HA2I77k7yVbTrVdy0f81r2l+E2SslivCy1JD3xKlgoaKl4xBnRxItWt8+DCw1Xm2lemYl2LGoh1Wk9gvlXQvr2Jv2+dFX3RNs
|
||||
i5sd00KS9sePszfjoTkQ6fmpRd7ZgFCGFWYB9JZ92aGUFQRE14OTST2uwSf32YCfsoATDNs4V6dB8YDoTGKFGrcoc4gtHPKySGNt7z/fOW4/01ZGzKqoVY
|
||||
Fp3jPq7R0qyt5P6fU5NshbLh5VKcnQvwg62BuKsdwV9u4NV36b2a546hGRl/GBneQ+QDA7NRrgITR33Sz02Oq8yJr3sy24GfZRTbtLJ4qiWkjtw==
|
||||
|
||||
## config ( {configVersion}/{clientId}/{deviceFamily}/{sdkVersion}/{applicationRuntime}/{deviceProfile}/{environment} ) ##
|
||||
# Browser (windows, chrome) : /browser/v34.4/windows/chrome/prod.json
|
||||
# Android Phone : /android/v18.0.0/google/handset/prod.json
|
||||
# Android TV : /android/v18.0.0/google/tv/prod.json
|
||||
# Amazon Fire TV : /android/v18.0.0/amazon/tv/prod.json
|
||||
# Apple Iphone(old) : https://bam-sdk-configs.bamgrid.com/bam-sdk/v2.0/disney-svod-3d9324fc/apple/v9.10.0/ios/iphone/prod.json
|
||||
# Apple Ipad(old) : https://bam-sdk-configs.bamgrid.com/bam-sdk/v2.0/disney-svod-3d9324fc/apple/v9.10.0/ios/ipad/prod.json
|
||||
|
||||
endpoints:
|
||||
config: "https://client-sdk-configs.bamgrid.com/bam-sdk/v7.0/disney-svod-3d9324fc/android/v22.0.0/google/tv/prod.json"
|
||||
|
||||
## user_agent (okhttp/5.0.0-alpha.14) ##
|
||||
# android-phone : BAMSDK/v18.0.0 (disney-svod-3d9324fc 26.0.2+rc1-2026.01.29.0; v7.0/v18.0.0; android; phone)
|
||||
# android-tv : BAMSDK/v18.0.0 (disney-svod-3d9324fc 26.0.2+rc1-2026.01.29.0; v7.0/v18.0.0; android; tv)
|
||||
|
||||
## api_key ##
|
||||
# browser : ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84
|
||||
# android : ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA
|
||||
# apple : ZGlzbmV5JmFwcGxlJjEuMC4w.H9L7eJvc2oPYwDgmkoar6HzhBJRuUUzt_PcaC3utBI4
|
||||
|
||||
## yp_service_id ##
|
||||
# browser : 63626081279ebe65eb50fb54
|
||||
# android : 624b805dafc5c73635b1a216
|
||||
|
||||
bamsdk:
|
||||
sdk_version: "22.0.0"
|
||||
application_version: "26.9.2+rc1-2026.06.12.0"
|
||||
explore_version: "v1.18"
|
||||
client: "disney-svod-3d9324fc"
|
||||
user_agent: "BAMSDK/v22.0.0 (disney-svod-3d9324fc 26.9.2+rc1-2026.06.12.0; v7.0/v22.0.0; android; tv)"
|
||||
api_key: "ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA"
|
||||
yp_service_id: "624b805dafc5c73635b1a216"
|
||||
|
||||
device:
|
||||
family: "android"
|
||||
profile: "tv"
|
||||
platform: "android/google/tv" # {deviceFamily}/{applicationRuntime}/{deviceProfile}
|
||||
platform_id: "android-tv"
|
||||
applicationRuntime: "android"
|
||||
manufacturer: "Google"
|
||||
operatingSystem: "Android"
|
||||
operatingSystemVersion: "16"
|
||||
|
||||
# ## 사용자 환경설정
|
||||
# ## User configuration
|
||||
# # 해당 설정값이 주석처리 되어 있는 경우에는 설정값들이 자동으로 선택됩니다.
|
||||
# # If these settings are commented out, values will be selected automatically.
|
||||
# preferences:
|
||||
# # 사용할 프로필의 인덱스 번호를 지정합니다. (0 = 첫 번째 프로필, 1 = 두 번째 프로필 등)
|
||||
# # Specifies the index of the profile to use. (0 = first profile, 1 = second profile, etc.)
|
||||
# # 값이 설정되지 않은 경우에는 자동으로 PIN이 안 걸려 있고 키즈 모드가 아닌 프로필로 자동 선택됩니다.
|
||||
# # If no value is set, a profile without a PIN and not in Kids Mode will be automatically selected.
|
||||
# profile: 0
|
||||
|
||||
# # 서비스 내에서 표시되는 메타데이터 언어를 선택합니다.
|
||||
# # Selects the metadata language displayed within the service.
|
||||
# # 언어 설정은 Disney+에서 지원하는 언어 코드(예: "ko", "en")만 사용 가능합니다.
|
||||
# # Language settings are only available for language codes supported by Disney+ (e.g., "ko", "en").
|
||||
# # 값이 설정되지 않은 경우에는 현재 프로필에 설정된 언어 설정을 사용합니다.
|
||||
# # If no value is set, the language settings of the current profile will be used.
|
||||
# language: "ko"
|
||||
|
||||
# # 매니페스트 로그 출력 레벨을 설정합니다.
|
||||
# # Sets the manifest log output level.
|
||||
# # 로그를 항상 표시해야 하는 경우 "info"를 사용하고, 그 외의 모든 경우에는 가급적 "debug"를 사용하십시오.
|
||||
# # Use "info" if the log must always be displayed; otherwise, use "debug" whenever possible.
|
||||
# # 값이 설정되지 않은 경우 기본값은 "debug"로 적용됩니다.
|
||||
# # If no value is set, the default level is "debug".
|
||||
# manifest_log: "debug"
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
# REQUEST_DEVICE_CODE = """mutation requestLicensePlate($input: RequestLicensePlateInput!) { requestLicensePlate(requestLicensePlate: $input) { licensePlate expirationTime expiresInSeconds } }"""
|
||||
CHECK_EMAIL = """query check($email: String!) { check(email: $email) { operations nextOperation } }"""
|
||||
LOGIN = """mutation login($input: LoginInput!, $includeIdentity: Boolean!, $includeAccountConsentToken: Boolean!) { login(login: $input) { account { __typename ...accountGraphFragment } actionGrant activeSession { __typename ...sessionGraphFragment } identity @include(if: $includeIdentity) { __typename ...identityGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled isGeminiOnboarded profileLinked languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } linkedProfile { profileLinkType pinProtected } } } fragment accountGraphFragment on Account { id umpMessages { data { messages { messageId messageSource displayLocations content } } } accountConsentToken @include(if: $includeAccountConsentToken) activeProfile { id umpMessages { data { messages { messageId content } } } } profiles { __typename ...profileGraphFragment } profileRequirements { primaryProfiles { personalInfo { requiresCollection } } secondaryProfiles { personalInfo { requiresCollection } personalInfoJrMode { requiresCollection } } } parentalControls { isProfileCreationProtected } flows { star { isOnboarded } } attributes { email emailVerified userVerified maxNumberOfProfilesAllowed locations { manual { country } purchase { country } registration { geoIp { country } } } } } fragment sessionGraphFragment on Session { sessionId device { id } entitlements experiments { featureId variantId version } features { coPlay download noAds } homeLocation { countryCode adsSupported } inSupportedLocation isSubscriber location { countryCode adsSupported } portabilityLocation { countryCode } preferredMaturityRating { impliedMaturityRating ratingSystem } } fragment identityGraphFragment on Identity { id email repromptSubscriberAgreement attributes { passwordResetRequired } commerce { notifications { subscriptionId type showNotification offerData { productType expectedTransition { date price { amount currency } } cypherKeys { key value type } } currentOffer { offerId price { amount currency frequency } } } } flows { marketingPreferences { isOnboarded eligibleForOnboarding } personalInfo { eligibleForCollection requiresCollection } } personalInfo { dateOfBirth gender } locations { purchase { country } } subscriber { subscriberStatus subscriptionAtRisk overlappingSubscription doubleBilled doubleBilledProviders subscriptions { id groupId state partner isEntitled source { sourceProvider sourceType subType sourceRef } product { id sku name entitlements { id name partner } bundle subscriptionPeriod earlyAccess trial { duration } categoryCodes } stacking { status overlappingSubscriptionProviders previouslyStacked previouslyStackedByProvider } term { purchaseDate startDate expiryDate nextRenewalDate pausedDate churnedDate isFreeTrial } } } consent { id idType token } }"""
|
||||
LOGIN_ACTION_GRANT = """mutation loginWithActionGrant($input: LoginWithActionGrantInput!, $includeAccountConsentToken: Boolean!) { loginWithActionGrant(login: $input) { account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } identity { __typename ...identityGraphFragment } actionGrant } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } } fragment accountGraphFragment on Account { id umpMessages { data { messages { messageId messageSource displayLocations content } } } accountConsentToken @include(if: $includeAccountConsentToken) activeProfile { id umpMessages { data { messages { messageId content } } } } profiles { __typename ...profileGraphFragment } profileRequirements { primaryProfiles { personalInfo { requiresCollection } } secondaryProfiles { personalInfo { requiresCollection } personalInfoJrMode { requiresCollection } } } parentalControls { isProfileCreationProtected } flows { star { isOnboarded } } attributes { email emailVerified userVerified maxNumberOfProfilesAllowed locations { manual { country } purchase { country } registration { geoIp { country } } } } } fragment sessionGraphFragment on Session { sessionId device { id } entitlements experiments { featureId variantId version } features { coPlay download noAds } homeLocation { countryCode adsSupported } inSupportedLocation isSubscriber location { countryCode adsSupported } portabilityLocation { countryCode } preferredMaturityRating { impliedMaturityRating ratingSystem } } fragment identityGraphFragment on Identity { id email repromptSubscriberAgreement attributes { passwordResetRequired } commerce { notifications { subscriptionId type showNotification offerData { productType expectedTransition { date price { amount currency } } cypherKeys { key value type } } currentOffer { offerId price { amount currency frequency } } } } flows { marketingPreferences { isOnboarded eligibleForOnboarding } personalInfo { eligibleForCollection requiresCollection } } personalInfo { dateOfBirth gender } locations { purchase { country } } subscriber { subscriberStatus subscriptionAtRisk overlappingSubscription doubleBilled doubleBilledProviders subscriptions { id groupId state partner isEntitled source { sourceProvider sourceType subType sourceRef } product { id sku name entitlements { id name partner } bundle subscriptionPeriod earlyAccess trial { duration } categoryCodes } stacking { status overlappingSubscriptionProviders previouslyStacked previouslyStackedByProvider } term { purchaseDate startDate expiryDate nextRenewalDate pausedDate churnedDate isFreeTrial } } } consent { id idType token } }"""
|
||||
LOGIN_OTP = """mutation authenticateWithOtp($input: AuthenticateWithOtpInput!) { authenticateWithOtp(authenticateWithOtp: $input) { actionGrant securityAction passwordRules { __typename ...passwordRulesFragment } } } fragment passwordRulesFragment on PasswordRules { minLength charTypes }"""
|
||||
ME = """query me($includeAccountConsentToken: Boolean!) { me { account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } identity { __typename ...identityGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } } fragment accountGraphFragment on Account { id umpMessages { data { messages { messageId messageSource displayLocations content } } } accountConsentToken @include(if: $includeAccountConsentToken) activeProfile { id umpMessages { data { messages { messageId content } } } } profiles { __typename ...profileGraphFragment } profileRequirements { primaryProfiles { personalInfo { requiresCollection } } secondaryProfiles { personalInfo { requiresCollection } personalInfoJrMode { requiresCollection } } } parentalControls { isProfileCreationProtected } flows { star { isOnboarded } } attributes { email emailVerified userVerified maxNumberOfProfilesAllowed locations { manual { country } purchase { country } registration { geoIp { country } } } } } fragment sessionGraphFragment on Session { sessionId device { id } entitlements experiments { featureId variantId version } features { coPlay download noAds } homeLocation { countryCode adsSupported } inSupportedLocation isSubscriber location { countryCode adsSupported } portabilityLocation { countryCode } preferredMaturityRating { impliedMaturityRating ratingSystem } } fragment identityGraphFragment on Identity { id email repromptSubscriberAgreement attributes { passwordResetRequired } commerce { notifications { subscriptionId type showNotification offerData { productType expectedTransition { date price { amount currency } } cypherKeys { key value type } } currentOffer { offerId price { amount currency frequency } } } } flows { marketingPreferences { isOnboarded eligibleForOnboarding } personalInfo { eligibleForCollection requiresCollection } } personalInfo { dateOfBirth gender } locations { purchase { country } } subscriber { subscriberStatus subscriptionAtRisk overlappingSubscription doubleBilled doubleBilledProviders subscriptions { id groupId state partner isEntitled source { sourceProvider sourceType subType sourceRef } product { id sku name entitlements { id name partner } bundle subscriptionPeriod earlyAccess trial { duration } categoryCodes } stacking { status overlappingSubscriptionProviders previouslyStacked previouslyStackedByProvider } term { purchaseDate startDate expiryDate nextRenewalDate pausedDate churnedDate isFreeTrial } } } consent { id idType token } }"""
|
||||
REFRESH_TOKEN = """mutation refreshToken($refreshToken: RefreshTokenInput!) { refreshToken(refreshToken: $refreshToken) { activeSession { sessionId } } }"""
|
||||
REGISTER_DEVICE = """mutation registerDevice($registerDevice: RegisterDeviceInput!) { registerDevice(registerDevice: $registerDevice) { __typename } }"""
|
||||
REQUESET_OTP = """mutation requestOtp($input: RequestOtpInput!) { requestOtp(requestOtp: $input) { accepted } }"""
|
||||
SET_IMAX = """mutation updateProfileImaxEnhancedVersion($input: UpdateProfileImaxEnhancedVersionInput!, $includeProfile: Boolean!) { updateProfileImaxEnhancedVersion(updateProfileImaxEnhancedVersion: $input) { accepted profile @include(if: $includeProfile) { __typename ...profileGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } }"""
|
||||
SET_REMASTERED_AR = """mutation updateProfileRemasteredAspectRatio($input: UpdateProfileRemasteredAspectRatioInput!, $includeProfile: Boolean!) { updateProfileRemasteredAspectRatio(updateProfileRemasteredAspectRatio: $input) { accepted profile @include(if: $includeProfile) { __typename ...profileGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } }"""
|
||||
SET_APP_LANGUAGE = """mutation updateProfileAppLanguage($input: UpdateProfileAppLanguageInput!, $includeProfile: Boolean!) { updateProfileAppLanguage(updateProfileAppLanguage: $input) { accepted profile @include(if: $includeProfile) { __typename ...profileGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled isGeminiOnboarded profileLinked languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } linkedProfile { pinProtected } } }"""
|
||||
SWITCH_PROFILE = """mutation switchProfile($input: SwitchProfileInput!, $includeIdentity: Boolean!, $includeAccountConsentToken: Boolean!) { switchProfile(switchProfile: $input) { account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } identity @include(if: $includeIdentity) { __typename ...identityGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } } fragment accountGraphFragment on Account { id umpMessages { data { messages { messageId messageSource displayLocations content } } } accountConsentToken @include(if: $includeAccountConsentToken) activeProfile { id umpMessages { data { messages { messageId content } } } } profiles { __typename ...profileGraphFragment } profileRequirements { primaryProfiles { personalInfo { requiresCollection } } secondaryProfiles { personalInfo { requiresCollection } personalInfoJrMode { requiresCollection } } } parentalControls { isProfileCreationProtected } flows { star { isOnboarded } } attributes { email emailVerified userVerified maxNumberOfProfilesAllowed locations { manual { country } purchase { country } registration { geoIp { country } } } } } fragment sessionGraphFragment on Session { sessionId device { id } entitlements experiments { featureId variantId version } features { coPlay download noAds } homeLocation { countryCode adsSupported } inSupportedLocation isSubscriber location { countryCode adsSupported } portabilityLocation { countryCode } preferredMaturityRating { impliedMaturityRating ratingSystem } } fragment identityGraphFragment on Identity { id email repromptSubscriberAgreement attributes { passwordResetRequired } commerce { notifications { subscriptionId type showNotification offerData { productType expectedTransition { date price { amount currency } } cypherKeys { key value type } } currentOffer { offerId price { amount currency frequency } } } } flows { marketingPreferences { isOnboarded eligibleForOnboarding } personalInfo { eligibleForCollection requiresCollection } } personalInfo { dateOfBirth gender } locations { purchase { country } } subscriber { subscriberStatus subscriptionAtRisk overlappingSubscription doubleBilled doubleBilledProviders subscriptions { id groupId state partner isEntitled source { sourceProvider sourceType subType sourceRef } product { id sku name entitlements { id name partner } bundle subscriptionPeriod earlyAccess trial { duration } categoryCodes } stacking { status overlappingSubscriptionProviders previouslyStacked previouslyStackedByProvider } term { purchaseDate startDate expiryDate nextRenewalDate pausedDate churnedDate isFreeTrial } } } consent { id idType token } }"""
|
||||
UPDATE_DEVICE = """mutation updateDeviceOperatingSystem($updateDeviceOperatingSystem: UpdateDeviceOperatingSystemInput!) {updateDeviceOperatingSystem(updateDeviceOperatingSystem: $updateDeviceOperatingSystem) {accepted}}"""
|
||||
+638
@@ -0,0 +1,638 @@
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from http.cookiejar import CookieJar
|
||||
from typing import List, Optional
|
||||
from collections.abc import Generator
|
||||
import click
|
||||
import jwt
|
||||
from langcodes import Language
|
||||
from envied.core.constants import AnyTrack
|
||||
from envied.core.credential import Credential
|
||||
from envied.core.manifests import DASH
|
||||
from envied.core.search_result import SearchResult
|
||||
from envied.core.service import Service
|
||||
from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
|
||||
from envied.core.tracks import Subtitle, Tracks
|
||||
|
||||
|
||||
class KNPY(Service):
|
||||
"""
|
||||
Service code for Kanopy (https://kanopy.com).
|
||||
www.nostalgic.cc
|
||||
Authorization: Cookies, Credentials
|
||||
Security: FHD@L3
|
||||
Geofence: US, CA, UK, AU, NZ
|
||||
"""
|
||||
|
||||
TITLE_RE = r"^https?://(?:www\.)?kanopy\.com/.+/(?P<id>\d+)$"
|
||||
GEOFENCE = ()
|
||||
NO_SUBTITLES = False
|
||||
|
||||
@staticmethod
|
||||
@click.command(name="KNPY", short_help="https://kanopy.com")
|
||||
@click.argument("title", type=str)
|
||||
@click.pass_context
|
||||
def cli(ctx, **kwargs):
|
||||
return KNPY(ctx, **kwargs)
|
||||
|
||||
def __init__(self, ctx, title: str):
|
||||
super().__init__(ctx)
|
||||
if not self.config:
|
||||
raise ValueError("KNPY configuration not found.")
|
||||
|
||||
self.cdm = ctx.obj.cdm
|
||||
|
||||
match = re.match(self.TITLE_RE, title)
|
||||
if match:
|
||||
self.content_id = match.group("id")
|
||||
else:
|
||||
self.content_id = None
|
||||
self.search_query = title
|
||||
|
||||
self.API_VERSION = self.config["client"]["api_version"]
|
||||
self.USER_AGENT = self.config["client"]["user_agent"]
|
||||
self.WIDEVINE_UA = self.config["client"]["widevine_ua"]
|
||||
|
||||
self.session.headers.update({
|
||||
"x-version": self.API_VERSION,
|
||||
"user-agent": self.USER_AGENT,
|
||||
})
|
||||
|
||||
subdomain_match = re.search(r'kanopy\.com/[a-z]{2}/([^/]+)', title)
|
||||
self._subdomain = subdomain_match.group(1) if subdomain_match else None
|
||||
|
||||
try:
|
||||
from pyplayready.cdm import Cdm as PlayReadyCdm
|
||||
self.use_playready: bool = isinstance(ctx.obj.cdm, PlayReadyCdm)
|
||||
except ImportError:
|
||||
self.use_playready = False
|
||||
|
||||
self._jwt = None
|
||||
self._visitor_id = None
|
||||
self._user_id = None
|
||||
self._domain_id = None
|
||||
self.widevine_license_url = None
|
||||
self.playready_license_url = None
|
||||
|
||||
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
||||
if cookies:
|
||||
jwt_token = None
|
||||
cookie_visitor_id = None
|
||||
cookie_uid = None
|
||||
|
||||
for cookie in cookies:
|
||||
if cookie.name == "kapi_token":
|
||||
jwt_token = cookie.value
|
||||
elif cookie.name == "visitor_id":
|
||||
cookie_visitor_id = cookie.value
|
||||
elif cookie.name == "uid":
|
||||
cookie_uid = cookie.value
|
||||
|
||||
if jwt_token:
|
||||
self.log.info("Attempting cookie-based authentication.")
|
||||
self._jwt = jwt_token
|
||||
self.session.headers.update({"authorization": f"Bearer {self._jwt}"})
|
||||
|
||||
try:
|
||||
decoded_jwt = jwt.decode(self._jwt, options={"verify_signature": False})
|
||||
|
||||
exp_timestamp = decoded_jwt.get("exp")
|
||||
if exp_timestamp and exp_timestamp < datetime.now(timezone.utc).timestamp():
|
||||
self.log.warning("Cookie token has expired.")
|
||||
if credential:
|
||||
self.log.info("Falling back to credential-based authentication.")
|
||||
else:
|
||||
raise ValueError("Cookie token expired and no credentials provided.")
|
||||
else:
|
||||
jwt_data = decoded_jwt.get("data", {})
|
||||
identity_id = jwt_data.get("identity_id")
|
||||
uid = jwt_data.get("uid")
|
||||
self._user_id = (identity_id if identity_id and str(identity_id) != "0" else None) \
|
||||
or (uid if uid and str(uid) != "0" else None) \
|
||||
or cookie_uid
|
||||
self._visitor_id = jwt_data.get("visitor_id") or cookie_visitor_id
|
||||
|
||||
self.log.info(f"Successfully authenticated via cookies (user_id: {self._user_id or 0})")
|
||||
self._fetch_user_details()
|
||||
return
|
||||
|
||||
except jwt.DecodeError as e:
|
||||
self.log.error(f"Failed to decode cookie token: {e}")
|
||||
if credential:
|
||||
self.log.info("Falling back to credential-based authentication.")
|
||||
else:
|
||||
raise ValueError(f"Invalid kapi_token cookie: {e}")
|
||||
except KeyError as e:
|
||||
self.log.error(f"Missing expected field in cookie token: {e}")
|
||||
if credential:
|
||||
self.log.info("Falling back to credential-based authentication.")
|
||||
else:
|
||||
raise ValueError(f"Invalid kapi_token structure: {e}")
|
||||
else:
|
||||
self.log.info("No kapi_token found in cookies.")
|
||||
if not credential:
|
||||
raise ValueError("No kapi_token cookie found and no credentials provided.")
|
||||
self.log.info("Falling back to credential-based authentication.")
|
||||
|
||||
if not self._jwt:
|
||||
if not credential or not credential.username or not credential.password:
|
||||
raise ValueError("Kanopy requires either cookies (with kapi_token) or email/password for authentication.")
|
||||
|
||||
cache = self.cache.get("auth_token")
|
||||
|
||||
if cache and not cache.expired:
|
||||
cached_data = cache.data
|
||||
valid_token = None
|
||||
|
||||
if isinstance(cached_data, dict) and "token" in cached_data:
|
||||
if cached_data.get("username") == credential.username:
|
||||
valid_token = cached_data["token"]
|
||||
self.log.info("Using cached authentication token")
|
||||
else:
|
||||
self.log.info(f"Cached token belongs to '{cached_data.get('username')}', but logging in as '{credential.username}'.")
|
||||
|
||||
elif isinstance(cached_data, str):
|
||||
self.log.info("Found legacy cached token format.")
|
||||
|
||||
if valid_token:
|
||||
self._jwt = valid_token
|
||||
self.session.headers.update({"authorization": f"Bearer {self._jwt}"})
|
||||
|
||||
if not self._user_id or not self._domain_id or not self._visitor_id:
|
||||
try:
|
||||
decoded_jwt = jwt.decode(self._jwt, options={"verify_signature": False})
|
||||
self._user_id = decoded_jwt["data"]["uid"]
|
||||
self._visitor_id = decoded_jwt["data"]["visitor_id"]
|
||||
self.log.info("Extracted user_id and visitor_id from cached token.")
|
||||
self._fetch_user_details()
|
||||
return
|
||||
except (KeyError, jwt.DecodeError) as e:
|
||||
self.log.error(f"Could not decode cached token: {e}.")
|
||||
|
||||
self.log.info("Performing handshake to get visitor token.")
|
||||
r = self.session.get(self.config["endpoints"]["handshake"])
|
||||
r.raise_for_status()
|
||||
handshake_data = r.json()
|
||||
self._visitor_id = handshake_data["visitorId"]
|
||||
initial_jwt = handshake_data["jwt"]
|
||||
|
||||
self.log.info(f"Logging in as {credential.username}.")
|
||||
login_payload = {
|
||||
"credentialType": "email",
|
||||
"emailUser": {
|
||||
"email": credential.username,
|
||||
"password": credential.password,
|
||||
},
|
||||
}
|
||||
r = self.session.post(
|
||||
self.config["endpoints"]["login"],
|
||||
json=login_payload,
|
||||
headers={"authorization": f"Bearer {initial_jwt}"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
login_data = r.json()
|
||||
self._jwt = login_data["jwt"]
|
||||
self._user_id = login_data["userId"]
|
||||
|
||||
self.session.headers.update({"authorization": f"Bearer {self._jwt}"})
|
||||
self.log.info(f"Successfully authenticated as {credential.username}")
|
||||
|
||||
self._fetch_user_details()
|
||||
|
||||
try:
|
||||
decoded_jwt = jwt.decode(self._jwt, options={"verify_signature": False})
|
||||
exp_timestamp = decoded_jwt.get("exp")
|
||||
cache_payload = {"token": self._jwt, "username": credential.username}
|
||||
|
||||
if exp_timestamp:
|
||||
expiration_in_seconds = int(exp_timestamp - datetime.now(timezone.utc).timestamp())
|
||||
self.log.info(f"Caching token for {expiration_in_seconds / 60:.2f} minutes.")
|
||||
cache.set(data=cache_payload, expiration=expiration_in_seconds)
|
||||
else:
|
||||
self.log.warning("JWT has no 'exp' claim, caching for 1 hour as a fallback.")
|
||||
cache.set(data=cache_payload, expiration=3600)
|
||||
except Exception as e:
|
||||
self.log.error(f"Failed to decode JWT for caching: {e}. Caching for 1 hour as a fallback.")
|
||||
cache.set(data={"token": self._jwt, "username": credential.username}, expiration=3600)
|
||||
|
||||
def _fetch_user_details(self):
|
||||
if not self._user_id or str(self._user_id) == "0":
|
||||
if not self._subdomain:
|
||||
raise ValueError(
|
||||
"Cannot determine library domain."
|
||||
)
|
||||
self.log.info(f"Looking up institution by subdomain: {self._subdomain}")
|
||||
r = self.session.get(self.config["endpoints"]["institutions"].format(subdomain=self._subdomain))
|
||||
r.raise_for_status()
|
||||
inst = r.json()
|
||||
self._domain_id = str(inst["domainId"])
|
||||
self.log.info(f"Found library: {inst.get('sitename', self._subdomain)} (domain ID: {self._domain_id})")
|
||||
return
|
||||
|
||||
self.log.info("Fetching user library memberships...")
|
||||
r = self.session.get(self.config["endpoints"]["memberships"].format(user_id=self._user_id))
|
||||
r.raise_for_status()
|
||||
memberships = r.json()
|
||||
|
||||
for membership in memberships.get("list", []):
|
||||
if membership.get("status") == "active" and membership.get("isDefault", False):
|
||||
self._domain_id = str(membership["domainId"])
|
||||
self.log.info(f"Using default library: {membership.get('sitename', 'Unknown')} (ID: {self._domain_id})")
|
||||
return
|
||||
|
||||
for membership in memberships.get("list", []):
|
||||
if membership.get("status") == "active":
|
||||
self._domain_id = str(membership["domainId"])
|
||||
self.log.warning(f"No default library found. Using first active domain: {self._domain_id}")
|
||||
return
|
||||
|
||||
if memberships.get("list"):
|
||||
self._domain_id = str(memberships["list"][0]["domainId"])
|
||||
self.log.warning(f"No active library found. Using first available domain: {self._domain_id}")
|
||||
else:
|
||||
raise ValueError("No library memberships found for this user.")
|
||||
|
||||
def get_titles(self) -> Titles_T:
|
||||
if not self.content_id:
|
||||
raise ValueError("A content ID is required to get titles.")
|
||||
if not self._domain_id:
|
||||
raise ValueError("Domain ID not set.")
|
||||
|
||||
r = self.session.get(self.config["endpoints"]["video_info"].format(video_id=self.content_id, domain_id=self._domain_id))
|
||||
r.raise_for_status()
|
||||
content_data = r.json()
|
||||
|
||||
content_type = content_data.get("type")
|
||||
|
||||
def parse_lang(taxonomies_data: dict) -> Language:
|
||||
try:
|
||||
langs = taxonomies_data.get("languages", [])
|
||||
if langs:
|
||||
lang_name = langs[0].get("name")
|
||||
if lang_name:
|
||||
return Language.find(lang_name)
|
||||
except (IndexError, AttributeError, TypeError):
|
||||
pass
|
||||
return Language.get("en")
|
||||
|
||||
if content_type == "video":
|
||||
video_data = content_data["video"]
|
||||
return Movies([Movie(
|
||||
id_=str(video_data["videoId"]),
|
||||
service=self.__class__,
|
||||
name=video_data["title"],
|
||||
year=video_data.get("productionYear"),
|
||||
description=video_data.get("descriptionHtml", ""),
|
||||
language=parse_lang(video_data.get("taxonomies", {})),
|
||||
data=video_data,
|
||||
)])
|
||||
|
||||
elif content_type == "playlist":
|
||||
playlist_data = content_data.get("playlist")
|
||||
if not playlist_data:
|
||||
raise ValueError("Could not find 'playlist' data dictionary.")
|
||||
|
||||
series_title = playlist_data["title"]
|
||||
series_year = playlist_data.get("productionYear")
|
||||
|
||||
season_match = re.search(r'(?:Season|S)\s*(\d+)', series_title, re.IGNORECASE)
|
||||
season_num = int(season_match.group(1)) if season_match else 1
|
||||
|
||||
r_items = self.session.get(self.config["endpoints"]["video_items"].format(video_id=self.content_id, domain_id=self._domain_id))
|
||||
r_items.raise_for_status()
|
||||
items_data = r_items.json()
|
||||
|
||||
episodes = []
|
||||
for i, item in enumerate(items_data.get("list", [])):
|
||||
if item.get("type") != "video":
|
||||
continue
|
||||
video_data = item["video"]
|
||||
ep_num = i + 1
|
||||
ep_match = re.search(r'Ep(?:isode)?\.?\s*(\d+)', video_data.get("title", ""), re.IGNORECASE)
|
||||
if ep_match:
|
||||
ep_num = int(ep_match.group(1))
|
||||
episodes.append(Episode(
|
||||
id_=str(video_data["videoId"]),
|
||||
service=self.__class__,
|
||||
title=series_title,
|
||||
season=season_num,
|
||||
number=ep_num,
|
||||
name=video_data["title"],
|
||||
description=video_data.get("descriptionHtml", ""),
|
||||
year=video_data.get("productionYear", series_year),
|
||||
language=parse_lang(video_data.get("taxonomies", {})),
|
||||
data=video_data,
|
||||
))
|
||||
|
||||
series = Series(episodes)
|
||||
series.name = series_title
|
||||
series.description = playlist_data.get("descriptionHtml", "")
|
||||
series.year = series_year
|
||||
return series
|
||||
|
||||
elif content_type == "collection":
|
||||
collection_data = content_data.get("collection")
|
||||
if not collection_data:
|
||||
raise ValueError("Could not find 'collection' data dictionary.")
|
||||
|
||||
series_title_main = collection_data["title"]
|
||||
series_description_main = collection_data.get("descriptionHtml", "")
|
||||
series_year_main = collection_data.get("productionYear")
|
||||
|
||||
r_seasons = self.session.get(self.config["endpoints"]["video_items"].format(video_id=self.content_id, domain_id=self._domain_id))
|
||||
r_seasons.raise_for_status()
|
||||
seasons_data = r_seasons.json()
|
||||
|
||||
all_episodes = []
|
||||
self.log.info(f"Processing collection '{series_title_main}', found {len(seasons_data.get('list', []))} seasons.")
|
||||
|
||||
season_counter = 1
|
||||
for season_item in seasons_data.get("list", []):
|
||||
if season_item.get("type") != "playlist":
|
||||
self.log.warning(f"Skipping unexpected item of type '{season_item.get('type')}' in collection.")
|
||||
continue
|
||||
|
||||
season_playlist_data = season_item["playlist"]
|
||||
season_id = season_playlist_data["videoId"]
|
||||
season_title = season_playlist_data["title"]
|
||||
|
||||
self.log.info(f"Fetching episodes for season: {season_title}")
|
||||
|
||||
season_match = re.search(r'(?:Season|S)\s*(\d+)', season_title, re.IGNORECASE)
|
||||
if season_match:
|
||||
season_num = int(season_match.group(1))
|
||||
else:
|
||||
self.log.warning(f"Could not parse season number from '{season_title}'. Using sequential number {season_counter}.")
|
||||
season_num = season_counter
|
||||
season_counter += 1
|
||||
|
||||
r_episodes = self.session.get(self.config["endpoints"]["video_items"].format(video_id=season_id, domain_id=self._domain_id))
|
||||
r_episodes.raise_for_status()
|
||||
episodes_data = r_episodes.json()
|
||||
|
||||
for i, episode_item in enumerate(episodes_data.get("list", [])):
|
||||
if episode_item.get("type") != "video":
|
||||
continue
|
||||
video_data = episode_item["video"]
|
||||
ep_num = i + 1
|
||||
ep_match = re.search(r'Ep(?:isode)?\.?\s*(\d+)', video_data.get("title", ""), re.IGNORECASE)
|
||||
if ep_match:
|
||||
ep_num = int(ep_match.group(1))
|
||||
all_episodes.append(Episode(
|
||||
id_=str(video_data["videoId"]),
|
||||
service=self.__class__,
|
||||
title=series_title_main,
|
||||
season=season_num,
|
||||
number=ep_num,
|
||||
name=video_data["title"],
|
||||
description=video_data.get("descriptionHtml", ""),
|
||||
year=video_data.get("productionYear", series_year_main),
|
||||
language=parse_lang(video_data.get("taxonomies", {})),
|
||||
data=video_data,
|
||||
))
|
||||
|
||||
if not all_episodes:
|
||||
self.log.error(f"Collection '{series_title_main}' did not show any episodes.")
|
||||
return Series([])
|
||||
|
||||
series = Series(all_episodes)
|
||||
series.name = series_title_main
|
||||
series.description = series_description_main
|
||||
series.year = series_year_main
|
||||
return series
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unsupported content type: {content_type}")
|
||||
|
||||
def get_tracks(self, title: Title_T) -> Tracks:
|
||||
play_payload = {
|
||||
"videoId": int(title.id),
|
||||
"domainId": int(self._domain_id),
|
||||
"visitorId": self._visitor_id,
|
||||
}
|
||||
|
||||
self.session.headers.setdefault("authorization", f"Bearer {self._jwt}")
|
||||
self.session.headers.setdefault("x-version", self.API_VERSION)
|
||||
self.session.headers.setdefault("user-agent", self.USER_AGENT)
|
||||
|
||||
r = self.session.post(self.config["endpoints"]["plays"], json=play_payload)
|
||||
response_json = None
|
||||
try:
|
||||
response_json = r.json()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if r.status_code == 403:
|
||||
if response_json and response_json.get("errorSubcode") == "playRegionRestricted":
|
||||
self.log.error("This video is not available in your country.")
|
||||
raise PermissionError(
|
||||
"Playback blocked by region restriction."
|
||||
)
|
||||
else:
|
||||
self.log.error(f"Access forbidden. Response: {response_json}")
|
||||
raise PermissionError("Kanopy denied access to this video.")
|
||||
|
||||
r.raise_for_status()
|
||||
play_data = response_json or r.json()
|
||||
|
||||
manifest_url = None
|
||||
manifest_type = None
|
||||
drm_info = {}
|
||||
|
||||
for manifest in play_data.get("manifests", []):
|
||||
manifest_type_raw = manifest["manifestType"]
|
||||
url = manifest["url"].strip()
|
||||
|
||||
if url.startswith("/"):
|
||||
url = f"https://www.kanopy.com{url}"
|
||||
|
||||
drm_type = manifest.get("drmType")
|
||||
|
||||
if manifest_type_raw == "dash":
|
||||
manifest_url = url
|
||||
manifest_type = "dash"
|
||||
|
||||
if drm_type in ("kanopyDrm", "studioDrm"):
|
||||
license_id = manifest.get("drmLicenseID") or f"{play_data.get('playId')}-0"
|
||||
self.widevine_license_url = self.config["endpoints"]["widevine_license"].format(
|
||||
license_id=license_id
|
||||
)
|
||||
self.playready_license_url = self.config["endpoints"]["playready_license"].format(
|
||||
license_id=license_id
|
||||
)
|
||||
else:
|
||||
self.log.warning(f"Unknown DASH drmType: {drm_type}")
|
||||
self.widevine_license_url = None
|
||||
self.playready_license_url = None
|
||||
break
|
||||
|
||||
elif manifest_type_raw == "hls" and not manifest_url:
|
||||
manifest_url = url
|
||||
manifest_type = "hls"
|
||||
|
||||
if drm_type == "fairplay":
|
||||
self.log.warning("HLS with FairPlay DRM is not supported.")
|
||||
self.widevine_license_url = None
|
||||
drm_info["fairplay"] = True
|
||||
else:
|
||||
self.widevine_license_url = None
|
||||
drm_info["clear"] = True
|
||||
|
||||
if not manifest_url:
|
||||
raise ValueError("Could not find a DASH or HLS manifest for this title.")
|
||||
if manifest_type == "dash" and not self.widevine_license_url and not self.playready_license_url:
|
||||
raise ValueError("Could not construct a license URL for DASH manifest.")
|
||||
|
||||
self.log.info(f"Fetching {manifest_type.upper()} manifest from: {manifest_url}")
|
||||
r = self.session.get(manifest_url)
|
||||
r.raise_for_status()
|
||||
|
||||
if manifest_type == "dash":
|
||||
if not self.use_playready:
|
||||
import xml.etree.ElementTree as ET
|
||||
ET.register_namespace('', 'urn:mpeg:dash:schema:mpd:2011')
|
||||
ET.register_namespace('cenc', 'urn:mpeg:cenc:2013')
|
||||
ET.register_namespace('mspr', 'urn:microsoft:playready')
|
||||
root = ET.fromstring(r.text)
|
||||
for adaptation_set in root.findall('.//{urn:mpeg:dash:schema:mpd:2011}AdaptationSet'):
|
||||
for cp in list(adaptation_set.findall('{urn:mpeg:dash:schema:mpd:2011}ContentProtection')):
|
||||
if '9a04f079-9840-4286-ab92-e65be0885f95' in cp.get('schemeIdUri', ''):
|
||||
adaptation_set.remove(cp)
|
||||
mpd_text = ET.tostring(root, encoding='unicode')
|
||||
else:
|
||||
mpd_text = r.text
|
||||
tracks = DASH.from_text(mpd_text, url=manifest_url).to_tracks(language=title.language)
|
||||
elif manifest_type == "hls":
|
||||
try:
|
||||
from envied.core.manifests import HLS
|
||||
tracks = HLS.from_text(r.text, url=manifest_url).to_tracks(language=title.language)
|
||||
self.log.info("Successfully parsed HLS manifest")
|
||||
except ImportError:
|
||||
self.log.error(
|
||||
"HLS manifest parser not available in envied. "
|
||||
"Ensure your unshackle installation supports HLS."
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
self.log.error(f"Failed to parse HLS manifest: {e}")
|
||||
raise
|
||||
else:
|
||||
raise ValueError(f"Unsupported manifest type: {manifest_type}")
|
||||
|
||||
self.session.headers.update({
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Origin": "https://www.kanopy.com",
|
||||
"Referer": "https://www.kanopy.com/",
|
||||
})
|
||||
self.session.headers.pop("x-version", None)
|
||||
self.session.headers.pop("authorization", None)
|
||||
|
||||
for caption_data in play_data.get("captions", []):
|
||||
lang = caption_data.get("language", "en")
|
||||
label = caption_data.get("label", lang)
|
||||
|
||||
slug = label.lower()
|
||||
slug = re.sub(r'[\s\[\]\(\)]+', '-', slug)
|
||||
slug = re.sub(r'[^a-z0-9-]', '', slug)
|
||||
slug = slug.strip('-')
|
||||
|
||||
track_id = f"caption-{lang}-{slug}"
|
||||
|
||||
for file_info in caption_data.get("files", []):
|
||||
if file_info.get("type") == "webvtt":
|
||||
tracks.add(Subtitle(
|
||||
id_=track_id,
|
||||
name=label,
|
||||
url=file_info["url"].strip(),
|
||||
codec=Subtitle.Codec.WebVTT,
|
||||
language=Language.get(lang),
|
||||
))
|
||||
break
|
||||
|
||||
return tracks
|
||||
|
||||
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes:
|
||||
if not self.widevine_license_url:
|
||||
raise ValueError("Widevine license URL was not set.")
|
||||
|
||||
r = self.session.post(
|
||||
self.widevine_license_url,
|
||||
data=challenge,
|
||||
headers={
|
||||
"Content-Type": "application/octet-stream",
|
||||
"User-Agent": self.WIDEVINE_UA,
|
||||
"Authorization": f"Bearer {self._jwt}",
|
||||
"X-Version": self.API_VERSION,
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.content
|
||||
|
||||
def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes:
|
||||
if not self.playready_license_url:
|
||||
raise ValueError("PlayReady license URL was not set.")
|
||||
|
||||
self.log.info(f"Requesting PlayReady license from: {self.playready_license_url}")
|
||||
r = self.session.post(
|
||||
self.playready_license_url,
|
||||
data=challenge,
|
||||
headers={
|
||||
"Content-Type": "text/xml; charset=utf-8",
|
||||
"User-Agent": self.WIDEVINE_UA,
|
||||
"Authorization": f"Bearer {self._jwt}",
|
||||
"X-Version": self.API_VERSION,
|
||||
},
|
||||
)
|
||||
self.log.info(f"PlayReady license response: HTTP {r.status_code}")
|
||||
if not r.ok:
|
||||
self.log.error(f"PlayReady license error body: {r.text[:500]}")
|
||||
r.raise_for_status()
|
||||
return r.content
|
||||
|
||||
def search(self) -> Generator[SearchResult, None, None]:
|
||||
if not hasattr(self, 'search_query') or not self.search_query:
|
||||
self.log.error("Search query not set.")
|
||||
return
|
||||
|
||||
self.log.info(f"Searching for '{self.search_query}'...")
|
||||
|
||||
if not self._domain_id:
|
||||
self._fetch_user_details()
|
||||
|
||||
params = {
|
||||
"query": self.search_query,
|
||||
"sort": "relevance",
|
||||
"domainId": self._domain_id,
|
||||
"isKids": "false",
|
||||
"page": 0,
|
||||
"perPage": 40,
|
||||
}
|
||||
|
||||
r = self.session.get(self.config["endpoints"]["search"], params=params)
|
||||
r.raise_for_status()
|
||||
search_data = r.json()
|
||||
|
||||
results_list = search_data.get("list", [])
|
||||
|
||||
if not results_list:
|
||||
self.log.warning(f"No results found for '{self.search_query}'")
|
||||
return
|
||||
|
||||
for item in results_list:
|
||||
video_id = item.get("videoId")
|
||||
if not video_id:
|
||||
continue
|
||||
title = item.get("title", "Unknown Title")
|
||||
yield SearchResult(
|
||||
id_=str(video_id),
|
||||
title=title,
|
||||
label="VIDEO/SERIES",
|
||||
url=f"https://www.kanopy.com/video/{video_id}",
|
||||
)
|
||||
|
||||
def get_chapters(self, title: Title_T) -> list:
|
||||
return []
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
client:
|
||||
api_version: "Android/com.kanopy/6.21.0/952 (SM-A525F; Android 15)"
|
||||
user_agent: "okhttp/5.2.1"
|
||||
widevine_ua: "KanopyApplication/6.21.0 (Linux;Android 15) AndroidXMedia3/1.8.0"
|
||||
|
||||
endpoints:
|
||||
handshake: "https://www.kanopy.com/kapi/handshake"
|
||||
login: "https://www.kanopy.com/kapi/login"
|
||||
memberships: "https://www.kanopy.com/kapi/memberships?userId={user_id}"
|
||||
institutions: "https://www.kanopy.com/kapi/institutions/alias/{subdomain}"
|
||||
video_info: "https://www.kanopy.com/kapi/videos/{video_id}?domainId={domain_id}"
|
||||
video_items: "https://www.kanopy.com/kapi/videos/{video_id}/items?domainId={domain_id}"
|
||||
search: "https://www.kanopy.com/kapi/search/videos"
|
||||
plays: "https://www.kanopy.com/kapi/plays"
|
||||
widevine_license: "https://www.kanopy.com/kapi/licenses/widevine/{license_id}"
|
||||
playready_license: "https://www.kanopy.com/kapi/licenses/playready/{license_id}"
|
||||
+2
-3
@@ -16,8 +16,7 @@ from envied.core.tracks import Chapters, Tracks
|
||||
class NFBC(Service):
|
||||
"""
|
||||
Service code for National Film Board of Canada (https://www.nfb.ca)
|
||||
|
||||
Author: n0stal6ic
|
||||
www.nostalgic.cc
|
||||
Authorization: None
|
||||
Geofence: CA, US
|
||||
"""
|
||||
@@ -139,4 +138,4 @@ class NFBC(Service):
|
||||
return tracks
|
||||
|
||||
def get_chapters(self, title: Title_T) -> Chapters:
|
||||
return Chapters()
|
||||
return Chapters()
|
||||
+48
-4
@@ -18,8 +18,7 @@ from envied.core.tracks import Chapters, Tracks
|
||||
class PBS(Service):
|
||||
"""
|
||||
Service code for PBS (https://www.pbs.org)
|
||||
|
||||
Author: n0stal6ic
|
||||
www.nostalgic.cc
|
||||
Authorization: Cookies
|
||||
Geofence: US
|
||||
"""
|
||||
@@ -100,8 +99,53 @@ class PBS(Service):
|
||||
|
||||
m3u8_url = self._resolve_encoding(encodings[0])
|
||||
self.log.debug(f"HLS master: {m3u8_url}")
|
||||
tracks = HLS.from_url(url=m3u8_url, session=self.session).to_tracks(language=title.language)
|
||||
kept_subs = []
|
||||
for sub in tracks.subtitles:
|
||||
if self._subtitle_has_cues(sub):
|
||||
kept_subs.append(sub)
|
||||
else:
|
||||
self.log.warning(
|
||||
f" - Dropping empty subtitle track ({sub.language}{' SDH' if sub.sdh else ''})."
|
||||
)
|
||||
tracks.subtitles = kept_subs
|
||||
|
||||
return HLS.from_url(url=m3u8_url, session=self.session).to_tracks(language=title.language)
|
||||
return tracks
|
||||
|
||||
def _subtitle_has_cues(self, sub: Any) -> bool:
|
||||
from urllib.parse import urljoin
|
||||
|
||||
url = sub.url[0] if isinstance(sub.url, list) and sub.url else sub.url
|
||||
if not isinstance(url, str):
|
||||
return True
|
||||
try:
|
||||
resp = self.session.get(url, timeout=15)
|
||||
if not resp.ok:
|
||||
return True
|
||||
text = resp.text
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
if "-->" in text:
|
||||
return True
|
||||
if "#EXTM3U" not in text:
|
||||
return False
|
||||
|
||||
checked = 0
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
try:
|
||||
seg = self.session.get(urljoin(url, line), timeout=15)
|
||||
except Exception:
|
||||
return True
|
||||
if seg.ok and "-->" in seg.text:
|
||||
return True
|
||||
checked += 1
|
||||
if checked >= 40:
|
||||
break
|
||||
return False
|
||||
|
||||
def get_chapters(self, title: Title_T) -> Chapters:
|
||||
return Chapters()
|
||||
@@ -337,4 +381,4 @@ class PBS(Service):
|
||||
try:
|
||||
return int(date_str[:4])
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return None
|
||||
+43
-27
@@ -22,8 +22,7 @@ from envied.core.tracks import Chapters, Tracks, Video
|
||||
class PCOK(Service):
|
||||
"""
|
||||
Service code for Peacock TV (https://peacocktv.com)
|
||||
|
||||
Author: n0stal6ic
|
||||
www.nostalgic.cc
|
||||
Authorization: Cookies, Credentials
|
||||
Geofence: US
|
||||
"""
|
||||
@@ -35,6 +34,8 @@ class PCOK(Service):
|
||||
r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset)?(?P<id>/tv/[a-z0-9_./-]+/[a-f0-9-]{36})",
|
||||
r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset)?(?P<id>/tv/[a-z0-9_./-]+/\d+)",
|
||||
r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset)?(?P<id>/news/[a-z0-9_./-]+/[a-f0-9-]{36})",
|
||||
r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset)?(?P<id>/sports/[a-z0-9_./-]+/[a-f0-9-]{36})",
|
||||
r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset)?(?P<id>/sports/[a-z0-9_./-]+/\d+)",
|
||||
r"(?:https?://(?:www\.)?peacocktv\.com/watch/asset)?(?P<id>/-/[a-z0-9_./-]+/\d+)",
|
||||
r"(?:https?://(?:www\.)?peacocktv\.com/stream-tv/)?(?P<id>[a-z0-9-]+)$",
|
||||
]
|
||||
@@ -42,30 +43,29 @@ class PCOK(Service):
|
||||
@staticmethod
|
||||
@click.command(name="PCOK", short_help="https://peacocktv.com")
|
||||
@click.argument("title", type=str)
|
||||
@click.option("-m", "--movie", is_flag=True, default=False, help="Title is a movie.")
|
||||
@click.pass_context
|
||||
def cli(ctx, **kwargs):
|
||||
return PCOK(ctx, **kwargs)
|
||||
|
||||
def __init__(self, ctx, title: str, movie: bool):
|
||||
def __init__(self, ctx, title: str):
|
||||
super().__init__(ctx)
|
||||
|
||||
self.title = title
|
||||
self.movie = movie
|
||||
self.movie = False
|
||||
|
||||
range_param = ctx.parent.params.get("range_")
|
||||
self.range = range_param[0].name if range_param else "SDR"
|
||||
|
||||
vcodec_param = ctx.parent.params.get("vcodec")
|
||||
self.vcodec = vcodec_param if vcodec_param else "H264"
|
||||
self.vcodec = vcodec_param[0] if vcodec_param else "h264"
|
||||
|
||||
self.profile_name = ctx.parent.params.get("profile") or "default"
|
||||
|
||||
prof_key = self.config["client"].get("profile", "tv")
|
||||
self.prof_key = self.config["client"].get("profile", "tv")
|
||||
profiles = self.config.get("profiles", {})
|
||||
if prof_key not in profiles:
|
||||
raise ValueError(f"Unknown device profile {prof_key!r}. Valid: {list(profiles)}")
|
||||
self.prof = profiles[prof_key]
|
||||
if self.prof_key not in profiles:
|
||||
raise ValueError(f"Unknown device profile {self.prof_key!r}. Valid: {list(profiles)}")
|
||||
self.prof = profiles[self.prof_key]
|
||||
self.hmac_key: bytes = self.prof["hmac_key"].encode()
|
||||
|
||||
try:
|
||||
@@ -75,7 +75,6 @@ class PCOK(Service):
|
||||
self.use_playready = False
|
||||
|
||||
self.tokens: Optional[dict] = None
|
||||
self.license_url: Optional[str] = None
|
||||
|
||||
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
||||
super().authenticate(cookies, credential)
|
||||
@@ -113,9 +112,9 @@ class PCOK(Service):
|
||||
.get("categoryErrors", [{}])[0]
|
||||
.get("code", "unknown")
|
||||
)
|
||||
raise EnvironmentError(f"Login failed: {code}")
|
||||
except (ValueError, KeyError, IndexError):
|
||||
raise EnvironmentError(f"Login failed with HTTP {r.status_code}.")
|
||||
code = f"HTTP {r.status_code}"
|
||||
raise EnvironmentError(f"Login failed: {code}")
|
||||
|
||||
def _sky_headers(self, extra: Optional[dict] = None) -> dict:
|
||||
h = {
|
||||
@@ -165,8 +164,7 @@ class PCOK(Service):
|
||||
return f'SkyOTT client="{sdk}",signature="{sig}",timestamp="{ts}",version="1.0"'
|
||||
|
||||
def _get_tokens(self) -> dict:
|
||||
prof_key = self.config["client"].get("profile", "tv")
|
||||
cache_key = f"tokens_{self.profile_name}_{prof_key}"
|
||||
cache_key = f"tokens_{self.profile_name}_{self.prof_key}"
|
||||
cache = self.cache.get(cache_key)
|
||||
|
||||
if cache and cache.data:
|
||||
@@ -301,6 +299,7 @@ class PCOK(Service):
|
||||
name=res["attributes"]["title"],
|
||||
year=res["attributes"].get("year"),
|
||||
data=res,
|
||||
description=res["attributes"].get("synopsis"),
|
||||
)
|
||||
])
|
||||
|
||||
@@ -320,6 +319,7 @@ class PCOK(Service):
|
||||
name=ep["attributes"].get("title"),
|
||||
year=ep["attributes"].get("year"),
|
||||
data=ep,
|
||||
description=ep["attributes"].get("synopsis"),
|
||||
)
|
||||
for ep in episodes
|
||||
])
|
||||
@@ -328,7 +328,7 @@ class PCOK(Service):
|
||||
attrs = title.data["attributes"]
|
||||
formats = attrs.get("formats", {})
|
||||
|
||||
want_uhd = self.vcodec == "H265"
|
||||
want_uhd = self.vcodec.lower() in ("hevc", "h.265")
|
||||
if want_uhd and "UHD" in formats:
|
||||
content_id = formats["UHD"]["contentId"]
|
||||
elif "HD" in formats:
|
||||
@@ -353,11 +353,21 @@ class PCOK(Service):
|
||||
"container": "ISOBMFF",
|
||||
"transport": "DASH",
|
||||
"acodec": "AAC",
|
||||
"vcodec": self.vcodec,
|
||||
"vcodec": "H265" if want_uhd else "H264",
|
||||
}
|
||||
]
|
||||
|
||||
sky_h = self._sky_headers({"X-SkyOTT-UserToken": self.tokens["userToken"]})
|
||||
sky_h = {
|
||||
"X-SkyOTT-Agent": ".".join([
|
||||
self.config["client"]["proposition"],
|
||||
self.prof["device"],
|
||||
self.prof["platform"],
|
||||
]).lower(),
|
||||
"X-SkyOTT-PinOverride": "false",
|
||||
"X-SkyOTT-Provider": self.config["client"]["provider"],
|
||||
"X-SkyOTT-Territory": self.config["client"]["territory"],
|
||||
"X-SkyOTT-UserToken": self.tokens["userToken"],
|
||||
}
|
||||
body = json.dumps(
|
||||
{
|
||||
"device": {
|
||||
@@ -393,7 +403,7 @@ class PCOK(Service):
|
||||
f"Playout error: {manifest.get('description', 'unknown')} [{manifest['errorCode']}]"
|
||||
)
|
||||
|
||||
self.license_url = manifest["protection"]["licenceAcquisitionUrl"]
|
||||
license_url = manifest["protection"]["licenceAcquisitionUrl"]
|
||||
|
||||
endpoints = manifest["asset"]["endpoints"]
|
||||
dash_url = next(
|
||||
@@ -415,17 +425,23 @@ class PCOK(Service):
|
||||
|
||||
for audio in tracks.audio:
|
||||
if audio.language.territory == "AD":
|
||||
audio.language.territory = None
|
||||
audio.descriptive = True
|
||||
audio.language = Language.make(language=audio.language.language)
|
||||
audio.name = None
|
||||
|
||||
for track in tracks:
|
||||
track.data["license_url"] = license_url
|
||||
|
||||
return tracks
|
||||
|
||||
def get_chapters(self, title: Title_T) -> Chapters:
|
||||
return Chapters()
|
||||
|
||||
def _license_request(self, challenge: bytes) -> bytes:
|
||||
path = urlparse(self.license_url).path
|
||||
def _license_request(self, challenge: bytes, track: AnyTrack) -> bytes:
|
||||
license_url = track.data["license_url"]
|
||||
path = urlparse(license_url).path
|
||||
r = self.session.post(
|
||||
url=self.license_url,
|
||||
url=license_url,
|
||||
data=challenge,
|
||||
headers={
|
||||
"X-Sky-Signature": self._sign("POST", path, {}, challenge),
|
||||
@@ -435,11 +451,11 @@ class PCOK(Service):
|
||||
return r.content
|
||||
|
||||
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]:
|
||||
if not self.license_url:
|
||||
if not track.data.get("license_url"):
|
||||
return None
|
||||
return self._license_request(challenge)
|
||||
return self._license_request(challenge, track)
|
||||
|
||||
def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]:
|
||||
if not self.license_url:
|
||||
if not track.data.get("license_url"):
|
||||
return None
|
||||
return self._license_request(challenge)
|
||||
return self._license_request(challenge, track)
|
||||
+8
-3
@@ -27,7 +27,12 @@ client:
|
||||
endpoints:
|
||||
login: https://rango.id.peacocktv.com/signin/service/international
|
||||
personas: https://persona.id.peacocktv.com/persona-store/personas
|
||||
tokens: https://ovp.peacocktv.com/auth/tokens
|
||||
me: https://ovp.peacocktv.com/auth/users/me
|
||||
tokens: https://play.ovp.peacocktv.com/auth/tokens
|
||||
me: https://play.ovp.peacocktv.com/auth/users/me
|
||||
node: https://atom.peacocktv.com/adapter-calypso/v3/query/node
|
||||
vod: https://ovp.peacocktv.com/video/playouts/vod
|
||||
vod: https://play.ovp.peacocktv.com/video/playouts/vod
|
||||
|
||||
legacy_endpoints:
|
||||
tokens: https://ovp.peacocktv.com/auth/tokens
|
||||
me: https://ovp.peacocktv.com/auth/users/me
|
||||
vod: https://ovp.peacocktv.com/video/playouts/vod
|
||||
+757
@@ -0,0 +1,757 @@
|
||||
from __future__ import annotations
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from http.cookiejar import CookieJar
|
||||
from typing import Any, Iterable, Optional, Union
|
||||
import click
|
||||
from envied.core.constants import AnyTrack
|
||||
from envied.core.credential import Credential
|
||||
from envied.core.manifests import DASH
|
||||
from envied.core.service import Service
|
||||
from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
|
||||
from envied.core.tracks import Chapter, Chapters, Tracks
|
||||
|
||||
|
||||
class PHLO(Service):
|
||||
"""
|
||||
Service code for Philo (https://www.philo.com).
|
||||
www.nostalgic.cc
|
||||
Authorization: Cookies
|
||||
Security: FHD@L3
|
||||
"""
|
||||
|
||||
ALIASES = ("PHLO", "philo")
|
||||
|
||||
TITLE_RE = (
|
||||
r"^(?:https?://(?:www\.)?philo\.com/(?:[^?#]+/)?)?"
|
||||
r"(?P<id>[A-Za-z0-9_\-=]{10,})(?:[/?#].*)?$"
|
||||
)
|
||||
|
||||
LANGUAGE = "en"
|
||||
|
||||
CAPABILITIES = [
|
||||
"COLLECTION_TILE_GROUPS", "HERO_PROMOTION", "MOVIE_SHOWINGS", "GUIDE_FILTERS",
|
||||
"SEARCH_PAGE_RECS", "UNIFIED_SHOWS_MOVIES_SEARCH_RESULTS", "EXTERNAL_CONTENT",
|
||||
"SHOW_PAGE_V2", "COLLECTION_GROUPS", "OUT_OF_PLAN_CONTENT", "CHANNEL_TILE_GROUPS_V2",
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
@click.command(name="PHLO", short_help="https://www.philo.com", help=__doc__)
|
||||
@click.argument("title", type=str)
|
||||
@click.option("--no-ads", is_flag=True, default=False,
|
||||
help="Skip the ad-break chapter markers.")
|
||||
@click.option("--single", is_flag=True, default=False,
|
||||
help="Only take the single title the URL points at.")
|
||||
@click.pass_context
|
||||
def cli(ctx, **kwargs):
|
||||
return PHLO(ctx, **kwargs)
|
||||
|
||||
def __init__(self, ctx, title: str, no_ads: bool, single: bool):
|
||||
super().__init__(ctx)
|
||||
self.title = title
|
||||
self.no_ads = no_ads
|
||||
self.single = single
|
||||
|
||||
if not self.config:
|
||||
self.log.error(" - config.yaml is missing or empty")
|
||||
raise SystemExit(1)
|
||||
|
||||
self.timeout = self.config.get("request_timeout") or 30
|
||||
self.ccextract = bool(int(self.config.get("ccextract") or 0))
|
||||
self.player_id: Optional[str] = None
|
||||
self.session_data: dict = {}
|
||||
self._session_cache: dict[str, dict] = {}
|
||||
self._manifest_cache: dict[str, Any] = {}
|
||||
|
||||
@property
|
||||
def player_path(self):
|
||||
return self.cache_dir / "player.json"
|
||||
|
||||
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
||||
super().authenticate(cookies, credential)
|
||||
if not cookies:
|
||||
self.log.error(" - Philo needs browser cookies.")
|
||||
raise SystemExit(1)
|
||||
|
||||
self.session.headers.update(self.config.get("headers") or {})
|
||||
|
||||
user = self.session.get(self.config["endpoints"]["user"], timeout=self.timeout)
|
||||
if user.status_code != 200:
|
||||
self.log.error(f" - Could not read the Philo Account (HTTP {user.status_code}). "
|
||||
"The cookies may be expired.")
|
||||
raise SystemExit(1)
|
||||
|
||||
subscription = self._graphql("userSubscription", {})
|
||||
if subscription:
|
||||
has_access = subscription.get("hasContentAccess")
|
||||
self.log.info(f" + Subscription: {subscription.get('state', 'Unknown')} "
|
||||
f"(Access: {'Yes' if has_access else 'No'})")
|
||||
if has_access is False:
|
||||
self.log.warning(" - This account has no content access.")
|
||||
|
||||
self.player_id = self._register_player()
|
||||
self.log.info(" + Authenticated with Philo")
|
||||
|
||||
def _register_player(self) -> str:
|
||||
cached = {}
|
||||
if self.player_path.exists():
|
||||
try:
|
||||
cached = json.loads(self.player_path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
self.log.debug(f"Could not read cached player: {e}")
|
||||
|
||||
device_ident = cached.get("deviceIdent") or str(uuid.uuid4())
|
||||
|
||||
profile = dict(self.config.get("player") or {})
|
||||
profile["deviceIdent"] = device_ident
|
||||
|
||||
data = self._graphql("registerPlayerV2", profile)
|
||||
player = (data or {}).get("player") or {}
|
||||
player_id = player.get("id")
|
||||
if not player_id:
|
||||
self.log.error(f" - registerPlayerV2 returned no player id: {json.dumps(data)[:300]}")
|
||||
raise SystemExit(1)
|
||||
|
||||
try:
|
||||
self.player_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.player_path.write_text(
|
||||
json.dumps({"deviceIdent": device_ident, "playerId": player_id}, indent=2),
|
||||
encoding="utf-8")
|
||||
except Exception as e:
|
||||
self.log.debug(f"Could not cache player: {e}")
|
||||
|
||||
return player_id
|
||||
|
||||
def _graphql(self, operation: str, variables: dict, data_key: Optional[str] = None,
|
||||
soft: bool = False) -> Optional[dict]:
|
||||
def fail(message: str) -> Optional[dict]:
|
||||
if soft:
|
||||
self.log.debug(f"{operation}: {message}")
|
||||
return None
|
||||
self.log.error(f" - {message}")
|
||||
raise SystemExit(1)
|
||||
|
||||
queries = self.config.get("persisted_queries") or {}
|
||||
sha = queries.get(operation)
|
||||
if not sha:
|
||||
return fail(f"config.yaml has no persisted_queries.{operation}")
|
||||
|
||||
res = self.session.post(
|
||||
self.config["endpoints"]["graphql"],
|
||||
json=[{
|
||||
"operationName": operation,
|
||||
"variables": variables,
|
||||
"extensions": {"persistedQuery": {"version": 1, "sha256Hash": sha}},
|
||||
}],
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if res.status_code != 200:
|
||||
return fail(f"GraphQL {operation} failed: HTTP {res.status_code} {res.text[:200]}")
|
||||
|
||||
try:
|
||||
payload = res.json()
|
||||
except Exception as e:
|
||||
return fail(f"GraphQL {operation} returned non-JSON: {e}")
|
||||
|
||||
entry = (payload[0] if isinstance(payload, list) and payload else payload) or {}
|
||||
|
||||
for error in entry.get("errors") or []:
|
||||
code = ((error.get("extensions") or {}).get("code") or "").upper()
|
||||
if code == "PERSISTED_QUERY_NOT_FOUND":
|
||||
return fail(f"Philo no longer recognises the {operation} query hash. ")
|
||||
return fail(f"GraphQL {operation} error: {error.get('message') or error}")
|
||||
|
||||
return (entry.get("data") or {}).get(data_key or operation)
|
||||
|
||||
_DECODED_ID = re.compile(r"^(?P<kind>[A-Z][A-Za-z0-9]{1,30}):(?P<value>[A-Za-z0-9_.:+/=-]+)$")
|
||||
|
||||
@classmethod
|
||||
def _extract_id(cls, title: str) -> Optional[str]:
|
||||
text = (title or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
for part in reversed([p for p in re.split(r"[/\\]", text.split("#")[0].split("?")[0]) if p]):
|
||||
if cls._decode_kind(part):
|
||||
return part
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _decode_kind(cls, node_id: str) -> Optional[str]:
|
||||
if not re.fullmatch(r"[A-Za-z0-9_\-=]{10,}", node_id or ""):
|
||||
return None
|
||||
match = cls._DECODED_ID.match(cls._decode_node_id(node_id))
|
||||
return match.group("kind") if match else None
|
||||
|
||||
@classmethod
|
||||
def _decode_value(cls, node_id: str) -> Optional[str]:
|
||||
match = cls._DECODED_ID.match(cls._decode_node_id(node_id))
|
||||
return match.group("value") if match else None
|
||||
|
||||
@staticmethod
|
||||
def _decode_node_id(node_id: str) -> str:
|
||||
try:
|
||||
padded = node_id + "=" * (-len(node_id) % 4)
|
||||
return base64.urlsafe_b64decode(padded.encode()).decode("utf-8", errors="ignore")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _first_int(*values: Any) -> Optional[int]:
|
||||
for value in values:
|
||||
if value is None:
|
||||
continue
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
def _playback_session(self, title_id: str) -> dict:
|
||||
if title_id in self._session_cache:
|
||||
return self._session_cache[title_id]
|
||||
|
||||
data = self._graphql("createPlaybackSessionV2", {
|
||||
"id": title_id,
|
||||
"playerId": self.player_id,
|
||||
"idfa": None,
|
||||
"lat": None,
|
||||
"givn": None,
|
||||
"tileGroupId": None,
|
||||
"broadcastAt": None,
|
||||
"startAtOverride": None,
|
||||
"isPreload": False,
|
||||
})
|
||||
if not data:
|
||||
self.log.error(f" - No playback session for {title_id}. The title may not be in "
|
||||
"your plan, or the ID is wrong.")
|
||||
raise SystemExit(1)
|
||||
|
||||
self._session_cache[title_id] = data
|
||||
return data
|
||||
|
||||
def get_titles(self) -> Titles_T:
|
||||
title_id = self._extract_id(self.title)
|
||||
if not title_id:
|
||||
self.log.error(" - Could not find a Philo title ID in that URL. "
|
||||
"Expected a player link or a Bare ID.")
|
||||
raise SystemExit(1)
|
||||
|
||||
kind = self._decode_kind(title_id) or "?"
|
||||
self.log.debug(f" + Title ID {title_id} ({kind})")
|
||||
|
||||
if kind == "Show" and not self.single:
|
||||
titles = self._show_titles(title_id)
|
||||
if titles:
|
||||
return titles
|
||||
self.log.debug("Falling back to the playback session for this Show ID.")
|
||||
|
||||
return self._single_title(title_id)
|
||||
|
||||
def _single_title(self, title_id: str) -> Titles_T:
|
||||
session = self._playback_session(title_id)
|
||||
node = session.get("node") or {}
|
||||
holder = self._presentation(node)
|
||||
|
||||
show = holder.get("show") or {}
|
||||
episode = holder.get("episode") or {}
|
||||
|
||||
name = show.get("title") or holder.get("title") or "Unknown"
|
||||
description = show.get("longDescription") or show.get("shortDescription")
|
||||
year = self._year(show)
|
||||
show_type = (show.get("type") or "").upper()
|
||||
|
||||
if show_type == "MOVIE" or (not show_type and not episode):
|
||||
return Movies([
|
||||
Movie(
|
||||
id_=title_id,
|
||||
service=self.__class__,
|
||||
name=name,
|
||||
year=year,
|
||||
description=description,
|
||||
language=self.LANGUAGE,
|
||||
data={"session": session},
|
||||
)
|
||||
])
|
||||
|
||||
season = self._first_int(episode.get("seasonNum"), episode.get("seasonNumber"), episode.get("season"))
|
||||
number = self._first_int(episode.get("episodeNum"), episode.get("episodeNumber"), episode.get("number"))
|
||||
air_date = episode.get("originalAirDate") or holder.get("startsAt")
|
||||
|
||||
return Series([
|
||||
Episode(
|
||||
id_=title_id,
|
||||
service=self.__class__,
|
||||
title=name,
|
||||
season=season or 0,
|
||||
number=number or 0,
|
||||
name=episode.get("subtitle") or episode.get("title") or episode.get("name"),
|
||||
description=episode.get("longDescription") or description,
|
||||
year=year,
|
||||
air_date=air_date if number is None else None,
|
||||
language=self.LANGUAGE,
|
||||
data={"session": session},
|
||||
)
|
||||
])
|
||||
|
||||
@staticmethod
|
||||
def _presentation(node: dict) -> dict:
|
||||
current = node or {}
|
||||
for _ in range(4):
|
||||
if current.get("show") or current.get("episode"):
|
||||
return current
|
||||
for key in ("broadcast", "recording", "vod", "presentation", "node"):
|
||||
nested = current.get(key)
|
||||
if isinstance(nested, dict):
|
||||
current = nested
|
||||
break
|
||||
else:
|
||||
break
|
||||
return current or {}
|
||||
|
||||
@staticmethod
|
||||
def _year(show: dict) -> Optional[int]:
|
||||
for key in ("movieReleaseYear", "releaseYear", "year", "originalAirDate"):
|
||||
value = show.get(key)
|
||||
if not value:
|
||||
continue
|
||||
match = re.search(r"(\d{4})", str(value))
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
_SEASON_FILTER = re.compile(r"^SeasonPageFilter:(?P<show>[^:]+):(?P<season>-?\d+)$")
|
||||
|
||||
def _show_titles(self, show_id: str) -> Optional[Titles_T]:
|
||||
page = self._graphql("page", {
|
||||
"pageType": "SHOW",
|
||||
"typeId": show_id,
|
||||
"filterId": None,
|
||||
"filter": None,
|
||||
"sorterId": None,
|
||||
"endCursor": None,
|
||||
"startCursor": None,
|
||||
"firstGroups": 5,
|
||||
"initialTiles": 12,
|
||||
"lastGroups": None,
|
||||
"numSparseGroups": None,
|
||||
"includeTileChannel": False,
|
||||
"iconFormat": "SVG",
|
||||
"capabilities": self.CAPABILITIES,
|
||||
"startTime": None,
|
||||
"endTime": None,
|
||||
}, soft=True)
|
||||
if not page:
|
||||
return None
|
||||
|
||||
tile = page.get("tile") or {}
|
||||
show = tile.get("show") or {}
|
||||
show_type = (show.get("type") or "").upper()
|
||||
name = page.get("title") or tile.get("title") or show.get("title") or "Unknown"
|
||||
description = (page.get("node") or {}).get("longDescription") or tile.get("longDescription")
|
||||
year = self._year(show)
|
||||
|
||||
if show_type == "MOVIE":
|
||||
return Movies([
|
||||
Movie(
|
||||
id_=tile.get("playableAssetId") or show_id,
|
||||
service=self.__class__,
|
||||
name=name,
|
||||
year=year,
|
||||
description=description,
|
||||
language=self.LANGUAGE,
|
||||
)
|
||||
])
|
||||
|
||||
episodes = self._season_episodes(show_id, page, name, year, description)
|
||||
if not episodes:
|
||||
return None
|
||||
|
||||
seasons = sorted({episode.season for episode in episodes})
|
||||
self.log.info(f" + {len(episodes)} episodes across {len(seasons)} "
|
||||
f"season{'s'[:len(seasons) ^ 1]}")
|
||||
return Series(episodes)
|
||||
|
||||
def _season_episodes(self, show_id: str, page: dict, name: str, year: Optional[int],
|
||||
description: Optional[str]) -> list[Episode]:
|
||||
show_value = self._decode_value(show_id)
|
||||
episodes: list[Episode] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
seasons: list[int] = []
|
||||
for season_filter in page.get("filters") or []:
|
||||
match = self._SEASON_FILTER.match(self._decode_node_id(season_filter.get("id") or ""))
|
||||
if not match:
|
||||
continue
|
||||
season = int(match.group("season"))
|
||||
if season < 0 or not season_filter.get("hasPlayablePresentations"):
|
||||
continue
|
||||
show_value = show_value or match.group("show")
|
||||
seasons.append(season)
|
||||
|
||||
for season in sorted(seasons):
|
||||
if not show_value:
|
||||
break
|
||||
group = self._graphql("tileGroup", {
|
||||
"tileGroupId": self._season_tile_group_id(show_value, season),
|
||||
"initialTiles": 100,
|
||||
"includeTileChannel": False,
|
||||
"iconFormat": "SVG",
|
||||
"startTime": None,
|
||||
"endTime": None,
|
||||
}, data_key="node", soft=True)
|
||||
tiles = self._all_tiles(group)
|
||||
if not tiles:
|
||||
self.log.debug(f"Season {season} returned no tiles.")
|
||||
continue
|
||||
episodes += self._episodes_from_tiles(tiles, name, year, description, seen)
|
||||
|
||||
if not episodes:
|
||||
for edge in ((page.get("groups") or {}).get("edges") or []):
|
||||
node = edge.get("node") or {}
|
||||
if (node.get("type") or "").upper() != "PLAYABLE":
|
||||
continue
|
||||
if "Season" not in self._tile_group_name(node.get("id") or ""):
|
||||
continue
|
||||
episodes += self._episodes_from_tiles(
|
||||
self._all_tiles(node), name, year, description, seen)
|
||||
|
||||
return episodes
|
||||
|
||||
def _all_tiles(self, group: Optional[dict]) -> list[dict]:
|
||||
tiles = (group or {}).get("tiles") or {}
|
||||
nodes = [edge.get("node") or {} for edge in (tiles.get("edges") or [])]
|
||||
|
||||
page_info = tiles.get("pageInfo") or {}
|
||||
cursor = page_info.get("endCursor")
|
||||
while page_info.get("hasNextPage") and cursor:
|
||||
more = self._graphql("tiles", {
|
||||
"endCursor": cursor,
|
||||
"startCursor": None,
|
||||
"first": 50,
|
||||
"last": None,
|
||||
"initialCursor": None,
|
||||
"includeTileDescription": True,
|
||||
"iconFormat": "SVG",
|
||||
}, soft=True)
|
||||
if not more:
|
||||
break
|
||||
nodes += [edge.get("node") or {} for edge in (more.get("edges") or [])]
|
||||
page_info = more.get("pageInfo") or {}
|
||||
next_cursor = page_info.get("endCursor")
|
||||
if next_cursor == cursor:
|
||||
break
|
||||
cursor = next_cursor
|
||||
|
||||
return nodes
|
||||
|
||||
def _episodes_from_tiles(self, tiles: Iterable[dict], name: str, year: Optional[int],
|
||||
description: Optional[str], seen: set[str]) -> list[Episode]:
|
||||
episodes = []
|
||||
for tile in tiles:
|
||||
asset = tile.get("playableAssetId")
|
||||
if not asset or not tile.get("hasPlayable") or tile.get("isInPlan") is False:
|
||||
continue
|
||||
if asset in seen:
|
||||
continue
|
||||
seen.add(asset)
|
||||
|
||||
episode = tile.get("episode") or {}
|
||||
season = self._first_int(episode.get("seasonNum"), episode.get("seasonNumber"))
|
||||
number = self._first_int(episode.get("episodeNum"), episode.get("episodeNumber"))
|
||||
|
||||
episodes.append(Episode(
|
||||
id_=asset,
|
||||
service=self.__class__,
|
||||
title=tile.get("title") or name,
|
||||
season=season or 0,
|
||||
number=number or 0,
|
||||
name=tile.get("subtitle"),
|
||||
description=tile.get("longDescription") or tile.get("description") or description,
|
||||
year=year,
|
||||
air_date=episode.get("originalAirDate") if number is None else None,
|
||||
language=self.LANGUAGE,
|
||||
))
|
||||
return episodes
|
||||
|
||||
@staticmethod
|
||||
def _season_tile_group_id(show_value: str, season: int) -> str:
|
||||
inner = json.dumps(
|
||||
{"name": "Season V2", "id": f"{show_value}:{season}", "includeExternalAssets": True},
|
||||
separators=(",", ":"))
|
||||
encoded = base64.urlsafe_b64encode(inner.encode()).decode().rstrip("=")
|
||||
return base64.urlsafe_b64encode(f"TileGroup:{encoded}".encode()).decode().rstrip("=")
|
||||
|
||||
@classmethod
|
||||
def _tile_group_name(cls, tile_group_id: str) -> str:
|
||||
decoded = cls._decode_node_id(tile_group_id)
|
||||
if not decoded.startswith("TileGroup:"):
|
||||
return ""
|
||||
try:
|
||||
return json.loads(cls._decode_node_id(decoded.split(":", 1)[1])).get("name") or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def get_tracks(self, title: Title_T) -> Tracks:
|
||||
title_id = str(title.id)
|
||||
session = self._playback_session(title_id)
|
||||
|
||||
dash_url = session.get("dashURL")
|
||||
if not dash_url:
|
||||
self.log.error(" - The playback session carried no dashURL. Returns: "
|
||||
f"hlsURL/dashJSONURL: {sorted(session)}")
|
||||
raise SystemExit(1)
|
||||
|
||||
self.session_data = session.get("drmProvider") or {}
|
||||
|
||||
manifest_text = self._fetch_manifest(dash_url)
|
||||
dash = DASH.from_text(manifest_text, dash_url) if manifest_text \
|
||||
else DASH.from_url(url=dash_url, session=self.session)
|
||||
self._manifest_cache[title_id] = dash.manifest
|
||||
|
||||
ad_periods = self._ad_period_ids(dash.manifest, session)
|
||||
|
||||
tracks = dash.to_tracks(
|
||||
language=title.language or self.LANGUAGE,
|
||||
period_filter=(lambda period: (period.get("id") or "").strip() in ad_periods)
|
||||
if ad_periods else None,
|
||||
)
|
||||
|
||||
if ad_periods:
|
||||
for track in tracks:
|
||||
data = track.data.get("dash")
|
||||
if isinstance(data, dict):
|
||||
data["filtered_period_ids"] = sorted(ad_periods)
|
||||
self.log.info(f" + Dropped {len(ad_periods)} ad periods")
|
||||
|
||||
for track in tracks.audio:
|
||||
track.language = track.language or title.language or self.LANGUAGE
|
||||
|
||||
if not self.ccextract:
|
||||
for track in tracks.videos:
|
||||
self._disable_ccextractor(track)
|
||||
self.log.info(" + Closed captions disabled.")
|
||||
|
||||
return tracks
|
||||
|
||||
def _disable_ccextractor(self, track) -> None:
|
||||
track.closed_captions = []
|
||||
|
||||
def skipped(*_: Any, **__: Any) -> None:
|
||||
self.log.debug(f"ccextractor skipped for {getattr(track, 'id', '?')} ")
|
||||
return None
|
||||
|
||||
track.ccextractor = skipped
|
||||
|
||||
def _fetch_manifest(self, dash_url: str) -> Optional[str]:
|
||||
try:
|
||||
res = self.session.get(dash_url, timeout=self.timeout)
|
||||
if res.status_code != 200:
|
||||
self.log.debug(f"Could not fetch the MPD: HTTP {res.status_code}")
|
||||
return None
|
||||
except Exception as e:
|
||||
self.log.debug(f"Could not fetch the MPD: {e}")
|
||||
return None
|
||||
|
||||
try:
|
||||
path = self.cache_dir / "last_manifest.mpd"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(res.text, encoding="utf-8")
|
||||
self.log.debug(f" + Saved MPD to {path}")
|
||||
except Exception as e:
|
||||
self.log.debug(f"Could not save the MPD: {e}")
|
||||
|
||||
return res.text
|
||||
|
||||
_AD_PERIOD_ID = re.compile(r"^\d+(?:\.\d+)+$")
|
||||
|
||||
def _ad_period_ids(self, manifest, session: dict) -> set:
|
||||
periods = manifest.findall("Period")
|
||||
if len(periods) < 2:
|
||||
return set()
|
||||
|
||||
windows = self._ad_windows(session)
|
||||
break_ids = self._ad_break_ids(session)
|
||||
protected = [bool(period.xpath(".//ContentProtection")) for period in periods]
|
||||
any_protected = any(protected)
|
||||
|
||||
ads: set = set()
|
||||
content = 0
|
||||
|
||||
for period, is_protected in zip(periods, protected):
|
||||
period_id = (period.get("id") or "").strip()
|
||||
start = self._period_start(period)
|
||||
|
||||
reason = None
|
||||
if period_id and self._AD_PERIOD_ID.match(period_id):
|
||||
reason = "dotted period id"
|
||||
elif period_id and "." in period_id and period_id.split(".")[0] in break_ids:
|
||||
reason = "adBreaks id"
|
||||
elif any_protected and not is_protected:
|
||||
reason = "no ContentProtection"
|
||||
elif start is not None and any(s - 0.5 <= start < e - 0.5 for s, e in windows):
|
||||
reason = "adBreaks window"
|
||||
|
||||
if not reason:
|
||||
content += 1
|
||||
continue
|
||||
if not period_id:
|
||||
self.log.debug(f"Ad period at {start} has no ID and can't be filtered ({reason}).")
|
||||
content += 1
|
||||
continue
|
||||
|
||||
self.log.debug(f"Period {period_id} is an ad ({reason}).")
|
||||
ads.add(period_id)
|
||||
|
||||
if not content:
|
||||
self.log.warning(" - Every MPD period seems like an ad.")
|
||||
return set()
|
||||
|
||||
return ads
|
||||
|
||||
@staticmethod
|
||||
def _ad_break_ids(session: dict) -> set:
|
||||
breaks = ((session.get("manifestMetadata") or {}).get("adBreaks")) or []
|
||||
return {str(b.get("id")) for b in breaks if b.get("id") is not None}
|
||||
|
||||
@staticmethod
|
||||
def _ad_windows(session: dict) -> list:
|
||||
breaks = ((session.get("manifestMetadata") or {}).get("adBreaks")) or []
|
||||
windows = []
|
||||
for ad in breaks:
|
||||
start, end = ad.get("start"), ad.get("end")
|
||||
if start is None or end is None:
|
||||
continue
|
||||
windows.append((float(start), float(end)))
|
||||
return windows
|
||||
|
||||
_ISO8601 = re.compile(
|
||||
r"^P(?:(?P<d>[\d.]+)D)?T?(?:(?P<h>[\d.]+)H)?(?:(?P<m>[\d.]+)M)?(?:(?P<s>[\d.]+)S)?$")
|
||||
|
||||
@classmethod
|
||||
def _duration(cls, raw: Optional[str]) -> Optional[float]:
|
||||
match = cls._ISO8601.match((raw or "").strip())
|
||||
if not match:
|
||||
return None
|
||||
parts = match.groupdict()
|
||||
if not any(parts.values()):
|
||||
return None
|
||||
return (float(parts["d"] or 0) * 86400 + float(parts["h"] or 0) * 3600
|
||||
+ float(parts["m"] or 0) * 60 + float(parts["s"] or 0))
|
||||
|
||||
@classmethod
|
||||
def _period_start(cls, period) -> Optional[float]:
|
||||
return cls._duration(period.get("start"))
|
||||
|
||||
def get_chapters(self, title: Title_T) -> Chapters:
|
||||
if self.no_ads:
|
||||
return Chapters()
|
||||
|
||||
title_id = str(title.id)
|
||||
session = self._playback_session(title_id)
|
||||
|
||||
manifest = self._manifest_cache.get(title_id)
|
||||
if manifest is not None:
|
||||
chapters = self._chapters_from_periods(manifest, session)
|
||||
if chapters is not None:
|
||||
return chapters
|
||||
|
||||
return self._chapters_from_ad_breaks(session)
|
||||
|
||||
def _chapters_from_periods(self, manifest, session: dict) -> Optional[Chapters]:
|
||||
periods = manifest.findall("Period")
|
||||
if len(periods) < 2:
|
||||
return None
|
||||
|
||||
ad_periods = self._ad_period_ids(manifest, session)
|
||||
if not ad_periods:
|
||||
return None
|
||||
|
||||
starts = []
|
||||
for index, period in enumerate(periods):
|
||||
start = self._period_start(period)
|
||||
starts.append(0.0 if start is None and index == 0 else start)
|
||||
if any(start is None for start in starts):
|
||||
return None
|
||||
|
||||
total = self._duration(manifest.get("mediaPresentationDuration"))
|
||||
|
||||
marks: list[float] = []
|
||||
elapsed = 0.0
|
||||
previous_was_ad = False
|
||||
|
||||
for index, period in enumerate(periods):
|
||||
end = starts[index + 1] if index + 1 < len(periods) else total
|
||||
is_ad = (period.get("id") or "").strip() in ad_periods
|
||||
if is_ad:
|
||||
if not previous_was_ad and elapsed > 0:
|
||||
marks.append(elapsed)
|
||||
elif end is not None:
|
||||
elapsed += max(0.0, end - starts[index])
|
||||
previous_was_ad = is_ad
|
||||
|
||||
marks = [mark for mark in marks if mark < elapsed]
|
||||
if not marks:
|
||||
return None
|
||||
|
||||
chapters = Chapters()
|
||||
for index, mark in enumerate(marks, 1):
|
||||
chapters.add(Chapter(timestamp=mark, name=f"Ad Break {index}"))
|
||||
return chapters
|
||||
|
||||
def _chapters_from_ad_breaks(self, session: dict) -> Chapters:
|
||||
breaks = ((session.get("manifestMetadata") or {}).get("adBreaks")) or []
|
||||
|
||||
chapters = Chapters()
|
||||
removed = 0.0
|
||||
for index, ad in enumerate(breaks, 1):
|
||||
start, end = ad.get("start"), ad.get("end")
|
||||
if start is None:
|
||||
continue
|
||||
chapters.add(Chapter(timestamp=max(0.0, float(start) - removed),
|
||||
name=f"Ad Break {index}"))
|
||||
if end is not None:
|
||||
removed += float(end) - float(start)
|
||||
return chapters
|
||||
|
||||
def get_widevine_service_certificate(self, **_: Any) -> Optional[str]:
|
||||
return self.config.get("certificate")
|
||||
|
||||
def get_widevine_license(self, *, challenge: bytes, title: Title_T,
|
||||
track: AnyTrack = None, **_) -> Optional[Union[bytes, str]]:
|
||||
drm = {}
|
||||
if title is not None:
|
||||
drm = self._playback_session(str(title.id)).get("drmProvider") or {}
|
||||
drm = drm or self.session_data
|
||||
|
||||
license_url = next(
|
||||
(s.get("licenseURL") for s in (drm.get("drmSystems") or [])
|
||||
if (s.get("system") or "").upper() == "WIDEVINE" and s.get("licenseURL")),
|
||||
self.config["endpoints"].get("widevine_license"),
|
||||
)
|
||||
auth_token = drm.get("authToken")
|
||||
if not auth_token:
|
||||
self.log.error(" - The playback session carried no DRMtoday auth token.")
|
||||
raise SystemExit(1)
|
||||
|
||||
res = self.session.post(
|
||||
license_url,
|
||||
data=challenge,
|
||||
headers={"x-dt-auth-token": auth_token, "content-type": "application/octet-stream"},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if res.status_code != 200:
|
||||
raise ValueError(f"Widevine licence denied: HTTP {res.status_code} {res.text[:300]}")
|
||||
|
||||
try:
|
||||
payload = res.json()
|
||||
except Exception:
|
||||
return res.content
|
||||
|
||||
if payload.get("status") not in (None, "OK", "SUCCESS"):
|
||||
raise ValueError(f"Widevine licence denied by DRMtoday: {json.dumps(payload)[:300]}")
|
||||
if not payload.get("license"):
|
||||
raise ValueError(f"No licence in DRMtoday response: {json.dumps(payload)[:300]}")
|
||||
|
||||
return payload["license"]
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
headers:
|
||||
user-agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:153.0) Gecko/20100101 Firefox/153.0"
|
||||
origin: "https://www.philo.com"
|
||||
referer: "https://www.philo.com/"
|
||||
accept: "*/*"
|
||||
accept-language: "en-US,en;q=0.9"
|
||||
|
||||
request_timeout: 30
|
||||
ccextract: 0 # 1
|
||||
|
||||
endpoints:
|
||||
graphql: "https://www.philo.com/graphql"
|
||||
user: "https://www.philo.com/user"
|
||||
profiles: "https://www.philo.com/user/profiles/list.json"
|
||||
geo: "https://content-us-east-2-fastly-b.www.philo.com/geo"
|
||||
widevine_license: "https://lic.drmtoday.com/license-proxy-widevine/cenc/"
|
||||
|
||||
persisted_queries:
|
||||
createPlaybackSessionV2: "2ef315fa06929d36f1a1b332f3a46fe4edf296ad5f13491eb0329b55c06ac432"
|
||||
registerPlayerV2: "8312f5c234270aa2de0f199e79667ca23e41ac6e23d8f4bc31d3007702fbe9a9"
|
||||
endPlaybackSession: "3d4ae806437944478011172aac5dffb92f59cece3670b635f8246fc2d6f1100d"
|
||||
sessionStatus: "6b3d0f3dbb1ef4870d38442103b1cc73a11ba3915fd93457503d6494ca6ebe34"
|
||||
userSubscription: "990a8b451f87cf3cca1ce78ae3895ebc12c87f5082f48d61249ab086e2b68c10"
|
||||
nextPresentations: "c0b92cd127fc00d9b837ecda68f72ff42666628e60591748dc554c1ad749f1c2"
|
||||
previousPresentation: "c6438f893682924f68e5c8974a2cf383d5470a80fc872e12208521ae8e4b4e2e"
|
||||
page: "0dccb4f56182daa1949f520f9d151e8af0b1e7b55a6fcad85cc4a98991ca1ad5"
|
||||
tileGroup: "f976fca086cd18496de0d4b92eed6c961ac1139c139cbc47f901e0f68e451115"
|
||||
tiles: "a4d626992eeede79db8afc3c7d5025af1259379f5a5f5821094e99596b553102"
|
||||
hasFollow: "5706102469c7213170c799f5bc106904b038d9ff2d525a0d0ddd5b63cdf12c3f"
|
||||
preferences: "6bf97e2467f3b8d1fd038e0a8cc717f6dfd3d243c5bcc33fe456999a2c2ddacb"
|
||||
notifications: "71541212b5a04cbef9f483e81c38b6469010c18e4c34dfc1634d48af6a50541c"
|
||||
fetchRemotePlayers: "b1abf2e8cf3bafe4e3db7ed5c53821abe2ee46d018f4aef14111ef29b8d60c24"
|
||||
assignExperiment: "64e8cfaf6a2f468aae6fe2aa7480ab158a0fe0ec8a3570bbbff7fff0435da111"
|
||||
clickTile: "2303363eb24a09b6a8bc52682f7f251f2d41b6e0f5659390358d386599ae6dfe"
|
||||
UpdatePlayheadV2: "b28b78473f96bc3ea19f64913486264784517f022a206ef6e0370f1e914bc377"
|
||||
partnerActivationDetails: "ce5020ab7475567b48600be5d7c852c15ecf4934b7313e5db002187920e7abc0"
|
||||
userSubscriptionQuery: "861aaf9e182476668c8f67356943670484bf9e1d684b11e2139c4997827deef0"
|
||||
|
||||
player:
|
||||
deviceType: "WEB"
|
||||
deviceIcon: "PHONE"
|
||||
deviceName: "Firefox on Windows"
|
||||
deviceModel: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:153.0) Gecko/20100101 Firefox/153.0"
|
||||
deviceManufacturer: ""
|
||||
applicationVersion: "2026.7.23-1386250"
|
||||
osVersion: "153.0.0"
|
||||
captionsEnabled: false
|
||||
volume: 0.75
|
||||
properties:
|
||||
- name: "supportsChunkLoading"
|
||||
value: "true"
|
||||
|
||||
certificate: null
|
||||
+505
@@ -0,0 +1,505 @@
|
||||
from __future__ import annotations
|
||||
import base64
|
||||
import hashlib
|
||||
import re
|
||||
import time
|
||||
from http.cookiejar import CookieJar
|
||||
from typing import Any, Optional
|
||||
import click
|
||||
import requests
|
||||
from envied.core.config import config
|
||||
from envied.core.credential import Credential
|
||||
from envied.core.music import MusicTrackOption
|
||||
from envied.core.service import Service
|
||||
from envied.core.titles import Music, Song, Titles_T
|
||||
from envied.core.tracks import Audio, Chapters, Tracks
|
||||
from envied.core.tracks.track import Track
|
||||
|
||||
|
||||
class QOBZ(Service):
|
||||
"""
|
||||
Service code for Qobuz (https://qobuz.com)
|
||||
www.nostalgic.cc
|
||||
Authorization: Credentials, Tokens
|
||||
Security: None
|
||||
"""
|
||||
|
||||
ALIASES = ("QOBZ", "qobuz")
|
||||
GROUP_AUDIO_DOWNLOADS = True
|
||||
|
||||
TITLE_RE = r"^(?:https?://(?:www\.|open\.|play\.)?qobuz\.com/(?:[a-z]{2}-[a-z]{2}/)?(?P<type>album|track|playlist|interpreter|artist|label)/(?:[^/]+/)*)?(?P<id>[A-Za-z0-9]+)"
|
||||
|
||||
FORMATS = {
|
||||
5: ("MP3", "MP3 320 kb/s"),
|
||||
6: ("FLAC", "FLAC 16-bit/44.1kHz"),
|
||||
7: ("FLAC", "FLAC 24-bit ≤96kHz"),
|
||||
27: ("FLAC", "FLAC 24-bit ≤192kHz"),
|
||||
}
|
||||
|
||||
QUALITY_MAP = {
|
||||
"MP3": 5, "CD": 6, "HIFI": 7, "HIRES": 27,
|
||||
"5": 5, "6": 6, "7": 7, "27": 27,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@click.command(name="QOBZ", short_help="https://qobuz.com", help=__doc__)
|
||||
@click.argument("title", type=str)
|
||||
@click.option("-q", "--quality", "quality",
|
||||
type=click.Choice(["MP3", "CD", "HIFI", "HIRES", "5", "6", "7", "27"], case_sensitive=False),
|
||||
default=None,
|
||||
help="Quality: MP3/5=MP3 320, CD/6=FLAC 16/44.1, HIFI/7=FLAC 24/96, "
|
||||
"HIRES/27=FLAC 24/192 (default).")
|
||||
@click.pass_context
|
||||
def cli(ctx, **kwargs):
|
||||
return QOBZ(ctx, **kwargs)
|
||||
|
||||
def __init__(self, ctx, title: str, quality: Optional[str]):
|
||||
super().__init__(ctx)
|
||||
self.title = title
|
||||
if quality:
|
||||
self.quality = self.QUALITY_MAP[quality.upper()]
|
||||
else:
|
||||
self.quality = int(self.config.get("default_format_id", 27))
|
||||
|
||||
self.app_id: Optional[str] = None
|
||||
self.secrets: list[str] = []
|
||||
self.valid_secret: Optional[str] = None
|
||||
self.user_auth_token: Optional[str] = None
|
||||
|
||||
m = re.search(self.TITLE_RE, self.title)
|
||||
self.item_type = (m.group("type") if m and m.group("type") else "album")
|
||||
self.item_id = m.group("id") if m else self.title
|
||||
|
||||
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
||||
super().authenticate(cookies, credential)
|
||||
self.session.headers.update({"User-Agent": self.config["user_agent"]})
|
||||
|
||||
self.app_id = str(self.config.get("app_id") or "").strip() or None
|
||||
self.secrets = [s for s in (self.config.get("secrets") or []) if s]
|
||||
if not self.app_id or not self.secrets:
|
||||
spoofed_id, spoofed_secrets = self._spoof_app_credentials()
|
||||
self.app_id = self.app_id or spoofed_id
|
||||
if not self.secrets:
|
||||
self.secrets = spoofed_secrets
|
||||
if not self.app_id:
|
||||
self.log.error(" - Could not find a Qobuz app_id."); raise SystemExit(1)
|
||||
self.session.headers["X-App-Id"] = self.app_id
|
||||
|
||||
token = None
|
||||
if credential:
|
||||
user = (credential.username or "").strip()
|
||||
pw = (credential.password or "").strip()
|
||||
if user.lower() in ("token", "auth", "authtoken", "user_auth_token"):
|
||||
token = pw
|
||||
elif user and pw:
|
||||
token = self._login(user, pw)
|
||||
elif pw and not user:
|
||||
token = pw
|
||||
elif user and not pw:
|
||||
token = None if "@" in user else user
|
||||
if not token:
|
||||
for key in ("user_auth_token", "token", "auth_token"):
|
||||
value = str(self.config.get(key) or "").strip()
|
||||
if value:
|
||||
token = value
|
||||
break
|
||||
if not token and cookies:
|
||||
for cookie in cookies:
|
||||
if cookie.name in ("X-User-Auth-Token", "user_auth_token", "qobuz_token"):
|
||||
token = cookie.value
|
||||
break
|
||||
|
||||
if not token:
|
||||
self.log.error(
|
||||
" - No Qobuz auth. Set it in your unshackle config under "
|
||||
"services: QOBZ: token: \"YOUR_AUTH_TOKEN\", or use "
|
||||
"credentials as 'token:YOUR_AUTH_TOKEN' or 'email:password'."
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
self.user_auth_token = token
|
||||
self.session.headers["X-User-Auth-Token"] = token
|
||||
self.log.info(" + Authenticated with Qobuz")
|
||||
|
||||
def _login(self, email: str, password: str) -> str:
|
||||
login_app_id, _ = self._spoof_app_credentials()
|
||||
login_app_id = login_app_id or self.app_id
|
||||
self.log.info(f" + Logging in with app_id={login_app_id}")
|
||||
attempts = [("plain", password), ("md5", hashlib.md5(password.encode()).hexdigest())]
|
||||
for label, pwd in attempts:
|
||||
resp = self.session.get(
|
||||
self.config["base_url"] + "user/login",
|
||||
params={"email": email, "password": pwd, "app_id": login_app_id},
|
||||
headers={"X-App-Id": login_app_id},
|
||||
)
|
||||
self.log.info(f" login[{label}] HTTP {resp.status_code}: {resp.text[:160]}")
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
token = data.get("user_auth_token")
|
||||
if token:
|
||||
self.log.info(f" + Logged in as {data.get('user', {}).get('display_name', email)}")
|
||||
return token
|
||||
self.log.error(
|
||||
" - Qobuz password login failed. If it's a 401 'User authentication is required', the "
|
||||
"login app_id couldn't be extracted from the web bundle. Use a bare user_auth_token in "
|
||||
"envied.yaml credentials instead of email:password."
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
def _spoof_app_credentials(self) -> tuple[Optional[str], list[str]]:
|
||||
try:
|
||||
login_page = self.session.get(self.config["web_url"] + "/login").text
|
||||
bundle_match = re.search(
|
||||
r'<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>', login_page
|
||||
)
|
||||
if not bundle_match:
|
||||
self.log.warning(" - Could not locate Qobuz bundle.js for credential extraction.")
|
||||
return None, []
|
||||
bundle = self.session.get(self.config["web_url"] + bundle_match.group(1)).text
|
||||
|
||||
app_id_match = re.search(r'production:\{api:\{appId:"(\d+)",appSecret:', bundle)
|
||||
app_id = app_id_match.group(1) if app_id_match else None
|
||||
|
||||
seeds: dict[str, str] = {}
|
||||
for m in re.finditer(
|
||||
r'[a-z]\.initialSeed\("([\w=]+)",window\.utimezone\.(?P<tz>[a-z]+)\)', bundle
|
||||
):
|
||||
seeds[m.group("tz").capitalize()] = m.group(1)
|
||||
|
||||
secrets: list[str] = []
|
||||
for m in re.finditer(
|
||||
r'name:"\w+/(?P<tz>[A-Z][a-z]+)",info:"(?P<info>[\w=]+)",extras:"(?P<extras>[\w=]+)"', bundle
|
||||
):
|
||||
tz = m.group("tz")
|
||||
if tz not in seeds:
|
||||
continue
|
||||
combined = seeds[tz] + m.group("info") + m.group("extras")
|
||||
try:
|
||||
secret = base64.standard_b64decode(combined[:-44]).decode("utf-8")
|
||||
if secret:
|
||||
secrets.append(secret)
|
||||
except Exception:
|
||||
continue
|
||||
self.log.debug(f" + Extracted app_id={app_id}, {len(secrets)} secret(s)")
|
||||
return app_id, secrets
|
||||
except Exception as e:
|
||||
self.log.warning(f" - Qobuz credential extraction failed: {e}")
|
||||
return None, []
|
||||
|
||||
def _api(self, endpoint: str, params: dict, allow_error: bool = False) -> Optional[dict]:
|
||||
resp = self.session.get(self.config["base_url"] + endpoint, params=params)
|
||||
if resp.status_code != 200:
|
||||
if allow_error:
|
||||
return None
|
||||
self.log.error(f" - Qobuz API error on {endpoint}: {resp.status_code} {resp.text[:200]}")
|
||||
raise SystemExit(1)
|
||||
return resp.json()
|
||||
|
||||
def get_titles(self) -> Titles_T:
|
||||
if self.item_type == "track":
|
||||
track = self._api("track/get", params={"track_id": self.item_id})
|
||||
album = self._api("album/get", params={"album_id": track["album"]["id"]})
|
||||
songs = [self._build_song(track, album)]
|
||||
return self._build_music(album, songs, kind="single")
|
||||
|
||||
if self.item_type == "playlist":
|
||||
return self._get_playlist()
|
||||
|
||||
album = self._get_album_full(self.item_id)
|
||||
tracks = album.get("tracks", {}).get("items", [])
|
||||
songs = [self._build_song(t, album) for t in tracks]
|
||||
return self._build_music(album, songs, kind=self._release_kind(album))
|
||||
|
||||
def _get_album_full(self, album_id: str) -> dict:
|
||||
album = self._api("album/get", params={"album_id": album_id, "limit": 500, "offset": 0})
|
||||
items = album.get("tracks", {}).get("items", [])
|
||||
total = album.get("tracks", {}).get("total", len(items))
|
||||
offset = 500
|
||||
while len(items) < total:
|
||||
page = self._api("album/get", params={"album_id": album_id, "limit": 500, "offset": offset})
|
||||
page_items = page.get("tracks", {}).get("items", [])
|
||||
if not page_items:
|
||||
break
|
||||
items.extend(page_items)
|
||||
offset += 500
|
||||
album.setdefault("tracks", {})["items"] = items
|
||||
return album
|
||||
|
||||
def _get_playlist(self) -> Music:
|
||||
playlist = self._api("playlist/get", params={
|
||||
"playlist_id": self.item_id, "extra": "tracks", "limit": 500, "offset": 0,
|
||||
})
|
||||
items = playlist.get("tracks", {}).get("items", [])
|
||||
total = playlist.get("tracks", {}).get("total", len(items))
|
||||
offset = 500
|
||||
while len(items) < total:
|
||||
page = self._api("playlist/get", params={
|
||||
"playlist_id": self.item_id, "extra": "tracks", "limit": 500, "offset": offset,
|
||||
})
|
||||
page_items = page.get("tracks", {}).get("items", [])
|
||||
if not page_items:
|
||||
break
|
||||
items.extend(page_items)
|
||||
offset += 500
|
||||
|
||||
songs = []
|
||||
for position, track in enumerate(items, start=1):
|
||||
album = track.get("album") or {}
|
||||
song = self._build_song(track, album, playlist_position=position)
|
||||
songs.append(song)
|
||||
|
||||
music = Music(
|
||||
songs,
|
||||
kind="playlist",
|
||||
title=playlist.get("name"),
|
||||
artist=(playlist.get("owner") or {}).get("name"),
|
||||
total_tracks=len(songs) or None,
|
||||
owner=(playlist.get("owner") or {}).get("name"),
|
||||
description=(playlist.get("description") or None),
|
||||
)
|
||||
return music
|
||||
|
||||
def _build_music(self, album: dict, songs: list[Song], kind: str) -> Music:
|
||||
artwork = self._cover_url(album)
|
||||
year = self._year(album)
|
||||
return Music(
|
||||
songs,
|
||||
kind=kind,
|
||||
title=self._album_title(album),
|
||||
artist=(album.get("artist") or {}).get("name"),
|
||||
year=year,
|
||||
total_tracks=album.get("tracks_count") or (len(songs) or None),
|
||||
total_discs=album.get("media_count") or None,
|
||||
artwork_url=artwork,
|
||||
total_duration=int(album.get("duration") or 0) or None,
|
||||
)
|
||||
|
||||
def _build_song(self, track: dict, album: dict, playlist_position: Optional[int] = None) -> Song:
|
||||
album = album or track.get("album") or {}
|
||||
album_artist = (album.get("artist") or {}).get("name") or "Various Artists"
|
||||
performer = (track.get("performer") or {}).get("name") or album_artist
|
||||
year = self._year(album) or self._year(track) or 1
|
||||
artwork = self._cover_url(album)
|
||||
release_date = self._release_date(album) or self._release_date(track)
|
||||
|
||||
title = track.get("title") or ""
|
||||
if track.get("version"):
|
||||
title = f"{title.strip()} ({track['version']})"
|
||||
|
||||
genre = (album.get("genre") or {}).get("name")
|
||||
label = (album.get("label") or {}).get("name")
|
||||
composer = (track.get("composer") or {}).get("name")
|
||||
|
||||
data = {
|
||||
"service": self.ALIASES[0],
|
||||
"source": self.ALIASES[0],
|
||||
"track_id": str(track.get("id")),
|
||||
"album_id": str(album.get("id")) if album.get("id") else None,
|
||||
"track_url": track.get("url"),
|
||||
"album_url": album.get("url"),
|
||||
"title": title,
|
||||
"artist": performer,
|
||||
"album": self._album_title(album),
|
||||
"album_artist": album_artist,
|
||||
"performer": performer,
|
||||
"composer": composer,
|
||||
"track_number": track.get("track_number"),
|
||||
"total_tracks": album.get("tracks_count"),
|
||||
"disc_number": track.get("media_number") or 1,
|
||||
"total_discs": album.get("media_count") or 1,
|
||||
"release_date": release_date,
|
||||
"year": year,
|
||||
"genre": genre,
|
||||
"label": label,
|
||||
"isrc": track.get("isrc"),
|
||||
"upc": album.get("upc"),
|
||||
"copyright": album.get("copyright"),
|
||||
"explicit": bool(track.get("parental_warning")),
|
||||
"duration": track.get("duration"),
|
||||
"channels": 2,
|
||||
"artwork_url": artwork,
|
||||
"quality": self.FORMATS.get(self.quality, ("", ""))[1],
|
||||
"maximum_bit_depth": track.get("maximum_bit_depth") or album.get("maximum_bit_depth"),
|
||||
"maximum_sampling_rate": track.get("maximum_sampling_rate") or album.get("maximum_sampling_rate"),
|
||||
}
|
||||
if config.tag:
|
||||
data["comment"] = config.tag
|
||||
|
||||
return Song(
|
||||
id_=str(track.get("id")),
|
||||
service=self.__class__,
|
||||
name=title or "Unknown",
|
||||
artist=performer,
|
||||
album=self._album_title(album) or "Unknown Album",
|
||||
track=int(playlist_position or track.get("track_number") or 1),
|
||||
disc=int(track.get("media_number") or 1),
|
||||
year=int(year),
|
||||
album_artist=album_artist,
|
||||
release_type=self._release_kind(album),
|
||||
total_tracks=int(album["tracks_count"]) if album.get("tracks_count") else None,
|
||||
total_discs=int(album["media_count"]) if album.get("media_count") else None,
|
||||
genre=genre,
|
||||
explicit=bool(track.get("parental_warning")),
|
||||
isrc=track.get("isrc") or None,
|
||||
upc=str(album.get("upc")) if album.get("upc") else None,
|
||||
copyright=album.get("copyright") or None,
|
||||
label=label,
|
||||
artwork_url=artwork,
|
||||
data=data,
|
||||
)
|
||||
|
||||
def get_music_track_options(self, song: Song) -> list[MusicTrackOption]:
|
||||
data = song.data if isinstance(song.data, dict) else {}
|
||||
codec = self.FORMATS.get(self.quality, ("FLAC", ""))[0]
|
||||
bit_depth = None
|
||||
sample_rate = None
|
||||
if self.quality != 5:
|
||||
max_bd = data.get("maximum_bit_depth")
|
||||
max_sr = data.get("maximum_sampling_rate")
|
||||
bit_depth = min(int(max_bd or 24), 16 if self.quality == 6 else 24)
|
||||
sr_cap = {6: 44.1, 7: 96.0, 27: 192.0}.get(self.quality, 192.0)
|
||||
sample_rate = int(min(float(max_sr or sr_cap), sr_cap) * 1000)
|
||||
hires = bool(bit_depth and bit_depth > 16) or bool(sample_rate and sample_rate > 48000)
|
||||
return [MusicTrackOption(
|
||||
codec=codec,
|
||||
bit_depth=bit_depth,
|
||||
sample_rate=sample_rate,
|
||||
bitrate=320000 if self.quality == 5 else None,
|
||||
channels=2.0,
|
||||
lossless=self.quality != 5,
|
||||
hires=hires,
|
||||
explicit=bool(data.get("explicit")),
|
||||
duration=int(data["duration"]) if data.get("duration") else None,
|
||||
quality_label=self.FORMATS.get(self.quality, ("", ""))[1],
|
||||
)]
|
||||
|
||||
def get_tracks(self, title: Song) -> Tracks:
|
||||
track_id = str(title.id)
|
||||
file_info = self._get_file_url(track_id, self.quality)
|
||||
url = file_info.get("url")
|
||||
if not url:
|
||||
self.log.error(f" - No file URL for track {track_id}."); raise SystemExit(1)
|
||||
|
||||
actual_format = int(file_info.get("format_id") or self.quality)
|
||||
is_mp3 = actual_format == 5 or (file_info.get("mime_type") or "").endswith("mpeg")
|
||||
codec = Audio.Codec.FLAC if not is_mp3 else None
|
||||
extension = "mp3" if is_mp3 else "flac"
|
||||
|
||||
bit_depth = file_info.get("bit_depth")
|
||||
sampling_rate = file_info.get("sampling_rate")
|
||||
bitrate = 320000 if is_mp3 else None
|
||||
|
||||
audio = Audio(
|
||||
url,
|
||||
language=title.language or "en",
|
||||
codec=codec,
|
||||
bitrate=bitrate,
|
||||
channels=2,
|
||||
descriptor=Track.Descriptor.URL,
|
||||
id_=track_id,
|
||||
data={
|
||||
"qobuz_ext": extension,
|
||||
"bit_depth": bit_depth,
|
||||
"sampling_rate": sampling_rate,
|
||||
},
|
||||
)
|
||||
return Tracks([audio])
|
||||
|
||||
def get_chapters(self, title: Song) -> Chapters:
|
||||
return Chapters()
|
||||
|
||||
def _get_file_url(self, track_id: str, format_id: int) -> dict:
|
||||
secrets_to_try = ([self.valid_secret] if self.valid_secret else []) + [
|
||||
s for s in self.secrets if s != self.valid_secret
|
||||
]
|
||||
last_error = ""
|
||||
for secret in secrets_to_try:
|
||||
ts = int(time.time())
|
||||
sig_str = f"trackgetFileUrlformat_id{format_id}intentstreamtrack_id{track_id}{ts}{secret}"
|
||||
request_sig = hashlib.md5(sig_str.encode()).hexdigest()
|
||||
resp = self.session.get(
|
||||
self.config["base_url"] + "track/getFileUrl",
|
||||
params={
|
||||
"request_ts": ts,
|
||||
"request_sig": request_sig,
|
||||
"track_id": track_id,
|
||||
"format_id": format_id,
|
||||
"intent": "stream",
|
||||
},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if data.get("url"):
|
||||
self.valid_secret = secret
|
||||
return data
|
||||
last_error = data.get("message", "no url in response")
|
||||
else:
|
||||
last_error = f"HTTP {resp.status_code}: {resp.text[:120]}"
|
||||
self.log.error(f" - Failed to get file URL for track {track_id}: {last_error}")
|
||||
raise SystemExit(1)
|
||||
|
||||
def on_track_downloaded(self, track: Any) -> None:
|
||||
try:
|
||||
path = getattr(track, "path", None)
|
||||
data = getattr(track, "data", None)
|
||||
if not path or not path.exists() or not isinstance(data, dict):
|
||||
return
|
||||
extension = data.get("qobuz_ext")
|
||||
if not extension or path.suffix.lower() == f".{extension}":
|
||||
return
|
||||
new_path = path.with_suffix(f".{extension}")
|
||||
if new_path.exists():
|
||||
new_path.unlink()
|
||||
path.rename(new_path)
|
||||
track.path = new_path
|
||||
except Exception as e:
|
||||
self.log.debug(f"Extension rename skipped: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _album_title(album: dict) -> str:
|
||||
title = album.get("title") or ""
|
||||
if album.get("version"):
|
||||
title = f"{title.strip()} ({album['version']})"
|
||||
return title.strip()
|
||||
|
||||
@staticmethod
|
||||
def _year(obj: dict) -> int:
|
||||
for key in ("release_date_original", "release_date_stream", "released_at", "release_date"):
|
||||
value = obj.get(key)
|
||||
if not value:
|
||||
continue
|
||||
if isinstance(value, (int, float)):
|
||||
try:
|
||||
return int(time.gmtime(int(value)).tm_year)
|
||||
except Exception:
|
||||
continue
|
||||
match = re.match(r"(\d{4})", str(value))
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _release_date(obj: dict) -> Optional[str]:
|
||||
for key in ("release_date_original", "release_date_stream", "release_date"):
|
||||
value = obj.get(key)
|
||||
if value and re.match(r"\d{4}-\d{2}-\d{2}", str(value)):
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _cover_url(album: dict) -> Optional[str]:
|
||||
image = album.get("image") or {}
|
||||
url = image.get("large") or image.get("small") or image.get("thumbnail")
|
||||
if not url:
|
||||
return None
|
||||
return re.sub(r"_(\d+|max|org)\.jpg", "_org.jpg", url)
|
||||
|
||||
@staticmethod
|
||||
def _release_kind(album: dict) -> str:
|
||||
release_type = (album.get("release_type") or album.get("product_type") or "").lower()
|
||||
if release_type in ("single", "ep", "compilation", "album"):
|
||||
return release_type
|
||||
tracks_count = album.get("tracks_count") or 0
|
||||
if tracks_count and tracks_count <= 3:
|
||||
return "single"
|
||||
return "album"
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# Logins go in envied.yaml credentials and are formatted as: (email:password) or (token:X-User-Auth-Token)
|
||||
# Format IDs: 5 = MP3 320 | 6 = FLAC 16-bit/44.1kHz | 7 = FLAC 24-bit ≤96kHz | 27 = FLAC 24-bit ≤192kHz
|
||||
|
||||
user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
|
||||
|
||||
base_url: 'https://www.qobuz.com/api.json/0.2/'
|
||||
web_url: 'https://play.qobuz.com'
|
||||
|
||||
app_id:
|
||||
- '798273057'
|
||||
secrets:
|
||||
- 'abb21364945c0583309667d13ca3d93a'
|
||||
- '806331c3b0b641da923b890aed01d04a'
|
||||
- 'f69a7734686cb9427629378a4b7ac381'
|
||||
|
||||
default_format_id: 27
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
# twinvine-services
|
||||
Service scripts
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user