forked from FairTrade/unshackle-services
		
	
		
			
				
	
	
		
			149 lines
		
	
	
		
			4.7 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			149 lines
		
	
	
		
			4.7 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
import json
 | 
						|
import re
 | 
						|
from typing import Optional
 | 
						|
from http.cookiejar import CookieJar
 | 
						|
from langcodes import Language
 | 
						|
import click
 | 
						|
 | 
						|
from unshackle.core.constants import AnyTrack
 | 
						|
from unshackle.core.credential import Credential
 | 
						|
from unshackle.core.manifests import DASH
 | 
						|
from unshackle.core.service import Service
 | 
						|
from unshackle.core.titles import Movie, Movies, Title_T, Titles_T
 | 
						|
from unshackle.core.tracks import Tracks
 | 
						|
 | 
						|
 | 
						|
class PTHS(Service):
 | 
						|
    """
 | 
						|
    Service code for Pathé Thuis (pathe-thuis.nl)
 | 
						|
    Version: 1.0.0
 | 
						|
 | 
						|
    Security: SD @ L3 (Widevine)
 | 
						|
              FHD @ L1
 | 
						|
    Authorization: Cookies or authentication token
 | 
						|
 | 
						|
    Supported:
 | 
						|
      • Movies → https://www.pathe-thuis.nl/film/{id}
 | 
						|
 | 
						|
    Note:
 | 
						|
      Pathé Thuis does not have episodic content, only movies.
 | 
						|
    """
 | 
						|
 | 
						|
    TITLE_RE = (
 | 
						|
        r"^(?:https?://(?:www\.)?pathe-thuis\.nl/film/)?(?P<id>\d+)(?:/[^/]+)?$"
 | 
						|
    )
 | 
						|
    GEOFENCE = ("NL",)
 | 
						|
    NO_SUBTITLES = True 
 | 
						|
 | 
						|
    @staticmethod
 | 
						|
    @click.command(name="PTHS", short_help="https://www.pathe-thuis.nl")
 | 
						|
    @click.argument("title", type=str)
 | 
						|
    @click.pass_context
 | 
						|
    def cli(ctx, **kwargs):
 | 
						|
        return PTHS(ctx, **kwargs)
 | 
						|
 | 
						|
    def __init__(self, ctx, title: str):
 | 
						|
        super().__init__(ctx)
 | 
						|
 | 
						|
        m = re.match(self.TITLE_RE, title)
 | 
						|
        if not m:
 | 
						|
            raise ValueError(
 | 
						|
                f"Unsupported Pathé Thuis URL or ID: {title}\n"
 | 
						|
                "Use e.g. https://www.pathe-thuis.nl/film/30591"
 | 
						|
            )
 | 
						|
 | 
						|
        self.movie_id = m.group("id")
 | 
						|
        self.drm_token = None
 | 
						|
 | 
						|
        if self.config is None:
 | 
						|
            raise EnvironmentError("Missing service config for Pathé Thuis.")
 | 
						|
 | 
						|
    def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
 | 
						|
        super().authenticate(cookies, credential)
 | 
						|
 | 
						|
        if not cookies:
 | 
						|
            self.log.warning("No cookies provided, proceeding unauthenticated.")
 | 
						|
            return
 | 
						|
 | 
						|
        token = next((c.value for c in cookies if c.name == "authenticationToken"), None)
 | 
						|
        if not token:
 | 
						|
            self.log.info("No authenticationToken cookie found, unauthenticated mode.")
 | 
						|
            return
 | 
						|
 | 
						|
        self.session.headers.update({
 | 
						|
            "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Gecko/20100101 Firefox/143.0",
 | 
						|
            "X-Pathe-Device-Identifier": "web-widevine-1",
 | 
						|
            "X-Pathe-Auth-Session-Token": token,
 | 
						|
        })
 | 
						|
        self.log.info("Authentication token successfully attached to session.")
 | 
						|
 | 
						|
 | 
						|
    def get_titles(self) -> Titles_T:
 | 
						|
        url = self.config["endpoints"]["metadata"].format(movie_id=self.movie_id)
 | 
						|
        r = self.session.get(url)
 | 
						|
        r.raise_for_status()
 | 
						|
        data = r.json()
 | 
						|
 | 
						|
        movie = Movie(
 | 
						|
            id_=str(data["id"]),
 | 
						|
            service=self.__class__,
 | 
						|
            name=data["name"],
 | 
						|
            description=data.get("intro", ""),
 | 
						|
            year=data.get("year"),
 | 
						|
            language=Language.get(data.get("language", "en")),
 | 
						|
            data=data,
 | 
						|
        )
 | 
						|
        return Movies([movie])
 | 
						|
 | 
						|
 | 
						|
    def get_tracks(self, title: Title_T) -> Tracks:
 | 
						|
        ticket_id = self._get_ticket_id(title)
 | 
						|
        url = self.config["endpoints"]["ticket"].format(ticket_id=ticket_id)
 | 
						|
 | 
						|
        r = self.session.get(url)
 | 
						|
        r.raise_for_status()
 | 
						|
        data = r.json()
 | 
						|
        stream = data["stream"]
 | 
						|
 | 
						|
        manifest_url = stream.get("url") or stream.get("drmurl")
 | 
						|
        if not manifest_url:
 | 
						|
            raise ValueError("No stream manifest URL found.")
 | 
						|
 | 
						|
        self.drm_token = stream["token"]
 | 
						|
        self.license_url = stream["rawData"]["licenseserver"]
 | 
						|
 | 
						|
        tracks = DASH.from_url(manifest_url, session=self.session).to_tracks(language=title.language)
 | 
						|
 | 
						|
        return tracks
 | 
						|
 | 
						|
 | 
						|
    def _get_ticket_id(self, title: Title_T) -> str:
 | 
						|
        """Fetch the user's owned ticket ID if present."""
 | 
						|
        data = title.data
 | 
						|
        for t in (data.get("tickets") or []):
 | 
						|
            if t.get("playable") and str(t.get("movieId")) == str(self.movie_id):
 | 
						|
                return str(t["id"])
 | 
						|
        raise ValueError("No valid ticket found for this movie. Ensure purchase or login.")
 | 
						|
 | 
						|
 | 
						|
    def get_chapters(self, title: Title_T):
 | 
						|
        return []
 | 
						|
 | 
						|
 | 
						|
    def get_widevine_license(self, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes:
 | 
						|
        if not self.license_url or not self.drm_token:
 | 
						|
            raise ValueError("Missing license URL or token.")
 | 
						|
 | 
						|
        headers = {
 | 
						|
            "Content-Type": "application/octet-stream",
 | 
						|
            "Authorization": f"Bearer {self.drm_token}",
 | 
						|
        }
 | 
						|
 | 
						|
        params = {"custom_data": self.drm_token}
 | 
						|
 | 
						|
        r = self.session.post(self.license_url, params=params, data=challenge, headers=headers)
 | 
						|
        r.raise_for_status()
 | 
						|
 | 
						|
        if not r.content:
 | 
						|
            raise ValueError("Empty license response, likely invalid or expired token.")
 | 
						|
        return r.content |