mirror of
https://github.com/vinefeeder/TwinVine.git
synced 2026-07-15 18:10:04 +02:00
services update
This commit is contained in:
@@ -305,7 +305,7 @@ accessible outside their hosting platform.
|
|||||||
### Using an API Vault
|
### Using an API Vault
|
||||||
|
|
||||||
API vaults use a specific HTTP request format, therefore API or HTTP Key Vault APIs from other projects or services may
|
API vaults use a specific HTTP request format, therefore API or HTTP Key Vault APIs from other projects or services may
|
||||||
not work in envied. The API format can be seen in the [API Vault Code](envied/vaults/API.py).
|
not work in envied.The API format can be seen in the [API Vault Code](envied/vaults/API.py).
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- type: API
|
- type: API
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
## What is envied?
|
## What is envied?
|
||||||
|
|
||||||
Envied is a fork of [Devine](https://github.com/devine-dl/devine/). The name 'envied' is an anagram of Devine, and as such, pays homage to the original author.
|
Envied is a fork of [Devine](https://github.com/devine-dl/devine/). The name 'envied' is an anagram of Devine, and as such, pays homage to the original author.
|
||||||
Is is based on v 1.4.7 of envied. It is a powerful archival tool for downloading movies, TV shows, and music from streaming services. Built with a focus on modularity and extensibility, it provides a robust framework for content acquisition with support for DRM-protected content.
|
Is is based on v 1.4.7 of envied.It is a powerful archival tool for downloading movies, TV shows, and music from streaming services. Built with a focus on modularity and extensibility, it provides a robust framework for content acquisition with support for DRM-protected content.
|
||||||
|
|
||||||
No commands have been changed 'uv run envied' still works as usual.
|
No commands have been changed 'uv run envied' still works as usual.
|
||||||
|
|
||||||
|
|||||||
@@ -33,28 +33,28 @@ from envied.core.tracks.subtitle import Subtitle
|
|||||||
|
|
||||||
class VideoNoAudio(Video):
|
class VideoNoAudio(Video):
|
||||||
"""
|
"""
|
||||||
Video track qui enlève automatiquement l'audio après téléchargement.
|
Video track that automatically removes audio after recording.
|
||||||
Nécessaire car ADN fournit des streams HLS avec audio muxé.
|
Necessary because ADN provides HLS streams with muxed audio.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def download(self, session, prepare_drm, max_workers=None, progress=None, *, cdm=None):
|
def download(self, session, prepare_drm, max_workers=None, progress=None, *, cdm=None):
|
||||||
"""Override : télécharge puis demuxe pour enlever l'audio."""
|
"""Override: download then demux to remove audio."""
|
||||||
import logging
|
import logging
|
||||||
log = logging.getLogger('ADN.VideoNoAudio')
|
log = logging.getLogger('ADN.VideoNoAudio')
|
||||||
|
|
||||||
# Téléchargement normal
|
# Normal download
|
||||||
super().download(session, prepare_drm, max_workers, progress, cdm=cdm)
|
super().download(session, prepare_drm, max_workers, progress, cdm=cdm)
|
||||||
|
|
||||||
# Si pas de path, échec du téléchargement
|
# If no path, download failed
|
||||||
if not self.path or not self.path.exists():
|
if not self.path or not self.path.exists():
|
||||||
return
|
return
|
||||||
|
|
||||||
# Vérifier FFmpeg disponible
|
# Check FFmpeg available
|
||||||
if not binaries.FFMPEG:
|
if not binaries.FFMPEG:
|
||||||
log.warning("FFmpeg not found, cannot remove audio from video")
|
log.warning("FFmpeg not found, cannot remove audio from video")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Demuxer : enlever l'audio
|
# Demux: remove audio
|
||||||
if progress:
|
if progress:
|
||||||
progress(downloaded="Removing audio")
|
progress(downloaded="Removing audio")
|
||||||
|
|
||||||
@@ -68,8 +68,8 @@ class VideoNoAudio(Video):
|
|||||||
[
|
[
|
||||||
binaries.FFMPEG,
|
binaries.FFMPEG,
|
||||||
'-i', str(original_path),
|
'-i', str(original_path),
|
||||||
'-vcodec', 'copy', # Copie vidéo sans réencodage
|
'-vcodec', 'copy', # Copy video without re-encoding
|
||||||
'-an', # Enlève l'audio
|
'-an', # Remove audio
|
||||||
'-y',
|
'-y',
|
||||||
str(noaudio_path)
|
str(noaudio_path)
|
||||||
],
|
],
|
||||||
@@ -88,7 +88,7 @@ class VideoNoAudio(Video):
|
|||||||
noaudio_path.unlink(missing_ok=True)
|
noaudio_path.unlink(missing_ok=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Remplacer le fichier original
|
# Replace original file
|
||||||
log.debug(f"Video demuxed successfully: {noaudio_path.stat().st_size} bytes")
|
log.debug(f"Video demuxed successfully: {noaudio_path.stat().st_size} bytes")
|
||||||
original_path.unlink()
|
original_path.unlink()
|
||||||
noaudio_path.rename(original_path)
|
noaudio_path.rename(original_path)
|
||||||
@@ -106,30 +106,30 @@ class VideoNoAudio(Video):
|
|||||||
|
|
||||||
class AudioExtracted(Audio):
|
class AudioExtracted(Audio):
|
||||||
"""
|
"""
|
||||||
Audio track déjà extrait d'un flux HLS muxé.
|
Audio track already extracted from muxed HLS stream.
|
||||||
Override download() pour copier le fichier au lieu de télécharger.
|
Override download() to copy the file instead of downloading.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *args, extracted_path: Path, **kwargs):
|
def __init__(self, *args, extracted_path: Path, **kwargs):
|
||||||
# URL vide pour éviter que curl essaie de télécharger
|
# Empty URL to prevent curl from trying to download
|
||||||
super().__init__(*args, url="", **kwargs)
|
super().__init__(*args, url="", **kwargs)
|
||||||
self.extracted_path = extracted_path
|
self.extracted_path = extracted_path
|
||||||
|
|
||||||
def download(self, session, prepare_drm, max_workers=None, progress=None, *, cdm=None):
|
def download(self, session, prepare_drm, max_workers=None, progress=None, *, cdm=None):
|
||||||
"""Override : copie le fichier extrait au lieu de télécharger."""
|
"""Override: copies the extracted file instead of downloading."""
|
||||||
if not self.extracted_path or not self.extracted_path.exists():
|
if not self.extracted_path or not self.extracted_path.exists():
|
||||||
if progress:
|
if progress:
|
||||||
progress(downloaded="[red]FAILED")
|
progress(downloaded="[red]FAILED")
|
||||||
raise ValueError(f"Extracted audio file not found: {self.extracted_path}")
|
raise ValueError(f"Extracted audio file not found: {self.extracted_path}")
|
||||||
|
|
||||||
# Créer le path de destination (même logique que Track.download)
|
# Create destination path (same logic as Track.download)
|
||||||
track_type = self.__class__.__name__
|
track_type = self.__class__.__name__
|
||||||
save_path = config.directories.temp / f"{track_type}_{self.id}.m4a"
|
save_path = config.directories.temp / f"{track_type}_{self.id}.m4a"
|
||||||
|
|
||||||
if progress:
|
if progress:
|
||||||
progress(downloaded="Copying", total=100, completed=0)
|
progress(downloaded="Copying", total=100, completed=0)
|
||||||
|
|
||||||
# Copier le fichier extrait vers le path final
|
# Copy the extracted file to the final destination
|
||||||
config.directories.temp.mkdir(parents=True, exist_ok=True)
|
config.directories.temp.mkdir(parents=True, exist_ok=True)
|
||||||
shutil.copy2(self.extracted_path, save_path)
|
shutil.copy2(self.extracted_path, save_path)
|
||||||
|
|
||||||
@@ -141,30 +141,30 @@ class AudioExtracted(Audio):
|
|||||||
|
|
||||||
class SubtitleEmbedded(Subtitle):
|
class SubtitleEmbedded(Subtitle):
|
||||||
"""
|
"""
|
||||||
Subtitle avec contenu embarqué (data URI).
|
Subtitle with embedded content (data URI).
|
||||||
Override download() pour écrire le contenu directement.
|
Override download() to write the content directly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *args, embedded_content: str, **kwargs):
|
def __init__(self, *args, embedded_content: str, **kwargs):
|
||||||
# URL vide pour éviter que curl essaie de télécharger
|
# Empty URL to prevent curl from trying to download
|
||||||
super().__init__(*args, url="", **kwargs)
|
super().__init__(*args, url="", **kwargs)
|
||||||
self.embedded_content = embedded_content
|
self.embedded_content = embedded_content
|
||||||
|
|
||||||
def download(self, session, prepare_drm, max_workers=None, progress=None, *, cdm=None):
|
def download(self, session, prepare_drm, max_workers=None, progress=None, *, cdm=None):
|
||||||
"""Override : écrit le contenu embarqué au lieu de télécharger."""
|
"""Override: writes the embedded content instead of downloading."""
|
||||||
if not self.embedded_content:
|
if not self.embedded_content:
|
||||||
if progress:
|
if progress:
|
||||||
progress(downloaded="[red]FAILED")
|
progress(downloaded="[red]FAILED")
|
||||||
raise ValueError("No embedded content in subtitle")
|
raise ValueError("No embedded content in subtitle")
|
||||||
|
|
||||||
# Créer le path de destination
|
# Create destination path
|
||||||
track_type = "Subtitle"
|
track_type = "Subtitle"
|
||||||
save_path = config.directories.temp / f"{track_type}_{self.id}.{self.codec.extension}"
|
save_path = config.directories.temp / f"{track_type}_{self.id}.{self.codec.extension}"
|
||||||
|
|
||||||
if progress:
|
if progress:
|
||||||
progress(downloaded="Writing", total=100, completed=0)
|
progress(downloaded="Writing", total=100, completed=0)
|
||||||
|
|
||||||
# Écrire le contenu
|
# Write content
|
||||||
config.directories.temp.mkdir(parents=True, exist_ok=True)
|
config.directories.temp.mkdir(parents=True, exist_ok=True)
|
||||||
save_path.write_text(self.embedded_content, encoding='utf-8')
|
save_path.write_text(self.embedded_content, encoding='utf-8')
|
||||||
|
|
||||||
@@ -216,29 +216,29 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
@click.command(
|
@click.command(
|
||||||
name="ADN",
|
name="ADN",
|
||||||
short_help="Téléchargement depuis Animation Digital Network",
|
short_help="https://animationdigitalnetwork.com",
|
||||||
help=(
|
help=(
|
||||||
"Télécharge des séries ou films depuis ADN.\n\n"
|
"Downloads series or movies from ADN.\n\n"
|
||||||
"TITLE : L'URL de la série ou son ID (ex: 1125).\n\n"
|
"TITLE: Series URL or ID (eg. 1125).\n\n"
|
||||||
"SYSTÈME DE SÉLECTION :\n"
|
"SELECTION SYSTEM:\n"
|
||||||
" - Simple : '-e 1-5' (épisodes 1 à 5)\n"
|
" - Simple: '-e 1-5' (episodes 1 to 5)\n"
|
||||||
" - Saisons : '-e S2' ou '-e S02' (toute la saison 2) ou '-e S2E1-12'\n"
|
" - Seasons: '-e S2' or '-e S02' (all season 2) or '-e S2E1-12'\n"
|
||||||
" - Mixte : '-e 1,3,S2E5' ou '-e 1,3,S02E05'\n"
|
" - Mixed: '-e 1,3,S2E5' or '-e 1,3,S02E05'\n"
|
||||||
" - Bonus : '-e NC1,OAV1'"
|
" - Bonus: '-e NC1,OAV1'"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@click.argument("title", type=str, required=True)
|
@click.argument("title", type=str, required=True)
|
||||||
@click.option(
|
@click.option(
|
||||||
"-e", "--episode", "select", type=str,
|
"-e", "--episode", "select", type=str,
|
||||||
help="Sélection : numéros, plages (5-10), saisons (S1, S2) ou combiné (S1E5)."
|
help="Selection: numbers, ranges (5-10), seasons (S1, S2) or combined (S1E5)."
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--but", is_flag=True,
|
"--but", is_flag=True,
|
||||||
help="Inverse la sélection : télécharge tout SAUF les épisodes spécifiés avec -e."
|
help="Invert selection: download everything EXCEPT episodes specified with -e."
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--all", "all_eps", is_flag=True,
|
"--all", "all_eps", is_flag=True,
|
||||||
help="Ignore toutes les restrictions et télécharge l'intégralité de la série."
|
help="Ignore all restrictions and download the entire series."
|
||||||
)
|
)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def cli(ctx, **kwargs) -> "ADN":
|
def cli(ctx, **kwargs) -> "ADN":
|
||||||
@@ -277,7 +277,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
}
|
}
|
||||||
|
|
||||||
def ensure_authenticated(self) -> None:
|
def ensure_authenticated(self) -> None:
|
||||||
"""Vérifie le token et rafraîchit si nécessaire."""
|
"""Checks the token and refreshes if necessary."""
|
||||||
current_time = int(time.time())
|
current_time = int(time.time())
|
||||||
|
|
||||||
if self.access_token and self.token_expiration and current_time < (self.token_expiration - 60):
|
if self.access_token and self.token_expiration and current_time < (self.token_expiration - 60):
|
||||||
@@ -340,13 +340,13 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
self.token_expiration = int(time.time()) + expires_in
|
self.token_expiration = int(time.time()) + expires_in
|
||||||
self.session.headers.update(self.auth_header)
|
self.session.headers.update(self.auth_header)
|
||||||
|
|
||||||
def _parse_select(self, ep_id: str, short_number: str, season_num: int) -> bool:
|
def _parse_select(self, ep_id: str, short_number: str, season_num: int, relative_number: Optional[int] = None) -> bool:
|
||||||
"""Retourne True si l'épisode doit être inclus."""
|
"""Returns True if the episode should be included."""
|
||||||
if self.all_eps or not self.select_str:
|
if self.all_eps or not self.select_str:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Préparation des identifiants possibles pour cet épisode
|
# Preparing possible identifiers for this episode
|
||||||
# On teste : "30353" (id), "1" (numéro), "S02E01" (format complet), "S02" (saison entière)
|
# We test: "30353" (id), "1" (number), "S02E01" (full format), "S02" (entire season)
|
||||||
candidates = [
|
candidates = [
|
||||||
str(ep_id),
|
str(ep_id),
|
||||||
str(short_number).lstrip("0"),
|
str(short_number).lstrip("0"),
|
||||||
@@ -354,25 +354,29 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
f"S{season_num:02d}"
|
f"S{season_num:02d}"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Add the relative match (e.g. S03E06 for episode 30 which is the 6th ep in S3)
|
||||||
|
if relative_number is not None:
|
||||||
|
candidates.append(f"S{season_num:02d}E{relative_number:02d}")
|
||||||
|
|
||||||
parts = re.split(r'[ ,]+', self.select_str.strip().upper())
|
parts = re.split(r'[ ,]+', self.select_str.strip().upper())
|
||||||
selection: set[str] = set()
|
selection: set[str] = set()
|
||||||
|
|
||||||
for part in parts:
|
for part in parts:
|
||||||
if '-' in part:
|
if '-' in part:
|
||||||
start_p, end_p = part.split('-', 1)
|
start_p, end_p = part.split('-', 1)
|
||||||
# Gestion des plages S02E01-S02E04
|
# Handle ranges S02E01-S02E04
|
||||||
m_start = re.match(r'^S(\d+)E(\d+)$', start_p)
|
m_start = re.match(r'^S(\d+)E(\d+)$', start_p)
|
||||||
m_end = re.match(r'^S(\d+)E(\d+)$', end_p)
|
m_end = re.match(r'^S(\d+)E(\d+)$', end_p)
|
||||||
|
|
||||||
if m_start and m_end:
|
if m_start and m_end:
|
||||||
s_start, e_start = map(int, m_start.groups())
|
s_start, e_start = map(int, m_start.groups())
|
||||||
s_end, e_end = map(int, m_end.groups())
|
s_end, e_end = map(int, m_end.groups())
|
||||||
if s_start == s_end: # Même saison
|
if s_start == s_end: # Same season
|
||||||
for i in range(e_start, e_end + 1):
|
for i in range(e_start, e_end + 1):
|
||||||
selection.add(f"S{s_start:02d}E{i:02d}")
|
selection.add(f"S{s_start:02d}E{i:02d}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Plages classiques (1-10)
|
# Classic ranges (1-10)
|
||||||
nums = re.findall(r'\d+', part)
|
nums = re.findall(r'\d+', part)
|
||||||
if len(nums) >= 2:
|
if len(nums) >= 2:
|
||||||
for i in range(int(nums[0]), int(nums[1]) + 1):
|
for i in range(int(nums[0]), int(nums[1]) + 1):
|
||||||
@@ -384,18 +388,18 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
return not included if self.but else included
|
return not included if self.but else included
|
||||||
|
|
||||||
def get_titles(self) -> Series:
|
def get_titles(self) -> Series:
|
||||||
"""Récupère les épisodes avec le titre réel de la série."""
|
"""Retrieves episodes with the actual title of the series."""
|
||||||
show_id = self.parse_show_id(self.title)
|
show_id = self.parse_show_id(self.title)
|
||||||
|
|
||||||
# 1. Récupérer d'abord les infos globales du show pour avoir le titre propre
|
# 1. Fetch the overall show info first to get the proper title
|
||||||
show_url = self.config["endpoints"]["show"].format(show_id=show_id)
|
show_url = self.config["endpoints"]["show"].format(show_id=show_id)
|
||||||
show_res = self.session.get(show_url).json()
|
show_res = self.session.get(show_url).json()
|
||||||
|
|
||||||
# On extrait le titre de la série (ex: "Demon Slave")
|
# We extract the series title (e.g. "Demon Slave")
|
||||||
# C'est ce titre qui servira de nom au dossier unique
|
# This title will be used as the unique folder name
|
||||||
series_title = show_res["videos"][0]["show"]["title"] if show_res.get("videos") else "ADN Show"
|
series_title = show_res["videos"][0]["show"]["title"] if show_res.get("videos") else "ADN Show"
|
||||||
|
|
||||||
# 2. Récupérer ensuite la structure par saisons
|
# 2. Fetch the season structure afterwards
|
||||||
url_seasons = self.config["endpoints"].get("seasons")
|
url_seasons = self.config["endpoints"].get("seasons")
|
||||||
if not url_seasons:
|
if not url_seasons:
|
||||||
url_seasons = "https://gw.api.animationdigitalnetwork.com/video/show/{show_id}/seasons?maxAgeCategory=18&order=asc"
|
url_seasons = "https://gw.api.animationdigitalnetwork.com/video/show/{show_id}/seasons?maxAgeCategory=18&order=asc"
|
||||||
@@ -403,7 +407,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
res = self.session.get(url_seasons.format(show_id=show_id)).json()
|
res = self.session.get(url_seasons.format(show_id=show_id)).json()
|
||||||
|
|
||||||
if not res.get("seasons"):
|
if not res.get("seasons"):
|
||||||
self.log.error(f"Aucune saison trouvée pour l'ID {show_id}")
|
self.log.error(f"No seasons found for ID {show_id}")
|
||||||
return Series([])
|
return Series([])
|
||||||
|
|
||||||
episodes = []
|
episodes = []
|
||||||
@@ -411,40 +415,178 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
s_val = str(season_data.get("season", "1"))
|
s_val = str(season_data.get("season", "1"))
|
||||||
season_num = int(s_val) if s_val.isdigit() else 1
|
season_num = int(s_val) if s_val.isdigit() else 1
|
||||||
|
|
||||||
for vid in season_data.get("videos", []):
|
for idx, vid in enumerate(season_data.get("videos", []), 1):
|
||||||
video_id = str(vid["id"])
|
video_id = str(vid["id"])
|
||||||
|
|
||||||
# Nettoyage du numéro d'épisode (on ne garde que les chiffres)
|
# Clean the episode number (keep only digits)
|
||||||
num_match = re.search(r'\d+', str(vid.get("number", "0")))
|
num_match = re.search(r'\d+', str(vid.get("number", "0")))
|
||||||
short_number = num_match.group() if num_match else "0"
|
short_number = num_match.group() if num_match else "0"
|
||||||
|
|
||||||
# Logique de sélection (SxxEyy)
|
# Selection logic (SxxEyy) - Relative and absolute support
|
||||||
if not self._parse_select(video_id, short_number, season_num):
|
if not self._parse_select(video_id, short_number, season_num, relative_number=idx):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Création de l'épisode
|
# Create the episode
|
||||||
episodes.append(Episode(
|
episodes.append(Episode(
|
||||||
id_=video_id,
|
id_=video_id,
|
||||||
service=self.__class__,
|
service=self.__class__,
|
||||||
title=series_title, # Dossier : "Demon Slave"
|
title=series_title, # Folder: "Demon Slave"
|
||||||
season=season_num, # Saison : 2
|
season=season_num, # Season: 2
|
||||||
number=int(short_number),
|
number=idx, # Force relative number (e.g. 30 becomes 6 in S3)
|
||||||
name=vid.get("name") or "", # Nom : "La grande réunion..."
|
name=vid.get("name") or "", # Name: "The big reunion..."
|
||||||
data=vid
|
data=vid
|
||||||
))
|
))
|
||||||
|
|
||||||
episodes.sort(key=lambda x: (x.season, x.number))
|
episodes.sort(key=lambda x: (x.season, x.number))
|
||||||
return Series(episodes)
|
return Series(episodes)
|
||||||
|
|
||||||
|
def get_discovery(self, n: int = 12) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Fetch the latest releases (Series) via the catalog API.
|
||||||
|
"""
|
||||||
|
self.ensure_authenticated()
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = self.config["endpoints"]["search"]
|
||||||
|
response = self.session.get(
|
||||||
|
url,
|
||||||
|
params={
|
||||||
|
"maxAgeCategory": 18,
|
||||||
|
"order": "new",
|
||||||
|
"limit": n
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
self.log.error(f"Catalog fetch failed: {response.status_code}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return data.get("shows", [])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.log.error(f"Error fetching discovery: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_latest_releases_calendar(self, n: int = 12) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Fetch the latest releases (Episodes) via the calendar API.
|
||||||
|
Returns recent episodes with their actual release dates.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# self.ensure_authenticated()
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = []
|
||||||
|
seen_episodes = set()
|
||||||
|
|
||||||
|
# Use a fresh session to avoid auth issues if calendar is public
|
||||||
|
cal_session = requests.Session()
|
||||||
|
cal_session.headers.update({
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Fetch calendar for the last 7 days including today
|
||||||
|
today = datetime.now()
|
||||||
|
|
||||||
|
for day_offset in range(15):
|
||||||
|
date = today - timedelta(days=day_offset)
|
||||||
|
date_str = date.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# Calendar endpoint
|
||||||
|
calendar_url = f"https://gw.api.animationdigitalnetwork.com/video/calendar?maxAgeCategory=18&date={date_str}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = cal_session.get(calendar_url, timeout=5)
|
||||||
|
if res.status_code != 200: continue
|
||||||
|
|
||||||
|
data = res.json()
|
||||||
|
# Handle dict response (API returns {"videos": [...]})
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data = data.get("videos", [])
|
||||||
|
|
||||||
|
for video in data:
|
||||||
|
show = video.get("show", {})
|
||||||
|
show_id = str(show.get("id", ""))
|
||||||
|
ep_id = str(video.get("id", ""))
|
||||||
|
|
||||||
|
if (show_id, ep_id) in seen_episodes: continue
|
||||||
|
seen_episodes.add((show_id, ep_id))
|
||||||
|
|
||||||
|
results.append(video)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Sort by releaseDate
|
||||||
|
results.sort(key=lambda x: x.get("releaseDate", ""), reverse=True)
|
||||||
|
return results[:n]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.log.error(f"Error fetching calendar discovery: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_latest_episode(self, show_id: str) -> Episode | None:
|
||||||
|
"""
|
||||||
|
Retrieves the absolute latest episode of a series without filtering.
|
||||||
|
Used by the GUI to display 'Latest' info.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 1. Fetch seasons
|
||||||
|
url_seasons = self.config["endpoints"].get("seasons")
|
||||||
|
if not url_seasons:
|
||||||
|
url_seasons = "https://gw.api.animationdigitalnetwork.com/video/show/{show_id}/seasons?maxAgeCategory=18&order=asc"
|
||||||
|
|
||||||
|
res = self.session.get(url_seasons.format(show_id=show_id))
|
||||||
|
if res.status_code != 200:
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = res.json()
|
||||||
|
if not data.get("seasons"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
episodes = []
|
||||||
|
for season_data in data["seasons"]:
|
||||||
|
s_val = str(season_data.get("season", "1"))
|
||||||
|
season_num = int(s_val) if s_val.isdigit() else 1
|
||||||
|
|
||||||
|
for vid in season_data.get("videos", []):
|
||||||
|
# Simplified parsing similar to get_titles
|
||||||
|
num_match = re.search(r'\d+', str(vid.get("number", "0")))
|
||||||
|
short_number = int(num_match.group()) if num_match else 0
|
||||||
|
|
||||||
|
episodes.append(Episode(
|
||||||
|
id_=str(vid["id"]),
|
||||||
|
service=self.__class__,
|
||||||
|
title="", # We don't need the series title here for simple display
|
||||||
|
season=season_num,
|
||||||
|
number=short_number,
|
||||||
|
name=vid.get("name") or "",
|
||||||
|
data=vid
|
||||||
|
))
|
||||||
|
|
||||||
|
if not episodes:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Sort: Season desc, Number desc to find absolute latest?
|
||||||
|
# Or Season asc, Number asc and take [-1]
|
||||||
|
episodes.sort(key=lambda x: (x.season, x.number))
|
||||||
|
return episodes[-1]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.log.error(f"Error in get_latest_episode: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def get_tracks(self, title: Episode) -> Tracks:
|
def get_tracks(self, title: Episode) -> Tracks:
|
||||||
"""
|
"""
|
||||||
Récupère les pistes en pré-extrayant les audios.
|
Fetches tracks by pre-extracting audio.
|
||||||
Les audios sont extraits maintenant et seront copiés pendant download().
|
Audio is extracted now and will be copied during download().
|
||||||
"""
|
"""
|
||||||
self.ensure_authenticated()
|
self.ensure_authenticated()
|
||||||
vid_id = title.id
|
vid_id = title.id
|
||||||
|
|
||||||
# Configuration du lecteur
|
# Player configuration
|
||||||
config_url = self.config["endpoints"]["player_config"].format(video_id=vid_id)
|
config_url = self.config["endpoints"]["player_config"].format(video_id=vid_id)
|
||||||
config_res = self.session.get(config_url).json()
|
config_res = self.session.get(config_url).json()
|
||||||
|
|
||||||
@@ -452,7 +594,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
if not player_opts["user"]["hasAccess"]:
|
if not player_opts["user"]["hasAccess"]:
|
||||||
raise PermissionError("No access to this video (Premium required?)")
|
raise PermissionError("No access to this video (Premium required?)")
|
||||||
|
|
||||||
# Token du lecteur
|
# Player token
|
||||||
refresh_url = player_opts["user"].get("refreshTokenUrl") or self.config["endpoints"]["player_refresh"]
|
refresh_url = player_opts["user"].get("refreshTokenUrl") or self.config["endpoints"]["player_refresh"]
|
||||||
token_res = self.session.post(
|
token_res = self.session.post(
|
||||||
refresh_url,
|
refresh_url,
|
||||||
@@ -462,7 +604,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
player_token = token_res["token"]
|
player_token = token_res["token"]
|
||||||
links_url = player_opts["video"].get("url") or self.config["endpoints"]["player_links"].format(video_id=vid_id)
|
links_url = player_opts["video"].get("url") or self.config["endpoints"]["player_links"].format(video_id=vid_id)
|
||||||
|
|
||||||
# Chiffrement RSA
|
# RSA Encryption
|
||||||
rand_key = uuid.uuid4().hex[:16]
|
rand_key = uuid.uuid4().hex[:16]
|
||||||
payload = json.dumps({"k": rand_key, "t": player_token}).encode('utf-8')
|
payload = json.dumps({"k": rand_key, "t": player_token}).encode('utf-8')
|
||||||
|
|
||||||
@@ -474,7 +616,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
encrypted = public_key.encrypt(payload, padding.PKCS1v15())
|
encrypted = public_key.encrypt(payload, padding.PKCS1v15())
|
||||||
auth_header_val = base64.b64encode(encrypted).decode('utf-8')
|
auth_header_val = base64.b64encode(encrypted).decode('utf-8')
|
||||||
|
|
||||||
# Récupération des liens
|
# Fetching links
|
||||||
links_res = self.session.get(
|
links_res = self.session.get(
|
||||||
links_url,
|
links_url,
|
||||||
params={"freeWithAds": "true", "adaptive": "true", "withMetadata": "true", "source": "Web"},
|
params={"freeWithAds": "true", "adaptive": "true", "withMetadata": "true", "source": "Web"},
|
||||||
@@ -484,7 +626,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
tracks = Tracks()
|
tracks = Tracks()
|
||||||
streaming_links = links_res.get("links", {}).get("streaming", {})
|
streaming_links = links_res.get("links", {}).get("streaming", {})
|
||||||
|
|
||||||
# Map des langues
|
# Language map
|
||||||
lang_map = {
|
lang_map = {
|
||||||
"vf": "fr",
|
"vf": "fr",
|
||||||
"vostf": "ja",
|
"vostf": "ja",
|
||||||
@@ -492,7 +634,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
"vostde": "ja",
|
"vostde": "ja",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Priorité: VOSTF (original) pour la vidéo principale
|
# Priority: VOSTF (original) for the main video
|
||||||
priority_order = ["vostf", "vf", "vde", "vostde"]
|
priority_order = ["vostf", "vf", "vde", "vostde"]
|
||||||
available_streams = {k: v for k, v in streaming_links.items() if k in lang_map}
|
available_streams = {k: v for k, v in streaming_links.items() if k in lang_map}
|
||||||
|
|
||||||
@@ -504,7 +646,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
if not sorted_streams:
|
if not sorted_streams:
|
||||||
raise ValueError("No supported streams found")
|
raise ValueError("No supported streams found")
|
||||||
|
|
||||||
# Vidéo principale (VOSTF ou premier disponible)
|
# Main video (VOSTF or first available)
|
||||||
primary_stream = sorted_streams[0]
|
primary_stream = sorted_streams[0]
|
||||||
primary_lang = lang_map[primary_stream]
|
primary_lang = lang_map[primary_stream]
|
||||||
|
|
||||||
@@ -521,7 +663,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
tracks.add(video_track)
|
tracks.add(video_track)
|
||||||
self.log.info(f"Video track added: {video_track.width}x{video_track.height}")
|
self.log.info(f"Video track added: {video_track.width}x{video_track.height}")
|
||||||
|
|
||||||
# Extraire audios pour toutes les langues disponibles
|
# Extract audio for all available languages
|
||||||
for stream_type in sorted_streams:
|
for stream_type in sorted_streams:
|
||||||
audio_lang = lang_map[stream_type]
|
audio_lang = lang_map[stream_type]
|
||||||
is_original = stream_type in ["vostf", "vostde"]
|
is_original = stream_type in ["vostf", "vostde"]
|
||||||
@@ -540,12 +682,12 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
tracks.add(audio_track, warn_only=True)
|
tracks.add(audio_track, warn_only=True)
|
||||||
self.log.info(f"Audio track added: {audio_lang}")
|
self.log.info(f"Audio track added: {audio_lang}")
|
||||||
|
|
||||||
# Stocker les données de chapitres pour get_chapters()
|
# Store chapter data for get_chapters()
|
||||||
if "video" in links_res:
|
if "video" in links_res:
|
||||||
title.data["chapter_data"] = links_res["video"]
|
title.data["chapter_data"] = links_res["video"]
|
||||||
self.log.debug(f"Stored chapter data: intro={links_res['video'].get('tcIntroStart')}, ending={links_res['video'].get('tcEndingStart')}")
|
self.log.debug(f"Stored chapter data: intro={links_res['video'].get('tcIntroStart')}, ending={links_res['video'].get('tcEndingStart')}")
|
||||||
|
|
||||||
# Sous-titres
|
# Subtitles
|
||||||
self._process_subtitles(links_res, rand_key, title, tracks)
|
self._process_subtitles(links_res, rand_key, title, tracks)
|
||||||
|
|
||||||
if not tracks.videos:
|
if not tracks.videos:
|
||||||
@@ -554,7 +696,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
return tracks
|
return tracks
|
||||||
|
|
||||||
def _get_video_track(self, stream_data: dict, stream_type: str, lang: str, is_original: bool):
|
def _get_video_track(self, stream_data: dict, stream_type: str, lang: str, is_original: bool):
|
||||||
"""Récupère la piste vidéo principale (sans audio)."""
|
"""Fetches the main video track (without audio)."""
|
||||||
try:
|
try:
|
||||||
m3u8_url = self._resolve_stream_url(stream_data, stream_type)
|
m3u8_url = self._resolve_stream_url(stream_data, stream_type)
|
||||||
if not m3u8_url:
|
if not m3u8_url:
|
||||||
@@ -567,13 +709,13 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
self.log.warning(f"No video tracks found for {stream_type}")
|
self.log.warning(f"No video tracks found for {stream_type}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Meilleure qualité
|
# Best quality
|
||||||
best_video = max(
|
best_video = max(
|
||||||
hls_tracks.videos,
|
hls_tracks.videos,
|
||||||
key=lambda v: (v.height or 0, v.width or 0, v.bitrate or 0)
|
key=lambda v: (v.height or 0, v.width or 0, v.bitrate or 0)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Convertir en VideoNoAudio pour demuxer automatiquement
|
# Convert to VideoNoAudio to demux automatically
|
||||||
video_no_audio = VideoNoAudio(
|
video_no_audio = VideoNoAudio(
|
||||||
id_=best_video.id,
|
id_=best_video.id,
|
||||||
url=best_video.url,
|
url=best_video.url,
|
||||||
@@ -599,36 +741,67 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
|
|
||||||
def _extract_audio_track(self, stream_data: dict, stream_type: str, lang: str, is_original: bool, title: Episode):
|
def _extract_audio_track(self, stream_data: dict, stream_type: str, lang: str, is_original: bool, title: Episode):
|
||||||
"""
|
"""
|
||||||
Extrait l'audio et retourne un AudioExtracted.
|
Extracts audio and returns an AudioExtracted.
|
||||||
L'audio est extrait MAINTENANT et sera copié pendant download().
|
Audio is extracted NOW and will be copied during download().
|
||||||
"""
|
"""
|
||||||
if not binaries.FFMPEG:
|
if not binaries.FFMPEG:
|
||||||
self.log.warning("FFmpeg not found, cannot extract audio")
|
self.log.warning("FFmpeg not found, cannot extract audio")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
m3u8_url = self._resolve_stream_url(stream_data, stream_type)
|
m3u8_url = self._resolve_stream_url(stream_data, stream_type, prioritize_auto=True)
|
||||||
if not m3u8_url:
|
if not m3u8_url:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Créer un répertoire temp pour ADN dans le temp d'Unshackle
|
# Create an ADN temp directory inside envied.s temp
|
||||||
adn_temp = config.directories.temp / "adn_audio_extracts"
|
adn_temp = config.directories.temp / "adn_audio_extracts"
|
||||||
adn_temp.mkdir(parents=True, exist_ok=True)
|
adn_temp.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Nom de fichier unique basé sur video_id + langue
|
# Unique filename based on video_id + language
|
||||||
audio_filename = f"audio_{title.id}_{stream_type}.m4a"
|
audio_filename = f"audio_{title.id}_{stream_type}.m4a"
|
||||||
audio_path = adn_temp / audio_filename
|
audio_path = adn_temp / audio_filename
|
||||||
|
|
||||||
# Si déjà extrait, réutiliser
|
# If already extracted, reuse it
|
||||||
if audio_path.exists() and audio_path.stat().st_size > 1000:
|
if audio_path.exists() and audio_path.stat().st_size > 1000:
|
||||||
self.log.debug(f"Reusing existing extracted audio: {audio_path}")
|
self.log.debug(f"Reusing existing extracted audio: {audio_path}")
|
||||||
else:
|
else:
|
||||||
|
|
||||||
# Extraire avec FFmpeg
|
# Parse M3U8 to find best audio
|
||||||
|
best_m3u8_url = m3u8_url
|
||||||
|
try:
|
||||||
|
import m3u8
|
||||||
|
variant_m3u8 = m3u8.load(m3u8_url)
|
||||||
|
|
||||||
|
audio_playlists = []
|
||||||
|
# Check for alternative audio in media
|
||||||
|
for media in variant_m3u8.media:
|
||||||
|
if media.type == "AUDIO" and media.uri:
|
||||||
|
audio_playlists.append(media.uri)
|
||||||
|
|
||||||
|
# If no media, check strict playlists (variants)
|
||||||
|
if not audio_playlists and variant_m3u8.playlists:
|
||||||
|
# Sort by bandwidth descending
|
||||||
|
sorted_playlists = sorted(variant_m3u8.playlists, key=lambda x: x.stream_info.bandwidth or 0, reverse=True)
|
||||||
|
audio_playlists = [p.uri for p in sorted_playlists]
|
||||||
|
|
||||||
|
if audio_playlists:
|
||||||
|
# Construct absolute URL if needed
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
best_audio_uri = audio_playlists[0]
|
||||||
|
if not best_audio_uri.startswith("http"):
|
||||||
|
best_m3u8_url = urljoin(m3u8_url, best_audio_uri)
|
||||||
|
else:
|
||||||
|
best_m3u8_url = best_audio_uri
|
||||||
|
self.log.info(f"Selected best audio stream: {best_m3u8_url}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.log.warning(f"Failed to parse M3U8 for best audio, using default: {e}")
|
||||||
|
|
||||||
|
# Extract with FFmpeg
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[
|
[
|
||||||
binaries.FFMPEG,
|
binaries.FFMPEG,
|
||||||
'-i', m3u8_url,
|
'-i', best_m3u8_url,
|
||||||
'-vn',
|
'-vn',
|
||||||
'-acodec', 'copy',
|
'-acodec', 'copy',
|
||||||
'-y',
|
'-y',
|
||||||
@@ -649,14 +822,35 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
audio_path.unlink(missing_ok=True)
|
audio_path.unlink(missing_ok=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Créer AudioExtracted avec le fichier pré-extrait
|
# Detect actual bitrate
|
||||||
|
detected_bitrate = 128000
|
||||||
|
try:
|
||||||
|
from pymediainfo import MediaInfo
|
||||||
|
media_info = MediaInfo.parse(audio_path)
|
||||||
|
if media_info.audio_tracks:
|
||||||
|
track = media_info.audio_tracks[0]
|
||||||
|
if track.bit_rate:
|
||||||
|
detected_bitrate = int(track.bit_rate)
|
||||||
|
elif track.other_bit_rate:
|
||||||
|
# Fallback for some formats
|
||||||
|
try:
|
||||||
|
# other_bit_rate is typically list like ['128 kb/s']
|
||||||
|
raw = track.other_bit_rate[0]
|
||||||
|
detected_bitrate = int(re.sub(r'[^\d]', '', raw)) * 1000
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
self.log.debug(f"Detected audio bitrate: {detected_bitrate}")
|
||||||
|
except Exception as e:
|
||||||
|
self.log.warning(f"Failed to detect bitrate: {e}")
|
||||||
|
|
||||||
|
# Create AudioExtracted with the pre-extracted file
|
||||||
audio_track = AudioExtracted(
|
audio_track = AudioExtracted(
|
||||||
id_=f"audio-{stream_type}-{lang}",
|
id_=f"audio-{stream_type}-{lang}",
|
||||||
extracted_path=audio_path,
|
extracted_path=audio_path,
|
||||||
codec=Audio.Codec.AAC,
|
codec=Audio.Codec.AAC,
|
||||||
language=Language.get(lang),
|
language=Language.get(lang),
|
||||||
is_original_lang=is_original,
|
is_original_lang=is_original,
|
||||||
bitrate=128000,
|
bitrate=detected_bitrate,
|
||||||
channels=2.0,
|
channels=2.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -669,9 +863,12 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
self.log.error(f"Failed to extract audio for {stream_type}: {e}")
|
self.log.error(f"Failed to extract audio for {stream_type}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _resolve_stream_url(self, stream_data: dict, stream_type: str) -> Optional[str]:
|
def _resolve_stream_url(self, stream_data: dict, stream_type: str, prioritize_auto: bool = False) -> Optional[str]:
|
||||||
"""Résout l'URL du stream."""
|
"""Resolves the stream URL."""
|
||||||
preferred_keys = ["fhd", "hd", "auto", "sd", "mobile"]
|
if prioritize_auto:
|
||||||
|
preferred_keys = ["auto", "fhd", "hd", "sd", "mobile"]
|
||||||
|
else:
|
||||||
|
preferred_keys = ["fhd", "hd", "auto", "sd", "mobile"]
|
||||||
|
|
||||||
m3u8_url = None
|
m3u8_url = None
|
||||||
for key in preferred_keys:
|
for key in preferred_keys:
|
||||||
@@ -706,7 +903,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _process_subtitles(self, links_res: dict, rand_key: str, title: Episode, tracks: Tracks):
|
def _process_subtitles(self, links_res: dict, rand_key: str, title: Episode, tracks: Tracks):
|
||||||
"""Traite les sous-titres."""
|
"""Processes subtitles."""
|
||||||
subs_root = links_res.get("links", {}).get("subtitles", {})
|
subs_root = links_res.get("links", {}).get("subtitles", {})
|
||||||
if "all" not in subs_root:
|
if "all" not in subs_root:
|
||||||
self.log.debug("No subtitles available")
|
self.log.debug("No subtitles available")
|
||||||
@@ -732,13 +929,13 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
decryptor = cipher.decryptor()
|
decryptor = cipher.decryptor()
|
||||||
decrypted_padded = decryptor.update(ciphertext) + decryptor.finalize()
|
decrypted_padded = decryptor.update(ciphertext) + decryptor.finalize()
|
||||||
|
|
||||||
# TOUJOURS retirer le padding PKCS7 (Python ne le fait pas automatiquement)
|
# ALWAYS remove PKCS7 padding (Python doesn't do it automatically)
|
||||||
pad_len = decrypted_padded[-1]
|
pad_len = decrypted_padded[-1]
|
||||||
if not (1 <= pad_len <= 16):
|
if not (1 <= pad_len <= 16):
|
||||||
self.log.error(f"Invalid PKCS7 padding length: {pad_len}")
|
self.log.error(f"Invalid PKCS7 padding length: {pad_len}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Vérifier que tous les bytes de padding ont la même valeur
|
# Ensure all padding bytes have the same value
|
||||||
padding = decrypted_padded[-pad_len:]
|
padding = decrypted_padded[-pad_len:]
|
||||||
if not all(b == pad_len for b in padding):
|
if not all(b == pad_len for b in padding):
|
||||||
self.log.error(f"Invalid PKCS7 padding bytes")
|
self.log.error(f"Invalid PKCS7 padding bytes")
|
||||||
@@ -759,7 +956,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
self.log.warning("subs_data is empty!")
|
self.log.warning("subs_data is empty!")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Debug chaque clé
|
# Debug each key
|
||||||
for key in subs_data.keys():
|
for key in subs_data.keys():
|
||||||
value = subs_data[key]
|
value = subs_data[key]
|
||||||
if isinstance(value, list) and len(value) > 0:
|
if isinstance(value, list) and len(value) > 0:
|
||||||
@@ -781,24 +978,32 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
self.log.debug(f" Cues count: {len(cues)}")
|
self.log.debug(f" Cues count: {len(cues)}")
|
||||||
self.log.debug(f" First cue: {cues[0]}")
|
self.log.debug(f" First cue: {cues[0]}")
|
||||||
|
|
||||||
if "vf" in sub_lang_key.lower() or "vostf" in sub_lang_key.lower():
|
if "vf" in sub_lang_key.lower():
|
||||||
target_lang = "fr"
|
target_lang = "fr"
|
||||||
elif "vde" in sub_lang_key.lower() or "vostde" in sub_lang_key.lower():
|
is_forced = True
|
||||||
|
elif "vostf" in sub_lang_key.lower():
|
||||||
|
target_lang = "fr"
|
||||||
|
is_forced = False
|
||||||
|
elif "vde" in sub_lang_key.lower():
|
||||||
target_lang = "de"
|
target_lang = "de"
|
||||||
|
is_forced = True
|
||||||
|
elif "vostde" in sub_lang_key.lower():
|
||||||
|
target_lang = "de"
|
||||||
|
is_forced = False
|
||||||
else:
|
else:
|
||||||
self.log.debug(f"Skipping subtitle language: {sub_lang_key}")
|
self.log.debug(f"Skipping subtitle language: {sub_lang_key}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if target_lang in processed_langs:
|
if (target_lang, is_forced) in processed_langs:
|
||||||
self.log.debug(f"Already processed {target_lang}, skipping")
|
self.log.debug(f"Already processed {target_lang} (forced={is_forced}), skipping")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
processed_langs.add(target_lang)
|
processed_langs.add((target_lang, is_forced))
|
||||||
|
|
||||||
# Convertir en ASS
|
# Convert to ASS
|
||||||
ass_content = self._json_to_ass(cues, title.title, title.number)
|
ass_content = self._json_to_ass(cues, title.title, title.number)
|
||||||
|
|
||||||
# Vérifier si le fichier ASS a du contenu
|
# Check if ASS file has content
|
||||||
event_count = ass_content.count("Dialogue:")
|
event_count = ass_content.count("Dialogue:")
|
||||||
self.log.debug(f"Generated ASS with {event_count} dialogue events")
|
self.log.debug(f"Generated ASS with {event_count} dialogue events")
|
||||||
|
|
||||||
@@ -806,13 +1011,13 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
self.log.warning(f"ASS file has no dialogue events!")
|
self.log.warning(f"ASS file has no dialogue events!")
|
||||||
self.log.warning(f"First cue was: {cues[0] if cues else 'EMPTY LIST'}")
|
self.log.warning(f"First cue was: {cues[0] if cues else 'EMPTY LIST'}")
|
||||||
|
|
||||||
# Créer SubtitleEmbedded avec le contenu ASS directement
|
# Create SubtitleEmbedded directly with ASS content
|
||||||
subtitle = SubtitleEmbedded(
|
subtitle = SubtitleEmbedded(
|
||||||
id_=f"sub-{target_lang}-{sub_lang_key}",
|
id_=f"sub-{target_lang}-{sub_lang_key}",
|
||||||
embedded_content=ass_content, # Contenu ASS directement
|
embedded_content=ass_content, # ASS content directly
|
||||||
codec=Subtitle.Codec.SubStationAlphav4,
|
codec=Subtitle.Codec.SubStationAlphav4,
|
||||||
language=Language.get(target_lang),
|
language=Language.get(target_lang),
|
||||||
forced=False,
|
forced=is_forced,
|
||||||
sdh=False,
|
sdh=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -829,19 +1034,19 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
|
|
||||||
def get_chapters(self, title: Episode) -> Chapters:
|
def get_chapters(self, title: Episode) -> Chapters:
|
||||||
"""
|
"""
|
||||||
Crée les chapitres à partir des timecodes ADN.
|
Creates chapters from ADN timecodes.
|
||||||
- Si tcIntroStart existe:
|
- If tcIntroStart exists:
|
||||||
- Si tcIntroStart != "00:00:00": ajouter "Prologue" à 00:00:00
|
- If tcIntroStart != "00:00:00": add "Prologue" at 00:00:00
|
||||||
- Ajouter "Opening" à tcIntroStart
|
- Add "Opening" at tcIntroStart
|
||||||
- Ajouter "Episode" à tcIntroEnd
|
- Add "Episode" at tcIntroEnd
|
||||||
- Sinon: ajouter "Episode" à 00:00:00
|
- Otherwise: add "Episode" at 00:00:00
|
||||||
- Si tcEndingStart existe:
|
- If tcEndingStart exists:
|
||||||
- Ajouter "Ending Start" à tcEndingStart
|
- Add "Ending Start" at tcEndingStart
|
||||||
- Ajouter "Ending End" à tcEndingEnd
|
- Add "Ending End" at tcEndingEnd
|
||||||
"""
|
"""
|
||||||
chapters = Chapters()
|
chapters = Chapters()
|
||||||
|
|
||||||
# Récupérer les données de chapitres stockées dans get_tracks()
|
# Retrieve chapter data stored in get_tracks()
|
||||||
chapter_data = title.data.get("chapter_data", {})
|
chapter_data = title.data.get("chapter_data", {})
|
||||||
if not chapter_data:
|
if not chapter_data:
|
||||||
self.log.debug("No chapter data available")
|
self.log.debug("No chapter data available")
|
||||||
@@ -855,51 +1060,73 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
self.log.debug(f"Chapter timecodes: intro={tc_intro_start}->{tc_intro_end}, ending={tc_ending_start}->{tc_ending_end}")
|
self.log.debug(f"Chapter timecodes: intro={tc_intro_start}->{tc_intro_end}, ending={tc_ending_start}->{tc_ending_end}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
chapter_num = 1
|
||||||
|
|
||||||
if tc_intro_start:
|
if tc_intro_start:
|
||||||
# Si l'intro ne commence pas à 00:00:00, ajouter un prologue
|
# If intro does not start at 00:00:00, add a prologue (Chapter 1)
|
||||||
if tc_intro_start != "00:00:00":
|
if tc_intro_start != "00:00:00":
|
||||||
chapters.add(Chapter(
|
chapters.add(Chapter(
|
||||||
timestamp=0,
|
timestamp=0,
|
||||||
name="Prologue"
|
name=f"Chapter {chapter_num}"
|
||||||
))
|
))
|
||||||
self.log.debug("Added Prologue chapter at 00:00:00")
|
self.log.debug(f"Added Chapter {chapter_num} at 00:00:00")
|
||||||
|
chapter_num += 1
|
||||||
|
|
||||||
# Opening
|
# Opening
|
||||||
chapters.add(Chapter(
|
chapters.add(Chapter(
|
||||||
timestamp=self._timecode_to_ms(tc_intro_start),
|
timestamp=self._timecode_to_ms(tc_intro_start),
|
||||||
name="Opening"
|
name="Opening"
|
||||||
))
|
))
|
||||||
self.log.debug(f"Added Opening chapter at {tc_intro_start}")
|
self.log.debug(f"Added Opening at {tc_intro_start}")
|
||||||
|
|
||||||
# Episode (après l'intro)
|
# Episode (after intro)
|
||||||
if tc_intro_end:
|
if tc_intro_end:
|
||||||
chapters.add(Chapter(
|
chapters.add(Chapter(
|
||||||
timestamp=self._timecode_to_ms(tc_intro_end),
|
timestamp=self._timecode_to_ms(tc_intro_end),
|
||||||
name="Episode"
|
name=f"Chapter {chapter_num}"
|
||||||
))
|
))
|
||||||
self.log.debug(f"Added Episode chapter at {tc_intro_end}")
|
self.log.debug(f"Added Chapter {chapter_num} at {tc_intro_end}")
|
||||||
|
chapter_num += 1
|
||||||
else:
|
else:
|
||||||
# Pas d'intro, épisode commence à 00:00:00
|
# No intro, episode starts at 00:00:00
|
||||||
chapters.add(Chapter(
|
chapters.add(Chapter(
|
||||||
timestamp=0,
|
timestamp=0,
|
||||||
name="Episode"
|
name=f"Chapter {chapter_num}"
|
||||||
))
|
))
|
||||||
self.log.debug("Added Episode chapter at 00:00:00 (no intro)")
|
self.log.debug(f"Added Chapter {chapter_num} at 00:00:00 (no intro)")
|
||||||
|
chapter_num += 1
|
||||||
|
|
||||||
# Ending
|
# Ending
|
||||||
if tc_ending_start:
|
if tc_ending_start:
|
||||||
chapters.add(Chapter(
|
chapters.add(Chapter(
|
||||||
timestamp=self._timecode_to_ms(tc_ending_start),
|
timestamp=self._timecode_to_ms(tc_ending_start),
|
||||||
name="Ending Start"
|
name="Ending"
|
||||||
))
|
))
|
||||||
self.log.debug(f"Added Ending Start chapter at {tc_ending_start}")
|
self.log.debug(f"Added Ending at {tc_ending_start}")
|
||||||
|
|
||||||
if tc_ending_end:
|
if tc_ending_end:
|
||||||
chapters.add(Chapter(
|
# Check if the remaining chapter has a significant duration (> 10s)
|
||||||
timestamp=self._timecode_to_ms(tc_ending_end),
|
# to avoid micro-chapters of 2s, while keeping actual post-credits scenes.
|
||||||
name="Ending End"
|
|
||||||
))
|
tc_end_ms = self._timecode_to_ms(tc_ending_end)
|
||||||
self.log.debug(f"Added Ending End chapter at {tc_ending_end}")
|
total_duration_s = title.data.get("duration", 0)
|
||||||
|
total_duration_ms = int(total_duration_s * 1000)
|
||||||
|
|
||||||
|
# If duration is unknown or > 10 seconds remaining
|
||||||
|
should_add = True
|
||||||
|
if total_duration_ms > 0:
|
||||||
|
remaining_ms = total_duration_ms - tc_end_ms
|
||||||
|
if remaining_ms < 10000: # Less than 10s
|
||||||
|
should_add = False
|
||||||
|
self.log.debug(f"Skipping post-ending chapter (only {remaining_ms}ms remaining)")
|
||||||
|
|
||||||
|
if should_add:
|
||||||
|
chapters.add(Chapter(
|
||||||
|
timestamp=tc_end_ms,
|
||||||
|
name=f"Chapter {chapter_num}"
|
||||||
|
))
|
||||||
|
self.log.debug(f"Added Chapter {chapter_num} at {tc_ending_end}")
|
||||||
|
chapter_num += 1
|
||||||
|
|
||||||
self.log.info(f"✓ Created {len(chapters)} chapters")
|
self.log.info(f"✓ Created {len(chapters)} chapters")
|
||||||
|
|
||||||
@@ -935,17 +1162,20 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
|||||||
raise ValueError(f"Invalid ADN Show ID/URL: {input_str}")
|
raise ValueError(f"Invalid ADN Show ID/URL: {input_str}")
|
||||||
|
|
||||||
def _json_to_ass(self, cues: List[dict], title: str, ep_num: Union[int, str]) -> str:
|
def _json_to_ass(self, cues: List[dict], title: str, ep_num: Union[int, str]) -> str:
|
||||||
"""Convertit les sous-titres JSON en ASS."""
|
"""Converts JSON subtitles to ASS."""
|
||||||
header = """[Script Info]
|
header = """[Script Info]
|
||||||
ScriptType: v4.00+
|
ScriptType: v4.00+
|
||||||
WrapStyle: 0
|
WrapStyle: 0
|
||||||
PlayResX: 1280
|
Collisions: Normal
|
||||||
PlayResY: 720
|
PlayResX: 1920
|
||||||
|
PlayResY: 1080
|
||||||
|
Timer: 0.0000
|
||||||
|
WrapStyle: 0
|
||||||
ScaledBorderAndShadow: yes
|
ScaledBorderAndShadow: yes
|
||||||
|
|
||||||
[V4+ Styles]
|
[V4+ Styles]
|
||||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||||
Style: Default,Arial,50,&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,1.95,0,2,0,0,70,0
|
Style: Default,Trebuchet MS,66,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,3,3,2,75,75,75,1
|
||||||
|
|
||||||
[Events]
|
[Events]
|
||||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||||
@@ -955,7 +1185,7 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|||||||
line_align_map = {"middle": 8, "end": 4}
|
line_align_map = {"middle": 8, "end": 4}
|
||||||
|
|
||||||
def format_time(seconds: float) -> str:
|
def format_time(seconds: float) -> str:
|
||||||
"""Format exact d'adn : HH:MM:SS.CC (centisecondes sur 2 chiffres)"""
|
"""Exact ADN format: HH:MM:SS.CC (2-digit centiseconds)"""
|
||||||
secs = int(seconds)
|
secs = int(seconds)
|
||||||
centiseconds = round((seconds - secs) * 100)
|
centiseconds = round((seconds - secs) * 100)
|
||||||
|
|
||||||
@@ -963,7 +1193,7 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|||||||
minutes = (secs % 3600) // 60
|
minutes = (secs % 3600) // 60
|
||||||
remaining_seconds = secs % 60
|
remaining_seconds = secs % 60
|
||||||
|
|
||||||
# Padding sur 2 chiffres pour TOUT (hours inclus)
|
# 2-digit padding for EVERYTHING (including hours)
|
||||||
return f"{hours:02d}:{minutes:02d}:{remaining_seconds:02d}.{centiseconds:02d}"
|
return f"{hours:02d}:{minutes:02d}:{remaining_seconds:02d}.{centiseconds:02d}"
|
||||||
|
|
||||||
for cue in cues:
|
for cue in cues:
|
||||||
@@ -971,11 +1201,11 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|||||||
end_time = cue.get("endTime", 0)
|
end_time = cue.get("endTime", 0)
|
||||||
text = cue.get("text", "")
|
text = cue.get("text", "")
|
||||||
|
|
||||||
# Skip si texte vide
|
# Skip if text is empty
|
||||||
if not text or not text.strip():
|
if not text or not text.strip():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Nettoyage EXACT du code adn
|
# EXACT cleanup corresponding to ADN code
|
||||||
text = text.replace(' \\N', '\\N') # remove space before \\N at end
|
text = text.replace(' \\N', '\\N') # remove space before \\N at end
|
||||||
if text.endswith('\\N'):
|
if text.endswith('\\N'):
|
||||||
text = text[:-2] # remove \\N at end
|
text = text[:-2] # remove \\N at end
|
||||||
@@ -993,7 +1223,7 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|||||||
text = text[:-2]
|
text = text[:-2]
|
||||||
text = text.rstrip() # remove trailing spaces
|
text = text.rstrip() # remove trailing spaces
|
||||||
|
|
||||||
# Skip après nettoyage si vide
|
# Skip after cleanup if empty
|
||||||
if not text.strip():
|
if not text.strip():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
# Animation Digital Network API Configuration
|
# API Configuration
|
||||||
|
|
||||||
# Endpoints API
|
# API Endpoints
|
||||||
endpoints:
|
endpoints:
|
||||||
# Authentification
|
# Authentication
|
||||||
login: "https://gw.api.animationdigitalnetwork.com/authentication/login"
|
login: "https://gw.api.animationdigitalnetwork.com/authentication/login"
|
||||||
refresh: "https://gw.api.animationdigitalnetwork.com/authentication/refresh"
|
refresh: "https://gw.api.animationdigitalnetwork.com/authentication/refresh"
|
||||||
|
|
||||||
# Catalogue
|
# Catalog
|
||||||
search: "https://gw.api.animationdigitalnetwork.com/show/catalog"
|
search: "https://gw.api.animationdigitalnetwork.com/show/catalog"
|
||||||
show: "https://gw.api.animationdigitalnetwork.com/video/show/{show_id}?maxAgeCategory=18&limit=-1&order=asc"
|
show: "https://gw.api.animationdigitalnetwork.com/video/show/{show_id}?maxAgeCategory=18&limit=-1&order=asc"
|
||||||
seasons: "https://gw.api.animationdigitalnetwork.com/video/show/{show_id}/seasons?maxAgeCategory=18&order=asc"
|
seasons: "https://gw.api.animationdigitalnetwork.com/video/show/{show_id}/seasons?maxAgeCategory=18&order=asc"
|
||||||
|
|
||||||
# Player & Lecture
|
# Player & Playback
|
||||||
player_config: "https://gw.api.animationdigitalnetwork.com/player/video/{video_id}/configuration"
|
player_config: "https://gw.api.animationdigitalnetwork.com/player/video/{video_id}/configuration"
|
||||||
player_refresh: "https://gw.api.animationdigitalnetwork.com/player/refresh/token"
|
player_refresh: "https://gw.api.animationdigitalnetwork.com/player/refresh/token"
|
||||||
player_links: "https://gw.api.animationdigitalnetwork.com/player/video/{video_id}/link"
|
player_links: "https://gw.api.animationdigitalnetwork.com/player/video/{video_id}/link"
|
||||||
|
|
||||||
# Headers par défaut
|
# Default Headers
|
||||||
headers:
|
headers:
|
||||||
User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||||
Origin: "https://animationdigitalnetwork.com"
|
Origin: "https://animationdigitalnetwork.com"
|
||||||
@@ -24,6 +24,6 @@ headers:
|
|||||||
Content-Type: "application/json"
|
Content-Type: "application/json"
|
||||||
X-Target-Distribution: "fr"
|
X-Target-Distribution: "fr"
|
||||||
|
|
||||||
# Paramètres
|
# Parameters
|
||||||
params:
|
params:
|
||||||
locale: "fr"
|
locale: "fr"
|
||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -9,72 +9,29 @@ certificate: |
|
|||||||
uGl1ComHxXV9i1AqHwzzY2JY2vFqo73jR3IElr6oChPIwcNokmNc0D4TXtjE0BoYkbWKJfHvJJihzMOvDicWUsemVHvua9/FBtpbHgpbgwijFPjtQF9Ldb
|
uGl1ComHxXV9i1AqHwzzY2JY2vFqo73jR3IElr6oChPIwcNokmNc0D4TXtjE0BoYkbWKJfHvJJihzMOvDicWUsemVHvua9/FBtpbHgpbgwijFPjtQF9Ldb
|
||||||
8Swf
|
8Swf
|
||||||
|
|
||||||
|
# device:
|
||||||
|
# new:
|
||||||
|
# domain: Device
|
||||||
|
# app_name: 'com.amazon.amazonvideo.livingroom' # AIV, com.amazon.amazonvideo.livingroom
|
||||||
|
# app_version: '1.1' # AIV: 3.12.0, livingroom: 1.1
|
||||||
|
# device_model: 'Nexus Player' # SHIELD Android TV, Nexus Player
|
||||||
|
# os_version: '8.0.0' # 25, 8.0.0
|
||||||
|
# device_type: 'A2SNKIF736WF4T'
|
||||||
|
# device_name: '%FIRST_NAME%''s%DUPE_STRATEGY_1ST% Nexus Player'
|
||||||
|
# device_serial: 'a906a7f9bfd6a7ab' # f8eb5625fd608718
|
||||||
|
|
||||||
|
|
||||||
device:
|
device:
|
||||||
|
|
||||||
# old: # !<< take note that this is done per-profile
|
|
||||||
# domain: Device
|
|
||||||
# app_name: AIV
|
|
||||||
# app_version: '3.12.0'
|
|
||||||
# device_model: 'SHIELD Android TV'
|
|
||||||
# os_version: '28'
|
|
||||||
# device_type: A1KAXIG6VXSG8Y
|
|
||||||
# device_serial: '13f5b56b4a17de5d136f0e4c28236109' # os.urandom(16).hex()
|
|
||||||
# device_name: "Build/LMY47D Shield TV"
|
|
||||||
# software_version: '248'
|
|
||||||
|
|
||||||
# old2:
|
|
||||||
# 'domain': 'DeviceLegacy'
|
|
||||||
# 'device_type': A1KAXIG6VXSG8Y,
|
|
||||||
# 'device_serial': '870f53d1b509594c2f8cd5e340a7d374'
|
|
||||||
# 'app_name': 'com.amazon.avod.thirdpartyclient'
|
|
||||||
# 'app_version': '296016847'
|
|
||||||
# 'device_model': 'mdarcy/nvidia/SHIELD Android TV'
|
|
||||||
# 'os_version': 'NVIDIA/mdarcy/mdarcy:11/RQ1A.210105.003/7094531_2971.7725:user/release-keys'
|
|
||||||
|
|
||||||
# old3:
|
|
||||||
# domain: Device
|
|
||||||
# app_name: com.amazon.amazonvideo.livingroom
|
|
||||||
# app_version: '1.4'
|
|
||||||
# device_model: PadFone
|
|
||||||
# os_version: '6.2.5'
|
|
||||||
# device_type: 'A2SNKIF736WF4T'
|
|
||||||
# device_name: 'T008 Build/JSS15Q PadFone' # "%FIRST_NAME%'s%DUPE_STRATEGY_1ST% PadFone"
|
|
||||||
# device_serial: 'c1ebbb433da4afdf'
|
|
||||||
|
|
||||||
# old4:
|
|
||||||
# domain: Device
|
|
||||||
# app_name: com.amazon.amazonvideo.livingroom
|
|
||||||
# app_version: '1.4'
|
|
||||||
# device_model: 'Hisense TV'
|
|
||||||
# os_version: '3.9.5'
|
|
||||||
# device_type: 'A3T3XXY42KZQNP' # A2B5DGIWVDH8J3, A3GTP8TAF8V3YG, AFTHA001 # https://developer.amazon.com/docs/fire-tv/identify-amazon-fire-tv-devices.html, https://github.com/giofrida/Hisense-Amazon-Enabler
|
|
||||||
# device_name: "%FIRST_NAME%'s%DUPE_STRATEGY_1ST% Hisense" # KS964, Build/RP1A.201005.001
|
|
||||||
# device_serial: '8e3ddf49ee384247'
|
|
||||||
|
|
||||||
# old5:
|
|
||||||
# domain: Device
|
|
||||||
# app_name: com.amazon.amazonvideo.livingroom
|
|
||||||
# app_version: '1.1'
|
|
||||||
# device_model: Hisense
|
|
||||||
# os_version: '6.0.1' #6.10.19
|
|
||||||
# device_type: 'A3REWRVYBYPKUM'
|
|
||||||
# device_name: '%FIRST_NAME%''s%DUPE_STRATEGY_1ST% Hisense'
|
|
||||||
# device_serial: 'cd24294bffb75a46' # os.urandom(8).hex()
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
domain: Device
|
domain: Device
|
||||||
app_name: com.amazon.amazonvideo.livingroom
|
app_name: 'com.amazon.amazonvideo.livingroom'
|
||||||
app_version: '1.4'
|
app_version: '7.3.4'
|
||||||
device_model: 'MTC'
|
device_model: 'SHIELD Android TV'
|
||||||
os_version: '6.0.1' #6.10.19
|
device_name: "%FIRST_NAME%'s%DUPE_STRATEGY_1ST% Shield TV"
|
||||||
device_type: 'A2HYAJ0FEWP6N3'
|
os_version: '28'
|
||||||
device_name: '%FIRST_NAME%''s%DUPE_STRATEGY_1ST% MTC'
|
device_type: 'A1KAXIG6VXSG8Y'
|
||||||
device_serial: 'e6eb1ecdc8e34320'
|
device_serial: '9a4740e6fa227483'
|
||||||
|
software_version: '248'
|
||||||
#Hisense_HU32E5600FHWV: A2RGJ95OVLR12U
|
|
||||||
#Hisense_HU50A6100UW: AAJ692ZPT1X85
|
|
||||||
#Hisense_HE55A700EUWTS: A3REWRVYBYPKUM
|
|
||||||
#MTC_ATV: A2HYAJ0FEWP6N3
|
|
||||||
|
|
||||||
device_types:
|
device_types:
|
||||||
browser: 'AOAGZA014O5RE' # all browsers? all platforms?
|
browser: 'AOAGZA014O5RE' # all browsers? all platforms?
|
||||||
@@ -104,19 +61,18 @@ endpoints:
|
|||||||
browse: '/cdp/catalog/Browse'
|
browse: '/cdp/catalog/Browse'
|
||||||
details: '/gp/video/api/getDetailPage'
|
details: '/gp/video/api/getDetailPage'
|
||||||
getDetailWidgets: '/gp/video/api/getDetailWidgets'
|
getDetailWidgets: '/gp/video/api/getDetailWidgets'
|
||||||
playback: '/cdp/catalog/GetPlaybackResources'
|
playback: '/playback/prs/GetVodPlaybackResources'
|
||||||
licence: '/cdp/catalog/GetPlaybackResources'
|
metadata: '/api/enrichItemMetadata'
|
||||||
|
licence: '/playback/drm-vod/GetWidevineLicense'
|
||||||
|
licence_pr: '/playback/drm-vod/GetPlayReadyLicense'
|
||||||
# chapters/scenes
|
# chapters/scenes
|
||||||
xray: '/swift/page/xray'
|
xray: '/swift/page/xray'
|
||||||
# device registration
|
# device registration
|
||||||
ontv: '/region/eu/ontv/code?ref_=atv_auth_red_aft' #/gp/video/ontv/code
|
ontv: '/gp/video/ontv/code'
|
||||||
ontvold: '/gp/video/ontv/code/ref=atv_device_code'
|
|
||||||
mytv: '/mytv'
|
|
||||||
devicelink: '/gp/video/api/codeBasedLinking'
|
devicelink: '/gp/video/api/codeBasedLinking'
|
||||||
codepair: '/auth/create/codepair'
|
codepair: '/auth/create/codepair'
|
||||||
register: '/auth/register'
|
register: '/auth/register'
|
||||||
token: '/auth/token'
|
token: '/auth/token'
|
||||||
#cookies: '/ap/exchangetoken/cookies'
|
|
||||||
|
|
||||||
regions:
|
regions:
|
||||||
us:
|
us:
|
||||||
@@ -131,6 +87,12 @@ regions:
|
|||||||
base_manifest: 'atv-ps-eu.amazon.co.uk'
|
base_manifest: 'atv-ps-eu.amazon.co.uk'
|
||||||
marketplace_id: 'A2IR4J4NTCP2M5' # A1F83G8C2ARO7P is also another marketplace_id
|
marketplace_id: 'A2IR4J4NTCP2M5' # A1F83G8C2ARO7P is also another marketplace_id
|
||||||
|
|
||||||
|
es:
|
||||||
|
base: 'www.amazon.es'
|
||||||
|
base_api: 'api.amazon.es'
|
||||||
|
base_manifest: 'atv-ps-eu.primevideo.com'
|
||||||
|
marketplace_id: 'A1RKKUPIHCS9HS'
|
||||||
|
|
||||||
it:
|
it:
|
||||||
base: 'www.amazon.it'
|
base: 'www.amazon.it'
|
||||||
base_api: 'api.amazon.it'
|
base_api: 'api.amazon.it'
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
# Crunchyroll API Configuration
|
# Crunchyroll API Configuration
|
||||||
client:
|
client:
|
||||||
id: "lkesi7snsy9oojmi2r9h"
|
id: "bludpho6aoup2q0lmw99"
|
||||||
secret: "-aGDXFFNTluZMLYXERngNYnEjvgH5odv"
|
secret: "bEmjmOi1INunmi96t98OFAjtMJq0TwER"
|
||||||
|
|
||||||
# API Endpoints
|
# API Endpoints
|
||||||
endpoints:
|
endpoints:
|
||||||
@@ -15,24 +15,20 @@ endpoints:
|
|||||||
# Content Metadata
|
# Content Metadata
|
||||||
series: "https://www.crunchyroll.com/content/v2/cms/series/{series_id}"
|
series: "https://www.crunchyroll.com/content/v2/cms/series/{series_id}"
|
||||||
seasons: "https://www.crunchyroll.com/content/v2/cms/series/{series_id}/seasons"
|
seasons: "https://www.crunchyroll.com/content/v2/cms/series/{series_id}/seasons"
|
||||||
episodes: "https://www.crunchyroll.com/content/v2/cms/objects/{episode_id}"
|
|
||||||
season_episodes: "https://www.crunchyroll.com/content/v2/cms/seasons/{season_id}/episodes"
|
season_episodes: "https://www.crunchyroll.com/content/v2/cms/seasons/{season_id}/episodes"
|
||||||
skip_events: "https://static.crunchyroll.com/skip-events/production/{episode_id}.json"
|
skip_events: "https://static.crunchyroll.com/skip-events/production/{episode_id}.json"
|
||||||
|
|
||||||
# Playback
|
# Playback
|
||||||
playback: "https://www.crunchyroll.com/playback/v2/{episode_id}/tv/android_tv/play"
|
playback: "https://www.crunchyroll.com/playback/v3/{episode_id}/tv/android_tv/play"
|
||||||
#playback_download: "https://www.crunchyroll.com/playback/v2/{episode_id}/android/phone/download"
|
playback_download: "https://www.crunchyroll.com/playback/v3/{episode_id}/android/phone/download"
|
||||||
playback_delete: "https://www.crunchyroll.com/playback/v1/token/{episode_id}/{token}"
|
playback_delete: "https://www.crunchyroll.com/playback/v1/token/{episode_id}/{token}"
|
||||||
playback_sessions: "https://www.crunchyroll.com/playback/v1/sessions/streaming"
|
playback_sessions: "https://www.crunchyroll.com/playback/v1/sessions/streaming"
|
||||||
license_widevine: "https://cr-license-proxy.prd.crunchyrollsvc.com/v1/license/widevine"
|
license_widevine: "https://cr-license-proxy.prd.crunchyrollsvc.com/v1/license/widevine"
|
||||||
license_playready: "https://cr-license-proxy.prd.crunchyrollsvc.com/v1/license/playReady"
|
|
||||||
|
|
||||||
# Discovery
|
|
||||||
search: "https://www.crunchyroll.com/content/v2/discover/search"
|
search: "https://www.crunchyroll.com/content/v2/discover/search"
|
||||||
|
|
||||||
# Headers for Android TV client
|
# Headers for Android TV client
|
||||||
headers:
|
headers:
|
||||||
user-agent: "Crunchyroll/ANDROIDTV/3.49.1_22281 (Android 11; en-US; SHIELD Android TV)"
|
user-agent: "Crunchyroll/ANDROIDTV/3.58.0_22336 (Android 11; en-US; SHIELD Android TV)"
|
||||||
accept: "application/json"
|
accept: "application/json"
|
||||||
accept-charset: "UTF-8"
|
accept-charset: "UTF-8"
|
||||||
accept-encoding: "gzip"
|
accept-encoding: "gzip"
|
||||||
@@ -41,7 +37,8 @@ headers:
|
|||||||
|
|
||||||
# Query parameters
|
# Query parameters
|
||||||
params:
|
params:
|
||||||
locale: "es-419"
|
locale: "en-US"
|
||||||
|
preferred_audio_language: "en-US"
|
||||||
|
|
||||||
# Device parameters for authentication
|
# Device parameters for authentication
|
||||||
device:
|
device:
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# DisneyPlus(디즈니플러스)
|
# DisneyPlus(디즈니플러스)
|
||||||
|
|
||||||
```
|
```
|
||||||
uv run unshackle dl -vl all -al orig -sl ko,en,ja -q 2160 -v h.265 -r HDR10 DSNP entity-4d12671a-f0ad-4c3f-8526-09ae6772390b
|
uv run envied.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
|
## Information(정보)
|
||||||
|
|
||||||
- Authorization: Credentials, Web Token
|
- Authorization: Credentials, Web Token
|
||||||
- Security: UHD@L1/SL3000, FHD@L1/SL3000, HD@L3/SL2000
|
- Security: UHD@L1/SL3000, FHD@L1/SL3000, HD@L3/SL2000
|
||||||
@@ -14,7 +14,7 @@ uv run unshackle dl -vl all -al orig -sl ko,en,ja -q 2160 -v h.265 -r HDR10 DSNP
|
|||||||
- Audio: AAC, AC3, ATMOS, DTS:X(P2:IMAX)
|
- Audio: AAC, AC3, ATMOS, DTS:X(P2:IMAX)
|
||||||
- Range: SDR, HDR10, DV
|
- Range: SDR, HDR10, DV
|
||||||
|
|
||||||
## Support Args
|
## Support Args(지원하는 명령어 인자)
|
||||||
|
|
||||||
- `-i`, `--imax`: Prefer IMAX Enhanced version if available.
|
- `-i`, `--imax`: Prefer IMAX Enhanced version if available.
|
||||||
- `-r`, `--remastered-ar`: Prefer Remastered Aspect Ratio if available.
|
- `-r`, `--remastered-ar`: Prefer Remastered Aspect Ratio if available.
|
||||||
@@ -23,18 +23,51 @@ uv run unshackle dl -vl all -al orig -sl ko,en,ja -q 2160 -v h.265 -r HDR10 DSNP
|
|||||||
|
|
||||||
## Tips
|
## 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.
|
- Configure user settings within the `envied.yaml` file.
|
||||||
|
사용자 설정은 `envied.yaml`에서 다음과 같이 사용하세요.
|
||||||
|
|
||||||
```
|
```
|
||||||
services:
|
services:
|
||||||
DSNP:
|
DSNP:
|
||||||
# Specifies the index of the profile to use. (0 = first profile, 1 = second profile, etc.)
|
## 사용자 환경설정
|
||||||
# Automatically select a profile or language when commenting.
|
## User configuration
|
||||||
# Language setting is only available in languages supported by Disney+.
|
# 해당 설정값이 주석처리 되어 있는 경우에는 설정값들이 자동으로 선택됩니다.
|
||||||
|
# If these settings are commented out, values will be selected automatically.
|
||||||
preferences:
|
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
|
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"
|
# 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`.
|
- To enable the tier_unlimits command by default, add the following to `envied.yaml`.
|
||||||
|
tier_unlimits 명령을 기본값으로 활성화하려면 `envied.yaml`에 다음을 추가하세요.
|
||||||
```
|
```
|
||||||
dl:
|
dl:
|
||||||
...
|
...
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ from . import queries
|
|||||||
class DSNP(Service):
|
class DSNP(Service):
|
||||||
"""
|
"""
|
||||||
Service code for Disney+ Streaming Service (https://disneyplus.com).\n
|
Service code for Disney+ Streaming Service (https://disneyplus.com).\n
|
||||||
Version: 26.02.27
|
Version: 26.03.28
|
||||||
|
|
||||||
Author: Made by CodeName393 with Special Thanks to narakama, Sam\n
|
Author: Made by CodeName393 with Special Thanks to narakama, Sam\n
|
||||||
Authorization: Credentials, Web Token\n
|
Authorization: Credentials, Web Token\n
|
||||||
@@ -575,7 +575,10 @@ class DSNP(Service):
|
|||||||
if self.tier_unlimits:
|
if self.tier_unlimits:
|
||||||
parsed_url = urlparse(manifest_url)
|
parsed_url = urlparse(manifest_url)
|
||||||
manifest_url = urlunparse(parsed_url._replace(query="")) # Delete tier params
|
manifest_url = urlunparse(parsed_url._replace(query="")) # Delete tier params
|
||||||
self.log.debug(f" + Manifest URL: {manifest_url}")
|
|
||||||
|
log_level = self.config.get("preferences", {}).get("manifest_log", "debug").lower()
|
||||||
|
log_func = getattr(self.log, log_level, self.log.debug)
|
||||||
|
log_func(f" + Manifest URL: {manifest_url}")
|
||||||
tracks = HLS.from_url(url=manifest_url, session=self.session).to_tracks(title.language)
|
tracks = HLS.from_url(url=manifest_url, session=self.session).to_tracks(title.language)
|
||||||
|
|
||||||
artwork_type = "background" if isinstance(title, Movie) else "thumbnail"
|
artwork_type = "background" if isinstance(title, Movie) else "thumbnail"
|
||||||
@@ -610,7 +613,7 @@ class DSNP(Service):
|
|||||||
if audio.channels == 6.0:
|
if audio.channels == 6.0:
|
||||||
audio.channels = 5.1
|
audio.channels = 5.1
|
||||||
if audio.channels == 10.0: # DTS-UHD
|
if audio.channels == 10.0: # DTS-UHD
|
||||||
audio.channels = "5.1.4" # Unshackle does not recommend
|
audio.channels = "5.1.4" # envied.does not recommend
|
||||||
audio.codec = Audio.Codec.DTS
|
audio.codec = Audio.Codec.DTS
|
||||||
audio.drm = None # It need HW decording
|
audio.drm = None # It need HW decording
|
||||||
|
|
||||||
@@ -623,7 +626,7 @@ class DSNP(Service):
|
|||||||
try:
|
try:
|
||||||
editorial = self.playback_data[title.id]["editorial"]
|
editorial = self.playback_data[title.id]["editorial"]
|
||||||
if not editorial:
|
if not editorial:
|
||||||
return Chapters()
|
return []
|
||||||
|
|
||||||
LABEL_MAP = {
|
LABEL_MAP = {
|
||||||
"intro_start": "intro_start",
|
"intro_start": "intro_start",
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
## 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: |
|
certificate: |
|
||||||
CAUSugUKtAIIAxIQbj3s4jO5oUyWjDWqjfr9WRjA2afZBSKOAjCCAQoCggEBALhKWfnyA+FGn5P3tl6ffDjoGq2Oq86hKGl6aZIaGaF7XHPO5mIk7Q35ml
|
CAUSugUKtAIIAxIQbj3s4jO5oUyWjDWqjfr9WRjA2afZBSKOAjCCAQoCggEBALhKWfnyA+FGn5P3tl6ffDjoGq2Oq86hKGl6aZIaGaF7XHPO5mIk7Q35ml
|
||||||
ZIgg1A458Udb4eXRws1n+kJFqtZXCY5S1yElLP0Om1WQsoEY2stpl+PZTGnVv/CsOJGKQ8K4KMr7rKjZem9lA9BrBoxgfXY3tbwlnSf3wTEohyANb5Qfpa
|
ZIgg1A458Udb4eXRws1n+kJFqtZXCY5S1yElLP0Om1WQsoEY2stpl+PZTGnVv/CsOJGKQ8K4KMr7rKjZem9lA9BrBoxgfXY3tbwlnSf3wTEohyANb5Qfpa
|
||||||
@@ -10,31 +16,34 @@ certificate: |
|
|||||||
|
|
||||||
## config ( {configVersion}/{clientId}/{deviceFamily}/{sdkVersion}/{applicationRuntime}/{deviceProfile}/{environment} ) ##
|
## config ( {configVersion}/{clientId}/{deviceFamily}/{sdkVersion}/{applicationRuntime}/{deviceProfile}/{environment} ) ##
|
||||||
# Browser (windows, chrome) : /browser/v34.4/windows/chrome/prod.json
|
# Browser (windows, chrome) : /browser/v34.4/windows/chrome/prod.json
|
||||||
# Android Phone : /android/v16.0.0/google/handset/prod.json
|
# Android Phone : /android/v18.0.0/google/handset/prod.json
|
||||||
# Android TV : /android/v16.0.0/google/tv/prod.json
|
# Android TV : /android/v18.0.0/google/tv/prod.json
|
||||||
# Amazon Fire TV : /android/v16.0.0/amazon/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:
|
endpoints:
|
||||||
config: "https://client-sdk-configs.bamgrid.com/bam-sdk/v7.0/disney-svod-3d9324fc/android/v16.0.0/google/tv/prod.json"
|
config: "https://client-sdk-configs.bamgrid.com/bam-sdk/v7.0/disney-svod-3d9324fc/android/v18.0.0/google/tv/prod.json"
|
||||||
|
|
||||||
## user_agent (okhttp/5.0.0-alpha.14) ##
|
## user_agent (okhttp/5.0.0-alpha.14) ##
|
||||||
# android-phone : BAMSDK/v16.0.0 (disney-svod-3d9324fc 26.0.2+rc1-2026.01.29.0; v7.0/v16.0.0; android; phone)
|
# 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/v16.0.0 (disney-svod-3d9324fc 26.0.2+rc1-2026.01.29.0; v7.0/v16.0.0; android; tv)
|
# 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 ##
|
## api_key ##
|
||||||
# browser : ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84
|
# browser : ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84
|
||||||
# android : ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA
|
# android : ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA
|
||||||
|
# apple : ZGlzbmV5JmFwcGxlJjEuMC4w.H9L7eJvc2oPYwDgmkoar6HzhBJRuUUzt_PcaC3utBI4
|
||||||
|
|
||||||
## yp_service_id ##
|
## yp_service_id ##
|
||||||
# browser : 63626081279ebe65eb50fb54
|
# browser : 63626081279ebe65eb50fb54
|
||||||
# android : 624b805dafc5c73635b1a216
|
# android : 624b805dafc5c73635b1a216
|
||||||
|
|
||||||
bamsdk:
|
bamsdk:
|
||||||
sdk_version: "16.0.0"
|
sdk_version: "18.0.1"
|
||||||
application_version: "26.0.2+rc1-2026.01.29.0"
|
application_version: "26.3.0+rc4-2026.03.12.0"
|
||||||
explore_version: "v1.13"
|
explore_version: "v1.13"
|
||||||
client: "disney-svod-3d9324fc"
|
client: "disney-svod-3d9324fc"
|
||||||
user_agent: "BAMSDK/v16.0.0 (disney-svod-3d9324fc 26.0.2+rc1-2026.01.29.0; v7.0/v16.0.0; android; tv)"
|
user_agent: "BAMSDK/v18.0.1 (disney-svod-3d9324fc 26.3.0+rc4-2026.03.12.0; v7.0/v18.0.0; android; tv)"
|
||||||
api_key: "ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA"
|
api_key: "ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA"
|
||||||
yp_service_id: "624b805dafc5c73635b1a216"
|
yp_service_id: "624b805dafc5c73635b1a216"
|
||||||
|
|
||||||
@@ -47,9 +56,29 @@ device:
|
|||||||
operatingSystem: "Android"
|
operatingSystem: "Android"
|
||||||
operatingSystemVersion: "16"
|
operatingSystemVersion: "16"
|
||||||
|
|
||||||
# Specifies the index of the profile to use. (0 = first profile, 1 = second profile, etc.)
|
# ## 사용자 환경설정
|
||||||
# Automatically select a profile or language when commenting.
|
# ## User configuration
|
||||||
# Language setting is only available in languages supported by Disney+.
|
# # 해당 설정값이 주석처리 되어 있는 경우에는 설정값들이 자동으로 선택됩니다.
|
||||||
|
# # If these settings are commented out, values will be selected automatically.
|
||||||
# preferences:
|
# 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
|
# 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"
|
# 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"
|
||||||
|
|||||||
+714
@@ -0,0 +1,714 @@
|
|||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from hashlib import md5
|
||||||
|
from typing import Optional, Union, Generator
|
||||||
|
from http.cookiejar import CookieJar
|
||||||
|
from collections import defaultdict
|
||||||
|
from copy import deepcopy
|
||||||
|
from zlib import crc32
|
||||||
|
|
||||||
|
import click
|
||||||
|
import requests
|
||||||
|
import xmltodict
|
||||||
|
from lxml import etree
|
||||||
|
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 Attachment, Chapter, Chapters, Subtitle, Tracks, Video
|
||||||
|
from envied.core.utilities import is_close_match
|
||||||
|
|
||||||
|
|
||||||
|
class HMAX(Service):
|
||||||
|
ALIASES = ("HMAX", "max", "hbomax")
|
||||||
|
#GEOFENCE = ("US",)
|
||||||
|
|
||||||
|
TITLE_RE = r"^(?:https?://(?:www\.|play\.)?hbomax\.com/)?(?P<type>[^/]+)/(?P<id>[^/]+)"
|
||||||
|
|
||||||
|
VIDEO_CODEC_MAP = {
|
||||||
|
"H264": [Video.Codec.AVC],
|
||||||
|
"H265": [Video.Codec.HEVC]
|
||||||
|
}
|
||||||
|
|
||||||
|
AUDIO_CODEC_MAP = {
|
||||||
|
"AAC": "mp4a",
|
||||||
|
"AC3": "ac-3",
|
||||||
|
"EC3": "ec-3"
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@click.command(name="HMAX", short_help="https://max.com")
|
||||||
|
@click.argument("title", type=str)
|
||||||
|
@click.option("-vcodec", "--video-codec", default=None, help="Video codec preference")
|
||||||
|
@click.option("-acodec", "--audio-codec", default=None, help="Audio codec preference")
|
||||||
|
@click.pass_context
|
||||||
|
def cli(ctx, **kwargs):
|
||||||
|
return HMAX(ctx, **kwargs)
|
||||||
|
|
||||||
|
def __init__(self, ctx, title, video_codec, audio_codec):
|
||||||
|
super().__init__(ctx)
|
||||||
|
|
||||||
|
self.title = title
|
||||||
|
self.vcodec = video_codec
|
||||||
|
self.acodec = audio_codec
|
||||||
|
|
||||||
|
range_param = ctx.parent.params.get("range_")
|
||||||
|
self.range = range_param[0].name if range_param else "SDR"
|
||||||
|
|
||||||
|
if self.range == 'HDR10':
|
||||||
|
self.vcodec = "H265"
|
||||||
|
|
||||||
|
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
||||||
|
super().authenticate(cookies, credential)
|
||||||
|
if not cookies:
|
||||||
|
raise EnvironmentError("Service requires Cookies for Authentication.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = next(cookie.value for cookie in cookies if cookie.name == "st")
|
||||||
|
session_data = next(cookie.value for cookie in cookies if cookie.name == "session")
|
||||||
|
device_id = json.loads(session_data)
|
||||||
|
except (StopIteration, json.JSONDecodeError):
|
||||||
|
raise EnvironmentError("Required authentication cookies not found.")
|
||||||
|
|
||||||
|
self.session.headers.update({
|
||||||
|
'User-Agent': 'BEAM-Android/1.0.0.104 (SONY/XR-75X95EL)',
|
||||||
|
'Accept': 'application/json, text/plain, */*',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-disco-client': 'SAMSUNGTV:124.0.0.0:beam:4.0.0.118',
|
||||||
|
'x-disco-params': 'realm=bolt,bid=beam,features=ar',
|
||||||
|
'x-device-info': 'beam/4.0.0.118 (Samsung/Samsung-Unknown; Tizen/124.0.0.0; f198a6c1-c582-4725-9935-64eb6b17c3cd/87a996fa-4917-41ae-9b6d-c7f521f0cb78)',
|
||||||
|
'traceparent': '00-315ac07a3de9ad1493956cf1dd5d1313-988e057938681391-01',
|
||||||
|
'tracestate': f'wbd=session:{device_id}',
|
||||||
|
'Origin': 'https://play.hbomax.com',
|
||||||
|
'Referer': 'https://play.hbomax.com/',
|
||||||
|
})
|
||||||
|
|
||||||
|
auth_token = self._get_device_token()
|
||||||
|
self.session.headers.update({
|
||||||
|
"x-wbd-session-state": auth_token
|
||||||
|
})
|
||||||
|
|
||||||
|
def search(self) -> Generator[SearchResult, None, None]:
|
||||||
|
search_url = "https://default.prd.api.hbomax.com/search"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.session.get(search_url, params={"q": self.title})
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
search_data = response.json()
|
||||||
|
|
||||||
|
for result in search_data.get("results", []):
|
||||||
|
yield SearchResult(
|
||||||
|
id_=result.get("id"),
|
||||||
|
title=result.get("title", "Unknown"),
|
||||||
|
label=result.get("type", "UNKNOWN").upper(),
|
||||||
|
url=f"https://play.hbomax.com/{result.get('type', 'content')}/{result.get('id')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.log.warning(f"Search functionality not fully implemented: {e}")
|
||||||
|
return
|
||||||
|
yield
|
||||||
|
|
||||||
|
def get_titles(self) -> Titles_T:
|
||||||
|
match = re.match(self.TITLE_RE, self.title)
|
||||||
|
if not match:
|
||||||
|
raise ValueError("Invalid title format. Expected format: type/id or full URL")
|
||||||
|
|
||||||
|
content_type = match.group('type')
|
||||||
|
external_id = match.group('id')
|
||||||
|
|
||||||
|
response = self.session.get(
|
||||||
|
self.config['endpoints']['contentRoutes'] % (content_type, external_id)
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
try:
|
||||||
|
content_data = [x for x in response.json()["included"] if "attributes" in x and "title" in
|
||||||
|
x["attributes"] and x["attributes"]["alias"] == "generic-%s-blueprint-page" % (re.sub(r"-", "", content_type))][0]["attributes"]
|
||||||
|
content_title = content_data["title"]
|
||||||
|
except:
|
||||||
|
content_data = [x for x in response.json()["included"] if "attributes" in x and "alternateId" in
|
||||||
|
x["attributes"] and x["attributes"]["alternateId"] == external_id and x["attributes"].get("originalName")][0]["attributes"]
|
||||||
|
content_title = content_data["originalName"]
|
||||||
|
|
||||||
|
if content_type == "sport" or content_type == "event":
|
||||||
|
included_dt = response.json()["included"]
|
||||||
|
|
||||||
|
for included in included_dt:
|
||||||
|
for key, data in included.items():
|
||||||
|
if key == "attributes":
|
||||||
|
for k, d in data.items():
|
||||||
|
if d == "VOD":
|
||||||
|
event_data = included
|
||||||
|
|
||||||
|
release_date = event_data["attributes"].get("airDate") or event_data["attributes"].get("firstAvailableDate")
|
||||||
|
year = datetime.strptime(release_date, '%Y-%m-%dT%H:%M:%SZ').year
|
||||||
|
|
||||||
|
return Movies([
|
||||||
|
Movie(
|
||||||
|
id_=external_id,
|
||||||
|
service=self.__class__,
|
||||||
|
name=content_title.title(),
|
||||||
|
year=year,
|
||||||
|
data=event_data,
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
if content_type == "movie" or content_type == "standalone":
|
||||||
|
metadata = self.session.get(
|
||||||
|
url=self.config['endpoints']['moviePages'] % external_id
|
||||||
|
).json()['data']
|
||||||
|
|
||||||
|
try:
|
||||||
|
edit_id = metadata['relationships']['edit']['data']['id']
|
||||||
|
except:
|
||||||
|
for x in response.json()["included"]:
|
||||||
|
if x.get("type") == "video" and x.get("relationships", {}).get("show", {}).get("data", {}).get("id") == external_id:
|
||||||
|
metadata = x
|
||||||
|
|
||||||
|
release_date = metadata["attributes"].get("airDate") or metadata["attributes"].get("firstAvailableDate")
|
||||||
|
year = datetime.strptime(release_date, '%Y-%m-%dT%H:%M:%SZ').year
|
||||||
|
|
||||||
|
return Movies([
|
||||||
|
Movie(
|
||||||
|
id_=external_id,
|
||||||
|
service=self.__class__,
|
||||||
|
name=content_title,
|
||||||
|
year=year,
|
||||||
|
data=metadata,
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
if content_type in ["show", "mini-series", "topical"]:
|
||||||
|
episodes = []
|
||||||
|
if content_type == "mini-series":
|
||||||
|
alias = "generic-miniseries-page-rail-episodes"
|
||||||
|
elif content_type == "topical":
|
||||||
|
alias = "generic-topical-show-page-rail-episodes"
|
||||||
|
else:
|
||||||
|
alias = "-%s-page-rail-episodes-tabbed-content" % (content_type)
|
||||||
|
|
||||||
|
included_dt = response.json()["included"]
|
||||||
|
|
||||||
|
season_data = [data for included in included_dt for key, data in included.items()
|
||||||
|
if key == "attributes" for k, d in data.items() if alias in str(d).lower()][0]
|
||||||
|
|
||||||
|
season_data = season_data["component"]["filters"][0]
|
||||||
|
|
||||||
|
seasons = [int(season["value"]) for season in season_data["options"]]
|
||||||
|
|
||||||
|
season_parameters = [(int(season["value"]), season["parameter"]) for season in season_data["options"]
|
||||||
|
for season_number in seasons if int(season["value"]) == int(season_number)]
|
||||||
|
|
||||||
|
if not season_parameters:
|
||||||
|
raise ValueError("No seasons found")
|
||||||
|
|
||||||
|
image_map = {} # accumulate images across all seasons
|
||||||
|
for (value, parameter) in season_parameters:
|
||||||
|
data = self.session.get(
|
||||||
|
url=self.config['endpoints']['showPages'] % (external_id, parameter)
|
||||||
|
).json()
|
||||||
|
|
||||||
|
try:
|
||||||
|
episodes_dt = sorted([dt for dt in data["included"] if "attributes" in dt and "videoType" in
|
||||||
|
dt["attributes"] and dt["attributes"]["videoType"] == "EPISODE"
|
||||||
|
and int(dt["attributes"]["seasonNumber"]) == int(parameter.split("=")[-1])],
|
||||||
|
key=lambda x: x["attributes"]["episodeNumber"])
|
||||||
|
except KeyError:
|
||||||
|
raise ValueError("Season episodes were not found")
|
||||||
|
|
||||||
|
episodes.extend(episodes_dt)
|
||||||
|
# Accumulate image objects across all seasons
|
||||||
|
image_map.update({
|
||||||
|
obj["id"]: obj
|
||||||
|
for obj in data["included"]
|
||||||
|
if obj.get("type") == "image"
|
||||||
|
})
|
||||||
|
|
||||||
|
episode_titles = []
|
||||||
|
release_date = episodes[0]["attributes"].get("airDate") or episodes[0]["attributes"].get("firstAvailableDate")
|
||||||
|
year = datetime.strptime(release_date, '%Y-%m-%dT%H:%M:%SZ').year
|
||||||
|
|
||||||
|
season_map = {int(item[1].split("=")[-1]): item[0] for item in season_parameters}
|
||||||
|
|
||||||
|
for episode in episodes:
|
||||||
|
# Resolve image objects for this episode using relationship IDs
|
||||||
|
ep_image_ids = [
|
||||||
|
img["id"]
|
||||||
|
for img in episode.get("relationships", {}).get("images", {}).get("data", [])
|
||||||
|
]
|
||||||
|
ep_images = [image_map[img_id] for img_id in ep_image_ids if img_id in image_map]
|
||||||
|
ep_data = dict(episode)
|
||||||
|
ep_data["_images"] = ep_images
|
||||||
|
episode_titles.append(
|
||||||
|
Episode(
|
||||||
|
id_=episode['id'],
|
||||||
|
service=self.__class__,
|
||||||
|
title=content_title,
|
||||||
|
season=season_map.get(episode['attributes'].get('seasonNumber')),
|
||||||
|
number=episode['attributes']['episodeNumber'],
|
||||||
|
name=episode['attributes']['name'],
|
||||||
|
year=year,
|
||||||
|
data=ep_data
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return Series(episode_titles)
|
||||||
|
|
||||||
|
def get_tracks(self, title: Title_T) -> Tracks:
|
||||||
|
edit_id = title.data['relationships']['edit']['data']['id']
|
||||||
|
|
||||||
|
response = self.session.post(
|
||||||
|
url=self.config['endpoints']['playbackInfo'],
|
||||||
|
json={
|
||||||
|
'appBundle': 'beam',
|
||||||
|
'consumptionType': 'streaming',
|
||||||
|
'deviceInfo': {
|
||||||
|
'deviceId': '2dec6cb0-eb34-45f9-bbc9-a0533597303c',
|
||||||
|
'browser': {
|
||||||
|
'name': 'chrome',
|
||||||
|
'version': '113.0.0.0',
|
||||||
|
},
|
||||||
|
'make': 'Microsoft',
|
||||||
|
'model': 'XBOX-Unknown',
|
||||||
|
'os': {
|
||||||
|
'name': 'Windows',
|
||||||
|
'version': '113.0.0.0',
|
||||||
|
},
|
||||||
|
'platform': 'XBOX',
|
||||||
|
'deviceType': 'xbox',
|
||||||
|
'player': {
|
||||||
|
'sdk': {
|
||||||
|
'name': 'Beam Player Console',
|
||||||
|
'version': '1.0.2.4',
|
||||||
|
},
|
||||||
|
'mediaEngine': {
|
||||||
|
'name': 'GLUON_BROWSER',
|
||||||
|
'version': '1.20.1',
|
||||||
|
},
|
||||||
|
'playerView': {
|
||||||
|
'height': 1080,
|
||||||
|
'width': 1920,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'editId': edit_id,
|
||||||
|
'capabilities': {
|
||||||
|
'manifests': {
|
||||||
|
'formats': {
|
||||||
|
'dash': {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'codecs': {
|
||||||
|
'video': {
|
||||||
|
'hdrFormats': [
|
||||||
|
'hlg',
|
||||||
|
'hdr10',
|
||||||
|
'dolbyvision5',
|
||||||
|
'dolbyvision8',
|
||||||
|
],
|
||||||
|
'decoders': [
|
||||||
|
{
|
||||||
|
'maxLevel': '6.2',
|
||||||
|
'codec': 'h265',
|
||||||
|
'levelConstraints': {
|
||||||
|
'width': {
|
||||||
|
'min': 1920,
|
||||||
|
'max': 3840,
|
||||||
|
},
|
||||||
|
'height': {
|
||||||
|
'min': 1080,
|
||||||
|
'max': 2160,
|
||||||
|
},
|
||||||
|
'framerate': {
|
||||||
|
'min': 15,
|
||||||
|
'max': 60,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'profiles': [
|
||||||
|
'main',
|
||||||
|
'main10',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'maxLevel': '4.2',
|
||||||
|
'codec': 'h264',
|
||||||
|
'levelConstraints': {
|
||||||
|
'width': {
|
||||||
|
'min': 640,
|
||||||
|
'max': 3840,
|
||||||
|
},
|
||||||
|
'height': {
|
||||||
|
'min': 480,
|
||||||
|
'max': 2160,
|
||||||
|
},
|
||||||
|
'framerate': {
|
||||||
|
'min': 15,
|
||||||
|
'max': 60,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'profiles': [
|
||||||
|
'high',
|
||||||
|
'main',
|
||||||
|
'baseline',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'audio': {
|
||||||
|
'decoders': [
|
||||||
|
{
|
||||||
|
'codec': 'aac',
|
||||||
|
'profiles': [
|
||||||
|
'lc',
|
||||||
|
'he',
|
||||||
|
'hev2',
|
||||||
|
'xhe',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'devicePlatform': {
|
||||||
|
'network': {
|
||||||
|
'lastKnownStatus': {
|
||||||
|
'networkTransportType': 'unknown',
|
||||||
|
},
|
||||||
|
'capabilities': {
|
||||||
|
'protocols': {
|
||||||
|
'http': {
|
||||||
|
'byteRangeRequests': True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'videoSink': {
|
||||||
|
'lastKnownStatus': {
|
||||||
|
'width': 1290,
|
||||||
|
'height': 2796,
|
||||||
|
},
|
||||||
|
'capabilities': {
|
||||||
|
'colorGamuts': [
|
||||||
|
'standard',
|
||||||
|
'wide',
|
||||||
|
],
|
||||||
|
'hdrFormats': [
|
||||||
|
'dolbyvision',
|
||||||
|
'hdr10plus',
|
||||||
|
'hdr10',
|
||||||
|
'hlg',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'gdpr': False,
|
||||||
|
'firstPlay': False,
|
||||||
|
'playbackSessionId': str(uuid.uuid4()),
|
||||||
|
'applicationSessionId': str(uuid.uuid4()),
|
||||||
|
'userPreferences': {},
|
||||||
|
'features': [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
playback_data = response.json()
|
||||||
|
|
||||||
|
video_info = next(x for x in playback_data['videos'] if x['type'] == 'main')
|
||||||
|
title.language = Language.get(video_info['defaultAudioSelection']['language'])
|
||||||
|
|
||||||
|
fallback_url = playback_data["fallback"]["manifest"]["url"]
|
||||||
|
fallback_url = fallback_url.replace('fly', 'akm').replace('gcp', 'akm')
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.wv_license_url = playback_data["drm"]["schemes"]["widevine"]["licenseUrl"]
|
||||||
|
except (KeyError, IndexError):
|
||||||
|
self.wv_license_url = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.pr_license_url = playback_data["drm"]["schemes"]["playready"]["licenseUrl"]
|
||||||
|
except (KeyError, IndexError):
|
||||||
|
self.pr_license_url = None
|
||||||
|
|
||||||
|
manifest_url = fallback_url.replace('_fallback', '')
|
||||||
|
self.log.info(f" + Manifest: {manifest_url}")
|
||||||
|
|
||||||
|
dash_manifest = DASH.from_url(url=manifest_url, session=self.session)
|
||||||
|
tracks = dash_manifest.to_tracks(language=title.language)
|
||||||
|
|
||||||
|
|
||||||
|
self.log.debug(tracks)
|
||||||
|
|
||||||
|
tracks.videos = self._dedupe(tracks.videos)
|
||||||
|
tracks.audio = self._dedupe(tracks.audio)
|
||||||
|
|
||||||
|
tracks.subtitles.clear()
|
||||||
|
|
||||||
|
new_subtitles = self._process_max_subtitles(dash_manifest, title.language)
|
||||||
|
|
||||||
|
for subtitle in new_subtitles:
|
||||||
|
tracks.add(subtitle)
|
||||||
|
|
||||||
|
if self.vcodec:
|
||||||
|
tracks.videos = [x for x in tracks.videos if x.codec in self.VIDEO_CODEC_MAP[self.vcodec]]
|
||||||
|
|
||||||
|
if self.acodec:
|
||||||
|
tracks.audio = [x for x in tracks.audio if (x.codec or "")[:4] == self.AUDIO_CODEC_MAP[self.acodec]]
|
||||||
|
|
||||||
|
for track in tracks:
|
||||||
|
if isinstance(track, Video):
|
||||||
|
codec = track.data.get("dash", {}).get("representation", {}).get("codecs", "")
|
||||||
|
track.hdr10 = track.range == Video.Range.HDR10
|
||||||
|
track.dv = codec[:4] in ("dvh1", "dvhe")
|
||||||
|
if isinstance(track, Subtitle) and not track.codec:
|
||||||
|
track.codec = Subtitle.Codec.WebVTT
|
||||||
|
|
||||||
|
title.data['info'] = video_info
|
||||||
|
|
||||||
|
for track in tracks.audio:
|
||||||
|
if hasattr(track, 'data') and track.data.get("dash", {}).get("adaptation_set"):
|
||||||
|
role = track.data["dash"]["adaptation_set"].find("Role")
|
||||||
|
if role is not None and role.get("value") in ["description", "alternative", "alternate"]:
|
||||||
|
track.descriptive = True
|
||||||
|
|
||||||
|
# Attachment: episode/movie image resolved from relationships._images
|
||||||
|
# _images is built in get_titles by cross-referencing the included image objects
|
||||||
|
# HBO Max image attributes use "src" (not "url") and "kind" for type
|
||||||
|
try:
|
||||||
|
images = title.data.get("_images", [])
|
||||||
|
image_url = None
|
||||||
|
# Prefer landscape (default/wide) images; fallback to first available
|
||||||
|
PREFERRED_KINDS = ("default", "tile", "episode", "cover", "banner",
|
||||||
|
"centered-background-small", "background")
|
||||||
|
kind_map = {}
|
||||||
|
for img in images:
|
||||||
|
attrs = img.get("attributes", {})
|
||||||
|
kind = attrs.get("kind", "").lower()
|
||||||
|
src = attrs.get("src", "") or attrs.get("url", "")
|
||||||
|
if src and kind not in kind_map:
|
||||||
|
kind_map[kind] = src
|
||||||
|
for k in PREFERRED_KINDS:
|
||||||
|
if k in kind_map:
|
||||||
|
image_url = kind_map[k]
|
||||||
|
break
|
||||||
|
# Fallback: take first available src
|
||||||
|
if not image_url and images:
|
||||||
|
for img in images:
|
||||||
|
attrs = img.get("attributes", {})
|
||||||
|
src = attrs.get("src", "") or attrs.get("url", "")
|
||||||
|
if src:
|
||||||
|
image_url = src
|
||||||
|
break
|
||||||
|
if image_url:
|
||||||
|
if isinstance(title, Movie):
|
||||||
|
image_name = title.name
|
||||||
|
else:
|
||||||
|
image_name = f"{title.title} - S{title.season:02d}E{title.number:02d}"
|
||||||
|
tracks.add(Attachment.from_url(
|
||||||
|
url=image_url,
|
||||||
|
name=image_name,
|
||||||
|
mime_type="image/jpeg",
|
||||||
|
session=self.session,
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
self.log.warning(f" - Attachment failed: {e}")
|
||||||
|
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
def get_chapters(self, title: Title_T) -> Chapters:
|
||||||
|
chapters = []
|
||||||
|
video_info = title.data.get('info', {})
|
||||||
|
if 'annotations' in video_info:
|
||||||
|
chapters.append(Chapter(timestamp=0.0, name='Chapter 1'))
|
||||||
|
chapters.append(Chapter(timestamp=self._convert_timecode(video_info['annotations'][0]['start']), name='Credits'))
|
||||||
|
chapters.append(Chapter(timestamp=self._convert_timecode(video_info['annotations'][0]['end']), name='Chapter 2'))
|
||||||
|
|
||||||
|
return Chapters(chapters)
|
||||||
|
|
||||||
|
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
|
||||||
|
if not self.wv_license_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
response = self.session.post(
|
||||||
|
url=self.wv_license_url,
|
||||||
|
data=challenge
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.content
|
||||||
|
|
||||||
|
def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]:
|
||||||
|
if not self.pr_license_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(challenge, bytes):
|
||||||
|
decoded_challenge = challenge.decode('utf-8')
|
||||||
|
else:
|
||||||
|
decoded_challenge = str(challenge)
|
||||||
|
|
||||||
|
response = self.session.post(
|
||||||
|
url=self.pr_license_url,
|
||||||
|
data=decoded_challenge,
|
||||||
|
headers={
|
||||||
|
'Content-Type': 'text/xml; charset=utf-8',
|
||||||
|
'SOAPAction': 'http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.content
|
||||||
|
|
||||||
|
def _get_device_token(self):
|
||||||
|
response = self.session.post(self.config['endpoints']['bootstrap'])
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.headers.get('x-wbd-session-state')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _convert_timecode(time_seconds):
|
||||||
|
return float(time_seconds)
|
||||||
|
|
||||||
|
def _process_max_subtitles(self, dash_manifest, language):
|
||||||
|
subtitle_groups = defaultdict(list)
|
||||||
|
|
||||||
|
for period in dash_manifest.manifest.findall("Period"):
|
||||||
|
for adaptation_set in period.findall("AdaptationSet"):
|
||||||
|
content_type = adaptation_set.get("contentType")
|
||||||
|
if content_type != "text":
|
||||||
|
continue
|
||||||
|
|
||||||
|
lang = adaptation_set.get("lang")
|
||||||
|
if not lang:
|
||||||
|
continue
|
||||||
|
|
||||||
|
role = adaptation_set.find("Role")
|
||||||
|
role_value = role.get("value") if role is not None else "subtitle"
|
||||||
|
label = adaptation_set.find("Label")
|
||||||
|
label_text = label.text if label is not None else ""
|
||||||
|
|
||||||
|
key = (lang, role_value, label_text)
|
||||||
|
subtitle_groups[key].append((period, adaptation_set))
|
||||||
|
|
||||||
|
final_tracks = []
|
||||||
|
|
||||||
|
for (lang, role_value, label_text), adapt_pairs in subtitle_groups.items():
|
||||||
|
if not adapt_pairs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
first_period, first_adapt = adapt_pairs[0]
|
||||||
|
first_rep = first_adapt.find("Representation")
|
||||||
|
if not first_rep:
|
||||||
|
continue
|
||||||
|
|
||||||
|
combined_adapt = deepcopy(first_adapt)
|
||||||
|
combined_rep = combined_adapt.find("Representation")
|
||||||
|
|
||||||
|
seg_template = combined_rep.find("SegmentTemplate")
|
||||||
|
if seg_template is None:
|
||||||
|
seg_template = combined_adapt.find("SegmentTemplate")
|
||||||
|
if seg_template is None:
|
||||||
|
continue
|
||||||
|
combined_adapt.remove(seg_template)
|
||||||
|
seg_template = deepcopy(seg_template)
|
||||||
|
combined_rep.append(seg_template)
|
||||||
|
|
||||||
|
timeline = etree.Element("SegmentTimeline")
|
||||||
|
|
||||||
|
segments_info = []
|
||||||
|
for period, adapt in adapt_pairs:
|
||||||
|
rep = adapt.find("Representation")
|
||||||
|
if not rep:
|
||||||
|
continue
|
||||||
|
|
||||||
|
template = rep.find("SegmentTemplate")
|
||||||
|
if not template:
|
||||||
|
template = adapt.find("SegmentTemplate")
|
||||||
|
if not template:
|
||||||
|
continue
|
||||||
|
|
||||||
|
start_num = int(template.get("startNumber", 1))
|
||||||
|
|
||||||
|
existing_timeline = template.find("SegmentTimeline")
|
||||||
|
if existing_timeline is not None:
|
||||||
|
for s in existing_timeline.findall("S"):
|
||||||
|
t = int(s.get("t", 0))
|
||||||
|
d = int(s.get("d", 0))
|
||||||
|
r = int(s.get("r", 0))
|
||||||
|
segments_info.append((start_num, t, d, r))
|
||||||
|
|
||||||
|
segments_info.sort(key=lambda x: x[0])
|
||||||
|
|
||||||
|
if segments_info:
|
||||||
|
for _, t, d, r in segments_info:
|
||||||
|
s_elem = etree.Element("S")
|
||||||
|
s_elem.set("t", str(t))
|
||||||
|
s_elem.set("d", str(d))
|
||||||
|
if r > 0:
|
||||||
|
s_elem.set("r", str(r))
|
||||||
|
timeline.append(s_elem)
|
||||||
|
|
||||||
|
old_timeline = seg_template.find("SegmentTimeline")
|
||||||
|
if old_timeline is not None:
|
||||||
|
seg_template.remove(old_timeline)
|
||||||
|
seg_template.append(timeline)
|
||||||
|
|
||||||
|
seg_template.set("startNumber", "1")
|
||||||
|
seg_template.set("endNumber", str(len(segments_info)))
|
||||||
|
|
||||||
|
track_id = hex(crc32(f"sub-{lang}-{role_value}-{label_text}".encode()))[2:]
|
||||||
|
|
||||||
|
is_sdh = role_value == "caption" or "sdh" in label_text.lower()
|
||||||
|
is_forced = role_value in ["forced-subtitle", "forced_subtitle"] or "forced" in label_text.lower()
|
||||||
|
|
||||||
|
lang_obj = Language.get(lang)
|
||||||
|
display_name = lang_obj.display_name()
|
||||||
|
|
||||||
|
subtitle_track = Subtitle(
|
||||||
|
id_=track_id,
|
||||||
|
url=dash_manifest.url,
|
||||||
|
codec=Subtitle.Codec.WebVTT,
|
||||||
|
language=Language.get(lang),
|
||||||
|
is_original_lang=bool(language and is_close_match(Language.get(lang), [language])),
|
||||||
|
descriptor=Video.Descriptor.DASH,
|
||||||
|
sdh=is_sdh,
|
||||||
|
forced=is_forced,
|
||||||
|
name=display_name,
|
||||||
|
data={
|
||||||
|
"dash": {
|
||||||
|
"manifest": dash_manifest.manifest,
|
||||||
|
"period": first_period,
|
||||||
|
"adaptation_set": combined_adapt,
|
||||||
|
"representation": combined_rep,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
final_tracks.append(subtitle_track)
|
||||||
|
|
||||||
|
return final_tracks
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _dedupe(items: list) -> list:
|
||||||
|
if not items:
|
||||||
|
return items
|
||||||
|
if isinstance(items[0].url, list):
|
||||||
|
return items
|
||||||
|
|
||||||
|
seen = {}
|
||||||
|
for item in items:
|
||||||
|
if hasattr(item, 'width') and hasattr(item, 'height'):
|
||||||
|
key = f"{item.codec}_{item.width}x{item.height}_{item.bitrate}"
|
||||||
|
elif hasattr(item, 'channels'):
|
||||||
|
key = f"{item.codec}_{item.language}_{item.bitrate}_{item.channels}"
|
||||||
|
else:
|
||||||
|
key = item.url
|
||||||
|
|
||||||
|
if key not in seen:
|
||||||
|
seen[key] = item
|
||||||
|
|
||||||
|
return list(seen.values())
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
endpoints:
|
||||||
|
contentRoutes: 'https://default.prd.api.hbomax.com/cms/routes/%s/%s?include=default'
|
||||||
|
moviePages: 'https://default.prd.api.hbomax.com/content/videos/%s/activeVideoForShow?&include=edit'
|
||||||
|
playbackInfo: 'https://default.prd.api.hbomax.com/any/playback/v1/playbackInfo'
|
||||||
|
showPages: 'https://default.prd.api.hbomax.com/cms/collections/generic-show-page-rail-episodes-tabbed-content?include=default&pf[show.id]=%s&%s'
|
||||||
|
bootstrap: 'https://default.prd.api.hbomax.com/session-context/headwaiter/v1/bootstrap'
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
|||||||
|
endpoints:
|
||||||
|
sessions: https://api.epixnow.com/v2/sessions
|
||||||
|
registration_code: https://api.epixnow.com/v2/devices/registration_code
|
||||||
|
graphql: https://api.epixnow.com/graphql
|
||||||
|
search: https://api.epixnow.com/v2/search
|
||||||
|
|
||||||
|
headers:
|
||||||
|
Accept: application/json
|
||||||
|
User-Agent: okhttp/5.1.0
|
||||||
|
|
||||||
|
client:
|
||||||
|
apikey: 592c205299de839aa3948643addfe0bc
|
||||||
|
device:
|
||||||
|
app_version: "225.0.2025225001"
|
||||||
|
device: android
|
||||||
|
display_height: 2160
|
||||||
|
display_width: 3840
|
||||||
|
dpi: 320
|
||||||
|
format: tv
|
||||||
|
manufacturer: NVIDIA
|
||||||
|
model: SHIELD Android TV
|
||||||
|
os: fire
|
||||||
|
os_version: "11"
|
||||||
|
|
||||||
|
web:
|
||||||
|
endpoints:
|
||||||
|
sessions: https://api.mgmplus.com/v2/sessions
|
||||||
|
graphql: https://api.mgmplus.com/graphql
|
||||||
|
apikey: "53e208a9bbaee479903f43b39d7301f7"
|
||||||
|
device:
|
||||||
|
format: console
|
||||||
|
os: web
|
||||||
|
display_width: 2560
|
||||||
|
display_height: 1440
|
||||||
|
app_version: "1.0.2"
|
||||||
|
model: browser
|
||||||
|
manufacturer: google
|
||||||
|
device_interface_type: desktop
|
||||||
|
|
||||||
|
amazon:
|
||||||
|
base: https://q423ce52d0d3f1.na.api.amazonvideo.com
|
||||||
|
device_type: AOAGZA014O5RE
|
||||||
|
marketplace: ATVPDKIKX0DER
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
MOVIE = """
|
||||||
|
query Movie($id: ID!) {
|
||||||
|
movie(id: $id) {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
shortName
|
||||||
|
releaseYear
|
||||||
|
synopsis
|
||||||
|
duration
|
||||||
|
mpaaRating
|
||||||
|
underlyingId
|
||||||
|
genres { name }
|
||||||
|
labels { text }
|
||||||
|
descriptiveAudio { available }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
SERIES = """
|
||||||
|
query Series($id: ID!) {
|
||||||
|
series(id: $id) {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
shortName
|
||||||
|
shortDescription
|
||||||
|
mpaaRating
|
||||||
|
genres { name }
|
||||||
|
labels { text }
|
||||||
|
descriptiveAudio { available }
|
||||||
|
seasons {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
number
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
EPISODES = """
|
||||||
|
query Episodes($seasonId: ID!, $first: Int, $after: String) {
|
||||||
|
episodes(seasonId: $seasonId, first: $first, after: $after) {
|
||||||
|
pageInfo {
|
||||||
|
endCursor
|
||||||
|
hasNextPage
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
releaseYear
|
||||||
|
duration
|
||||||
|
number
|
||||||
|
description
|
||||||
|
seasonNumber
|
||||||
|
shortTitle
|
||||||
|
rating { value }
|
||||||
|
series {
|
||||||
|
shortName
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
EPISODE = """
|
||||||
|
query Episode($seriesShortName: String!, $seasonNumber: Int!, $episodeNumber: Int!) {
|
||||||
|
episode(
|
||||||
|
seriesShortName: $seriesShortName
|
||||||
|
seasonNumber: $seasonNumber
|
||||||
|
episodeNumber: $episodeNumber
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
releaseYear
|
||||||
|
duration
|
||||||
|
number
|
||||||
|
description
|
||||||
|
seasonNumber
|
||||||
|
shortTitle
|
||||||
|
rating { value }
|
||||||
|
series {
|
||||||
|
shortName
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
PLAY = """
|
||||||
|
query Play($id: String!, $context: String, $behavior: BehaviorEnum, $supportedActions: [PlayFlowActionEnum!]!) {
|
||||||
|
playFlow(id: $id, context: $context, behavior: $behavior, supportedActions: $supportedActions) {
|
||||||
|
__typename
|
||||||
|
... on PlayContent {
|
||||||
|
type
|
||||||
|
continuationContext
|
||||||
|
playheadPosition
|
||||||
|
streams(types: [
|
||||||
|
{ packagingSystem: DASH, encryptionScheme: CBCS },
|
||||||
|
{ packagingSystem: DASH, encryptionScheme: CENC },
|
||||||
|
{ packagingSystem: HLS, encryptionScheme: NONE }
|
||||||
|
]) {
|
||||||
|
id
|
||||||
|
internalStreamId
|
||||||
|
videoQuality { width height }
|
||||||
|
widevine { authenticationToken licenseServerUrl }
|
||||||
|
packagingSystem
|
||||||
|
encryptionScheme
|
||||||
|
playlistUrl
|
||||||
|
}
|
||||||
|
closedCaptions {
|
||||||
|
vtt { location }
|
||||||
|
}
|
||||||
|
currentItem {
|
||||||
|
content {
|
||||||
|
__typename
|
||||||
|
... on Movie {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
underlyingId
|
||||||
|
duration
|
||||||
|
}
|
||||||
|
... on Episode {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
underlyingId
|
||||||
|
duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hints {
|
||||||
|
videoId
|
||||||
|
videoTitle
|
||||||
|
resourceId
|
||||||
|
isLive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
... on ShowNotice {
|
||||||
|
type
|
||||||
|
actions {
|
||||||
|
continuationContext
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
... on ContinuePlay {
|
||||||
|
type
|
||||||
|
}
|
||||||
|
... on LogIn {
|
||||||
|
type
|
||||||
|
}
|
||||||
|
... on Noop {
|
||||||
|
type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
WEB_PLAY = """
|
||||||
|
query PlayFlow($id: String!, $supportedActions: [PlayFlowActionEnum!]!, $context: String, $behavior: BehaviorEnum = DEFAULT, $streamTypes: [StreamDefinition!]) {
|
||||||
|
playFlow(
|
||||||
|
id: $id
|
||||||
|
supportedActions: $supportedActions
|
||||||
|
context: $context
|
||||||
|
behavior: $behavior
|
||||||
|
) {
|
||||||
|
... on ShowNotice {
|
||||||
|
type
|
||||||
|
actions { continuationContext text }
|
||||||
|
description
|
||||||
|
title
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
... on PlayContent {
|
||||||
|
type
|
||||||
|
continuationContext
|
||||||
|
heartbeatToken
|
||||||
|
currentItem {
|
||||||
|
content {
|
||||||
|
__typename
|
||||||
|
... on Movie { id shortName amazonContentId duration }
|
||||||
|
... on Episode {
|
||||||
|
id
|
||||||
|
series { shortName __typename }
|
||||||
|
seasonNumber
|
||||||
|
number
|
||||||
|
amazonContentId
|
||||||
|
duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
amazonPlayback {
|
||||||
|
playbackEnvelope
|
||||||
|
playbackId
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
playheadPosition
|
||||||
|
closedCaptions {
|
||||||
|
vtt { location __typename }
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
streams(types: $streamTypes) {
|
||||||
|
id
|
||||||
|
playlistUrl
|
||||||
|
packagingSystem
|
||||||
|
encryptionScheme
|
||||||
|
videoQuality { height width __typename }
|
||||||
|
widevine { authenticationToken licenseServerUrl __typename }
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
... on ContinuePlay { type __typename }
|
||||||
|
... on LogIn { type __typename }
|
||||||
|
... on Noop { type __typename }
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
@@ -44,7 +44,7 @@ class RKTN(Service):
|
|||||||
|
|
||||||
\b
|
\b
|
||||||
Command for Titles with no SDR (if not set range to HDR10 it will fail):
|
Command for Titles with no SDR (if not set range to HDR10 it will fail):
|
||||||
uv run unshackle dl -r HDR10 [OPTIONS] RKTN -m https://www.rakuten.tv/...
|
uv run envied.dl -r HDR10 [OPTIONS] RKTN -m https://www.rakuten.tv/...
|
||||||
|
|
||||||
\b
|
\b
|
||||||
TODO: - TV Shows are not yet supported as there's 0 TV Shows to purchase, rent, or watch in my region
|
TODO: - TV Shows are not yet supported as there's 0 TV Shows to purchase, rent, or watch in my region
|
||||||
@@ -587,7 +587,7 @@ class RKTN(Service):
|
|||||||
audio.channels = audio_info.channel_s or "2.0"
|
audio.channels = audio_info.channel_s or "2.0"
|
||||||
|
|
||||||
# Actualizar codec
|
# Actualizar codec
|
||||||
# Para Unshackle, necesitas mantener el formato correcto
|
# Para envied. necesitas mantener el formato correcto
|
||||||
audio.codec = Audio.Codec.from_codecs(codec_name)
|
audio.codec = Audio.Codec.from_codecs(codec_name)
|
||||||
|
|
||||||
# Agregar el track
|
# Agregar el track
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ class SEVEN(Service):
|
|||||||
|
|
||||||
\b
|
\b
|
||||||
Examples:
|
Examples:
|
||||||
- SERIES: unshackle dl -w s01e01 7plus https://7plus.com.au/ncis-los-angeles
|
- SERIES: envied.dl -w s01e01 7plus https://7plus.com.au/ncis-los-angeles
|
||||||
- EPISODE: unshackle dl 7plus https://7plus.com.au/ncis-los-angeles?episode-id=NCIL01-001
|
- EPISODE: envied.dl 7plus https://7plus.com.au/ncis-los-angeles?episode-id=NCIL01-001
|
||||||
- MOVIE: unshackle dl 7plus --movie https://7plus.com.au/puss-in-boots-the-last-wish
|
- MOVIE: envied.dl 7plus --movie https://7plus.com.au/puss-in-boots-the-last-wish
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1,42 @@
|
|||||||
|
endpoints:
|
||||||
|
signin: "https://rango.id.skyshowtime.com/signin/service/international"
|
||||||
|
tokens: "https://ovp.skyshowtime.com/auth/tokens"
|
||||||
|
personas: "https://web.clients.skyshowtime.com/bff/personas/v2"
|
||||||
|
atom_node: "https://atom.skyshowtime.com/adapter-calypso/v3/query/node"
|
||||||
|
atom_search: "https://atom.skyshowtime.com/adapter-calypso/v3/query/search"
|
||||||
|
playback: "https://ovp.skyshowtime.com/video/playouts/vod"
|
||||||
|
|
||||||
|
params:
|
||||||
|
provider: "SKYSHOWTIME"
|
||||||
|
proposition: "SKYSHOWTIME"
|
||||||
|
platform: "PC"
|
||||||
|
device: "COMPUTER"
|
||||||
|
client_version: "6.11.21-gsp"
|
||||||
|
|
||||||
|
signature:
|
||||||
|
app_id: "SHOWMAX-ANDROID-v1"
|
||||||
|
key: "kC2UFjsH6PHrc5ENGfyTgC5bPA7aBVZ4aJAyqBBP"
|
||||||
|
version: "1.0"
|
||||||
|
|
||||||
|
territories:
|
||||||
|
- NL
|
||||||
|
- PL
|
||||||
|
- ES
|
||||||
|
- PT
|
||||||
|
- SE
|
||||||
|
- NO
|
||||||
|
- DK
|
||||||
|
- FI
|
||||||
|
- CZ
|
||||||
|
- SK
|
||||||
|
- HU
|
||||||
|
- RO
|
||||||
|
- BG
|
||||||
|
- HR
|
||||||
|
- SI
|
||||||
|
- BA
|
||||||
|
- RS
|
||||||
|
- ME
|
||||||
|
- MK
|
||||||
|
- AL
|
||||||
|
- XK
|
||||||
@@ -0,0 +1,572 @@
|
|||||||
|
import re
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import click
|
||||||
|
import base64
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
from langcodes import Language
|
||||||
|
from typing import Optional, Union
|
||||||
|
from http.cookiejar import CookieJar
|
||||||
|
from urllib.parse import urlparse, parse_qsl, urlencode
|
||||||
|
|
||||||
|
from envied.core.service import Service
|
||||||
|
from envied.core.constants import AnyTrack
|
||||||
|
from envied.core.manifests import DASH, HLS
|
||||||
|
from envied.core.credential import Credential
|
||||||
|
from envied.core.tracks import Audio, Chapters, Tracks, Video
|
||||||
|
from envied.core.session import session as create_curl_session
|
||||||
|
from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
|
||||||
|
|
||||||
|
|
||||||
|
class TOD(Service):
|
||||||
|
"""
|
||||||
|
Service code for TODTV (TR) [https://todtv.com.tr]
|
||||||
|
Version: 2.0.0
|
||||||
|
Author: PREDATOR
|
||||||
|
Authorization: Cookie
|
||||||
|
Author Recommendation: Recommend using the "requests" download method.
|
||||||
|
Security: FHD@L3 [Widevine]
|
||||||
|
"""
|
||||||
|
|
||||||
|
TITLE_RE = r'^(?:https?://(?:www\.)?todtv\.com\.tr/)?(?:(?P<type>diziler|dizi|film|tod-studios|bein-series|belgesel)/)?(?P<slug>[^/\?]+)(?:/(?P<season>[^/\?]+))?(?:/(?P<episode>[^/\?]+))?'
|
||||||
|
ID_RE = r'^(?P<prefix>PS|PZ|PT)(?P<id>\d+)$'
|
||||||
|
PLAYER_CONFIG_RE = r'var\s+playerConfig\s*=\s*(\{[\s\S]*?\});'
|
||||||
|
SEASONS_RE = r'var\s+seasons\s*=\s*(\[[\s\S]*?\]);'
|
||||||
|
SEASON_HREF_RE = r'href="([^"]*?(\d+)sezon-[^"]*)"'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@click.command(name="TOD", short_help="https://todtv.com.tr", help=__doc__)
|
||||||
|
@click.argument("title", type=str)
|
||||||
|
@click.pass_context
|
||||||
|
def cli(ctx, **kwargs):
|
||||||
|
return TOD(ctx, **kwargs)
|
||||||
|
|
||||||
|
def __init__(self, ctx, title):
|
||||||
|
super().__init__(ctx)
|
||||||
|
self.title = title
|
||||||
|
self.movie = False
|
||||||
|
self.content_id = None
|
||||||
|
self.content_type = None
|
||||||
|
self.slug = None
|
||||||
|
self.license_url = None
|
||||||
|
self.drm_token = None
|
||||||
|
self.castleblack_token = None
|
||||||
|
|
||||||
|
if (id_match := re.match(self.ID_RE, self.title)):
|
||||||
|
self.content_id = self.title
|
||||||
|
prefix = id_match.group("prefix")
|
||||||
|
self.content_type = {"PS": "series", "PZ": "season", "PT": "episode"}.get(prefix)
|
||||||
|
if prefix == "PT":
|
||||||
|
self.movie = True
|
||||||
|
elif (url_match := re.match(self.TITLE_RE, self.title)):
|
||||||
|
self.content_type = url_match.group("type") or "diziler"
|
||||||
|
self.slug = url_match.group("slug")
|
||||||
|
if s := url_match.group("season"):
|
||||||
|
self.slug += f"/{s}"
|
||||||
|
if e := url_match.group("episode"):
|
||||||
|
self.slug += f"/{e}"
|
||||||
|
if self.content_type == "film":
|
||||||
|
self.movie = True
|
||||||
|
else:
|
||||||
|
self.slug = self.title
|
||||||
|
self.content_type = "film" if self.movie else "diziler"
|
||||||
|
|
||||||
|
# ──────────────────────────── Auth ──────────────────────────── #
|
||||||
|
|
||||||
|
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
||||||
|
super().authenticate(cookies, credential)
|
||||||
|
if not cookies:
|
||||||
|
raise EnvironmentError("TODTV Requires Cookies for Authentication...")
|
||||||
|
|
||||||
|
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",
|
||||||
|
"Referer": "https://www.todtv.com.tr/",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
})
|
||||||
|
self._sanitize_session()
|
||||||
|
self.log.info("Session Authenticated using Cookies...")
|
||||||
|
|
||||||
|
# ──────────────────────────── Titles ──────────────────────────── #
|
||||||
|
|
||||||
|
def get_titles(self) -> Titles_T:
|
||||||
|
return self._fetch_movie_titles() if self.movie else self._fetch_series_titles()
|
||||||
|
|
||||||
|
def _fetch_movie_titles(self) -> Movies:
|
||||||
|
if self.content_id and self.content_id.startswith("PT"):
|
||||||
|
return Movies([Movie(
|
||||||
|
id_=self.content_id, service=self.__class__,
|
||||||
|
name=self.content_id, language=Language.get("tr"),
|
||||||
|
data={"contentId": self.content_id},
|
||||||
|
)])
|
||||||
|
|
||||||
|
url = self._build_url(self.slug, "film")
|
||||||
|
response = self.session.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = self._extract_content_info(response.text)
|
||||||
|
if not data:
|
||||||
|
raise ValueError(f"Movie Info not Found: {self.slug}")
|
||||||
|
|
||||||
|
return Movies([Movie(
|
||||||
|
id_=data.get("contentId", self.slug),
|
||||||
|
service=self.__class__,
|
||||||
|
name=data.get("title", self.slug),
|
||||||
|
description=data.get("description", ""),
|
||||||
|
year=data.get("year"),
|
||||||
|
language=Language.get("tr"),
|
||||||
|
data=data,
|
||||||
|
)])
|
||||||
|
|
||||||
|
def _fetch_series_titles(self) -> Series:
|
||||||
|
base_path = self.content_type
|
||||||
|
response = None
|
||||||
|
fallback_types = self.config.get("fallback_types", ["diziler", "dizi", "tod-studios", "bein-series", "belgesel"])
|
||||||
|
|
||||||
|
paths_to_try = [base_path] if base_path else []
|
||||||
|
paths_to_try.extend([t for t in fallback_types if t != base_path])
|
||||||
|
|
||||||
|
for path in paths_to_try:
|
||||||
|
if not path:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
url = self._build_url(self.slug, path)
|
||||||
|
resp = self.session.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
response = resp
|
||||||
|
base_path = path
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not response:
|
||||||
|
raise ValueError(f"Series Page not Found: {self.slug}")
|
||||||
|
|
||||||
|
series_data = self._extract_series_data(response.text)
|
||||||
|
|
||||||
|
if not series_data["episodes"] and (ep_link := re.search(rf'href="(/[^/]+/{re.escape(self.slug)}/[^"]+)"', response.text)):
|
||||||
|
self.log.debug("Trying Data from Episode Page...")
|
||||||
|
base_url = self.config.get("endpoints", {}).get("base_url", "https://www.todtv.com.tr")
|
||||||
|
if (ep_resp := self.session.get(f"{base_url}{ep_link.group(1)}")) and ep_resp.status_code == 200:
|
||||||
|
series_data = self._extract_series_data(ep_resp.text)
|
||||||
|
|
||||||
|
existing_ids = {ep.get("contentId") for ep in series_data["episodes"]}
|
||||||
|
|
||||||
|
sorted_metadata = sorted(
|
||||||
|
series_data.get("seasons_metadata", []),
|
||||||
|
key=lambda x: int(x["no"]) if str(x["no"]).isdigit() else 999,
|
||||||
|
)
|
||||||
|
|
||||||
|
for meta in sorted_metadata:
|
||||||
|
s_no, s_id, s_slug = meta["no"], meta["id"], meta.get("slug")
|
||||||
|
cached_count = sum(1 for ep in series_data["episodes"] if str(ep.get("season")) == str(s_no))
|
||||||
|
|
||||||
|
if cached_count > 0:
|
||||||
|
self.log.info(f"Fetching Season {s_no} data (ID: {s_id or 'Cached'})...")
|
||||||
|
self.log.info(f"Season {s_no}: {cached_count} new episodes added.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not s_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.log.info(f"Fetching Season {s_no} data (ID: {s_id})...")
|
||||||
|
|
||||||
|
if s_slug:
|
||||||
|
configs_to_try = [self._build_url(s_slug)]
|
||||||
|
else:
|
||||||
|
page_v_ids = set(re.findall(r'\b(v\d+)\b', response.text))
|
||||||
|
series_base = self.slug.split('/')[0]
|
||||||
|
|
||||||
|
configs_to_try = []
|
||||||
|
for vid in page_v_ids:
|
||||||
|
configs_to_try.append(self._build_url(f"{series_base}/{s_no}-sezon-{vid}", base_path))
|
||||||
|
configs_to_try.append(self._build_url(f"{series_base}/{s_no}sezon-{vid}", base_path))
|
||||||
|
|
||||||
|
configs_to_try.append(self._build_url(f"{series_base}/{s_no}sezon-{s_id}", base_path))
|
||||||
|
|
||||||
|
if len(configs_to_try) > 2:
|
||||||
|
self.log.info(f"Auto-discovering Season {s_no} URL (scanning {len(page_v_ids)} candidates)...")
|
||||||
|
|
||||||
|
found_season = False
|
||||||
|
for url in configs_to_try:
|
||||||
|
if found_season:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
if (s_resp := self.session.get(url)) and s_resp.status_code == 200:
|
||||||
|
s_data = self._extract_series_data(s_resp.text)
|
||||||
|
added_count = 0
|
||||||
|
for ep in s_data["episodes"]:
|
||||||
|
if str(ep.get("season")) != str(s_no):
|
||||||
|
continue
|
||||||
|
if ep["contentId"] not in existing_ids:
|
||||||
|
series_data["episodes"].append(ep)
|
||||||
|
existing_ids.add(ep["contentId"])
|
||||||
|
added_count += 1
|
||||||
|
if added_count:
|
||||||
|
self.log.info(f"Season {s_no}: {added_count} new episodes added.")
|
||||||
|
found_season = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not found_season:
|
||||||
|
self.log.warning(f"Could not find valid episodes for Season {s_no}")
|
||||||
|
|
||||||
|
if not series_data["episodes"]:
|
||||||
|
raise ValueError(f"Episode not found: {self.slug}")
|
||||||
|
|
||||||
|
show_name = series_data.get("title", self.slug)
|
||||||
|
episodes = [
|
||||||
|
Episode(
|
||||||
|
id_=ep["contentId"],
|
||||||
|
service=self.__class__,
|
||||||
|
title=show_name,
|
||||||
|
season=ep["season"],
|
||||||
|
number=ep["episode"],
|
||||||
|
name=ep.get("name") or f"Bölüm {ep['episode']}",
|
||||||
|
description=ep.get("data", {}).get("description", ""),
|
||||||
|
year=ep.get("data", {}).get("year"),
|
||||||
|
language=Language.get("tr"),
|
||||||
|
data=ep["data"],
|
||||||
|
) for ep in series_data["episodes"]
|
||||||
|
]
|
||||||
|
|
||||||
|
return Series(episodes)
|
||||||
|
|
||||||
|
# ──────────────────────────── Tracks ──────────────────────────── #
|
||||||
|
|
||||||
|
def get_tracks(self, title: Title_T) -> Tracks:
|
||||||
|
if isinstance(title, Movie):
|
||||||
|
page_url = title.data.get("pageUrl") or self._build_url(title.data.get("slug") or self.slug, "film")
|
||||||
|
else:
|
||||||
|
slug = (title.data or {}).get("slug") or ((title.data or {}).get("customData") or {}).get("slug")
|
||||||
|
page_url = self._build_url(slug) if slug else None
|
||||||
|
|
||||||
|
if not page_url:
|
||||||
|
raise ValueError(f"URL not found: {title.id}")
|
||||||
|
|
||||||
|
self.log.info(f"🔗 Content Page: {page_url}")
|
||||||
|
resp = self.session.get(page_url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
info = self._extract_stream_info(resp.text)
|
||||||
|
manifest_url = info.get("manifestUrl")
|
||||||
|
license_url = info.get("licenseUrl")
|
||||||
|
|
||||||
|
if all(info.get(k) for k in ["contentId", "assetId", "usageSpecId"]):
|
||||||
|
try:
|
||||||
|
self.log.info("▶ Sending play request (playRequest)...")
|
||||||
|
play_data = self._play_request(resp.text, info)
|
||||||
|
|
||||||
|
if play_data.get("CdnUrl"):
|
||||||
|
manifest_url = play_data["CdnUrl"]
|
||||||
|
self.session.get(manifest_url, allow_redirects=True)
|
||||||
|
|
||||||
|
if play_data.get("DrmTicket"):
|
||||||
|
self.drm_token = play_data["DrmTicket"].replace("ticket=", "")
|
||||||
|
if play_data.get("manifestUrl"):
|
||||||
|
manifest_url = play_data["manifestUrl"]
|
||||||
|
if play_data.get("licenseUrl"):
|
||||||
|
license_url = play_data["licenseUrl"]
|
||||||
|
except Exception as e:
|
||||||
|
self.log.warning(f"Play request failed: {e}")
|
||||||
|
|
||||||
|
if not manifest_url:
|
||||||
|
raise ValueError(f"No Manifest URL: {title.id}")
|
||||||
|
|
||||||
|
self.license_url = license_url
|
||||||
|
self.log.info(f"Manifest URL: {manifest_url}")
|
||||||
|
|
||||||
|
manifest = HLS if "HLS" in manifest_url or manifest_url.endswith(".m3u8") else DASH
|
||||||
|
tracks = manifest.from_url(manifest_url, self.session).to_tracks(language=title.language)
|
||||||
|
|
||||||
|
parsed_manifest = urlparse(manifest_url)
|
||||||
|
manifest_query = dict(parse_qsl(parsed_manifest.query))
|
||||||
|
|
||||||
|
if token := manifest_query.get("hdnts"):
|
||||||
|
for track in tracks:
|
||||||
|
if isinstance(track, (Video, Audio)):
|
||||||
|
t_parsed = urlparse(track.url)
|
||||||
|
t_query = dict(parse_qsl(t_parsed.query))
|
||||||
|
if "hdnts" not in t_query:
|
||||||
|
t_query["hdnts"] = token
|
||||||
|
track.url = t_parsed._replace(query=urlencode(t_query)).geturl()
|
||||||
|
|
||||||
|
for track in tracks:
|
||||||
|
if isinstance(track, (Video, Audio)):
|
||||||
|
track.license_url = license_url
|
||||||
|
|
||||||
|
self._sanitize_session()
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
# ──────────────────────────── DRM ──────────────────────────── #
|
||||||
|
|
||||||
|
def get_widevine_service_certificate(self, **_) -> Optional[str]:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
|
||||||
|
endpoints = self.config.get("endpoints", {})
|
||||||
|
|
||||||
|
if self.castleblack_token:
|
||||||
|
license_url = endpoints.get("license_castleblack", "https://castleblack.digiturk.com.tr/api/widevine/license?version=1.0")
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self.castleblack_token}",
|
||||||
|
"X-CB-Ticket": self.drm_token,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
if not self.license_url:
|
||||||
|
raise ValueError("No License URL.")
|
||||||
|
license_url = self.license_url
|
||||||
|
headers = {"Content-Type": "application/octet-stream"}
|
||||||
|
if self.drm_token:
|
||||||
|
headers["X-ErDRM-Message"] = self.drm_token
|
||||||
|
|
||||||
|
resp = self.session.post(license_url, data=challenge, headers=headers)
|
||||||
|
|
||||||
|
if "<License>" in resp.text:
|
||||||
|
if match := re.search(r"<License>(.*?)</License>", resp.text):
|
||||||
|
return base64.b64decode(match.group(1))
|
||||||
|
|
||||||
|
return resp.content
|
||||||
|
|
||||||
|
def get_chapters(self, title: Title_T) -> Chapters:
|
||||||
|
return Chapters()
|
||||||
|
|
||||||
|
# ──────────────────────────── Helpers ──────────────────────────── #
|
||||||
|
|
||||||
|
def _sanitize_session(self):
|
||||||
|
"""Normalize non-latin-1 characters in session headers and cookies.
|
||||||
|
|
||||||
|
The requests download worker encodes headers/cookies as latin-1.
|
||||||
|
Characters like œ (\u0153) in cookie values set by the TOD server cause
|
||||||
|
UnicodeEncodeError. We strip them here at the source so core
|
||||||
|
downloaders stay untouched.
|
||||||
|
"""
|
||||||
|
for key in list(self.session.headers.keys()):
|
||||||
|
val = self.session.headers[key]
|
||||||
|
if isinstance(val, str):
|
||||||
|
try:
|
||||||
|
val.encode("latin-1")
|
||||||
|
except UnicodeEncodeError:
|
||||||
|
self.session.headers[key] = (
|
||||||
|
unicodedata.normalize("NFKD", val)
|
||||||
|
.encode("ascii", "ignore")
|
||||||
|
.decode("ascii")
|
||||||
|
)
|
||||||
|
|
||||||
|
# curl_cffi.Cookies → .jar / stdlib CookieJar → iterate directly
|
||||||
|
cookie_jar = getattr(self.session.cookies, "jar", None) or self.session.cookies
|
||||||
|
for cookie in cookie_jar:
|
||||||
|
if hasattr(cookie, "value") and isinstance(cookie.value, str):
|
||||||
|
try:
|
||||||
|
cookie.value.encode("latin-1")
|
||||||
|
except UnicodeEncodeError:
|
||||||
|
cookie.value = (
|
||||||
|
unicodedata.normalize("NFKD", cookie.value)
|
||||||
|
.encode("ascii", "ignore")
|
||||||
|
.decode("ascii")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _play_request(self, html_content: str, info: dict) -> dict:
|
||||||
|
verify_token = ""
|
||||||
|
if match := re.search(r'name="__RequestVerificationToken"\s+type="hidden"\s+value="([^"]+)"', html_content):
|
||||||
|
verify_token = match.group(1)
|
||||||
|
|
||||||
|
form_data = {
|
||||||
|
"__RequestVerificationToken": verify_token,
|
||||||
|
"contentId": info["contentId"],
|
||||||
|
"versionId": info.get("versionId", ""),
|
||||||
|
"assetId": info["assetId"],
|
||||||
|
"usageSpecId": info["usageSpecId"],
|
||||||
|
"contentType": info.get("contentType", "Movie"),
|
||||||
|
"assetType": info.get("assetType", "MUL"),
|
||||||
|
"videoType": "1",
|
||||||
|
"updateWatchingOptions": "True",
|
||||||
|
"restart": "False",
|
||||||
|
}
|
||||||
|
|
||||||
|
play_request_url = self.config.get("endpoints", {}).get("play_request", "https://www.todtv.com.tr/content/playRequest")
|
||||||
|
resp = self.session.post(
|
||||||
|
play_request_url,
|
||||||
|
data=form_data,
|
||||||
|
headers={"X-Requested-With": "XMLHttpRequest"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
if data.get("MessageCode") != 0 and not data.get("CdnUrl"):
|
||||||
|
raise ValueError(f"PlayRequest error: {data.get('Message', 'Unknown error')} (Code: {data.get('MessageCode')})")
|
||||||
|
|
||||||
|
self.drm_token = (data.get("DrmTicket") or "").replace("ticket=", "")
|
||||||
|
self.castleblack_token = data.get("CastleBlackToken")
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _extract_stream_info(self, html_content: str) -> dict:
|
||||||
|
info = {}
|
||||||
|
|
||||||
|
patterns = {
|
||||||
|
"contentId": [r'data-cms-id=["\']([^"\']+)["\']', r'data-content-id=["\']([^"\']+)["\']'],
|
||||||
|
"assetId": [r'data-play-asset-id=["\']([^"\']+)["\']', r'data-id=["\']([^"\']+)["\']'],
|
||||||
|
"usageSpecId": [r'data-usage-spec=["\']([^"\']+)["\']', r'data-usage-id=["\']([^"\']+)["\']'],
|
||||||
|
"versionId": [r'data-version-id=["\']([^"\']+)["\']'],
|
||||||
|
"assetType": [r'data-play-asset-asset-type=["\']([^"\']+)["\']', r'data-assettype=["\']([^"\']+)["\']'],
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, regexes in patterns.items():
|
||||||
|
for regex in regexes:
|
||||||
|
if match := re.search(regex, html_content):
|
||||||
|
val = match.group(1)
|
||||||
|
if "{{" not in val:
|
||||||
|
info[key] = val
|
||||||
|
break
|
||||||
|
|
||||||
|
if not info.get("assetId") and (match := re.search(r'data-asset-list=["\'](\[.*?\])["\']', html_content, re.DOTALL)):
|
||||||
|
if assets := self._parse_json_safe(match.group(1).replace('"', '"')):
|
||||||
|
asset = assets[0]
|
||||||
|
info.setdefault("assetId", asset.get("AssetId"))
|
||||||
|
info.setdefault("usageSpecId", asset.get("UsageSpecId"))
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
def _build_url(self, slug: str, content_type: str = None) -> str:
|
||||||
|
base_url = self.config.get("endpoints", {}).get("base_url", "https://www.todtv.com.tr")
|
||||||
|
if slug.startswith("http"):
|
||||||
|
return slug
|
||||||
|
if slug.startswith("/"):
|
||||||
|
return f"{base_url}{slug}"
|
||||||
|
base = content_type or self.content_type or "diziler"
|
||||||
|
if base in ("series", "season", "episode"):
|
||||||
|
base = "diziler"
|
||||||
|
return f"{base_url}/{base}/{slug}"
|
||||||
|
|
||||||
|
def _clean_title(self, raw_title: str) -> str:
|
||||||
|
if not raw_title:
|
||||||
|
return ""
|
||||||
|
title = html.unescape(raw_title.split("|")[0].strip())
|
||||||
|
title = re.sub(r'\s*-\s*\d+\.\s*Sezon.*', '', title, flags=re.IGNORECASE)
|
||||||
|
title = re.sub(r'\s*-\s*(?:TOD|beIN|Fantastik|Aksiyon|Dram|Komedi|Bilim Kurgu).*', '', title, flags=re.IGNORECASE)
|
||||||
|
return title.strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_json_safe(text: str) -> dict:
|
||||||
|
try:
|
||||||
|
return json.loads(re.sub(r'//[^\n]*', '', text))
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _extract_season_slugs(self, html_content: str) -> dict:
|
||||||
|
slugs = {}
|
||||||
|
for m in re.finditer(self.SEASON_HREF_RE, html_content):
|
||||||
|
slugs[int(m.group(2) or m.group(3))] = m.group(1)
|
||||||
|
return slugs
|
||||||
|
|
||||||
|
def _process_seasons_list(self, seasons: list, info: dict, source: str, seen_ids: set) -> None:
|
||||||
|
if "seasons_metadata" not in info:
|
||||||
|
info["seasons_metadata"] = []
|
||||||
|
|
||||||
|
for s_data in seasons:
|
||||||
|
s_id = s_data.get("id")
|
||||||
|
s_no = int(s_data.get("no", 1))
|
||||||
|
episodes = s_data.get("episodes")
|
||||||
|
|
||||||
|
if not any(m["id"] == s_id for m in info["seasons_metadata"]):
|
||||||
|
info["seasons_metadata"].append({
|
||||||
|
"id": s_id,
|
||||||
|
"no": s_no,
|
||||||
|
"slug": (s_data.get("customData") or {}).get("slug"),
|
||||||
|
"populated": bool(episodes),
|
||||||
|
})
|
||||||
|
|
||||||
|
for ep in episodes or []:
|
||||||
|
ep_id = ep.get("id")
|
||||||
|
if ep_id in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(ep_id)
|
||||||
|
|
||||||
|
info["episodes"].append({
|
||||||
|
"contentId": ep_id,
|
||||||
|
"name": ep.get("title"),
|
||||||
|
"season": s_no,
|
||||||
|
"episode": int(ep.get("no", len(info["episodes"]) + 1)),
|
||||||
|
"slug": (ep.get("customData") or {}).get("slug"),
|
||||||
|
"data": ep,
|
||||||
|
})
|
||||||
|
|
||||||
|
if info["episodes"]:
|
||||||
|
self.log.debug(f"{len(info['episodes'])} episodes found ({source})")
|
||||||
|
|
||||||
|
def _extract_series_data(self, html_content: str) -> dict:
|
||||||
|
info = {"episodes": []}
|
||||||
|
seen_ids = set()
|
||||||
|
turkish_title = None
|
||||||
|
|
||||||
|
if title_match := re.search(r'<title>([^<]+)</title>', html_content):
|
||||||
|
turkish_title = title_match.group(1)
|
||||||
|
|
||||||
|
if (match := re.search(self.SEASONS_RE, html_content)) and (seasons := self._parse_json_safe(match.group(1))):
|
||||||
|
self._process_seasons_list(seasons, info, "window.seasons", seen_ids)
|
||||||
|
|
||||||
|
if match := re.search(self.PLAYER_CONFIG_RE, html_content):
|
||||||
|
config = self._parse_json_safe(match.group(1))
|
||||||
|
if config.get("title"):
|
||||||
|
turkish_title = config["title"]
|
||||||
|
if seasons := config.get("seasons") or config.get("seriesSettings", {}).get("seasons"):
|
||||||
|
self._process_seasons_list(seasons, info, "playerConfig", seen_ids)
|
||||||
|
|
||||||
|
found_slugs = self._extract_season_slugs(html_content)
|
||||||
|
for meta in info.get("seasons_metadata", []):
|
||||||
|
if meta["no"] in found_slugs and not meta.get("slug"):
|
||||||
|
meta["slug"] = found_slugs[meta["no"]]
|
||||||
|
|
||||||
|
original_title = self._extract_original_title_from_breadcrumbs(html_content)
|
||||||
|
info["title"] = self._format_title(turkish_title, original_title)
|
||||||
|
return info
|
||||||
|
|
||||||
|
def _extract_original_title_from_breadcrumbs(self, html_content: str) -> Optional[str]:
|
||||||
|
if match := re.search(r'(<script type="application/ld\+json">[^<]*"BreadcrumbList"[^<]*</script>)', html_content, re.DOTALL):
|
||||||
|
try:
|
||||||
|
json_str = match.group(1).replace('<script type="application/ld+json">', '').replace('</script>', '')
|
||||||
|
data = json.loads(json_str)
|
||||||
|
items = data.get("itemListElement", [])
|
||||||
|
if items:
|
||||||
|
return items[-1].get("item", {}).get("name")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _format_title(self, turkish_title: str, original_title: Optional[str]) -> str:
|
||||||
|
if not turkish_title:
|
||||||
|
return ""
|
||||||
|
turkish_title = self._clean_title(turkish_title)
|
||||||
|
|
||||||
|
if original_title:
|
||||||
|
original_title = self._clean_title(original_title)
|
||||||
|
if original_title.lower() != turkish_title.lower():
|
||||||
|
safe = unicodedata.normalize("NFKD", original_title).encode("ascii", "ignore").decode("ascii").strip()
|
||||||
|
return f"{turkish_title} ({safe if safe else original_title})"
|
||||||
|
|
||||||
|
return turkish_title
|
||||||
|
|
||||||
|
def _extract_content_info(self, html_content: str) -> dict:
|
||||||
|
info = {}
|
||||||
|
|
||||||
|
if match := re.search(self.PLAYER_CONFIG_RE, html_content):
|
||||||
|
info.update(self._parse_json_safe(match.group(1)))
|
||||||
|
if match := re.search(r'"contentId"\s*:\s*"([^"]+)"', html_content):
|
||||||
|
info["contentId"] = match.group(1)
|
||||||
|
if match := re.search(r'var\s+videoEventObject\s*=\s*(\{[^;]+\});', html_content):
|
||||||
|
info.update(self._parse_json_safe(match.group(1)))
|
||||||
|
|
||||||
|
turkish_title = info.get("title")
|
||||||
|
if not turkish_title:
|
||||||
|
if match := re.search(r'<title>([^<]+)</title>', html_content):
|
||||||
|
turkish_title = match.group(1)
|
||||||
|
|
||||||
|
original_title = self._extract_original_title_from_breadcrumbs(html_content)
|
||||||
|
info["title"] = self._format_title(turkish_title, original_title)
|
||||||
|
return info
|
||||||
|
|
||||||
|
# ──────────────────────────── Session ──────────────────────────── #
|
||||||
|
@staticmethod
|
||||||
|
def get_session():
|
||||||
|
"""Creates a session with retries and browser impersonation."""
|
||||||
|
return create_curl_session("chrome124", max_retries=3, status_forcelist=[429, 500, 502, 503, 504])
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
endpoints:
|
||||||
|
base_url: "https://www.todtv.com.tr"
|
||||||
|
play_request: "https://www.todtv.com.tr/content/playRequest"
|
||||||
|
license_castleblack: "https://castleblack.digiturk.com.tr/api/widevine/license?version=1.0"
|
||||||
|
|
||||||
|
fallback_types:
|
||||||
|
- diziler
|
||||||
|
- dizi
|
||||||
|
- tod-studios
|
||||||
|
- bein-series
|
||||||
|
- belgesel
|
||||||
@@ -25,7 +25,7 @@ class TUBI(Service):
|
|||||||
Service code for TubiTV streaming service (https://tubitv.com/)
|
Service code for TubiTV streaming service (https://tubitv.com/)
|
||||||
|
|
||||||
\b
|
\b
|
||||||
Version: 1.0.7
|
Version: 1.0.8
|
||||||
Author: stabbedbybrick
|
Author: stabbedbybrick
|
||||||
Authorization: Cookies (Optional)
|
Authorization: Cookies (Optional)
|
||||||
Geofence: Locked to whatever region the user is in (API only)
|
Geofence: Locked to whatever region the user is in (API only)
|
||||||
@@ -206,10 +206,12 @@ class TUBI(Service):
|
|||||||
tracks = Tracks()
|
tracks = Tracks()
|
||||||
|
|
||||||
for codec in ["h264", "h265"]:
|
for codec in ["h264", "h265"]:
|
||||||
resource = next((
|
codec_matches = [x for x in resources if codec in x.get("codec", "").lower()]
|
||||||
x for x in resources
|
|
||||||
if self.drm_system in x.get("type", "") and codec in x.get("codec", "").lower()
|
resource = next(iter(sorted(codec_matches, key=lambda x: (
|
||||||
), None)
|
self.drm_system not in x.get("type", ""),
|
||||||
|
"dash" not in x.get("type", "")
|
||||||
|
))), None)
|
||||||
if not resource:
|
if not resource:
|
||||||
self.log.warning(f" - Could not find a {codec} video resource for this title")
|
self.log.warning(f" - Could not find a {codec} video resource for this title")
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# U-NEXT(유넥스트)
|
# U-NEXT(유넥스트)
|
||||||
|
|
||||||
```
|
```
|
||||||
uv run unshackle dl --list -vl all -al orig -sl all -q 2160 -v h.265 -r HDR10 UNXT SID0087607
|
uv run envied.dl --list -vl all -al orig -sl all -q 2160 -v h.265 -r HDR10 UNXT SID0087607
|
||||||
```
|
```
|
||||||
|
|
||||||
## Information
|
## Information
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ First be sure to follow Devine or envied's install and set-up procedure and ensu
|
|||||||
|
|
||||||
**If you have installed Devine by any other way than via poetry or by 'pip install devine' then remove it and re-install using the correct method before running VineFeeder! The re-install should pick-up the last configuration. if not re-configure Devine. Make absolutely sure devine can be called from any folder on your system.**
|
**If you have installed Devine by any other way than via poetry or by 'pip install devine' then remove it and re-install using the correct method before running VineFeeder! The re-install should pick-up the last configuration. if not re-configure Devine. Make absolutely sure devine can be called from any folder on your system.**
|
||||||
|
|
||||||
**If you are using envied make sure you have installed the tool version that runs from the command envied. See https://github.com/envied-dl/envied for installation details**
|
**If you are using envied make sure you have installed the tool version that runs from the command envied.See https://github.com/envied-dl/envied for installation details**
|
||||||
|
|
||||||
**Setup**
|
**Setup**
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ First be sure to follow Devine or envied's install and set-up procedure and ensu
|
|||||||
|
|
||||||
**If you have installed Devine by any other way than via poetry or by 'pip install devine' then remove it and re-install using the correct method before running VineFeeder! The re-install should pick-up the last configuration. if not re-configure Devine. Make absolutely sure devine can be called from any folder on your system.**
|
**If you have installed Devine by any other way than via poetry or by 'pip install devine' then remove it and re-install using the correct method before running VineFeeder! The re-install should pick-up the last configuration. if not re-configure Devine. Make absolutely sure devine can be called from any folder on your system.**
|
||||||
|
|
||||||
**If you are using envied make sure you have installed the tool version that runs from the command envied. See https://github.com/envied-dl/envied for installation details**
|
**If you are using envied make sure you have installed the tool version that runs from the command envied.See https://github.com/envied-dl/envied for installation details**
|
||||||
|
|
||||||
**Setup**
|
**Setup**
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user