forked from FairTrade/unshackle-services
		
	Compare commits
	
		
			2 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| f77da3b134 | |||
| f3ddf2bbc3 | 
							
								
								
									
										273
									
								
								KOWP/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										273
									
								
								KOWP/__init__.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,273 @@
 | 
				
			|||||||
 | 
					import json
 | 
				
			||||||
 | 
					import re
 | 
				
			||||||
 | 
					from http.cookiejar import CookieJar
 | 
				
			||||||
 | 
					from typing import Optional, List, Dict, Any
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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.service import Service
 | 
				
			||||||
 | 
					from unshackle.core.search_result import SearchResult
 | 
				
			||||||
 | 
					from unshackle.core.titles import Episode, Series, Title_T, Titles_T
 | 
				
			||||||
 | 
					from unshackle.core.tracks import Subtitle, Tracks
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class KOWP(Service):
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Service code for Kocowa Plus (kocowa.com).
 | 
				
			||||||
 | 
					    Version: 1.0.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Auth: Credential (username + password)
 | 
				
			||||||
 | 
					    Security: FHD@L3
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    Note: 
 | 
				
			||||||
 | 
					        Brightcove account_id and policy key (pk) are configurable per region.
 | 
				
			||||||
 | 
					        Put the custom Brightcove account ID and policy key on the config.yaml if the following doesn't work 
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    TITLE_RE = r"^(?:https?://(?:www\.)?kocowa\.com/[^/]+/season/)?(?P<title_id>\d+)"
 | 
				
			||||||
 | 
					    GEOFENCE = ("US", "CA", "PA")
 | 
				
			||||||
 | 
					    NO_SUBTITLES = False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    @click.command(name="kowp", short_help="https://www.kocowa.com")
 | 
				
			||||||
 | 
					    @click.argument("title", type=str)
 | 
				
			||||||
 | 
					    @click.option("--extras", is_flag=True, default=False, help="Include teasers/extras")
 | 
				
			||||||
 | 
					    @click.pass_context
 | 
				
			||||||
 | 
					    def cli(ctx, **kwargs):
 | 
				
			||||||
 | 
					        return KOWP(ctx, **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, ctx, title: str, extras: bool = False):
 | 
				
			||||||
 | 
					        super().__init__(ctx)
 | 
				
			||||||
 | 
					        if not self.config:
 | 
				
			||||||
 | 
					            raise EnvironmentError("Missing KOWP config")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        match = re.match(self.TITLE_RE, title)
 | 
				
			||||||
 | 
					        if not match:
 | 
				
			||||||
 | 
					            raise ValueError("Invalid Kocowa title ID or URL")
 | 
				
			||||||
 | 
					        self.title_id = match.group("title_id")
 | 
				
			||||||
 | 
					        self.include_extras = extras
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Load Brightcove config
 | 
				
			||||||
 | 
					        bc_conf = self.config.get("brightcove", {})
 | 
				
			||||||
 | 
					        self.brightcove_account_id = bc_conf.get("account_id", "6154734805001")
 | 
				
			||||||
 | 
					        self.brightcove_pk = bc_conf.get("policy_key", "BCpkADawqM1FKrSBim1gusdFR73Prfums__ZmQ7uJ4yCRqv-RKrq2HtZIkVOn4gsmPdAqO007VNtaKJCmg0Uu1rpuUnjnP-f9OPklQ9l2-HS_F_sJXgT96KpUahg9XNukAraAlob6XDuoecD")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
 | 
				
			||||||
 | 
					        if not credential:
 | 
				
			||||||
 | 
					            raise ValueError("KOWP requires username and password")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        payload = {
 | 
				
			||||||
 | 
					            "username": credential.username,
 | 
				
			||||||
 | 
					            "password": credential.password,
 | 
				
			||||||
 | 
					            "device_id": f"{credential.username}_browser",
 | 
				
			||||||
 | 
					            "device_type": "browser",
 | 
				
			||||||
 | 
					            "device_model": "Firefox",
 | 
				
			||||||
 | 
					            "device_version": "firefox/143.0",
 | 
				
			||||||
 | 
					            "push_token": None,
 | 
				
			||||||
 | 
					            "app_version": "v4.0.16",
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        r = self.session.post(
 | 
				
			||||||
 | 
					            self.config["endpoints"]["login"],
 | 
				
			||||||
 | 
					            json=payload,
 | 
				
			||||||
 | 
					            headers={"Authorization": "anonymous", "Origin": "https://www.kocowa.com"}
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        r.raise_for_status()
 | 
				
			||||||
 | 
					        res = r.json()
 | 
				
			||||||
 | 
					        if res.get("code") != "0000":
 | 
				
			||||||
 | 
					            raise PermissionError(f"Login failed: {res.get('message')}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.access_token = res["object"]["access_token"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        r = self.session.post(
 | 
				
			||||||
 | 
					            self.config["endpoints"]["middleware_auth"],
 | 
				
			||||||
 | 
					            json={"token": f"wA-Auth.{self.access_token}"},
 | 
				
			||||||
 | 
					            headers={"Origin": "https://www.kocowa.com"}
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        r.raise_for_status()
 | 
				
			||||||
 | 
					        self.middleware_token = r.json()["token"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_titles(self) -> Titles_T:
 | 
				
			||||||
 | 
					        all_episodes = []
 | 
				
			||||||
 | 
					        offset = 0
 | 
				
			||||||
 | 
					        limit = 20
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        while True:
 | 
				
			||||||
 | 
					            url = self.config["endpoints"]["metadata"].format(title_id=self.title_id)
 | 
				
			||||||
 | 
					            sep = "&" if "?" in url else "?"
 | 
				
			||||||
 | 
					            url += f"{sep}offset={offset}&limit={limit}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            r = self.session.get(
 | 
				
			||||||
 | 
					                url,
 | 
				
			||||||
 | 
					                headers={"Authorization": self.access_token, "Origin": "https://www.kocowa.com"}
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            r.raise_for_status()
 | 
				
			||||||
 | 
					            data = r.json()["object"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            page_objects = data.get("next_episodes", {}).get("objects", [])
 | 
				
			||||||
 | 
					            if not page_objects:
 | 
				
			||||||
 | 
					                break
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            for ep in page_objects:
 | 
				
			||||||
 | 
					                is_episode = ep.get("detail_type") == "episode"
 | 
				
			||||||
 | 
					                is_extra = ep.get("detail_type") in ("teaser", "extra")
 | 
				
			||||||
 | 
					                if is_episode or (self.include_extras and is_extra):
 | 
				
			||||||
 | 
					                    all_episodes.append(ep)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            offset += limit
 | 
				
			||||||
 | 
					            total = data.get("next_episodes", {}).get("total_count", 0)
 | 
				
			||||||
 | 
					            if len(all_episodes) >= total or len(page_objects) < limit:
 | 
				
			||||||
 | 
					                break
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        episodes = []
 | 
				
			||||||
 | 
					        series_title = data["meta"]["title"].get("en") or "Unknown"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for ep in all_episodes:
 | 
				
			||||||
 | 
					            meta = ep["meta"]
 | 
				
			||||||
 | 
					            ep_type = "Episode" if ep["detail_type"] == "episode" else ep["detail_type"].capitalize()
 | 
				
			||||||
 | 
					            ep_num = meta.get("episode_number", 0)
 | 
				
			||||||
 | 
					            title = meta["title"].get("en") or f"{ep_type} {ep_num}"
 | 
				
			||||||
 | 
					            desc = meta["description"].get("en") or ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            episodes.append(
 | 
				
			||||||
 | 
					                Episode(
 | 
				
			||||||
 | 
					                    id_=str(ep["id"]),
 | 
				
			||||||
 | 
					                    service=self.__class__,
 | 
				
			||||||
 | 
					                    title=series_title,
 | 
				
			||||||
 | 
					                    season=meta.get("season_number", 1),
 | 
				
			||||||
 | 
					                    number=ep_num,
 | 
				
			||||||
 | 
					                    name=title,
 | 
				
			||||||
 | 
					                    description=desc,
 | 
				
			||||||
 | 
					                    year=None,
 | 
				
			||||||
 | 
					                    language=Language.get("en"),
 | 
				
			||||||
 | 
					                    data=ep,
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Series(episodes)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_tracks(self, title: Title_T) -> Tracks:
 | 
				
			||||||
 | 
					        # Authorize playback
 | 
				
			||||||
 | 
					        r = self.session.post(
 | 
				
			||||||
 | 
					            self.config["endpoints"]["authorize"].format(episode_id=title.id),
 | 
				
			||||||
 | 
					            headers={"Authorization": f"Bearer {self.middleware_token}"}
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        r.raise_for_status()
 | 
				
			||||||
 | 
					        auth_data = r.json()
 | 
				
			||||||
 | 
					        if not auth_data.get("Success"):
 | 
				
			||||||
 | 
					            raise PermissionError("Playback authorization failed")
 | 
				
			||||||
 | 
					        self.playback_token = auth_data["token"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Fetch Brightcove manifest
 | 
				
			||||||
 | 
					        manifest_url = (
 | 
				
			||||||
 | 
					            f"https://edge.api.brightcove.com/playback/v1/accounts/{self.brightcove_account_id}/videos/ref:{title.id}"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        r = self.session.get(
 | 
				
			||||||
 | 
					            manifest_url,
 | 
				
			||||||
 | 
					            headers={"Accept": f"application/json;pk={self.brightcove_pk}"}
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        r.raise_for_status()
 | 
				
			||||||
 | 
					        manifest = r.json()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Get DASH URL + Widevine license
 | 
				
			||||||
 | 
					        dash_url = widevine_url = None
 | 
				
			||||||
 | 
					        for src in manifest.get("sources", []):
 | 
				
			||||||
 | 
					            if src.get("type") == "application/dash+xml":
 | 
				
			||||||
 | 
					                dash_url = src["src"]
 | 
				
			||||||
 | 
					                widevine_url = (
 | 
				
			||||||
 | 
					                    src.get("key_systems", {})
 | 
				
			||||||
 | 
					                    .get("com.widevine.alpha", {})
 | 
				
			||||||
 | 
					                    .get("license_url")
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                if dash_url and widevine_url:
 | 
				
			||||||
 | 
					                    break
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if not dash_url or not widevine_url:
 | 
				
			||||||
 | 
					            raise ValueError("No Widevine DASH stream found")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.widevine_license_url = widevine_url
 | 
				
			||||||
 | 
					        tracks = DASH.from_url(dash_url, session=self.session).to_tracks(language=title.language)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Add ALL subtitles from manifest
 | 
				
			||||||
 | 
					        for sub in manifest.get("text_tracks", []):
 | 
				
			||||||
 | 
					            srclang = sub.get("srclang")
 | 
				
			||||||
 | 
					            if not srclang or srclang == "thumbnails":
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					            tracks.add(
 | 
				
			||||||
 | 
					                Subtitle(
 | 
				
			||||||
 | 
					                    id_=sub["id"],
 | 
				
			||||||
 | 
					                    url=sub["src"],
 | 
				
			||||||
 | 
					                    codec=Subtitle.Codec.WebVTT,
 | 
				
			||||||
 | 
					                    language=Language.get(srclang),
 | 
				
			||||||
 | 
					                    sdh=True,
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return tracks
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes:
 | 
				
			||||||
 | 
					        r = self.session.post(
 | 
				
			||||||
 | 
					            self.widevine_license_url,
 | 
				
			||||||
 | 
					            data=challenge,
 | 
				
			||||||
 | 
					            headers={
 | 
				
			||||||
 | 
					                "BCOV-Auth": self.playback_token,
 | 
				
			||||||
 | 
					                "Content-Type": "application/octet-stream",
 | 
				
			||||||
 | 
					                "Origin": "https://www.kocowa.com",
 | 
				
			||||||
 | 
					                "Referer": "https://www.kocowa.com/",
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        r.raise_for_status()
 | 
				
			||||||
 | 
					        return r.content
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#    def search(self) -> List[SearchResult]:
 | 
				
			||||||
 | 
					#        if not hasattr(self, 'title_id') or not isinstance(self.title_id, str):
 | 
				
			||||||
 | 
					#            query = getattr(self, 'title', '')  # fallback if title_id isn't set yet
 | 
				
			||||||
 | 
					#        else:
 | 
				
			||||||
 | 
					#            query = self.title_id
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#        url = "https://prod-fms.kocowa.com/api/v01/fe/gks/autocomplete"
 | 
				
			||||||
 | 
					#        params = {
 | 
				
			||||||
 | 
					#            "search_category": "All",
 | 
				
			||||||
 | 
					#            "search_input": query,
 | 
				
			||||||
 | 
					#            "include_webtoon": "true",
 | 
				
			||||||
 | 
					#        }
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#        r = self.session.get(
 | 
				
			||||||
 | 
					#            url,
 | 
				
			||||||
 | 
					#            params=params,
 | 
				
			||||||
 | 
					#            headers={
 | 
				
			||||||
 | 
					#                "Authorization": self.access_token,
 | 
				
			||||||
 | 
					#                "Origin": "https://www.kocowa.com ",
 | 
				
			||||||
 | 
					#                "Referer": "https://www.kocowa.com/ ",
 | 
				
			||||||
 | 
					#            }
 | 
				
			||||||
 | 
					#        )
 | 
				
			||||||
 | 
					#        r.raise_for_status()
 | 
				
			||||||
 | 
					#        response = r.json()
 | 
				
			||||||
 | 
					#        contents = response.get("object", {}).get("contents", [])
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#        results = []
 | 
				
			||||||
 | 
					#        for item in contents:
 | 
				
			||||||
 | 
					#            if item.get("detail_type") != "season":
 | 
				
			||||||
 | 
					#                continue  # skip non-season items (e.g., actors, webtoons)
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#            meta = item["meta"]
 | 
				
			||||||
 | 
					#            title_en = meta["title"].get("en") or "[No Title]"
 | 
				
			||||||
 | 
					#            description_en = meta["description"].get("en") or ""
 | 
				
			||||||
 | 
					#            show_id = str(item["id"])
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#            results.append(
 | 
				
			||||||
 | 
					#                SearchResult(
 | 
				
			||||||
 | 
					#                    id_=show_id,
 | 
				
			||||||
 | 
					#                    title=title_en,
 | 
				
			||||||
 | 
					#                    description=description_en,
 | 
				
			||||||
 | 
					#                    label="season",
 | 
				
			||||||
 | 
					#                    url=f"https://www.kocowa.com/en_us/season/{show_id}/placeholder"
 | 
				
			||||||
 | 
					#                )
 | 
				
			||||||
 | 
					#            )
 | 
				
			||||||
 | 
					#        return results
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_chapters(self, title: Title_T) -> list:
 | 
				
			||||||
 | 
					        return []
 | 
				
			||||||
							
								
								
									
										9
									
								
								KOWP/config.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								KOWP/config.yaml
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,9 @@
 | 
				
			|||||||
 | 
					brightcove:
 | 
				
			||||||
 | 
					    account_id: "6154734805001"
 | 
				
			||||||
 | 
					    policy_key: "BCpkADawqM1FKrSBim1gusdFR73Prfums__ZmQ7uJ4yCRqv-RKrq2HtZIkVOn4gsmPdAqO007VNtaKJCmg0Uu1rpuUnjnP-f9OPklQ9l2-HS_F_sJXgT96KpUahg9XNukAraAlob6XDuoecD"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					endpoints:
 | 
				
			||||||
 | 
					  login: "https://prod-sgwv3.kocowa.com/api/v01/user/signin"
 | 
				
			||||||
 | 
					  middleware_auth: "https://middleware.bcmw.kocowa.com/authenticate-user"
 | 
				
			||||||
 | 
					  metadata: "https://prod-fms.kocowa.com/api/v01/fe/content/get?id={title_id}"
 | 
				
			||||||
 | 
					  authorize: "https://middleware.bcmw.kocowa.com/api/playback/authorize/{episode_id}"
 | 
				
			||||||
@ -17,21 +17,21 @@ from unshackle.core.tracks import Chapter, Tracks, Subtitle
 | 
				
			|||||||
class NPO(Service):
 | 
					class NPO(Service):
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    Service code for NPO Start (npo.nl)
 | 
					    Service code for NPO Start (npo.nl)
 | 
				
			||||||
    Version: 1.0.0
 | 
					    Version: 1.1.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Authorization: optional cookies (free/paid content supported)
 | 
					    Authorization: optional cookies (free/paid content supported)
 | 
				
			||||||
    Security: FHD @ L3 (Widevine)
 | 
					    Security: FHD @ L3
 | 
				
			||||||
 | 
					              FHD @ SL3000   
 | 
				
			||||||
 | 
					              (Widevine and PlayReady support) 
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Supports:
 | 
					    Supports:
 | 
				
			||||||
      • Series ↦ https://npo.nl/start/serie/{slug}
 | 
					      • Series ↦ https://npo.nl/start/serie/{slug}
 | 
				
			||||||
      • Movies ↦ https://npo.nl/start/video/{slug}
 | 
					      • Movies ↦ https://npo.nl/start/video/{slug}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Only supports widevine at the moment
 | 
					    Note: Movie inside a series can be downloaded as movie by converting URL to:
 | 
				
			||||||
 | 
					          https://npo.nl/start/video/slug
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Note: Movie that is inside  in a series (e.g.
 | 
					          To change between Widevine and Playready, you need to change the DrmType in config.yaml to either widevine or playready
 | 
				
			||||||
      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 = (
 | 
					    TITLE_RE = (
 | 
				
			||||||
@ -68,6 +68,9 @@ class NPO(Service):
 | 
				
			|||||||
        if self.config is None:
 | 
					        if self.config is None:
 | 
				
			||||||
            raise EnvironmentError("Missing service config.")
 | 
					            raise EnvironmentError("Missing service config.")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Store CDM reference
 | 
				
			||||||
 | 
					        self.cdm = ctx.obj.cdm
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
 | 
					    def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
 | 
				
			||||||
        super().authenticate(cookies, credential)
 | 
					        super().authenticate(cookies, credential)
 | 
				
			||||||
        if not cookies:
 | 
					        if not cookies:
 | 
				
			||||||
@ -165,7 +168,6 @@ class NPO(Service):
 | 
				
			|||||||
        if not product_id:
 | 
					        if not product_id:
 | 
				
			||||||
            raise ValueError("no productId detected.")
 | 
					            raise ValueError("no productId detected.")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Get JWT
 | 
					 | 
				
			||||||
        token_url = self.config["endpoints"]["player_token"].format(product_id=product_id)
 | 
					        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 = self.session.get(token_url, headers={"Referer": f"https://npo.nl/start/video/{self.slug}"})
 | 
				
			||||||
        r_tok.raise_for_status()
 | 
					        r_tok.raise_for_status()
 | 
				
			||||||
@ -176,7 +178,7 @@ class NPO(Service):
 | 
				
			|||||||
            self.config["endpoints"]["streams"],
 | 
					            self.config["endpoints"]["streams"],
 | 
				
			||||||
            json={
 | 
					            json={
 | 
				
			||||||
                "profileName": "dash",
 | 
					                "profileName": "dash",
 | 
				
			||||||
                "drmType": "widevine",
 | 
					                "drmType": self.config["DrmType"],
 | 
				
			||||||
                "referrerUrl": f"https://npo.nl/start/video/{self.slug}",
 | 
					                "referrerUrl": f"https://npo.nl/start/video/{self.slug}",
 | 
				
			||||||
                "ster": {"identifier": "npo-app-desktop", "deviceType": 4, "player": "web"},
 | 
					                "ster": {"identifier": "npo-app-desktop", "deviceType": 4, "player": "web"},
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
@ -205,12 +207,17 @@ class NPO(Service):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        # Subtitles
 | 
					        # Subtitles
 | 
				
			||||||
        subtitles = []
 | 
					        subtitles = []
 | 
				
			||||||
        for sub in data.get("assets", {}).get("subtitles", []):
 | 
					        for sub in (data.get("assets", {}) or {}).get("subtitles", []) or []:
 | 
				
			||||||
 | 
					            if not isinstance(sub, dict):
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
            lang = sub.get("iso", "und")
 | 
					            lang = sub.get("iso", "und")
 | 
				
			||||||
 | 
					            location = sub.get("location")
 | 
				
			||||||
 | 
					            if not location:
 | 
				
			||||||
 | 
					                continue  # skip if no URL provided
 | 
				
			||||||
            subtitles.append(
 | 
					            subtitles.append(
 | 
				
			||||||
                Subtitle(
 | 
					                Subtitle(
 | 
				
			||||||
                    id_=sub.get("name", lang),
 | 
					                    id_=sub.get("name", lang),
 | 
				
			||||||
                    url=sub["location"].strip(),
 | 
					                    url=location.strip(),
 | 
				
			||||||
                    language=Language.get(lang),
 | 
					                    language=Language.get(lang),
 | 
				
			||||||
                    is_original_lang=lang == "nl",
 | 
					                    is_original_lang=lang == "nl",
 | 
				
			||||||
                    codec=Subtitle.Codec.WebVTT,
 | 
					                    codec=Subtitle.Codec.WebVTT,
 | 
				
			||||||
@ -233,6 +240,11 @@ class NPO(Service):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
            for tr in tracks.videos + tracks.audio:
 | 
					            for tr in tracks.videos + tracks.audio:
 | 
				
			||||||
                if getattr(tr, "drm", None):
 | 
					                if getattr(tr, "drm", None):
 | 
				
			||||||
 | 
					                    if drm_type == "playready":
 | 
				
			||||||
 | 
					                        tr.drm.license = lambda challenge, **kw: self.get_playready_license(
 | 
				
			||||||
 | 
					                            challenge=challenge, title=title, track=tr
 | 
				
			||||||
 | 
					                        )
 | 
				
			||||||
 | 
					                    else:
 | 
				
			||||||
                        tr.drm.license = lambda challenge, **kw: self.get_widevine_license(
 | 
					                        tr.drm.license = lambda challenge, **kw: self.get_widevine_license(
 | 
				
			||||||
                            challenge=challenge, title=title, track=tr
 | 
					                            challenge=challenge, title=title, track=tr
 | 
				
			||||||
                        )
 | 
					                        )
 | 
				
			||||||
@ -244,11 +256,34 @@ class NPO(Service):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def get_widevine_license(self, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes:
 | 
					    def get_widevine_license(self, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes:
 | 
				
			||||||
        if not self.drm_token:
 | 
					        if not self.drm_token:
 | 
				
			||||||
            raise ValueError("DRM token not set – login or paid content may be required.")
 | 
					            raise ValueError("DRM token not set, login or paid content may be required.")
 | 
				
			||||||
        r = self.session.post(
 | 
					        r = self.session.post(
 | 
				
			||||||
            self.config["endpoints"]["widevine_license"],
 | 
					            self.config["endpoints"]["license"],
 | 
				
			||||||
            params={"custom_data": self.drm_token},
 | 
					            params={"custom_data": self.drm_token},
 | 
				
			||||||
            data=challenge,
 | 
					            data=challenge,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        r.raise_for_status()
 | 
					        r.raise_for_status()
 | 
				
			||||||
        return r.content
 | 
					        return r.content
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_playready_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.")
 | 
				
			||||||
 | 
					        headers = {
 | 
				
			||||||
 | 
					            "Content-Type": "text/xml; charset=utf-8",
 | 
				
			||||||
 | 
					            "SOAPAction": "http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense",
 | 
				
			||||||
 | 
					            "Origin": "https://npo.nl",
 | 
				
			||||||
 | 
					            "Referer": "https://npo.nl/",
 | 
				
			||||||
 | 
					            "User-Agent": (
 | 
				
			||||||
 | 
					                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
 | 
				
			||||||
 | 
					                "AppleWebKit/537.36 (KHTML, like Gecko) "
 | 
				
			||||||
 | 
					                "Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0"
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        r = self.session.post(
 | 
				
			||||||
 | 
					            self.config["endpoints"]["license"],
 | 
				
			||||||
 | 
					            params={"custom_data": self.drm_token},
 | 
				
			||||||
 | 
					            data=challenge,
 | 
				
			||||||
 | 
					            headers=headers,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        r.raise_for_status()
 | 
				
			||||||
 | 
					        return r.content
 | 
				
			||||||
 | 
				
			|||||||
@ -4,5 +4,6 @@ endpoints:
 | 
				
			|||||||
  metadata_episode: "https://npo.nl/start/_next/data/{build_id}/serie/{series_slug}/seizoen-{season_slug}/{episode_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"
 | 
					  streams: "https://prod.npoplayer.nl/stream-link"
 | 
				
			||||||
  player_token: "https://npo.nl/start/api/domain/player-token?productId={product_id}"
 | 
					  player_token: "https://npo.nl/start/api/domain/player-token?productId={product_id}"
 | 
				
			||||||
  widevine_license: "https://npo-drm-gateway.samgcloud.nepworldwide.nl/authentication"
 | 
					  license: "https://npo-drm-gateway.samgcloud.nepworldwide.nl/authentication"
 | 
				
			||||||
  homepage: "https://npo.nl/start"
 | 
					  homepage: "https://npo.nl/start"
 | 
				
			||||||
 | 
					DrmType: "widevine"
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,6 @@
 | 
				
			|||||||
These services is new and in development. Please feel free to submit pull requests for any mistakes or suggestions.
 | 
					These services is new and in development. Please feel free to submit pull requests for any mistakes or suggestions.
 | 
				
			||||||
Acknowledgment
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Acknowledgment
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Thanks to Adef for the NPO start downloader.
 | 
					Thanks to Adef for the NPO start downloader.
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user