forked from FairTrade/unshackle-services
		
	Added KOWP service
This commit is contained in:
		
							parent
							
								
									f3ddf2bbc3
								
							
						
					
					
						commit
						f77da3b134
					
				
							
								
								
									
										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}"
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user