first commit and NPO released
This commit is contained in:
		
						commit
						17fdde0225
					
				
							
								
								
									
										254
									
								
								NPO/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										254
									
								
								NPO/__init__.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,254 @@
 | 
				
			|||||||
 | 
					import json
 | 
				
			||||||
 | 
					import re
 | 
				
			||||||
 | 
					from http.cookiejar import CookieJar
 | 
				
			||||||
 | 
					from typing import Optional
 | 
				
			||||||
 | 
					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 Episode, Movie, Movies, Series, Title_T, Titles_T
 | 
				
			||||||
 | 
					from unshackle.core.tracks import Chapter, Tracks, Subtitle
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NPO(Service):
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Service code for NPO Start (npo.nl)
 | 
				
			||||||
 | 
					    Version: 1.0.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Authorization: optional cookies (free/paid content supported)
 | 
				
			||||||
 | 
					    Security: FHD @ L3 (Widevine)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Supports:
 | 
				
			||||||
 | 
					      • Series ↦ https://npo.nl/start/serie/{slug}
 | 
				
			||||||
 | 
					      • Movies ↦ https://npo.nl/start/video/{slug}
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    Only supports widevine at the moment
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Note: Movie that is inside  in a series (e.g.
 | 
				
			||||||
 | 
					      https://npo.nl/start/serie/zappbios/.../zappbios-captain-nova/afspelen)
 | 
				
			||||||
 | 
					      can be downloaded as movies by converting the URL to:
 | 
				
			||||||
 | 
					      https://npo.nl/start/video/zappbios-captain-nova
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    TITLE_RE = (
 | 
				
			||||||
 | 
					        r"^(?:https?://(?:www\.)?npo\.nl/start/)?"
 | 
				
			||||||
 | 
					        r"(?:(?P<type>video|serie)/(?P<slug>[^/]+)"
 | 
				
			||||||
 | 
					        r"(?:/afleveringen)?"
 | 
				
			||||||
 | 
					        r"(?:/seizoen-(?P<season>[^/]+)/(?P<episode>[^/]+)/afspelen)?)?$"
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    GEOFENCE = ("NL",)
 | 
				
			||||||
 | 
					    NO_SUBTITLES = False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    @click.command(name="NPO", short_help="https://npo.nl")
 | 
				
			||||||
 | 
					    @click.argument("title", type=str)
 | 
				
			||||||
 | 
					    @click.pass_context
 | 
				
			||||||
 | 
					    def cli(ctx, **kwargs):
 | 
				
			||||||
 | 
					        return NPO(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 NPO URL: {title}\n"
 | 
				
			||||||
 | 
					                "Use /video/slug for movies or /serie/slug for series."
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.slug = m.group("slug")
 | 
				
			||||||
 | 
					        self.kind = m.group("type") or "video"
 | 
				
			||||||
 | 
					        self.season_slug = m.group("season")
 | 
				
			||||||
 | 
					        self.episode_slug = m.group("episode")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if self.config is None:
 | 
				
			||||||
 | 
					            raise EnvironmentError("Missing service config.")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
 | 
				
			||||||
 | 
					        super().authenticate(cookies, credential)
 | 
				
			||||||
 | 
					        if not cookies:
 | 
				
			||||||
 | 
					            self.log.info("No cookies, proceeding anonymously.")
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        token = next((c.value for c in cookies if c.name == "__Secure-next-auth.session-token"), None)
 | 
				
			||||||
 | 
					        if not token:
 | 
				
			||||||
 | 
					            self.log.info("No session token, proceeding unauthenticated.")
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.session.headers.update({
 | 
				
			||||||
 | 
					            "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) Firefox/143.0",
 | 
				
			||||||
 | 
					            "Origin": "https://npo.nl",
 | 
				
			||||||
 | 
					            "Referer": "https://npo.nl/",
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        r = self.session.get("https://npo.nl/start/api/domain/user-profiles", cookies=cookies)
 | 
				
			||||||
 | 
					        if r.ok and isinstance(r.json(), list) and r.json():
 | 
				
			||||||
 | 
					            self.log.info(f"NPO login OK, profiles: {[p['name'] for p in r.json()]}")
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            self.log.warning("NPO auth check failed.")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _get_build_id(self, slug: str) -> str:
 | 
				
			||||||
 | 
					        """Fetch buildId from the actual video/series page."""
 | 
				
			||||||
 | 
					        url = f"https://npo.nl/start/{'video' if self.kind == 'video' else 'serie'}/{slug}"
 | 
				
			||||||
 | 
					        r = self.session.get(url)
 | 
				
			||||||
 | 
					        r.raise_for_status()
 | 
				
			||||||
 | 
					        match = re.search(r'<script id="__NEXT_DATA__" type="application/json">({.*?})</script>', r.text, re.DOTALL)
 | 
				
			||||||
 | 
					        if not match:
 | 
				
			||||||
 | 
					            raise RuntimeError("Failed to extract __NEXT_DATA__")
 | 
				
			||||||
 | 
					        data = json.loads(match.group(1))
 | 
				
			||||||
 | 
					        return data["buildId"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_titles(self) -> Titles_T:
 | 
				
			||||||
 | 
					        build_id = self._get_build_id(self.slug)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if self.kind == "serie":
 | 
				
			||||||
 | 
					            url = self.config["endpoints"]["metadata_series"].format(build_id=build_id, slug=self.slug)
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            url = self.config["endpoints"]["metadata"].format(build_id=build_id, slug=self.slug)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        resp = self.session.get(url)
 | 
				
			||||||
 | 
					        resp.raise_for_status()
 | 
				
			||||||
 | 
					        queries = resp.json()["pageProps"]["dehydratedState"]["queries"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        def get_data(fragment: str):
 | 
				
			||||||
 | 
					            return next((q["state"]["data"] for q in queries if fragment in str(q.get("queryKey", ""))), None)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if self.kind == "serie":
 | 
				
			||||||
 | 
					            series_data = get_data("series:detail-")
 | 
				
			||||||
 | 
					            if not series_data:
 | 
				
			||||||
 | 
					                raise ValueError("Series metadata not found")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            episodes = []
 | 
				
			||||||
 | 
					            seasons = get_data("series:seasons-") or []
 | 
				
			||||||
 | 
					            for season in seasons:
 | 
				
			||||||
 | 
					                eps = get_data(f"programs:season-{season['guid']}") or []
 | 
				
			||||||
 | 
					                for e in eps:
 | 
				
			||||||
 | 
					                    episodes.append(
 | 
				
			||||||
 | 
					                        Episode(
 | 
				
			||||||
 | 
					                            id_=e["guid"],
 | 
				
			||||||
 | 
					                            service=self.__class__,
 | 
				
			||||||
 | 
					                            title=series_data["title"],
 | 
				
			||||||
 | 
					                            season=int(season["seasonKey"]),
 | 
				
			||||||
 | 
					                            number=int(e["programKey"]),
 | 
				
			||||||
 | 
					                            name=e["title"],
 | 
				
			||||||
 | 
					                            description=(e.get("synopsis", {}) or {}).get("long", ""),
 | 
				
			||||||
 | 
					                            language=Language.get("nl"),
 | 
				
			||||||
 | 
					                            data=e,
 | 
				
			||||||
 | 
					                        )
 | 
				
			||||||
 | 
					                    )
 | 
				
			||||||
 | 
					            return Series(episodes)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Movie
 | 
				
			||||||
 | 
					        item = get_data("program:detail-") or queries[0]["state"]["data"]
 | 
				
			||||||
 | 
					        synopsis = item.get("synopsis", {})
 | 
				
			||||||
 | 
					        desc = synopsis.get("long") or synopsis.get("short", "") if isinstance(synopsis, dict) else str(synopsis)
 | 
				
			||||||
 | 
					        year = (int(item["firstBroadcastDate"]) // 31536000 + 1970) if item.get("firstBroadcastDate") else None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Movies([
 | 
				
			||||||
 | 
					            Movie(
 | 
				
			||||||
 | 
					                id_=item["guid"],
 | 
				
			||||||
 | 
					                service=self.__class__,
 | 
				
			||||||
 | 
					                name=item["title"],
 | 
				
			||||||
 | 
					                description=desc,
 | 
				
			||||||
 | 
					                year=year,
 | 
				
			||||||
 | 
					                language=Language.get("nl"),
 | 
				
			||||||
 | 
					                data=item,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        ])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_tracks(self, title: Title_T) -> Tracks:
 | 
				
			||||||
 | 
					        product_id = title.data.get("productId")
 | 
				
			||||||
 | 
					        if not product_id:
 | 
				
			||||||
 | 
					            raise ValueError("no productId detected.")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Get JWT
 | 
				
			||||||
 | 
					        token_url = self.config["endpoints"]["player_token"].format(product_id=product_id)
 | 
				
			||||||
 | 
					        r_tok = self.session.get(token_url, headers={"Referer": f"https://npo.nl/start/video/{self.slug}"})
 | 
				
			||||||
 | 
					        r_tok.raise_for_status()
 | 
				
			||||||
 | 
					        jwt = r_tok.json()["jwt"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Request stream
 | 
				
			||||||
 | 
					        r_stream = self.session.post(
 | 
				
			||||||
 | 
					            self.config["endpoints"]["streams"],
 | 
				
			||||||
 | 
					            json={
 | 
				
			||||||
 | 
					                "profileName": "dash",
 | 
				
			||||||
 | 
					                "drmType": "widevine",
 | 
				
			||||||
 | 
					                "referrerUrl": f"https://npo.nl/start/video/{self.slug}",
 | 
				
			||||||
 | 
					                "ster": {"identifier": "npo-app-desktop", "deviceType": 4, "player": "web"},
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            headers={
 | 
				
			||||||
 | 
					                "Authorization": jwt,
 | 
				
			||||||
 | 
					                "Content-Type": "application/json",
 | 
				
			||||||
 | 
					                "Origin": "https://npo.nl",
 | 
				
			||||||
 | 
					                "Referer": f"https://npo.nl/start/video/{self.slug}",
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        r_stream.raise_for_status()
 | 
				
			||||||
 | 
					        data = r_stream.json()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if "error" in data:
 | 
				
			||||||
 | 
					            raise PermissionError(f"Stream error: {data['error']}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        stream = data["stream"]
 | 
				
			||||||
 | 
					        manifest_url = stream.get("streamURL") or stream.get("url")
 | 
				
			||||||
 | 
					        if not manifest_url:
 | 
				
			||||||
 | 
					            raise ValueError("No stream URL in response")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        is_unencrypted = "unencrypted" in manifest_url.lower() or not any(k in stream for k in ["drmToken", "token"])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Parse DASH
 | 
				
			||||||
 | 
					        tracks = DASH.from_url(manifest_url, session=self.session).to_tracks(language=title.language)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Subtitles
 | 
				
			||||||
 | 
					        subtitles = []
 | 
				
			||||||
 | 
					        for sub in data.get("assets", {}).get("subtitles", []):
 | 
				
			||||||
 | 
					            lang = sub.get("iso", "und")
 | 
				
			||||||
 | 
					            subtitles.append(
 | 
				
			||||||
 | 
					                Subtitle(
 | 
				
			||||||
 | 
					                    id_=sub.get("name", lang),
 | 
				
			||||||
 | 
					                    url=sub["location"].strip(),
 | 
				
			||||||
 | 
					                    language=Language.get(lang),
 | 
				
			||||||
 | 
					                    is_original_lang=lang == "nl",
 | 
				
			||||||
 | 
					                    codec=Subtitle.Codec.WebVTT,
 | 
				
			||||||
 | 
					                    name=sub.get("name", "Unknown"),
 | 
				
			||||||
 | 
					                    forced=False,
 | 
				
			||||||
 | 
					                    sdh=False,
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        tracks.subtitles = subtitles
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # DRM
 | 
				
			||||||
 | 
					        if is_unencrypted:
 | 
				
			||||||
 | 
					            for tr in tracks.videos + tracks.audio:
 | 
				
			||||||
 | 
					                if hasattr(tr, "drm") and tr.drm:
 | 
				
			||||||
 | 
					                    tr.drm.clear()
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            self.drm_token = stream.get("drmToken") or stream.get("token") or stream.get("drm_token")
 | 
				
			||||||
 | 
					            if not self.drm_token:
 | 
				
			||||||
 | 
					                raise ValueError(f"No DRM token found. Available keys: {list(stream.keys())}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            for tr in tracks.videos + tracks.audio:
 | 
				
			||||||
 | 
					                if getattr(tr, "drm", None):
 | 
				
			||||||
 | 
					                    tr.drm.license = lambda challenge, **kw: self.get_widevine_license(
 | 
				
			||||||
 | 
					                        challenge=challenge, title=title, track=tr
 | 
				
			||||||
 | 
					                    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return tracks
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_chapters(self, title: Title_T) -> list[Chapter]:
 | 
				
			||||||
 | 
					        return []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_widevine_license(self, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes:
 | 
				
			||||||
 | 
					        if not self.drm_token:
 | 
				
			||||||
 | 
					            raise ValueError("DRM token not set – login or paid content may be required.")
 | 
				
			||||||
 | 
					        r = self.session.post(
 | 
				
			||||||
 | 
					            self.config["endpoints"]["widevine_license"],
 | 
				
			||||||
 | 
					            params={"custom_data": self.drm_token},
 | 
				
			||||||
 | 
					            data=challenge,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        r.raise_for_status()
 | 
				
			||||||
 | 
					        return r.content
 | 
				
			||||||
							
								
								
									
										8
									
								
								NPO/config.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								NPO/config.yaml
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,8 @@
 | 
				
			|||||||
 | 
					endpoints:
 | 
				
			||||||
 | 
					  metadata: "https://npo.nl/start/_next/data/{build_id}/video/{slug}.json"
 | 
				
			||||||
 | 
					  metadata_series: "https://npo.nl/start/_next/data/{build_id}/serie/{slug}.json"
 | 
				
			||||||
 | 
					  metadata_episode: "https://npo.nl/start/_next/data/{build_id}/serie/{series_slug}/seizoen-{season_slug}/{episode_slug}.json"
 | 
				
			||||||
 | 
					  streams: "https://prod.npoplayer.nl/stream-link"
 | 
				
			||||||
 | 
					  player_token: "https://npo.nl/start/api/domain/player-token?productId={product_id}"
 | 
				
			||||||
 | 
					  widevine_license: "https://npo-drm-gateway.samgcloud.nepworldwide.nl/authentication"
 | 
				
			||||||
 | 
					  homepage: "https://npo.nl/start"
 | 
				
			||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user