feat(services): Add My5 service
This commit is contained in:
		
							parent
							
								
									c1d517f18c
								
							
						
					
					
						commit
						1223210040
					
				
							
								
								
									
										214
									
								
								services/MY5/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										214
									
								
								services/MY5/__init__.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,214 @@
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
import base64
 | 
			
		||||
import hashlib
 | 
			
		||||
import hmac
 | 
			
		||||
import json
 | 
			
		||||
import re
 | 
			
		||||
from collections.abc import Generator
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
from typing import Any, Union
 | 
			
		||||
from urllib.parse import urlparse, urlunparse
 | 
			
		||||
 | 
			
		||||
import click
 | 
			
		||||
from click import Context
 | 
			
		||||
from Crypto.Cipher import AES
 | 
			
		||||
from Crypto.Util.Padding import unpad
 | 
			
		||||
from devine.core.manifests.dash import DASH
 | 
			
		||||
from devine.core.search_result import SearchResult
 | 
			
		||||
from devine.core.service import Service
 | 
			
		||||
from devine.core.titles import Episode, Movie, Movies, Series
 | 
			
		||||
from devine.core.tracks import Chapter, Tracks
 | 
			
		||||
from pywidevine.cdm import Cdm as WidevineCdm
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MY5(Service):
 | 
			
		||||
    """
 | 
			
		||||
    \b
 | 
			
		||||
    Service code for Channel 5's My5 streaming service (https://channel5.com).
 | 
			
		||||
    Credit to @Diazole(https://github.com/Diazole/my5-dl) for solving the hmac.
 | 
			
		||||
 | 
			
		||||
    \b
 | 
			
		||||
    Author: stabbedbybrick
 | 
			
		||||
    Authorization: None
 | 
			
		||||
    Robustness:
 | 
			
		||||
      L3: 1080p, AAC2.0
 | 
			
		||||
 | 
			
		||||
    \b
 | 
			
		||||
    Tips:
 | 
			
		||||
        - Input for series/films/episodes can be either complete URL or just the slug/path:
 | 
			
		||||
          https://www.channel5.com/the-cuckoo OR the-cuckoo OR the-cuckoo/season-1/episode-1
 | 
			
		||||
 | 
			
		||||
    \b
 | 
			
		||||
    Known bugs:
 | 
			
		||||
        - The progress bar is broken for certain DASH manifests
 | 
			
		||||
          See issue: https://github.com/devine-dl/devine/issues/106
 | 
			
		||||
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    ALIASES = ("channel5", "ch5", "c5")
 | 
			
		||||
    GEOFENCE = ("gb",)
 | 
			
		||||
    TITLE_RE = r"^(?:https?://(?:www\.)?channel5\.com(?:/show)?/)?(?P<id>[a-z0-9-]+)(?:/(?P<sea>[a-z0-9-]+))?(?:/(?P<ep>[a-z0-9-]+))?"
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    @click.command(name="MY5", short_help="https://channel5.com", help=__doc__)
 | 
			
		||||
    @click.argument("title", type=str)
 | 
			
		||||
    @click.pass_context
 | 
			
		||||
    def cli(ctx: Context, **kwargs: Any) -> MY5:
 | 
			
		||||
        return MY5(ctx, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def __init__(self, ctx: Context, title: str):
 | 
			
		||||
        self.title = title
 | 
			
		||||
        super().__init__(ctx)
 | 
			
		||||
 | 
			
		||||
        self.gist = self.session.get(
 | 
			
		||||
            self.config["endpoints"]["gist"].format(timestamp=datetime.now().timestamp())
 | 
			
		||||
        ).json()
 | 
			
		||||
 | 
			
		||||
    def search(self) -> Generator[SearchResult, None, None]:
 | 
			
		||||
        params = {
 | 
			
		||||
            "platform": "my5desktop",
 | 
			
		||||
            "friendly": "1",
 | 
			
		||||
            "query": self.title,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        r = self.session.get(self.config["endpoints"]["search"], params=params)
 | 
			
		||||
        r.raise_for_status()
 | 
			
		||||
 | 
			
		||||
        results = r.json()
 | 
			
		||||
        for result in results["shows"]:
 | 
			
		||||
            yield SearchResult(
 | 
			
		||||
                id_=result.get("f_name"),
 | 
			
		||||
                title=result.get("title"),
 | 
			
		||||
                description=result.get("s_desc"),
 | 
			
		||||
                label=result.get("genre"),
 | 
			
		||||
                url="https://www.channel5.com/show/" + result.get("f_name"),
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    def get_titles(self) -> Union[Movies, Series]:
 | 
			
		||||
        title, season, episode = (re.match(self.TITLE_RE, self.title).group(i) for i in ("id", "sea", "ep"))
 | 
			
		||||
        if not title:
 | 
			
		||||
            raise ValueError("Could not parse ID from title - is the URL correct?")
 | 
			
		||||
 | 
			
		||||
        if season and episode:
 | 
			
		||||
            r = self.session.get(
 | 
			
		||||
                self.config["endpoints"]["single"].format(
 | 
			
		||||
                    show=title,
 | 
			
		||||
                    season=season,
 | 
			
		||||
                    episode=episode,
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
            r.raise_for_status()
 | 
			
		||||
            episode = r.json()
 | 
			
		||||
            return Series(
 | 
			
		||||
                [
 | 
			
		||||
                    Episode(
 | 
			
		||||
                        id_=episode.get("id"),
 | 
			
		||||
                        service=self.__class__,
 | 
			
		||||
                        title=episode.get("sh_title"),
 | 
			
		||||
                        season=int(episode.get("sea_num")) if episode.get("sea_num") else 0,
 | 
			
		||||
                        number=int(episode.get("ep_num")) if episode.get("ep_num") else 0,
 | 
			
		||||
                        name=episode.get("sh_title"),
 | 
			
		||||
                        language="en",
 | 
			
		||||
                    )
 | 
			
		||||
                ]
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        r = self.session.get(self.config["endpoints"]["episodes"].format(show=title))
 | 
			
		||||
        r.raise_for_status()
 | 
			
		||||
        data = r.json()
 | 
			
		||||
 | 
			
		||||
        if data["episodes"][0]["genre"] == "Film":
 | 
			
		||||
            return Movies(
 | 
			
		||||
                [
 | 
			
		||||
                    Movie(
 | 
			
		||||
                        id_=movie.get("id"),
 | 
			
		||||
                        service=self.__class__,
 | 
			
		||||
                        year=None,
 | 
			
		||||
                        name=movie.get("sh_title"),
 | 
			
		||||
                        language="en",  # TODO: don't assume
 | 
			
		||||
                    )
 | 
			
		||||
                    for movie in data.get("episodes")
 | 
			
		||||
                ]
 | 
			
		||||
            )
 | 
			
		||||
        else:
 | 
			
		||||
            return Series(
 | 
			
		||||
                [
 | 
			
		||||
                    Episode(
 | 
			
		||||
                        id_=episode.get("id"),
 | 
			
		||||
                        service=self.__class__,
 | 
			
		||||
                        title=episode.get("sh_title"),
 | 
			
		||||
                        season=int(episode.get("sea_num")) if episode.get("sea_num") else 0,
 | 
			
		||||
                        number=int(episode.get("ep_num")) if episode.get("sea_num") else 0,
 | 
			
		||||
                        name=episode.get("title"),
 | 
			
		||||
                        language="en",  # TODO: don't assume
 | 
			
		||||
                    )
 | 
			
		||||
                    for episode in data["episodes"]
 | 
			
		||||
                ]
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
 | 
			
		||||
        self.manifest, self.license = self.get_playlist(title.id)
 | 
			
		||||
 | 
			
		||||
        tracks = DASH.from_url(self.manifest, self.session).to_tracks(title.language)
 | 
			
		||||
 | 
			
		||||
        for track in tracks.audio:
 | 
			
		||||
            role = track.data["dash"]["representation"].find("Role")
 | 
			
		||||
            if role is not None and role.get("value") in ["description", "alternative", "alternate"]:
 | 
			
		||||
                track.descriptive = True
 | 
			
		||||
 | 
			
		||||
        return tracks
 | 
			
		||||
 | 
			
		||||
    def get_chapters(self, title: Union[Movie, Episode]) -> list[Chapter]:
 | 
			
		||||
        return []
 | 
			
		||||
 | 
			
		||||
    def get_widevine_service_certificate(self, **_: Any) -> str:
 | 
			
		||||
        return WidevineCdm.common_privacy_cert
 | 
			
		||||
 | 
			
		||||
    def get_widevine_license(self, challenge: bytes, **_: Any) -> str:
 | 
			
		||||
        r = self.session.post(self.license, data=challenge)
 | 
			
		||||
        r.raise_for_status()
 | 
			
		||||
 | 
			
		||||
        return r.content
 | 
			
		||||
 | 
			
		||||
    # Service specific functions
 | 
			
		||||
 | 
			
		||||
    def decrypt_data(self, media: str) -> dict:
 | 
			
		||||
        key = base64.b64decode(self.gist["key"])
 | 
			
		||||
 | 
			
		||||
        r = self.session.get(media)
 | 
			
		||||
        if not r.ok:
 | 
			
		||||
            raise ConnectionError(r.json().get("message"))
 | 
			
		||||
 | 
			
		||||
        content = r.json()
 | 
			
		||||
 | 
			
		||||
        iv = base64.urlsafe_b64decode(content["iv"])
 | 
			
		||||
        data = base64.urlsafe_b64decode(content["data"])
 | 
			
		||||
 | 
			
		||||
        cipher = AES.new(key=key, iv=iv, mode=AES.MODE_CBC)
 | 
			
		||||
        decrypted_data = unpad(cipher.decrypt(data), AES.block_size)
 | 
			
		||||
        return json.loads(decrypted_data)
 | 
			
		||||
 | 
			
		||||
    def get_playlist(self, asset_id: str) -> tuple:
 | 
			
		||||
        secret = self.gist["hmac"]
 | 
			
		||||
 | 
			
		||||
        timestamp = datetime.now().timestamp()
 | 
			
		||||
        vod = self.config["endpoints"]["vod"].format(id=asset_id, timestamp=f"{timestamp}")
 | 
			
		||||
        sig = hmac.new(base64.b64decode(secret), vod.encode(), hashlib.sha256)
 | 
			
		||||
        auth = base64.urlsafe_b64encode(sig.digest()).decode()
 | 
			
		||||
        vod += f"&auth={auth}"
 | 
			
		||||
 | 
			
		||||
        data = self.decrypt_data(vod)
 | 
			
		||||
 | 
			
		||||
        asset = [x for x in data["assets"] if x["drm"] == "widevine"][0]
 | 
			
		||||
        rendition = asset["renditions"][0]
 | 
			
		||||
        mpd_url = rendition["url"]
 | 
			
		||||
        lic_url = asset["keyserver"]
 | 
			
		||||
 | 
			
		||||
        parse = urlparse(mpd_url)
 | 
			
		||||
        path = parse.path.split("/")
 | 
			
		||||
        path[-1] = path[-1].split("-")[0].split("_")[0]
 | 
			
		||||
        manifest = urlunparse(parse._replace(path="/".join(path)))
 | 
			
		||||
        manifest += ".mpd" if not manifest.endswith("mpd") else ""
 | 
			
		||||
 | 
			
		||||
        return manifest, lic_url
 | 
			
		||||
							
								
								
									
										8
									
								
								services/MY5/config.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								services/MY5/config.yaml
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,8 @@
 | 
			
		||||
endpoints:
 | 
			
		||||
  base: https://corona.channel5.com
 | 
			
		||||
  gist: https://gist.githubusercontent.com/stabbedbybrick/8726c719721eac50a28f7bc3c94f18e9/raw/s.txt?={timestamp}
 | 
			
		||||
  content: https://corona.channel5.com/shows/{show}.json?platform=my5desktop
 | 
			
		||||
  episodes: https://corona.channel5.com/shows/{show}/episodes.json?platform=my5desktop
 | 
			
		||||
  single: https://corona.channel5.com/shows/{show}/seasons/{season}/episodes/{episode}.json?platform=my5desktop
 | 
			
		||||
  vod: https://cassie.channel5.com/api/v2/media/my5desktopng/{id}.json?timestamp={timestamp}
 | 
			
		||||
  search: https://corona.channel5.com/shows/search.json
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user