Unshackle-Services/ADN/__init__.py

1015 lines
41 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import base64
import json
import re
import time
import uuid
import subprocess
import tempfile
import shutil
from typing import Generator, Optional, Union, List, Any
from pathlib import Path
from functools import partial
import click
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from langcodes import Language
from unshackle.core import binaries
from unshackle.core.config import config
from unshackle.core.manifests import HLS
from unshackle.core.search_result import SearchResult
from unshackle.core.service import Service
from unshackle.core.session import session
from unshackle.core.titles import Episode, Series
from unshackle.core.tracks import Tracks, Chapters, Chapter
from unshackle.core.tracks.audio import Audio
from unshackle.core.tracks.video import Video
from unshackle.core.tracks.audio import Audio
from unshackle.core.tracks.subtitle import Subtitle
class VideoNoAudio(Video):
"""
Video track qui enlève automatiquement l'audio après téléchargement.
Nécessaire car ADN fournit des streams HLS avec audio muxé.
"""
def download(self, session, prepare_drm, max_workers=None, progress=None, *, cdm=None):
"""Override : télécharge puis demuxe pour enlever l'audio."""
import logging
log = logging.getLogger('ADN.VideoNoAudio')
# Téléchargement normal
super().download(session, prepare_drm, max_workers, progress, cdm=cdm)
# Si pas de path, échec du téléchargement
if not self.path or not self.path.exists():
return
# Vérifier FFmpeg disponible
if not binaries.FFMPEG:
log.warning("FFmpeg not found, cannot remove audio from video")
return
# Demuxer : enlever l'audio
if progress:
progress(downloaded="Removing audio")
original_path = self.path
noaudio_path = original_path.with_stem(f"{original_path.stem}_noaudio")
try:
log.debug(f"Removing audio from {original_path.name}")
result = subprocess.run(
[
binaries.FFMPEG,
'-i', str(original_path),
'-vcodec', 'copy', # Copie vidéo sans réencodage
'-an', # Enlève l'audio
'-y',
str(noaudio_path)
],
capture_output=True,
text=True,
timeout=120
)
if result.returncode != 0:
log.error(f"FFmpeg demux failed: {result.stderr}")
noaudio_path.unlink(missing_ok=True)
return
if not noaudio_path.exists() or noaudio_path.stat().st_size < 1000:
log.error("Demuxed video is empty or too small")
noaudio_path.unlink(missing_ok=True)
return
# Remplacer le fichier original
log.debug(f"Video demuxed successfully: {noaudio_path.stat().st_size} bytes")
original_path.unlink()
noaudio_path.rename(original_path)
if progress:
progress(downloaded="Downloaded")
except subprocess.TimeoutExpired:
log.error("FFmpeg demux timeout")
noaudio_path.unlink(missing_ok=True)
except Exception as e:
log.error(f"Failed to demux video: {e}")
noaudio_path.unlink(missing_ok=True)
class AudioExtracted(Audio):
"""
Audio track déjà extrait d'un flux HLS muxé.
Override download() pour copier le fichier au lieu de télécharger.
"""
def __init__(self, *args, extracted_path: Path, **kwargs):
# URL vide pour éviter que curl essaie de télécharger
super().__init__(*args, url="", **kwargs)
self.extracted_path = extracted_path
def download(self, session, prepare_drm, max_workers=None, progress=None, *, cdm=None):
"""Override : copie le fichier extrait au lieu de télécharger."""
if not self.extracted_path or not self.extracted_path.exists():
if progress:
progress(downloaded="[red]FAILED")
raise ValueError(f"Extracted audio file not found: {self.extracted_path}")
# Créer le path de destination (même logique que Track.download)
track_type = self.__class__.__name__
save_path = config.directories.temp / f"{track_type}_{self.id}.m4a"
if progress:
progress(downloaded="Copying", total=100, completed=0)
# Copier le fichier extrait vers le path final
config.directories.temp.mkdir(parents=True, exist_ok=True)
shutil.copy2(self.extracted_path, save_path)
self.path = save_path
if progress:
progress(downloaded="Downloaded", completed=100)
class SubtitleEmbedded(Subtitle):
"""
Subtitle avec contenu embarqué (data URI).
Override download() pour écrire le contenu directement.
"""
def __init__(self, *args, embedded_content: str, **kwargs):
# URL vide pour éviter que curl essaie de télécharger
super().__init__(*args, url="", **kwargs)
self.embedded_content = embedded_content
def download(self, session, prepare_drm, max_workers=None, progress=None, *, cdm=None):
"""Override : écrit le contenu embarqué au lieu de télécharger."""
if not self.embedded_content:
if progress:
progress(downloaded="[red]FAILED")
raise ValueError("No embedded content in subtitle")
# Créer le path de destination
track_type = "Subtitle"
save_path = config.directories.temp / f"{track_type}_{self.id}.{self.codec.extension}"
if progress:
progress(downloaded="Writing", total=100, completed=0)
# Écrire le contenu
config.directories.temp.mkdir(parents=True, exist_ok=True)
save_path.write_text(self.embedded_content, encoding='utf-8')
self.path = save_path
if progress:
progress(downloaded="Downloaded", completed=100)
class ADN(Service):
"""
Service code for Animation Digital Network (ADN).
\b
Version: 3.2.1 (FINAL - Full multi-audio/subtitle support with custom Track classes)
Authorization: Credentials
Robustness:
Video: Clear HLS (Highest Quality)
Audio: Pre-extracted from muxed streams with AudioExtracted class
Subs: AES-128 Encrypted JSON -> ASS format with SubtitleEmbedded class
Technical Solution:
- ADN provides HLS streams with muxed video+audio (not separable)
- AudioExtracted: Extracts audio in get_tracks(), copies during download()
- SubtitleEmbedded: Decrypts and converts to ASS, writes during download()
- Result: MKV with 1 video + multiple audio tracks + subtitles
Custom Track Classes:
- AudioExtracted: Bypasses curl file:// limitation with direct file copy
- SubtitleEmbedded: Bypasses requests data: limitation with direct write
Made by: guilara_tv
"""
RSA_PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCbQrCJBRmaXM4gJidDmcpWDssg
numHinCLHAgS4buMtdH7dEGGEUfBofLzoEdt1jqcrCDT6YNhM0aFCqbLOPFtx9cg
/X2G/G5bPVu8cuFM0L+ehp8s6izK1kjx3OOPH/kWzvstM5tkqgJkNyNEvHdeJl6
KhS+IFEqwvZqgbBpKuwIDAQAB
-----END PUBLIC KEY-----"""
TITLE_RE = r"^(?:https?://(?:www\.)?animationdigitalnetwork\.com/video/[^/]+/)?(?P<id>\d+)"
@staticmethod
def get_session():
return session("okhttp4")
@staticmethod
@click.command(
name="ADN",
short_help="Téléchargement depuis Animation Digital Network",
help=(
"Télécharge des séries ou films depuis ADN.\n\n"
"TITLE : L'URL de la série ou son ID (ex: 1125).\n\n"
"SYSTÈME DE SÉLECTION :\n"
" - Simple : '-e 1-5' (épisodes 1 à 5)\n"
" - Saisons : '-e S2' ou '-e S02' (toute la saison 2) ou '-e S2E1-12'\n"
" - Mixte : '-e 1,3,S2E5' ou '-e 1,3,S02E05'\n"
" - Bonus : '-e NC1,OAV1'"
)
)
@click.argument("title", type=str, required=True)
@click.option(
"-e", "--episode", "select", type=str,
help="Sélection : numéros, plages (5-10), saisons (S1, S2) ou combiné (S1E5)."
)
@click.option(
"--but", is_flag=True,
help="Inverse la sélection : télécharge tout SAUF les épisodes spécifiés avec -e."
)
@click.option(
"--all", "all_eps", is_flag=True,
help="Ignore toutes les restrictions et télécharge l'intégralité de la série."
)
@click.pass_context
def cli(ctx, **kwargs) -> "ADN":
return ADN(ctx, **kwargs)
def __init__(self, ctx, title: str, select: Optional[str] = None, but: bool = False, all_eps: bool = False):
self.title = title
self.select_str = select
self.but = but
self.all_eps = all_eps
self.access_token: Optional[str] = None
self.refresh_token: Optional[str] = None
self.token_expiration: Optional[int] = None
super().__init__(ctx)
self.locale = self.config.get("params", {}).get("locale", "fr")
self.session.headers.update(self.config.get("headers", {}))
self.session.headers["x-target-distribution"] = self.locale
@staticmethod
def _timecode_to_ms(tc: str) -> int:
"""Convert HH:MM:SS timecode to milliseconds."""
parts = tc.split(':')
hours = int(parts[0])
minutes = int(parts[1])
seconds = int(parts[2])
return (hours * 3600 + minutes * 60 + seconds) * 1000
@property
def auth_header(self) -> dict:
return {
"Authorization": f"Bearer {self.access_token}",
"X-Access-Token": self.access_token
}
def ensure_authenticated(self) -> None:
"""Vérifie le token et rafraîchit si nécessaire."""
current_time = int(time.time())
if self.access_token and self.token_expiration and current_time < (self.token_expiration - 60):
return
cache_key = f"adn_auth_{self.credential.sha1 if self.credential else 'default'}"
cached = self.cache.get(cache_key)
if cached and not cached.expired:
self.access_token = cached.data["access_token"]
self.refresh_token = cached.data["refresh_token"]
self.token_expiration = cached.data["token_expiration"]
self.session.headers.update(self.auth_header)
self.log.debug("Loaded authentication from cache")
else:
self.authenticate(credential=self.credential)
def authenticate(self, cookies=None, credential=None) -> None:
super().authenticate(cookies, credential)
if self.refresh_token:
try:
self._do_refresh()
return
except Exception:
self.log.warning("Refresh failed, proceeding to full login")
if not credential:
raise ValueError("Credentials required for ADN")
response = self.session.post(
url=self.config["endpoints"]["login"],
json={
"username": credential.username,
"password": credential.password,
"source": "Web"
}
)
if response.status_code != 200:
self.log.error(f"Login failed: {response.status_code} - {response.text}")
response.raise_for_status()
self._save_tokens(response.json())
def _do_refresh(self):
response = self.session.post(
url=self.config["endpoints"]["refresh"],
json={"refreshToken": self.refresh_token},
headers=self.auth_header
)
if response.status_code != 200:
raise ValueError("Token refresh failed")
self._save_tokens(response.json())
def _save_tokens(self, data: dict):
self.access_token = data["accessToken"]
self.refresh_token = data["refreshToken"]
expires_in = data.get("expires_in", 3600)
self.token_expiration = int(time.time()) + expires_in
self.session.headers.update(self.auth_header)
def _parse_select(self, ep_id: str, short_number: str, season_num: int) -> bool:
"""Retourne True si l'épisode doit être inclus."""
if self.all_eps or not self.select_str:
return True
# Préparation des identifiants possibles pour cet épisode
# On teste : "30353" (id), "1" (numéro), "S02E01" (format complet), "S02" (saison entière)
candidates = [
str(ep_id),
str(short_number).lstrip("0"),
f"S{season_num:02d}E{int(short_number):02d}" if str(short_number).isdigit() else "",
f"S{season_num:02d}"
]
parts = re.split(r'[ ,]+', self.select_str.strip().upper())
selection: set[str] = set()
for part in parts:
if '-' in part:
start_p, end_p = part.split('-', 1)
# Gestion des plages S02E01-S02E04
m_start = re.match(r'^S(\d+)E(\d+)$', start_p)
m_end = re.match(r'^S(\d+)E(\d+)$', end_p)
if m_start and m_end:
s_start, e_start = map(int, m_start.groups())
s_end, e_end = map(int, m_end.groups())
if s_start == s_end: # Même saison
for i in range(e_start, e_end + 1):
selection.add(f"S{s_start:02d}E{i:02d}")
continue
# Plages classiques (1-10)
nums = re.findall(r'\d+', part)
if len(nums) >= 2:
for i in range(int(nums[0]), int(nums[1]) + 1):
selection.add(str(i))
else:
selection.add(part.lstrip("0"))
included = any(c in selection for c in candidates if c)
return not included if self.but else included
def get_titles(self) -> Series:
"""Récupère les épisodes avec le titre réel de la série."""
show_id = self.parse_show_id(self.title)
# 1. Récupérer d'abord les infos globales du show pour avoir le titre propre
show_url = self.config["endpoints"]["show"].format(show_id=show_id)
show_res = self.session.get(show_url).json()
# On extrait le titre de la série (ex: "Demon Slave")
# C'est ce titre qui servira de nom au dossier unique
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
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)).json()
if not res.get("seasons"):
self.log.error(f"Aucune saison trouvée pour l'ID {show_id}")
return Series([])
episodes = []
for season_data in res["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", []):
video_id = str(vid["id"])
# Nettoyage du numéro d'épisode (on ne garde que les chiffres)
num_match = re.search(r'\d+', str(vid.get("number", "0")))
short_number = num_match.group() if num_match else "0"
# Logique de sélection (SxxEyy)
if not self._parse_select(video_id, short_number, season_num):
continue
# Création de l'épisode
episodes.append(Episode(
id_=video_id,
service=self.__class__,
title=series_title, # Dossier : "Demon Slave"
season=season_num, # Saison : 2
number=int(short_number),
name=vid.get("name") or "", # Nom : "La grande réunion..."
data=vid
))
episodes.sort(key=lambda x: (x.season, x.number))
return Series(episodes)
def get_tracks(self, title: Episode) -> Tracks:
"""
Récupère les pistes en pré-extrayant les audios.
Les audios sont extraits maintenant et seront copiés pendant download().
"""
self.ensure_authenticated()
vid_id = title.id
# Configuration du lecteur
config_url = self.config["endpoints"]["player_config"].format(video_id=vid_id)
config_res = self.session.get(config_url).json()
player_opts = config_res["player"]["options"]
if not player_opts["user"]["hasAccess"]:
raise PermissionError("No access to this video (Premium required?)")
# Token du lecteur
refresh_url = player_opts["user"].get("refreshTokenUrl") or self.config["endpoints"]["player_refresh"]
token_res = self.session.post(
refresh_url,
headers={"X-Player-Refresh-Token": player_opts["user"]["refreshToken"]}
).json()
player_token = token_res["token"]
links_url = player_opts["video"].get("url") or self.config["endpoints"]["player_links"].format(video_id=vid_id)
# Chiffrement RSA
rand_key = uuid.uuid4().hex[:16]
payload = json.dumps({"k": rand_key, "t": player_token}).encode('utf-8')
public_key = serialization.load_pem_public_key(
self.RSA_PUBLIC_KEY.encode('utf-8'),
backend=default_backend()
)
encrypted = public_key.encrypt(payload, padding.PKCS1v15())
auth_header_val = base64.b64encode(encrypted).decode('utf-8')
# Récupération des liens
links_res = self.session.get(
links_url,
params={"freeWithAds": "true", "adaptive": "true", "withMetadata": "true", "source": "Web"},
headers={"X-Player-Token": auth_header_val}
).json()
tracks = Tracks()
streaming_links = links_res.get("links", {}).get("streaming", {})
# Map des langues
lang_map = {
"vf": "fr",
"vostf": "ja",
"vde": "de",
"vostde": "ja",
}
# Priorité: VOSTF (original) pour la vidéo principale
priority_order = ["vostf", "vf", "vde", "vostde"]
available_streams = {k: v for k, v in streaming_links.items() if k in lang_map}
sorted_streams = sorted(
available_streams.keys(),
key=lambda x: priority_order.index(x) if x in priority_order else 999
)
if not sorted_streams:
raise ValueError("No supported streams found")
# Vidéo principale (VOSTF ou premier disponible)
primary_stream = sorted_streams[0]
primary_lang = lang_map[primary_stream]
self.log.info(f"Primary video stream: {primary_stream} ({primary_lang})")
video_track = self._get_video_track(
streaming_links[primary_stream],
primary_stream,
primary_lang,
is_original=(primary_stream in ["vostf", "vostde"])
)
if video_track:
tracks.add(video_track)
self.log.info(f"Video track added: {video_track.width}x{video_track.height}")
# Extraire audios pour toutes les langues disponibles
for stream_type in sorted_streams:
audio_lang = lang_map[stream_type]
is_original = stream_type in ["vostf", "vostde"]
self.log.info(f"Processing audio for: {stream_type} ({audio_lang})")
audio_track = self._extract_audio_track(
streaming_links[stream_type],
stream_type,
audio_lang,
is_original,
title
)
if audio_track:
tracks.add(audio_track, warn_only=True)
self.log.info(f"Audio track added: {audio_lang}")
# Stocker les données de chapitres pour get_chapters()
if "video" in links_res:
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')}")
# Sous-titres
self._process_subtitles(links_res, rand_key, title, tracks)
if not tracks.videos:
raise ValueError("No video tracks were successfully added")
return tracks
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)."""
try:
m3u8_url = self._resolve_stream_url(stream_data, stream_type)
if not m3u8_url:
return None
hls_manifest = HLS.from_url(url=m3u8_url, session=self.session)
hls_tracks = hls_manifest.to_tracks(language=lang)
if not hls_tracks.videos:
self.log.warning(f"No video tracks found for {stream_type}")
return None
# Meilleure qualité
best_video = max(
hls_tracks.videos,
key=lambda v: (v.height or 0, v.width or 0, v.bitrate or 0)
)
# Convertir en VideoNoAudio pour demuxer automatiquement
video_no_audio = VideoNoAudio(
id_=best_video.id,
url=best_video.url,
codec=best_video.codec,
language=Language.get(lang),
is_original_lang=is_original,
bitrate=best_video.bitrate,
descriptor=best_video.descriptor,
width=best_video.width,
height=best_video.height,
fps=best_video.fps,
range_=best_video.range,
data=best_video.data,
)
video_no_audio.data["stream_type"] = stream_type
return video_no_audio
except Exception as e:
self.log.error(f"Failed to get video track for {stream_type}: {e}")
return None
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.
L'audio est extrait MAINTENANT et sera copié pendant download().
"""
if not binaries.FFMPEG:
self.log.warning("FFmpeg not found, cannot extract audio")
return None
try:
m3u8_url = self._resolve_stream_url(stream_data, stream_type)
if not m3u8_url:
return None
# Créer un répertoire temp pour ADN dans le temp d'Unshackle
adn_temp = config.directories.temp / "adn_audio_extracts"
adn_temp.mkdir(parents=True, exist_ok=True)
# Nom de fichier unique basé sur video_id + langue
audio_filename = f"audio_{title.id}_{stream_type}.m4a"
audio_path = adn_temp / audio_filename
# Si déjà extrait, réutiliser
if audio_path.exists() and audio_path.stat().st_size > 1000:
self.log.debug(f"Reusing existing extracted audio: {audio_path}")
else:
# Extraire avec FFmpeg
result = subprocess.run(
[
binaries.FFMPEG,
'-i', m3u8_url,
'-vn',
'-acodec', 'copy',
'-y',
str(audio_path)
],
capture_output=True,
text=True,
timeout=300
)
if result.returncode != 0:
self.log.error(f"FFmpeg failed for {stream_type}: {result.stderr}")
audio_path.unlink(missing_ok=True)
return None
if not audio_path.exists() or audio_path.stat().st_size < 1000:
self.log.error(f"Extracted audio is invalid for {stream_type}")
audio_path.unlink(missing_ok=True)
return None
# Créer AudioExtracted avec le fichier pré-extrait
audio_track = AudioExtracted(
id_=f"audio-{stream_type}-{lang}",
extracted_path=audio_path,
codec=Audio.Codec.AAC,
language=Language.get(lang),
is_original_lang=is_original,
bitrate=128000,
channels=2.0,
)
return audio_track
except subprocess.TimeoutExpired:
self.log.error(f"FFmpeg timeout for {stream_type}")
return None
except Exception as e:
self.log.error(f"Failed to extract audio for {stream_type}: {e}")
return None
def _resolve_stream_url(self, stream_data: dict, stream_type: str) -> Optional[str]:
"""Résout l'URL du stream."""
preferred_keys = ["fhd", "hd", "auto", "sd", "mobile"]
m3u8_url = None
for key in preferred_keys:
if key in stream_data and stream_data[key]:
m3u8_url = stream_data[key]
break
if not m3u8_url:
return None
try:
resp = self.session.get(m3u8_url, timeout=12)
if resp.status_code != 200:
return None
content_type = resp.headers.get("Content-Type", "")
resp_text = resp.text.strip()
if "application/json" in content_type or resp_text.startswith("{"):
try:
json_data = resp.json()
real_location = json_data.get("location")
if real_location:
return real_location
except json.JSONDecodeError:
pass
return m3u8_url
except Exception as e:
self.log.error(f"Failed to resolve URL for {stream_type}: {e}")
return None
def _process_subtitles(self, links_res: dict, rand_key: str, title: Episode, tracks: Tracks):
"""Traite les sous-titres."""
subs_root = links_res.get("links", {}).get("subtitles", {})
if "all" not in subs_root:
self.log.debug("No subtitles available")
return
aes_key_bytes = bytes.fromhex(rand_key + '7fac1178830cfe0c')
try:
sub_loc_res = self.session.get(subs_root["all"]).json()
encrypted_sub_res = self.session.get(sub_loc_res["location"]).text
self.log.debug(f"Encrypted subtitle length: {len(encrypted_sub_res)}")
iv_b64 = encrypted_sub_res[:24]
payload_b64 = encrypted_sub_res[24:]
iv = base64.b64decode(iv_b64)
ciphertext = base64.b64decode(payload_b64)
self.log.debug(f"IV length: {len(iv)}, Ciphertext length: {len(ciphertext)}")
cipher = Cipher(algorithms.AES(aes_key_bytes), modes.CBC(iv), backend=default_backend())
decryptor = cipher.decryptor()
decrypted_padded = decryptor.update(ciphertext) + decryptor.finalize()
# TOUJOURS retirer le padding PKCS7 (Python ne le fait pas automatiquement)
pad_len = decrypted_padded[-1]
if not (1 <= pad_len <= 16):
self.log.error(f"Invalid PKCS7 padding length: {pad_len}")
return
# Vérifier que tous les bytes de padding ont la même valeur
padding = decrypted_padded[-pad_len:]
if not all(b == pad_len for b in padding):
self.log.error(f"Invalid PKCS7 padding bytes")
return
decrypted_json = decrypted_padded[:-pad_len].decode('utf-8')
self.log.debug(f"Decrypted JSON length: {len(decrypted_json)}")
subs_data = json.loads(decrypted_json)
if not isinstance(subs_data, dict):
self.log.error(f"subs_data is not a dict! Type: {type(subs_data)}")
return
if len(subs_data) == 0:
self.log.warning("subs_data is empty!")
return
# Debug chaque clé
for key in subs_data.keys():
value = subs_data[key]
if isinstance(value, list) and len(value) > 0:
self.log.debug(f" First item type: {type(value[0])}")
self.log.debug(f" First item keys: {value[0].keys() if isinstance(value[0], dict) else 'NOT A DICT'}")
self.log.debug(f" First item sample: {str(value[0])[:200]}")
processed_langs = set()
for sub_lang_key, cues in subs_data.items():
if not isinstance(cues, list):
self.log.warning(f"Cues for {sub_lang_key} is not a list! Type: {type(cues)}")
continue
if len(cues) == 0:
self.log.debug(f"No subtitles for {sub_lang_key} (normal for dubbed versions)")
continue
self.log.debug(f" Cues count: {len(cues)}")
self.log.debug(f" First cue: {cues[0]}")
if "vf" in sub_lang_key.lower() or "vostf" in sub_lang_key.lower():
target_lang = "fr"
elif "vde" in sub_lang_key.lower() or "vostde" in sub_lang_key.lower():
target_lang = "de"
else:
self.log.debug(f"Skipping subtitle language: {sub_lang_key}")
continue
if target_lang in processed_langs:
self.log.debug(f"Already processed {target_lang}, skipping")
continue
processed_langs.add(target_lang)
# Convertir en ASS
ass_content = self._json_to_ass(cues, title.title, title.number)
# Vérifier si le fichier ASS a du contenu
event_count = ass_content.count("Dialogue:")
self.log.debug(f"Generated ASS with {event_count} dialogue events")
if event_count == 0:
self.log.warning(f"ASS file has no dialogue events!")
self.log.warning(f"First cue was: {cues[0] if cues else 'EMPTY LIST'}")
# Créer SubtitleEmbedded avec le contenu ASS directement
subtitle = SubtitleEmbedded(
id_=f"sub-{target_lang}-{sub_lang_key}",
embedded_content=ass_content, # Contenu ASS directement
codec=Subtitle.Codec.SubStationAlphav4,
language=Language.get(target_lang),
forced=False,
sdh=False,
)
tracks.add(subtitle, warn_only=True)
self.log.info(f"Subtitle added: {target_lang} ({event_count} events)")
except json.JSONDecodeError as e:
self.log.error(f"Failed to decode JSON: {e}")
self.log.error(f"Decrypted data (first 500 chars): {decrypted_json[:500] if 'decrypted_json' in locals() else 'NOT DECRYPTED'}")
except Exception as e:
self.log.error(f"Failed to process subtitles: {e}")
import traceback
self.log.debug(traceback.format_exc())
def get_chapters(self, title: Episode) -> Chapters:
"""
Crée les chapitres à partir des timecodes ADN.
- Si tcIntroStart existe:
- Si tcIntroStart != "00:00:00": ajouter "Prologue" à 00:00:00
- Ajouter "Opening" à tcIntroStart
- Ajouter "Episode" à tcIntroEnd
- Sinon: ajouter "Episode" à 00:00:00
- Si tcEndingStart existe:
- Ajouter "Ending Start" à tcEndingStart
- Ajouter "Ending End" à tcEndingEnd
"""
chapters = Chapters()
# Récupérer les données de chapitres stockées dans get_tracks()
chapter_data = title.data.get("chapter_data", {})
if not chapter_data:
self.log.debug("No chapter data available")
return chapters
tc_intro_start = chapter_data.get("tcIntroStart")
tc_intro_end = chapter_data.get("tcIntroEnd")
tc_ending_start = chapter_data.get("tcEndingStart")
tc_ending_end = chapter_data.get("tcEndingEnd")
self.log.debug(f"Chapter timecodes: intro={tc_intro_start}->{tc_intro_end}, ending={tc_ending_start}->{tc_ending_end}")
try:
if tc_intro_start:
# Si l'intro ne commence pas à 00:00:00, ajouter un prologue
if tc_intro_start != "00:00:00":
chapters.add(Chapter(
timestamp=0,
name="Prologue"
))
self.log.debug("Added Prologue chapter at 00:00:00")
# Opening
chapters.add(Chapter(
timestamp=self._timecode_to_ms(tc_intro_start),
name="Opening"
))
self.log.debug(f"Added Opening chapter at {tc_intro_start}")
# Episode (après l'intro)
if tc_intro_end:
chapters.add(Chapter(
timestamp=self._timecode_to_ms(tc_intro_end),
name="Episode"
))
self.log.debug(f"Added Episode chapter at {tc_intro_end}")
else:
# Pas d'intro, épisode commence à 00:00:00
chapters.add(Chapter(
timestamp=0,
name="Episode"
))
self.log.debug("Added Episode chapter at 00:00:00 (no intro)")
# Ending
if tc_ending_start:
chapters.add(Chapter(
timestamp=self._timecode_to_ms(tc_ending_start),
name="Ending Start"
))
self.log.debug(f"Added Ending Start chapter at {tc_ending_start}")
if tc_ending_end:
chapters.add(Chapter(
timestamp=self._timecode_to_ms(tc_ending_end),
name="Ending End"
))
self.log.debug(f"Added Ending End chapter at {tc_ending_end}")
self.log.info(f"✓ Created {len(chapters)} chapters")
except Exception as e:
self.log.error(f"Failed to create chapters: {e}")
import traceback
self.log.debug(traceback.format_exc())
return chapters
def search(self) -> Generator[SearchResult, None, None]:
res = self.session.get(
self.config["endpoints"]["search"],
params={"search": self.title, "limit": 20, "offset": 0}
).json()
for show in res.get("shows", []):
yield SearchResult(
id_=str(show["id"]),
title=show["title"],
label=show["type"],
description=show.get("summary", "")[:300],
url=f"https://animationdigitalnetwork.com/video/{show['id']}",
image=show.get("image")
)
def parse_show_id(self, input_str: str) -> str:
if input_str.isdigit():
return input_str
match = re.match(self.TITLE_RE, input_str)
if match:
return match.group("id")
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:
"""Convertit les sous-titres JSON en ASS."""
header = """[Script Info]
ScriptType: v4.00+
WrapStyle: 0
PlayResX: 1280
PlayResY: 720
ScaledBorderAndShadow: yes
[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
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
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
"""
events = []
pos_align_map = {"start": 1, "end": 3}
line_align_map = {"middle": 8, "end": 4}
def format_time(seconds: float) -> str:
"""Format exact d'adn : HH:MM:SS.CC (centisecondes sur 2 chiffres)"""
secs = int(seconds)
centiseconds = round((seconds - secs) * 100)
hours = secs // 3600
minutes = (secs % 3600) // 60
remaining_seconds = secs % 60
# Padding sur 2 chiffres pour TOUT (hours inclus)
return f"{hours:02d}:{minutes:02d}:{remaining_seconds:02d}.{centiseconds:02d}"
for cue in cues:
start_time = cue.get("startTime", 0)
end_time = cue.get("endTime", 0)
text = cue.get("text", "")
# Skip si texte vide
if not text or not text.strip():
continue
# Nettoyage EXACT du code adn
text = text.replace(' \\N', '\\N') # remove space before \\N at end
if text.endswith('\\N'):
text = text[:-2] # remove \\N at end
text = text.replace('\r', '')
text = text.replace('\n', '\\N')
text = re.sub(r'\\N +', r'\\N', text) # \\N followed by spaces
text = re.sub(r' +\\N', r'\\N', text) # spaces followed by \\N
text = re.sub(r'(\\N)+', r'\\N', text) # multiple \\N
text = re.sub(r'<b[^>]*>([^<]*)</b>', r'{\\b1}\1{\\b0}', text)
text = re.sub(r'<i[^>]*>([^<]*)</i>', r'{\\i1}\1{\\i0}', text)
text = re.sub(r'<u[^>]*>([^<]*)</u>', r'{\\u1}\1{\\u0}', text)
text = text.replace('&lt;', '<').replace('&gt;', '>').replace('&amp;', '&')
text = re.sub(r'<[^>]>', '', text) # remove any remaining single tags
if text.endswith('\\N'):
text = text[:-2]
text = text.rstrip() # remove trailing spaces
# Skip après nettoyage si vide
if not text.strip():
continue
p_align = pos_align_map.get(cue.get("positionAlign"), 2)
l_align = line_align_map.get(cue.get("lineAlign", ""), 0)
align_val = p_align + l_align
start = format_time(start_time)
end = format_time(end_time)
style_mod = f"{{\\a{align_val}}}" if align_val != 2 else ""
events.append(f"Dialogue: 0,{start},{end},Default,,0,0,0,,{style_mod}{text}")
self.log.debug(f"Converted {len(events)} subtitle events from {len(cues)} cues")
if not events:
self.log.warning(f"No subtitle events generated - all cues were empty or invalid (total cues: {len(cues)})")
return header + "\n".join(events)