PTHS added
This commit is contained in:
		
							parent
							
								
									17fdde0225
								
							
						
					
					
						commit
						7952381dca
					
				
							
								
								
									
										149
									
								
								PTHS/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										149
									
								
								PTHS/__init__.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,149 @@
 | 
				
			|||||||
 | 
					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
 | 
				
			||||||
							
								
								
									
										3
									
								
								PTHS/config.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								PTHS/config.yaml
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,3 @@
 | 
				
			|||||||
 | 
					endpoints:
 | 
				
			||||||
 | 
					  metadata: "https://www.pathe-thuis.nl/api/movies/{movie_id}?include=editions"
 | 
				
			||||||
 | 
					  ticket: "https://www.pathe-thuis.nl/api/tickets/{ticket_id}"
 | 
				
			||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user