599 lines
24 KiB
Python
599 lines
24 KiB
Python
import hashlib
|
|
import json
|
|
import re
|
|
from collections.abc import Generator
|
|
from datetime import datetime
|
|
from http.cookiejar import CookieJar
|
|
from typing import Optional, Union
|
|
|
|
import click
|
|
from langcodes import Language
|
|
|
|
from unshackle.core.constants import AnyTrack
|
|
from unshackle.core.credential import Credential
|
|
from unshackle.core.manifests import DASH
|
|
from unshackle.core.search_result import SearchResult
|
|
from unshackle.core.service import Service
|
|
from unshackle.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
|
|
from unshackle.core.tracks import Chapter, Subtitle, Tracks, Video
|
|
|
|
|
|
class Tabii(Service):
|
|
"""
|
|
Service code for Tabii (tabii.com)
|
|
Version: 1.1.0
|
|
Author: Riyadh
|
|
|
|
\b
|
|
Authorization: Credentials
|
|
Security: FHD@L3
|
|
|
|
\b
|
|
Supported URL formats:
|
|
- Direct title ID: 527983
|
|
- Watch URL: https://www.tabii.com/watch/527983
|
|
- Browse URL: https://www.tabii.com/browse/149106_149112?dt=527983
|
|
- Episode URL: https://www.tabii.com/watch/527983?trackId=545891
|
|
- Movie URL: https://www.tabii.com/browse/149265_149266?dt=544916
|
|
|
|
\b
|
|
Features:
|
|
- Automatic token caching and refresh
|
|
- Support for both movies and series
|
|
- Multi-language audio and subtitles
|
|
- Widevine L3 DRM decryption
|
|
"""
|
|
|
|
TITLE_RE = r"^(?:https?://(?:www\.)?tabii\.com/(?:watch|browse)/(?:[^?]+\?dt=)?)?(?P<title_id>[^/?&]+)(?:\?trackId=(?P<track_id>[^&]+))?"
|
|
GEOFENCE = ("TR",)
|
|
|
|
@staticmethod
|
|
@click.command(name="Tabii", short_help="https://tabii.com")
|
|
@click.argument("title", type=str)
|
|
@click.option("-m", "--movie", is_flag=True, default=False, help="Specify if it's a movie")
|
|
@click.pass_context
|
|
def cli(ctx, **kwargs):
|
|
return Tabii(ctx, **kwargs)
|
|
|
|
def __init__(self, ctx, title, movie):
|
|
super().__init__(ctx)
|
|
|
|
self.title = title
|
|
self.movie = movie
|
|
self.cdm = ctx.obj.cdm
|
|
|
|
if self.config is None:
|
|
raise Exception("Tabii service configuration is missing! Please add config.yaml to the service directory.")
|
|
|
|
self.access_token = None
|
|
self.refresh_token = None
|
|
self.device = "android"
|
|
self.license_url = None
|
|
|
|
# Validate required config sections
|
|
required_sections = ["endpoints", "client"]
|
|
for section in required_sections:
|
|
if section not in self.config:
|
|
raise Exception(f"Missing required config section: {section}")
|
|
|
|
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
|
"""Authenticate with Tabii service using email and password"""
|
|
super().authenticate(cookies, credential)
|
|
|
|
if not credential:
|
|
raise EnvironmentError("Tabii requires email and password credentials for authentication.")
|
|
|
|
# Setup basic session headers
|
|
self.log.debug("Setting up session headers")
|
|
self.session.headers.update({
|
|
"User-Agent": self.config["client"][self.device]["user_agent"],
|
|
"Accept-Encoding": "gzip",
|
|
"Accept-Language": "en",
|
|
"Connection": "Keep-Alive"
|
|
})
|
|
|
|
# Try cached tokens first
|
|
cache = self.cache.get(f"tabii_tokens")
|
|
|
|
if cache and cache.data:
|
|
expires_at = cache.data.get("expires_at", 0)
|
|
current_time = int(datetime.now().timestamp())
|
|
|
|
if expires_at > current_time:
|
|
self.log.info("Using cached authentication tokens")
|
|
self.log.debug(f"Token valid for {(expires_at - current_time) // 60} more minutes")
|
|
self.access_token = cache.data["access_token"]
|
|
self.refresh_token = cache.data["refresh_token"]
|
|
self._set_full_headers()
|
|
else:
|
|
self.log.info("Cached tokens expired, attempting refresh")
|
|
if not self._refresh_token(cache.data.get("refresh_token")):
|
|
self.log.warning("Token refresh failed, performing new login")
|
|
self._perform_login(credential.username, credential.password)
|
|
else:
|
|
self.log.info("Successfully refreshed authentication tokens")
|
|
else:
|
|
self.log.info("No cached tokens found, performing new login")
|
|
self._perform_login(credential.username, credential.password)
|
|
|
|
# Set authorization header with final token
|
|
self.session.headers.update({"Authorization": f"Bearer {self.access_token}"})
|
|
self.log.debug("Authentication completed successfully")
|
|
|
|
def _set_full_headers(self) -> None:
|
|
"""Set all required headers after authentication"""
|
|
self.log.debug("Configuring full session headers")
|
|
self.session.headers.update(self.config["client"][self.device]["headers"])
|
|
self.session.headers.update({"User-Agent": self.config["client"][self.device]["user_agent"]})
|
|
|
|
def _perform_login(self, email: str, password: str) -> None:
|
|
"""Perform complete login flow: initial auth -> profile selection -> profile token"""
|
|
|
|
self.log.info("Starting Tabii authentication flow")
|
|
|
|
# Step 1: Initial login
|
|
self.log.debug("Step 1/3: Requesting initial access token")
|
|
login_headers = {
|
|
"Content-Type": "application/json",
|
|
"Accept-Encoding": "gzip",
|
|
"Connection": "Keep-Alive",
|
|
"Trtdigitalprod": self.config["client"][self.device]["headers"]["Trtdigitalprod"],
|
|
"User-Agent": self.config["client"][self.device]["user_agent"],
|
|
"x-client-useragent": self.config["client"][self.device]["headers"]["x-client-useragent"],
|
|
"x-clientid": self.config["client"][self.device]["headers"]["x-clientid"],
|
|
"x-clientpass": self.config["client"][self.device]["headers"]["x-clientpass"],
|
|
"x-uid": self.config["client"][self.device]["headers"]["x-uid"]
|
|
}
|
|
|
|
try:
|
|
login_req = self.session.post(
|
|
url=self.config["endpoints"]["login"],
|
|
json={"email": email, "password": password},
|
|
headers=login_headers,
|
|
timeout=30
|
|
)
|
|
|
|
if login_req.status_code != 200:
|
|
error_msg = f"Login failed with HTTP {login_req.status_code}"
|
|
try:
|
|
error_data = login_req.json()
|
|
if "message" in error_data:
|
|
error_msg += f": {error_data['message']}"
|
|
except:
|
|
error_msg += f": {login_req.text[:200]}"
|
|
|
|
self.log.error(error_msg)
|
|
raise Exception(error_msg)
|
|
|
|
login_response = login_req.json()
|
|
self.log.debug("Initial authentication successful")
|
|
|
|
except Exception as e:
|
|
self.log.error(f"Login request failed: {str(e)}")
|
|
raise Exception(f"Failed to authenticate with Tabii: {str(e)}")
|
|
|
|
profile_access_token = login_response.get("accessToken")
|
|
profile_refresh_token = login_response.get("refreshToken")
|
|
|
|
if not profile_access_token or not profile_refresh_token:
|
|
error_msg = login_response.get("message", "Unknown error - missing tokens in response")
|
|
self.log.error(f"Failed to obtain access tokens: {error_msg}")
|
|
raise Exception(f"Authentication failed: {error_msg}")
|
|
|
|
# Step 2: Get profiles
|
|
self.log.debug("Step 2/3: Fetching available profiles")
|
|
self.session.headers.update({"Authorization": f"Bearer {profile_access_token}"})
|
|
|
|
try:
|
|
profiles_response = self.session.get(
|
|
url=self.config["endpoints"]["profiles"],
|
|
timeout=30
|
|
).json()
|
|
except Exception as e:
|
|
self.log.error(f"Failed to fetch profiles: {str(e)}")
|
|
raise Exception(f"Could not retrieve profile list: {str(e)}")
|
|
|
|
# Find non-kids profile
|
|
profile_sk = None
|
|
profile_name = None
|
|
profiles_data = profiles_response.get("data", [])
|
|
|
|
self.log.debug(f"Found {len(profiles_data)} profile(s)")
|
|
|
|
for profile in profiles_data:
|
|
kids_value = profile.get("kids", False)
|
|
if isinstance(kids_value, str):
|
|
kids_value = kids_value.lower() == "true"
|
|
|
|
if not kids_value:
|
|
profile_sk = profile.get("SK")
|
|
profile_name = profile.get("name", "Unknown")
|
|
self.log.debug(f"Selected profile: {profile_name}")
|
|
break
|
|
|
|
if not profile_sk:
|
|
self.log.error("No suitable adult profile found in account")
|
|
raise Exception("No adult profile available. Please create a non-kids profile in your Tabii account.")
|
|
|
|
# Step 3: Get profile token
|
|
self.log.debug(f"Step 3/3: Requesting token for profile '{profile_name}'")
|
|
|
|
try:
|
|
profile_token_response = self.session.post(
|
|
url=self.config["endpoints"]["profile_token"].format(profile_sk=profile_sk),
|
|
json={"refreshToken": profile_refresh_token},
|
|
headers={"Content-Type": "application/json"},
|
|
timeout=30
|
|
).json()
|
|
except Exception as e:
|
|
self.log.error(f"Failed to get profile token: {str(e)}")
|
|
raise Exception(f"Could not obtain profile-specific token: {str(e)}")
|
|
|
|
self.access_token = profile_token_response.get("accessToken")
|
|
self.refresh_token = profile_token_response.get("refreshToken")
|
|
|
|
if not self.access_token or not self.refresh_token:
|
|
self.log.error("Profile token response missing required tokens")
|
|
raise Exception("Failed to obtain profile tokens")
|
|
|
|
self._set_full_headers()
|
|
|
|
# Cache tokens with 24 hour expiry
|
|
cache_data = {
|
|
"access_token": self.access_token,
|
|
"refresh_token": self.refresh_token,
|
|
"expires_at": int(datetime.now().timestamp()) + 86400,
|
|
"profile_name": profile_name
|
|
}
|
|
self.cache.get(f"tabii_tokens").set(data=cache_data)
|
|
|
|
self.log.info(f"Successfully authenticated as profile '{profile_name}'")
|
|
self.log.debug("Authentication tokens cached for 24 hours")
|
|
|
|
def _refresh_token(self, refresh_token: str) -> bool:
|
|
"""Attempt to refresh access token using refresh token"""
|
|
|
|
if not refresh_token:
|
|
self.log.debug("No refresh token available")
|
|
return False
|
|
|
|
self.log.debug("Attempting to refresh access token")
|
|
|
|
try:
|
|
refresh_headers = {
|
|
"Content-Type": "application/json; charset=UTF-8",
|
|
"Trtdigitalprod": self.config["client"][self.device]["headers"]["Trtdigitalprod"],
|
|
"x-clientid": "ANDROID",
|
|
"x-clientpass": "000"
|
|
}
|
|
|
|
response = self.session.post(
|
|
url=self.config["endpoints"]["token_refresh"],
|
|
json={"refreshToken": refresh_token},
|
|
headers=refresh_headers,
|
|
timeout=30
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
self.log.warning(f"Token refresh returned HTTP {response.status_code}")
|
|
return False
|
|
|
|
response_data = response.json()
|
|
self.access_token = response_data.get("accessToken")
|
|
new_refresh_token = response_data.get("refreshToken")
|
|
|
|
if not self.access_token:
|
|
self.log.warning("Token refresh response missing access token")
|
|
return False
|
|
|
|
self.refresh_token = new_refresh_token or refresh_token
|
|
self._set_full_headers()
|
|
|
|
# Update cache
|
|
cache_data = {
|
|
"access_token": self.access_token,
|
|
"refresh_token": self.refresh_token,
|
|
"expires_at": int(datetime.now().timestamp()) + 86400
|
|
}
|
|
self.cache.get(f"tabii_tokens").set(data=cache_data)
|
|
|
|
self.log.debug("Access token refreshed successfully")
|
|
return True
|
|
|
|
except Exception as e:
|
|
self.log.warning(f"Token refresh failed: {str(e)}")
|
|
return False
|
|
|
|
def search(self) -> Generator[SearchResult, None, None]:
|
|
"""Search functionality - not implemented for Tabii"""
|
|
self.log.debug("Search called - Tabii does not support search, returning placeholder")
|
|
yield SearchResult(
|
|
id_=self.title,
|
|
title="Direct URL or ID required",
|
|
label="N/A",
|
|
url=f"https://www.tabii.com/watch/{self.title}"
|
|
)
|
|
|
|
def get_titles(self) -> Titles_T:
|
|
"""Fetch title metadata from Tabii API"""
|
|
|
|
match = re.match(self.TITLE_RE, self.title)
|
|
if not match:
|
|
self.log.error(f"Invalid title format: {self.title}")
|
|
raise Exception(f"Invalid title URL or ID format. Expected format: '527983' or 'https://www.tabii.com/watch/527983'")
|
|
|
|
title_id = match.group("title_id")
|
|
track_id = match.group("track_id")
|
|
|
|
self.log.info(f"Fetching metadata for title ID: {title_id}")
|
|
if track_id:
|
|
self.log.debug(f"Specific episode requested: {track_id}")
|
|
|
|
try:
|
|
metadata = self.session.get(
|
|
url=self.config["endpoints"]["metadata"].format(title_id=title_id),
|
|
timeout=30
|
|
).json()
|
|
except Exception as e:
|
|
self.log.error(f"Failed to fetch title metadata: {str(e)}")
|
|
raise Exception(f"Could not retrieve title information: {str(e)}")
|
|
|
|
if "data" not in metadata:
|
|
self.log.error("API response missing 'data' field")
|
|
raise Exception("Invalid API response format")
|
|
|
|
content_type = metadata.get("data", {}).get("contentType", "").lower()
|
|
title_name = metadata["data"].get("title", "Unknown")
|
|
|
|
self.log.debug(f"Content type: {content_type}, Title: {title_name}")
|
|
|
|
if content_type == "movie":
|
|
self.movie = True
|
|
|
|
if self.movie:
|
|
return self._parse_movie(metadata)
|
|
else:
|
|
return self._parse_series(metadata, track_id)
|
|
|
|
def _parse_movie(self, metadata: dict) -> Movies:
|
|
"""Parse movie metadata"""
|
|
|
|
current_content = metadata["data"].get("currentContent", {})
|
|
current_content_id = current_content.get("id")
|
|
|
|
if not current_content_id:
|
|
self.log.error("Movie content ID not found in metadata")
|
|
raise Exception("Could not find movie content ID")
|
|
|
|
title = metadata["data"]["title"]
|
|
year = int(metadata["data"].get("madeYear", 0)) if metadata["data"].get("madeYear") else None
|
|
|
|
self.log.info(f"Parsed movie: {title} ({year})")
|
|
self.log.debug(f"Content ID: {current_content_id}")
|
|
|
|
return Movies([
|
|
Movie(
|
|
id_=current_content_id,
|
|
service=self.__class__,
|
|
name=title,
|
|
description=metadata["data"].get("description", ""),
|
|
year=year,
|
|
language=Language.get("tr"),
|
|
data=current_content
|
|
)
|
|
])
|
|
|
|
def _parse_series(self, metadata: dict, track_id: Optional[str] = None) -> Series:
|
|
"""Parse series metadata and episodes"""
|
|
|
|
episodes = []
|
|
seasons = metadata["data"].get("seasons", [])
|
|
series_title = metadata["data"]["title"]
|
|
series_year = int(metadata["data"].get("madeYear", 0)) if metadata["data"].get("madeYear") else None
|
|
|
|
self.log.info(f"Parsing series: {series_title} ({series_year})")
|
|
self.log.debug(f"Found {len(seasons)} season(s)")
|
|
|
|
for season in seasons:
|
|
season_number = season.get("seasonNumber", 1)
|
|
season_episodes = season.get("episodes", [])
|
|
|
|
self.log.debug(f"Season {season_number}: {len(season_episodes)} episode(s)")
|
|
|
|
for episode in season_episodes:
|
|
episode_id = episode["id"]
|
|
|
|
# Filter by track_id if specified
|
|
if track_id and episode_id != track_id:
|
|
continue
|
|
|
|
episode_number = int(episode.get("episodeNumber", 0))
|
|
episode_title = episode.get("title", "")
|
|
|
|
# Clean episode title (remove leading numbers like "1. Episode Title")
|
|
if "." in episode_title:
|
|
parts = episode_title.split(".", 1)
|
|
if parts[0].strip().isdigit():
|
|
episode_title = parts[1].strip()
|
|
|
|
self.log.debug(f"Added S{season_number:02d}E{episode_number:02d}: {episode_title}")
|
|
|
|
episodes.append(Episode(
|
|
id_=episode_id,
|
|
service=self.__class__,
|
|
title=series_title,
|
|
season=season_number,
|
|
number=episode_number,
|
|
name=episode_title,
|
|
description=episode.get("description", ""),
|
|
year=series_year,
|
|
language=Language.get("tr"),
|
|
data=episode
|
|
))
|
|
|
|
if not episodes:
|
|
if track_id:
|
|
self.log.error(f"Episode with ID {track_id} not found")
|
|
raise Exception(f"Episode ID {track_id} not found in series")
|
|
else:
|
|
self.log.error("No episodes found in series")
|
|
raise Exception("No episodes found in series metadata")
|
|
|
|
self.log.info(f"Successfully parsed {len(episodes)} episode(s)")
|
|
return Series(episodes)
|
|
|
|
def get_tracks(self, title: Title_T) -> Tracks:
|
|
"""Get video, audio, and subtitle tracks for a title"""
|
|
|
|
self.log.info(f"Fetching tracks for: {title.name if hasattr(title, 'name') else title.id}")
|
|
|
|
# Get media array from title data
|
|
media_array = title.data.get("media", []) if hasattr(title, 'data') else []
|
|
|
|
# If not found, try fetching from API
|
|
if not media_array:
|
|
self.log.debug("Media array not in title data, fetching from API")
|
|
media_array = self._fetch_media_array(title)
|
|
|
|
if not media_array:
|
|
self.log.error("No media information found for title")
|
|
raise Exception("No media streams available for this title")
|
|
|
|
# Find best available stream
|
|
manifest_url, resource_id = self._select_media_stream(media_array)
|
|
|
|
if not manifest_url:
|
|
self.log.error("No valid media stream found")
|
|
raise Exception("No compatible media stream found (Widevine or Clear)")
|
|
|
|
# Get DRM ticket if needed
|
|
if resource_id:
|
|
self.log.debug("Content is DRM-protected, requesting entitlement ticket")
|
|
self._request_drm_ticket(title.id, resource_id)
|
|
else:
|
|
self.log.info("Content is DRM-free")
|
|
self.license_url = None
|
|
|
|
# Parse DASH manifest
|
|
self.log.debug(f"Parsing manifest: {manifest_url}")
|
|
try:
|
|
tracks = DASH.from_url(url=manifest_url, session=self.session).to_tracks(language=Language.get("tr"))
|
|
except Exception as e:
|
|
self.log.error(f"Failed to parse manifest: {str(e)}")
|
|
raise Exception(f"Could not parse video manifest: {str(e)}")
|
|
|
|
# Configure video tracks
|
|
for video in tracks.videos:
|
|
video.range = Video.Range.SDR
|
|
video.closed_captions = False # Prevent ccextractor calls
|
|
|
|
self.log.info(f"Found {len(tracks.videos)} video, {len(tracks.audio)} audio, {len(tracks.subtitles)} subtitle track(s)")
|
|
|
|
return tracks
|
|
|
|
def _fetch_media_array(self, title: Title_T) -> list:
|
|
"""Fetch media array from API if not in title data"""
|
|
|
|
show_id = self.title.split('?')[0] if '?' in self.title else self.title
|
|
match = re.match(self.TITLE_RE, show_id)
|
|
if match:
|
|
show_id = match.group("title_id")
|
|
|
|
try:
|
|
metadata = self.session.get(
|
|
url=self.config["endpoints"]["metadata"].format(title_id=show_id),
|
|
timeout=30
|
|
).json()
|
|
except Exception as e:
|
|
self.log.error(f"Failed to fetch media metadata: {str(e)}")
|
|
return []
|
|
|
|
# Search for episode in metadata
|
|
for season in metadata["data"].get("seasons", []):
|
|
for episode in season.get("episodes", []):
|
|
if episode["id"] == title.id:
|
|
self.log.debug(f"Found media array for episode {title.id}")
|
|
return episode.get("media", [])
|
|
|
|
return []
|
|
|
|
def _select_media_stream(self, media_array: list) -> tuple[Optional[str], Optional[str]]:
|
|
"""Select best available media stream (prefer Widevine over Clear)"""
|
|
|
|
manifest_url = None
|
|
resource_id = None
|
|
|
|
# Try Widevine first
|
|
for media in media_array:
|
|
if media.get("drmSchema") == "widevine":
|
|
manifest_url = self.config["endpoints"]["playback_manifest"].replace(
|
|
"{media_url}", media["url"]
|
|
) + "?width=-1&height=-1&bandwidth=-1&subtitleType=vtt"
|
|
resource_id = media.get("resourceId")
|
|
self.log.debug(f"Selected Widevine stream, resource ID: {resource_id}")
|
|
break
|
|
|
|
# Fallback to Clear if no Widevine
|
|
if not manifest_url:
|
|
for media in media_array:
|
|
if media.get("drmSchema") == "clear":
|
|
manifest_url = self.config["endpoints"]["playback_manifest"].replace(
|
|
"{media_url}", media["url"]
|
|
) + "?width=-1&height=-1&bandwidth=-1&subtitleType=vtt"
|
|
self.log.info("Selected DRM-free (Clear) stream")
|
|
break
|
|
|
|
return manifest_url, resource_id
|
|
|
|
def _request_drm_ticket(self, entitlement_id: str, resource_id: str) -> None:
|
|
"""Request DRM entitlement ticket"""
|
|
|
|
try:
|
|
ticket_response = self.session.get(
|
|
url=self.config["endpoints"]["entitlement_ticket"].format(entitlement_id=entitlement_id),
|
|
timeout=30
|
|
).json()
|
|
except Exception as e:
|
|
self.log.error(f"Failed to get entitlement ticket: {str(e)}")
|
|
raise Exception(f"Could not obtain DRM license ticket: {str(e)}")
|
|
|
|
ticket = ticket_response.get("ticket")
|
|
if not ticket:
|
|
self.log.error("Entitlement ticket not found in response")
|
|
raise Exception("Failed to obtain DRM entitlement. Check your subscription status.")
|
|
|
|
self.license_url = f"{self.config['endpoints']['widevine_license']}?ticket={ticket}&resource_id={resource_id}"
|
|
self.log.debug("DRM license URL configured successfully")
|
|
|
|
def get_chapters(self, title: Title_T) -> list[Chapter]:
|
|
"""Get chapter information - not available for Tabii content"""
|
|
return []
|
|
|
|
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
|
|
"""Request Widevine license for DRM-protected content"""
|
|
|
|
if not self.license_url:
|
|
self.log.error("No license URL available for DRM content")
|
|
raise Exception("License URL not configured (content may be DRM-free)")
|
|
|
|
self.log.debug(f"Requesting Widevine license for track: {track.id if hasattr(track, 'id') else 'unknown'}")
|
|
|
|
try:
|
|
response = self.session.post(
|
|
url=self.license_url,
|
|
data=challenge,
|
|
headers={
|
|
"Content-Type": "application/octet-stream",
|
|
"User-Agent": self.config["client"][self.device]["license_user_agent"],
|
|
"Trtdigitalprod": self.config["client"][self.device]["headers"]["Trtdigitalprod"]
|
|
},
|
|
timeout=30
|
|
)
|
|
|
|
response.raise_for_status()
|
|
self.log.debug(f"Received license response ({len(response.content)} bytes)")
|
|
return response.content
|
|
|
|
except Exception as e:
|
|
self.log.error(f"License request failed: {str(e)}")
|
|
raise Exception(f"Failed to obtain Widevine license: {str(e)}") |