From 7013de5c0146dfc21898d1191bbf1f0b45a9be8a Mon Sep 17 00:00:00 2001 From: FairTrade Date: Tue, 21 Apr 2026 10:47:12 +0200 Subject: [PATCH] Added GLA --- GLA/__init__.py | 471 ++++++++++++++++++++++++++++++++++++++++++++++++ GLA/config.yaml | 16 ++ README.md | 2 + 3 files changed, 489 insertions(+) create mode 100644 GLA/__init__.py create mode 100644 GLA/config.yaml diff --git a/GLA/__init__.py b/GLA/__init__.py new file mode 100644 index 0000000..cb95fd0 --- /dev/null +++ b/GLA/__init__.py @@ -0,0 +1,471 @@ +import base64 +import hashlib +import json +import re +import time +from collections.abc import Generator +from datetime import datetime +from http.cookiejar import CookieJar +from typing import Optional, Union +from urllib.parse import urljoin, parse_qs, urlparse + +import click +from langcodes import Language + +from unshackle.core.constants import AnyTrack +from unshackle.core.credential import Credential +from unshackle.core.manifests import DASH, HLS +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 GLA(Service): + """ + Service code for gagaoolala.com + Version: 1.0.1 + + Authorization: Email/Password or Cookies (PHPSESSID) + Security: FHD@L3 (Widevine/PlayReady DRM via ExpressPlay) + + Use full URL: https://www.gagaoolala.com/en/videos/6184/candy-2026 + Or title ID: 6184 (slug will be fetched from page if needed) + """ + + # Updated regex to optionally capture slug + TITLE_RE = r"^(?:https?://(?:www\.)?gagaoolala\.com/(?:en/)?videos/)?(?P\d+)(?:/(?P[^/?#]+))?" + GEOFENCE = () + NO_SUBTITLES = False + + VIDEO_RANGE_MAP = { + "SDR": "sdr", + "HDR10": "hdr10", + "DV": "dolby_vision", + } + + @staticmethod + @click.command(name="GLA", short_help="https://www.gagaoolala.com") + @click.argument("title", type=str) + @click.option("-m", "--movie", is_flag=True, default=False, help="Specify if it's a movie") + @click.option("-d", "--device", type=str, default="firefox_linux", help="Select device profile") + @click.pass_context + def cli(ctx, **kwargs): + return GLA(ctx, **kwargs) + + def __init__(self, ctx, title, movie, device, email=None, password=None): + super().__init__(ctx) + + self.title = title + self.movie = movie + self.device = device + self.email = email + self.password = password + self.cdm = ctx.obj.cdm + + # Override codec/range for L3 CDM limitations + if self.cdm and self.cdm.security_level == 3: + self.track_request.codecs = [Video.Codec.AVC] + self.track_request.ranges = [Video.Range.SDR] + + if self.config is None: + raise Exception("Config is missing!") + + self.profile = ctx.parent.params.get("profile") or "default" + self.user_id = None + self.license_data = {} + self.slug = None # Store slug for API calls + + def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: + super().authenticate(cookies, credential) + + if cookies: + self.session.cookies.update(cookies) + for cookie in cookies: + if cookie.name == "gli": + self.user_id = cookie.value + break + return + + if not credential or not credential.username or not credential.password: + raise EnvironmentError("Service requires Cookies or Credential (email/password) for Authentication.") + + login_url = "https://www.gagaoolala.com/en/user/login" + login_data = { + "email": credential.username, + "passwd": credential.password, + } + + headers = { + "User-Agent": self.config["client"][self.device]["user_agent"], + "Accept": "application/json, text/javascript, */*; q=0.01", + "Accept-Language": "en-US,en;q=0.9", + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "X-Requested-With": "XMLHttpRequest", + "Origin": "https://www.gagaoolala.com", + "Referer": login_url, + } + + response = self.session.post(login_url, data=login_data, headers=headers) + response.raise_for_status() + + result = response.json() + + if not result.get("success"): + error_msg = result.get("msg") or result.get("data", {}).get("msg") or "Unknown error" + raise AuthenticationError(f"Login failed: {error_msg}") + + self.user_id = result.get("data", {}).get("user_line_uid") + + def search(self) -> Generator[SearchResult, None, None]: + search_url = "https://www.gagaoolala.com/en/search" + params = {"q": self.title} + + headers = { + "User-Agent": self.config["client"][self.device]["user_agent"], + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + "X-Requested-With": "XMLHttpRequest", + } + + response = self.session.get(search_url, params=params, headers=headers) + response.raise_for_status() + + content_type = response.headers.get("Content-Type", "") + if "application/json" in content_type: + try: + data = response.json() + except json.JSONDecodeError: + data = None + else: + data = None + + if not data: + html = response.text + + json_ld_match = re.search( + r']+type=["\']application/ld\+json["\'][^>]*>\s*({.*?"@context".*?})\s*', + html, + re.DOTALL | re.IGNORECASE + ) + + if json_ld_match: + json_str = json_ld_match.group(1) + json_str = json_str.replace(r'\/', '/').replace(r'\"', '"') + try: + data = json.loads(json_str) + except json.JSONDecodeError as e: + self.log.debug(f"Failed to parse JSON-LD: {e}") + data = None + else: + fallback_match = re.search( + r'(\{[^{}]*"@context"[^{}]*"itemListElement"[^{}]*\[\s*\{[^{}]*"url"[^{}]*\][^{}]*\})', + html, + re.DOTALL + ) + if fallback_match: + try: + data = json.loads(fallback_match.group(1)) + except json.JSONDecodeError: + data = None + + if not data or "itemListElement" not in data: + self.log.warning(f"No search results found for '{self.title}'") + return + + for item in data["itemListElement"]: + url = item.get("url", "") + if not url: + continue + + match = re.match(self.TITLE_RE, url) + if not match: + continue + + title_id = match.group("title_id") + slug = match.group("slug") + + # Extract title name from slug or URL + title_name = slug if slug else url.rstrip("/").split("/")[-1] + if "-" in title_name: + parts = title_name.rsplit("-", 1) + # Remove year suffix if present (e.g., candy-2026 -> candy) + if parts[-1].isdigit() and len(parts[-1]) == 4: + title_name = parts[0] + title_name = title_name.replace("-", " ").title() + + # Detect series vs movie + is_series = bool(slug and ("-e" in slug or slug.endswith("-e01"))) + + yield SearchResult( + id_=title_id, + title=title_name, + label="SERIES" if is_series else "MOVIE", + url=url, + ) + + def _clean_title(self, raw_title: str, slug: Optional[str] = None) -> str: + """Clean up page titles by removing SEO/marketing suffixes.""" + title = re.sub(r'\s*\|\s*GagaOOLala\s*$', '', raw_title).strip() + + seo_patterns = [ + r'\s*-\s*Watch\s+Online.*$', + r'\s*-\s*Find\s+Your\s+Story.*$', + r'\s*-\s*Watch\s+BL\s+Movies.*$', + r'\s*-\s*Stream\s+Online.*$', + r'\s*-\s*Free\s+Streaming.*$', + r'\s*-\s*GagaOOLala.*$', + ] + for pattern in seo_patterns: + title = re.sub(pattern, '', title, flags=re.IGNORECASE) + + title = re.sub(r'\s*-\s*$', '', title).strip() + + if slug: + slug_title = slug.replace('-', ' ').title() + year_match = re.search(r'(\d{4})$', slug) + if year_match: + year = year_match.group(1) + slug_title = re.sub(r'\s*\d{4}\s*$', '', slug_title).strip() + candidate = f"{slug_title} ({year})" + if len(candidate) < len(title) or title.lower().startswith(slug_title.lower()): + return candidate + + return title if title else f"Title {self.title}" + + def get_titles(self) -> Titles_T: + match = re.match(self.TITLE_RE, self.title) + if not match: + raise ValueError(f"Could not parse title ID from: {self.title}") + + title_id = match.group("title_id") + self.slug = match.group("slug") + + video_url = f"https://www.gagaoolala.com/en/videos/{title_id}" + if self.slug: + video_url += f"/{self.slug}" + + response = self.session.get(video_url) + + if response.status_code == 404 and self.slug: + self.log.warning(f"URL with slug returned 404, trying without slug") + video_url = f"https://www.gagaoolala.com/en/videos/{title_id}" + response = self.session.get(video_url) + + response.raise_for_status() + + episodes_match = re.search(r'var\s+videoEpisodes\s*=\s*(\[.*?\]);\s*var\s+videoSeasons', response.text, re.DOTALL) + + if episodes_match: + episodes_data = json.loads(episodes_match.group(1)) + series_episodes = [ep for ep in episodes_data if ep.get("is_series")] + + if series_episodes: + first_name = series_episodes[0].get("name", "") + base_title = re.sub(r'\s*Episode\s*\d+.*$', '', first_name).strip() + if not base_title and self.slug: + base_title = self._clean_title(self.slug.replace('-', ' ').title(), None) + if not base_title: + base_title = f"Series {title_id}" + + episodes = [] + for ep in series_episodes: + ep_slug = ep.get("slug", f"{self.slug}-e{ep.get('episode', 1)}" if self.slug else None) + episodes.append( + Episode( + id_=str(ep["id"]), + service=self.__class__, + title=base_title, + season=ep.get("season", 1), + number=ep.get("episode", 1), + name=ep.get("name", f"Episode {ep.get('episode', 1)}"), + description=None, + year=None, + language=Language.get("en"), + data={**ep, "slug": ep_slug, "parent_slug": self.slug}, + ) + ) + return Series(episodes) + + title_match = re.search(r'([^<]+)', response.text) + raw_title = title_match.group(1) if title_match else (self.slug or f"Movie {title_id}") + movie_title = self._clean_title(raw_title, self.slug) + + year = None + year_match = re.search(r'\((\d{4})\)\s*$', movie_title) + if year_match: + year = int(year_match.group(1)) + movie_title = re.sub(r'\s*\(\d{4}\)\s*$', '', movie_title).strip() + elif self.slug: + slug_year = re.search(r'(\d{4})$', self.slug) + if slug_year: + year = int(slug_year.group(1)) + + return Movies( + [ + Movie( + id_=title_id, + service=self.__class__, + name=movie_title, + description=None, + year=year, + language=Language.get("en"), + data={"url": video_url, "slug": self.slug}, + ) + ] + ) + + def get_tracks(self, title: Title_T) -> Tracks: + def _fetch_variant( + title: Title_T, + codec: Optional[Video.Codec], + range_: Video.Range, + ) -> Tracks: + vcodec_str = "H265" if codec == Video.Codec.HEVC else "H264" + range_str = range_.name + video_format = self.VIDEO_RANGE_MAP.get(range_str, "sdr") + + tracks = self._fetch_manifest(title) + + if codec: + tracks.videos = [v for v in tracks.videos if v.codec == codec] + if range_ != Video.Range.SDR: + tracks.videos = [v for v in tracks.videos if v.range == range_] + + if not tracks.videos: + raise ValueError(f"No tracks available for {codec} {range_}") + + return tracks + + tracks = self._get_tracks_for_variants(title, _fetch_variant) + return tracks + + def get_tracks(self, title: Title_T) -> Tracks: + def _fetch_variant( + title: Title_T, + codec: Optional[Video.Codec], + range_: Video.Range, + ) -> Tracks: + vcodec_str = "H265" if codec == Video.Codec.HEVC else "H264" + range_str = range_.name + video_format = self.VIDEO_RANGE_MAP.get(range_str, "sdr") + + tracks = self._fetch_manifest(title) + + if codec: + tracks.videos = [v for v in tracks.videos if v.codec == codec] + if range_ != Video.Range.SDR: + tracks.videos = [v for v in tracks.videos if v.range == range_] + + if not tracks.videos: + raise ValueError(f"No tracks available for {codec} {range_}") + + return tracks + + return self._get_tracks_for_variants(title, _fetch_variant) + + def _fetch_manifest(self, title: Title_T) -> Tracks: + timestamp = int(time.time()) + + slug = title.data.get("slug") if isinstance(title.data, dict) else None + if not slug: + slug = title.data.get("parent_slug") if isinstance(title.data, dict) else self.slug + + if not slug: + match = re.match(self.TITLE_RE, self.title) + if match: + slug = match.group("slug") + + if slug: + play_url = f"https://www.gagaoolala.com/api/v1.0/en/videos/{title.id}/{slug}/play" + else: + play_url = f"https://www.gagaoolala.com/api/v1.0/en/videos/{title.id}/play" + self.log.warning(f"No slug available, attempting play request without slug: {play_url}") + + params = {"t": timestamp} + + response = self.session.get(play_url, params=params) + response.raise_for_status() + + playback = response.json() + if not playback.get("success"): + raise ValueError(f"Failed to get playback info: {playback}") + + data = playback["data"] + + drm_info = data.get("drm") + if drm_info: + self.license_data = { + "widevine": drm_info.get("widevine", {}).get("LA_URL"), + "playready": drm_info.get("playready", {}).get("LA_URL"), + } + else: + self.license_data = {} + + manifest_url = data.get("dash") or data.get("m3u8") + if not manifest_url: + raise ValueError("No manifest URL found in playback response") + + if ".mpd" in manifest_url: + tracks = DASH.from_url(url=manifest_url, session=self.session).to_tracks(language=title.language) + elif ".m3u8" in manifest_url: + tracks = HLS.from_url(url=manifest_url, session=self.session).to_tracks(language=title.language) + else: + raise ValueError(f"Unsupported manifest format: {manifest_url}") + + for video in tracks.videos: + if video.codec == Video.Codec.HEVC and video.profile and "Main10" in str(video.profile): + video.range = Video.Range.HDR10 + else: + video.range = Video.Range.SDR + + return tracks + + def get_chapters(self, title: Title_T) -> list[Chapter]: + return [] + + def get_widevine_service_certificate(self, **_: any) -> str: + return self.config.get("certificate", "") + + def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]: + if not self.license_data.get("widevine"): + raise ValueError("Widevine license URL not available for this title") + + license_url = self.license_data["widevine"] + + headers = { + "User-Agent": self.config["client"][self.device].get("license_user_agent", + self.config["client"][self.device]["user_agent"]), + "Content-Type": "application/octet-stream", + } + + response = self.session.post( + url=license_url, + data=challenge, + headers=headers, + ) + response.raise_for_status() + + return response.content + + def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]: + if not self.license_data.get("playready"): + raise ValueError("PlayReady license URL not available for this title") + + license_url = self.license_data["playready"] + + headers = { + "User-Agent": self.config["client"][self.device].get("license_user_agent", + self.config["client"][self.device]["user_agent"]), + "Content-Type": "text/xml", + "SOAPAction": "http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense", + } + + response = self.session.post( + url=license_url, + data=challenge, + headers=headers, + ) + response.raise_for_status() + + return response.content \ No newline at end of file diff --git a/GLA/config.yaml b/GLA/config.yaml new file mode 100644 index 0000000..8d5754e --- /dev/null +++ b/GLA/config.yaml @@ -0,0 +1,16 @@ +# config.yaml for GLA (GagaOOLala) +endpoints: + login: https://www.gagaoolala.com/en/user/login + play: https://www.gagaoolala.com/api/v1.0/en/videos/{title_id}/{slug}/play + search: https://www.gagaoolala.com/en/search + +client: + firefox_linux: + user_agent: "Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0" + license_user_agent: "Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0" + android_tv: + user_agent: "Mozilla/5.0 (Linux; Android 10; Android TV) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Safari/537.36" + license_user_agent: "ExoPlayerLib/2.18.1" + windows_chrome: + 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" + license_user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" \ No newline at end of file diff --git a/README.md b/README.md index 76d1b2f..1852f8e 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ - Music needs to be fixed since the output is a mp4 instead of m4a 13. SHUD: - PlayReady needed + 14. GLA: + - Subs sometimes broken (it's on there side) - Acknowledgment