diff --git a/packages/envied/src/envied/services/ALL4/__init__.py b/packages/envied/src/envied/services/ALL4/__init__.py index 67955dd..5ed1197 100644 --- a/packages/envied/src/envied/services/ALL4/__init__.py +++ b/packages/envied/src/envied/services/ALL4/__init__.py @@ -3,8 +3,8 @@ from __future__ import annotations import base64 import hashlib import json -import sys import re +import sys from collections.abc import Generator from datetime import datetime, timezone from http.cookiejar import MozillaCookieJar @@ -15,14 +15,12 @@ from click import Context from Crypto.Util.Padding import unpad from Cryptodome.Cipher import AES from pywidevine.cdm import Cdm as WidevineCdm - -from envied.core.constants import AnyTrack -from envied.core.credential import Credential -from envied.core.manifests import DASH -from envied.core.search_result import SearchResult -from envied.core.service import Service -from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T -from envied.core.tracks import Chapter, Subtitle, Tracks +from unshackle.core.credential import Credential +from unshackle.core.manifests.dash import DASH +from unshackle.core.search_result import SearchResult +from unshackle.core.service import Service +from unshackle.core.titles import Episode, Movie, Movies, Series +from unshackle.core.tracks import Chapter, Subtitle, Tracks class ALL4(Service): @@ -30,7 +28,7 @@ class ALL4(Service): Service code for Channel 4's All4 streaming service (https://channel4.com). \b - Version: 1.0.0 + Version: 1.0.1 Author: stabbedbybrick Authorization: Credentials Robustness: @@ -263,6 +261,11 @@ class ALL4(Service): else: self.log.warning("- Subtitles are either missing or empty") + 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]: diff --git a/packages/envied/src/envied/services/AUBC/__init__.py b/packages/envied/src/envied/services/AUBC/__init__.py index 42fd65b..27f7597 100644 --- a/packages/envied/src/envied/services/AUBC/__init__.py +++ b/packages/envied/src/envied/services/AUBC/__init__.py @@ -9,15 +9,13 @@ from urllib.parse import urljoin import click from click import Context - -from envied.core.constants import AnyTrack -from envied.core.credential import Credential -from envied.core.manifests import DASH -from envied.core.search_result import SearchResult -from envied.core.service import Service -from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T -from envied.core.tracks import Chapter, Subtitle, Tracks from requests import Request +from unshackle.core.constants import AnyTrack +from unshackle.core.manifests.dash import DASH +from unshackle.core.search_result import SearchResult +from unshackle.core.service import Service +from unshackle.core.titles import Episode, Movie, Movies, Series +from unshackle.core.tracks import Chapter, Chapters, Subtitle, Tracks class AUBC(Service): @@ -26,7 +24,7 @@ class AUBC(Service): Service code for ABC iView streaming service (https://iview.abc.net.au/). \b - Version: 1.0.1 + Version: 1.0.2 Author: stabbedbybrick Authorization: None Robustness: @@ -155,14 +153,14 @@ class AUBC(Service): return tracks def get_chapters(self, title: Union[Movie, Episode]) -> Chapters: - # if not title.data.get("cuePoints"): - # return Chapters() + if not title.data.get("cuePoints"): + return Chapters() - # credits = next((x.get("start") for x in title.data["cuePoints"] if x["type"] == "end-credits"), None) - # if credits: - # return Chapters([Chapter(name="Credits", timestamp=credits * 1000)]) + credits = next((x.get("start") for x in title.data["cuePoints"] if x["type"] == "end-credits"), None) + if credits: + return Chapters([Chapter(name="Credits", timestamp=credits * 1000)]) - return [] + return Chapters() def get_widevine_service_certificate(self, **_: Any) -> str: return None @@ -215,11 +213,10 @@ class AUBC(Service): def create_episode(self, episode: dict) -> Episode: title = episode["showTitle"] - season = re.search(r"Series (\d+)", episode.get("title")) - number = re.search(r"Episode (\d+)", episode.get("title")) - names_a = re.search(r"Series \d+ Episode \d+ (.+)", episode.get("title")) - names_b = re.search(r"Series \d+ (.+)", episode.get("title")) - name = names_a.group(1) if names_a else names_b.group(1) if names_b else episode.get("displaySubtitle") + series_id = episode.get("analytics", {}).get("dataLayer", {}).get("d_series_id", "") + episode_name = episode.get("analytics", {}).get("dataLayer", {}).get("d_episode_name", "") + number = re.search(r"Episode (\d+)", episode.get("displaySubtitle", "")) + name = re.search(r"S\d+\sEpisode\s\d+\s(.*)", episode_name) language = episode.get("analytics", {}).get("dataLayer", {}).get("d_language", "en") @@ -227,9 +224,9 @@ class AUBC(Service): id_=episode["id"], service=self.__class__, title=title, - season=int(season.group(1)) if season else 0, + season=int(series_id.split("-")[-1]) if series_id else 0, number=int(number.group(1)) if number else 0, - name=name, + name=name.group(1) if name else None, data=episode, language=language, ) diff --git a/packages/envied/src/envied/services/CBC/__init__.py b/packages/envied/src/envied/services/CBC/__init__.py index 3283f6c..c3ab426 100644 --- a/packages/envied/src/envied/services/CBC/__init__.py +++ b/packages/envied/src/envied/services/CBC/__init__.py @@ -10,14 +10,14 @@ from urllib.parse import urljoin import click from click import Context -from envied.core.constants import AnyTrack -from envied.core.credential import Credential -from envied.core.manifests import DASH, HLS -from envied.core.search_result import SearchResult -from envied.core.service import Service -from envied.core.titles import Episode, Movie, Movies, Series -from envied.core.tracks import Chapter, Chapters, Tracks from requests import Request +from unshackle.core.constants import AnyTrack +from unshackle.core.credential import Credential +from unshackle.core.manifests import DASH, HLS +from unshackle.core.search_result import SearchResult +from unshackle.core.service import Service +from unshackle.core.titles import Episode, Movie, Movies, Series +from unshackle.core.tracks import Chapter, Chapters, Tracks class CBC(Service): @@ -26,7 +26,7 @@ class CBC(Service): Service code for CBC Gem streaming service (https://gem.cbc.ca/). \b - Version: 1.0.0 + Version: 1.0.1 Author: stabbedbybrick Authorization: Credentials Robustness: diff --git a/packages/envied/src/envied/services/CBS/__init__.py b/packages/envied/src/envied/services/CBS/__init__.py index 44b27f7..a28c287 100644 --- a/packages/envied/src/envied/services/CBS/__init__.py +++ b/packages/envied/src/envied/services/CBS/__init__.py @@ -8,15 +8,15 @@ from typing import Any, Optional, Union from urllib.parse import urljoin import click -from envied.core.constants import AnyTrack -from envied.core.manifests import DASH -from envied.core.search_result import SearchResult -from envied.core.service import Service -from envied.core.titles import Episode, Series, Title_T, Titles_T -from envied.core.tracks import Chapter, Chapters, Tracks -from envied.core.utils.sslciphers import SSLCiphers -from envied.core.utils.xml import load_xml from requests import Request +from unshackle.core.constants import AnyTrack +from unshackle.core.manifests import DASH +from unshackle.core.search_result import SearchResult +from unshackle.core.service import Service +from unshackle.core.titles import Episode, Series, Title_T, Titles_T +from unshackle.core.tracks import Chapter, Chapters, Tracks +from unshackle.core.utils.sslciphers import SSLCiphers +from unshackle.core.utils.xml import load_xml class CBS(Service): @@ -26,7 +26,7 @@ class CBS(Service): Credit to @srpen6 for the tip on anonymous session \b - Version: 1.0.0 + Version: 1.0.1 Author: stabbedbybrick Authorization: None Robustness: diff --git a/packages/envied/src/envied/services/CTV/__init__.py b/packages/envied/src/envied/services/CTV/__init__.py index 4b283d0..2acad28 100644 --- a/packages/envied/src/envied/services/CTV/__init__.py +++ b/packages/envied/src/envied/services/CTV/__init__.py @@ -11,13 +11,12 @@ from typing import Any, Optional import click from pywidevine.cdm import Cdm as WidevineCdm - -from envied.core.credential import Credential -from envied.core.manifests import DASH -from envied.core.search_result import SearchResult -from envied.core.service import Service -from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T -from envied.core.tracks import Chapter, Subtitle, Video, Audio, Tracks +from unshackle.core.credential import Credential +from unshackle.core.manifests import DASH +from unshackle.core.search_result import SearchResult +from unshackle.core.service import Service +from unshackle.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T +from unshackle.core.tracks import Audio, Chapter, Subtitle, Tracks, Video class CTV(Service): @@ -25,7 +24,7 @@ class CTV(Service): Service code for CTV.ca (https://www.ctv.ca) \b - Version: 1.0.0 + Version: 1.0.1 Author: stabbedbybrick Authorization: Credentials for subscription, none for freely available titles Robustness: diff --git a/packages/envied/src/envied/services/CWTV/__init__.py b/packages/envied/src/envied/services/CWTV/__init__.py index f68d280..a61a9de 100644 --- a/packages/envied/src/envied/services/CWTV/__init__.py +++ b/packages/envied/src/envied/services/CWTV/__init__.py @@ -9,17 +9,13 @@ from urllib.parse import quote, urljoin import click from click import Context - - -from envied.core.manifests import DASH -from envied.core.search_result import SearchResult -from envied.core.service import Service -from envied.core.titles import Episode, Movie, Movies, Series -from envied.core.tracks import Chapter, Chapters, Tracks - - from lxml import etree from requests import Request +from unshackle.core.manifests import DASH +from unshackle.core.search_result import SearchResult +from unshackle.core.service import Service +from unshackle.core.titles import Episode, Movie, Movies, Series +from unshackle.core.tracks import Chapter, Chapters, Tracks class CWTV(Service): @@ -28,7 +24,7 @@ class CWTV(Service): Service code for CWTV streaming service (https://www.cwtv.com/). \b - Version: 1.0.0 + Version: 1.0.1 Author: stabbedbybrick Authorization: None Geofence: US (API and downloads) diff --git a/packages/envied/src/envied/services/ITV/__init__.py b/packages/envied/src/envied/services/ITV/__init__.py index f967c83..804c8ce 100644 --- a/packages/envied/src/envied/services/ITV/__init__.py +++ b/packages/envied/src/envied/services/ITV/__init__.py @@ -11,12 +11,12 @@ from typing import Any, Optional, Union import click from bs4 import BeautifulSoup from click import Context -from envied.core.credential import Credential -from envied.core.manifests.dash import DASH -from envied.core.search_result import SearchResult -from envied.core.service import Service -from envied.core.titles import Episode, Movie, Movies, Series -from envied.core.tracks import Chapter, Chapters, Subtitle, Tracks +from unshackle.core.credential import Credential +from unshackle.core.manifests.dash import DASH +from unshackle.core.search_result import SearchResult +from unshackle.core.service import Service +from unshackle.core.titles import Episode, Movie, Movies, Series +from unshackle.core.tracks import Chapter, Chapters, Subtitle, Tracks class ITV(Service): @@ -24,7 +24,7 @@ class ITV(Service): Service code for ITVx streaming service (https://www.itv.com/). \b - Version: 1.0.1 + Version: 1.0.2 Author: stabbedbybrick Authorization: Cookies (Optional for free content | Required for premium content) Robustness: @@ -41,9 +41,9 @@ class ITV(Service): \b Examples: - - SERIES: envied dl -w s01e01 itv https://www.itv.com/watch/bay-of-fires/10a5270 - - EPISODE: envied dl itv https://www.itv.com/watch/bay-of-fires/10a5270/10a5270a0001 - - FILM: envied dl itv https://www.itv.com/watch/mad-max-beyond-thunderdome/2a7095 + - SERIES: devine dl -w s01e01 itv https://www.itv.com/watch/bay-of-fires/10a5270 + - EPISODE: devine dl itv https://www.itv.com/watch/bay-of-fires/10a5270/10a5270a0001 + - FILM: devine dl itv https://www.itv.com/watch/mad-max-beyond-thunderdome/2a7095 \b Notes: @@ -301,10 +301,10 @@ class ITV(Service): ) ) - '''for track in tracks.audio: + 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''' + track.descriptive = True return tracks diff --git a/packages/envied/src/envied/services/MY5/__init__.py b/packages/envied/src/envied/services/MY5/__init__.py index 95a38da..2df8c72 100644 --- a/packages/envied/src/envied/services/MY5/__init__.py +++ b/packages/envied/src/envied/services/MY5/__init__.py @@ -1,9 +1,9 @@ from __future__ import annotations import base64 +import os import re import tempfile -import os from collections.abc import Generator from typing import Any, Union from urllib.parse import urlparse, urlunparse @@ -11,13 +11,13 @@ from urllib.parse import urlparse, urlunparse import click import requests from click import Context -from envied.core.manifests.dash import DASH -from envied.core.search_result import SearchResult -from envied.core.service import Service -from envied.core.titles import Episode, Movie, Movies, Series -from envied.core.tracks import Chapter, Tracks -from envied.core.utils.sslciphers import SSLCiphers from pywidevine.cdm import Cdm as WidevineCdm +from unshackle.core.manifests.dash import DASH +from unshackle.core.search_result import SearchResult +from unshackle.core.service import Service +from unshackle.core.titles import Episode, Movie, Movies, Series +from unshackle.core.tracks import Chapter, Tracks +from unshackle.core.utils.sslciphers import SSLCiphers class MY5(Service): @@ -26,7 +26,7 @@ class MY5(Service): Service code for Channel 5's My5 streaming service (https://channel5.com). \b - Version: 1.0.0 + Version: 1.0.1 Author: stabbedbybrick Authorization: None Robustness: @@ -40,7 +40,7 @@ class MY5(Service): \b Known bugs: - The progress bar is broken for certain DASH manifests - See issue: https://github.com/envied-dl/envied/issues/106 + See issue: https://github.com/devine-dl/devine/issues/106 """ @@ -148,10 +148,10 @@ class MY5(Service): tracks = DASH.from_url(self.manifest, self.session).to_tracks(title.language) - '''for track in tracks.audio: + 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''' + track.descriptive = True return tracks diff --git a/packages/envied/src/envied/services/PLEX/__init__.py b/packages/envied/src/envied/services/PLEX/__init__.py index e1fa499..4c12256 100644 --- a/packages/envied/src/envied/services/PLEX/__init__.py +++ b/packages/envied/src/envied/services/PLEX/__init__.py @@ -4,41 +4,20 @@ import json import re import uuid from collections.abc import Generator -from typing import Any, Optional -from urllib.parse import urlparse, urljoin, quote -from http.cookiejar import MozillaCookieJar from concurrent.futures import ThreadPoolExecutor +from http.cookiejar import MozillaCookieJar +from typing import Any, Optional +from urllib.parse import quote, urljoin, urlparse import click from click import Context - -try: - from devine.core.credential import Credential # type: ignore - from devine.core.manifests import DASH, HLS # type: ignore - from devine.core.search_result import SearchResult # type: ignore - from devine.core.service import Service # type: ignore - from devine.core.titles import Episode, Movie, Movies, Series # type: ignore - from devine.core.tracks import Chapter, Chapters, Tracks # type: ignore -except ImportError: - try: - from envied.core.credential import Credential - from envied.core.manifests import DASH, HLS - from envied.core.search_result import SearchResult - from envied.core.service import Service - from envied.core.titles import Episode, Movie, Movies, Series - from envied.core.tracks import Chapter, Chapters, Tracks - except ImportError: - try: - from envied.core.credential import Credential - from envied.core.manifests import DASH, HLS - from envied.core.search_result import SearchResult - from envied.core.service import Service - from envied.core.titles import Episode, Movie, Movies, Series - from envied.core.tracks import Chapter, Chapters, Tracks - except ImportError: - raise ImportError("PLEX service requires devine, envied or unshackle to be installed") - from requests import Request +from unshackle.core.credential import Credential +from unshackle.core.manifests import DASH, HLS +from unshackle.core.search_result import SearchResult +from unshackle.core.service import Service +from unshackle.core.titles import Episode, Movie, Movies, Series +from unshackle.core.tracks import Chapter, Chapters, Tracks class PLEX(Service): @@ -47,7 +26,7 @@ class PLEX(Service): Service code for Plex's free streaming service (https://watch.plex.tv/). \b - Version: 1.0.3 + Version: 1.0.4 Author: stabbedbybrick Authorization: None Geofence: API and downloads are locked into whatever region the user is in diff --git a/packages/envied/src/envied/services/PLUTO/__init__.py b/packages/envied/src/envied/services/PLUTO/__init__.py index f9edb16..f8d4539 100644 --- a/packages/envied/src/envied/services/PLUTO/__init__.py +++ b/packages/envied/src/envied/services/PLUTO/__init__.py @@ -7,13 +7,12 @@ from http.cookiejar import CookieJar from typing import Any, Optional import click - -from envied.core.credential import Credential -from envied.core.manifests import DASH, HLS -from envied.core.search_result import SearchResult -from envied.core.service import Service -from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T -from envied.core.tracks import Chapters, Tracks +from unshackle.core.credential import Credential +from unshackle.core.manifests import DASH, HLS +from unshackle.core.search_result import SearchResult +from unshackle.core.service import Service +from unshackle.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T +from unshackle.core.tracks import Chapters, Tracks class PLUTO(Service): @@ -23,7 +22,7 @@ class PLUTO(Service): Credit to @wks_uwu for providing an alternative API, making the codebase much cleaner \b - Version: 1.0.1 + Version: 1.0.2 Author: stabbedbybrick Authorization: None Robustness: @@ -38,7 +37,7 @@ class PLUTO(Service): MOVIE: /movies/635c1e430888bc001ad01a9b/details - Use --lang LANG_RANGE option to request non-English tracks - Use --hls to request HLS instead of DASH: - envied dl pluto URL --hls + devine dl pluto URL --hls \b Notes: diff --git a/packages/envied/src/envied/services/README.md b/packages/envied/src/envied/services/README.md new file mode 100644 index 0000000..6d8e4a2 --- /dev/null +++ b/packages/envied/src/envied/services/README.md @@ -0,0 +1,24 @@ +A collection of non-premium services for Unshackle. + +## Usage: +Clone repository: + +`git clone https://github.com/stabbedbybrick/services.git` + +Add folder to `unshackle.yaml`: + +``` +directories: + services: "path/to/services" +``` +See help text for each service: + +`unshackle dl SERVICE --help` + +## Notes: +Some versions of the dependencies work better than others. These are the recommended versions as of 25/11/11: + +- Shaka Packager: [v2.6.1](https://github.com/shaka-project/shaka-packager/releases/tag/v2.6.1) +- CCExtractor: [v0.93](https://github.com/CCExtractor/ccextractor/releases/tag/v0.93) +- MKVToolNix: [latest](https://mkvtoolnix.download/downloads.html) +- FFmpeg: [latest](https://ffmpeg.org/download.html) \ No newline at end of file diff --git a/packages/envied/src/envied/services/ROKU/__init__.py b/packages/envied/src/envied/services/ROKU/__init__.py index 8079633..dd1c6f6 100644 --- a/packages/envied/src/envied/services/ROKU/__init__.py +++ b/packages/envied/src/envied/services/ROKU/__init__.py @@ -10,13 +10,12 @@ from urllib.parse import unquote, urlparse import click import requests - -from envied.core.credential import Credential -from envied.core.manifests import DASH -from envied.core.search_result import SearchResult -from envied.core.service import Service -from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T -from envied.core.tracks import Chapter, Tracks +from unshackle.core.credential import Credential +from unshackle.core.manifests import DASH +from unshackle.core.search_result import SearchResult +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 class ROKU(Service): @@ -24,7 +23,7 @@ class ROKU(Service): Service code for The Roku Channel (https://therokuchannel.roku.com) \b - Version: 1.0.1 + Version: 1.0.2 Author: stabbedbybrick Authorization: Cookies Robustness: diff --git a/packages/envied/src/envied/services/RTE/__init__.py b/packages/envied/src/envied/services/RTE/__init__.py index 9e3b3a5..62f9da9 100644 --- a/packages/envied/src/envied/services/RTE/__init__.py +++ b/packages/envied/src/envied/services/RTE/__init__.py @@ -8,14 +8,14 @@ from typing import Any, Optional, Union from urllib.parse import urljoin import click -from envied.core.constants import AnyTrack -from envied.core.manifests import DASH -from envied.core.search_result import SearchResult -from envied.core.service import Service -from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T -from envied.core.tracks import Chapter, Chapters, Tracks -from envied.core.utils.xml import load_xml from requests import Request +from unshackle.core.constants import AnyTrack +from unshackle.core.manifests import DASH +from unshackle.core.search_result import SearchResult +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, Chapters, Tracks +from unshackle.core.utils.xml import load_xml class RTE(Service): @@ -24,7 +24,7 @@ class RTE(Service): Service code for RTE Player streaming service (https://www.rte.ie/player/). \b - Version: 1.0.0 + Version: 1.0.1 Author: stabbedbybrick Authorization: None Robustness: @@ -45,7 +45,7 @@ class RTE(Service): """ - #GEOFENCE = ("ie",) + # GEOFENCE = ("ie",) @staticmethod @click.command(name="RTE", short_help="https://www.rte.ie/player/", help=__doc__) diff --git a/packages/envied/src/envied/services/SEVEN/__init__.py b/packages/envied/src/envied/services/SEVEN/__init__.py new file mode 100644 index 0000000..e7f5d98 --- /dev/null +++ b/packages/envied/src/envied/services/SEVEN/__init__.py @@ -0,0 +1,439 @@ +from __future__ import annotations + +import re +from collections.abc import Generator +from concurrent.futures import ThreadPoolExecutor +from http.cookiejar import MozillaCookieJar +from typing import Any, List, Optional, Union +from uuid import uuid4 + +import click +from click import Context +from pyplayready.cdm import Cdm as PlayReadyCdm +from unshackle.core.credential import Credential +from unshackle.core.manifests.dash import DASH +from unshackle.core.search_result import SearchResult +from unshackle.core.service import Service +from unshackle.core.titles import Episode, Movie, Movies, Series +from unshackle.core.tracks import Chapter, Chapters, Tracks + + +class SEVEN(Service): + """ + Service code for 7Plus streaming service (https://7plus.com.au/). + + \b + Version: 1.0.0 + Author: stabbedbybrick + Authorization: Cookies + Geofence: AU (API and downloads) + Robustness: + Widevine: + L3: 720p + PlayReady: + SL2000: 720p + + \b + Tips: + - Use complete title URL as input: + SERIES: https://7plus.com.au/ncis-los-angeles + EPISODE: https://7plus.com.au/ncis-los-angeles?episode-id=NCIL01-001 + - There's no way to distinguish between series and movies, so use `--movie` to download as movie + + \b + Examples: + - SERIES: unshackle dl -w s01e01 7plus https://7plus.com.au/ncis-los-angeles + - EPISODE: unshackle dl 7plus https://7plus.com.au/ncis-los-angeles?episode-id=NCIL01-001 + - MOVIE: unshackle dl 7plus --movie https://7plus.com.au/puss-in-boots-the-last-wish + + """ + + GEOFENCE = ("au",) + ALIASES = ("7plus", "sevenplus",) + + @staticmethod + @click.command(name="SEVEN", short_help="https://7plus.com.au/", help=__doc__) + @click.option("-m", "--movie", is_flag=True, default=False, help="Download as Movie") + @click.argument("title", type=str) + @click.pass_context + def cli(ctx: Context, **kwargs: Any) -> SEVEN: + return SEVEN(ctx, **kwargs) + + def __init__(self, ctx: Context, movie: bool, title: str): + self.title = title + self.movie = movie + super().__init__(ctx) + + self.cdm = ctx.obj.cdm + self.drm_system = "playready" if isinstance(self.cdm, PlayReadyCdm) else "widevine" + self.key_system = "com.microsoft.playready" if isinstance(self.cdm, PlayReadyCdm) else "com.widevine.alpha" + + self.profile = ctx.parent.params.get("profile") + if not self.profile: + self.profile = "default" + + self.session.headers.update(self.config["headers"]) + + def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None: + super().authenticate(cookies, credential) + if cookies is None: + raise EnvironmentError("Service requires Cookies for Authentication.") + self.session.cookies.update(cookies) + + api_key = next((cookie.name.replace("gig_bootstrap_", "") for cookie in cookies if "login_ver" in cookie.value), None) + login_token = next((cookie.value for cookie in cookies if "glt_" in cookie.name), None) + if not api_key or not login_token: + raise ValueError("Invalid cookies. Try refreshing.") + + market = self.session.get( + "https://market-cdn.swm.digital/v1/market/ip/", + params={"apikey": "web"} + ).json() + + self.market_id = market.get("_id", 4) + + cache = self.cache.get(f"tokens_{self.profile}") + + if cache and not cache.expired: + # cached + self.log.info(" + Using cached tokens...") + tokens = cache.data + elif cache and cache.expired: + # expired, refresh + self.log.info("+ Refreshing tokens...") + payload = { + "platformId": self.config["PLATFORM_ID"], + "regSource": "7plus", + "refreshToken": cache.data.get("refresh_token"), + } + r = self.session.post("https://auth2.swm.digital/connect/token", data=payload) + if r.status_code != 200: + raise ConnectionError(f"Failed to refresh tokens: {r.text}") + tokens = r.json() + cache.set(tokens, expiration=int(tokens["expires_in"]) - 60) + + else: + # new + self.log.info(" + Authenticating...") + device_id = str(uuid4()) + payload = { + "platformId": self.config["PLATFORM_ID"], + "regSource": "7plus", + "deviceId": device_id, + "locationVerificationRequired": "false", + } + r = self.session.post("https://auth2.swm.digital/account/device/authorize", data=payload) + if r.status_code != 200: + raise ConnectionError(f"Failed to authenticate: {r.text}") + auth = r.json() + + uri = auth.get("verification_uri_complete") + user_code = auth.get("user_code") + device_code = auth.get("device_code") + if not uri or not user_code or not device_code: + raise ValueError(f"Failed to authenticate device: {auth}") + + data = { + "APIKey": api_key, + "sdk": "js_next", + "login_token": login_token, + "authMode": "cookie", + "pageURL": "https://7plus.com.au/connect", + "sdkBuild": "18051", + "format": "json", + } + + response = self.session.post("https://login.7plus.com.au/accounts.getJWT", cookies=cookies, data=data) + if response.status_code != 200: + raise ConnectionError(f"Failed to fetch JWT: {response.text}") + + id_token = response.json().get("id_token") + if not id_token: + raise ValueError(f"Failed to fetch JWT: {response.text}") + + headers = { + "accept": "application/json, text/plain, */*", + "accept-language": "en-US,en;q=0.9", + "authorization": f"Bearer {id_token}", + "content-type": "application/json;charset=UTF-8", + "origin": "https://7plus.com.au", + "referer": "https://7plus.com.au/connect", + } + + payload = { + "platformId": "web", + "regSource": "7plus", + "code": user_code, + "attemptLocationPairing": False, + } + r = self.session.post("https://7plus.com.au/auth/otp", headers=headers, json=payload) + if r.status_code != 200: + raise ConnectionError(f"Failed to verify OTP: {r.status_code}") + + payload = { + "platformId": self.config["PLATFORM_ID"], + "regSource": "7plus", + "deviceCode": device_code, + } + r = self.session.post("https://auth2.swm.digital/connect/token", data=payload) + if r.status_code != 200: + raise ConnectionError(f"Failed to fetch device token: {r.text}") + tokens = r.json() + + tokens["device_id"] = device_id + cache.set(tokens, expiration=int(tokens["expires_in"]) - 60) + + self.device_id = tokens.get("device_id") or str(uuid4()) + self.session.headers.update({"authorization": f"Bearer {tokens['access_token']}"}) + + def search(self) -> Generator[SearchResult, None, None]: + params = { + "searchTerm": self.title, + "market-id": self.market_id, + "api-version": "4.4", + "platform-id": self.config["PLATFORM_ID"], + "platform-version": self.config["PLATFORM_VERSION"], + } + + r = self.session.get("https://searchapi.swm.digital/3.0/api/Search", params=params) + r.raise_for_status() + + results = r.json() + if isinstance(results, list): + for result in results: + title = result.get("image", {}).get("altTag") + slug = result.get("contentLink", {}).get("url") + + yield SearchResult( + id_=f"https://7plus.com.au{slug}", + title=title, + url=f"https://7plus.com.au{slug}", + ) + + def get_titles(self) -> Movies | Series: + if match := re.match(r"https:\/\/7plus\.com\.au\/([^?\/]+)(?:\?.*episode-id=([^&]+))?", self.title): + slug, episode_id = match.groups() + else: + raise ValueError(f"Invalid title: {self.title}") + + params = { + "platform-id": self.config["PLATFORM_ID"], + "market-id": self.market_id, + "platform-version": self.config["PLATFORM_VERSION"], + "api-version": self.config["API_VERSION"], + } + + r = self.session.get(f"https://component-cdn.swm.digital/content/{slug}", params=params) + if r.status_code != 200: + raise ConnectionError(f"Failed to fetch content: {r.text}") + + content = r.json() + + if episode_id: + episodes = self._series(content, slug) + episode = next((e for e in episodes if e.id == episode_id), None) + return Series([episode]) + + elif self.movie: + movie = self._movie(content) + return Movies([movie]) + + else: + episodes = self._series(content, slug) + return Series(episodes) + + def get_tracks(self, title: Movie | Episode) -> Tracks: + params = { + "appId": "7plus", + "deviceType": self.config["PLATFORM_ID"], + "platformType": "tv", + "deviceId": self.device_id, + "pc": 3181, + "advertid": "null", + "accountId": "5303576322001", + "referenceId": f"ref:{title.id}", + "deliveryId": "csai", + "marketId": self.market_id, + "ozid": "dc6095c7-e895-41d3-6609-79f673fc7f63", + "sdkverification": "true", + "cp.encryptionType": "cenc", + "cp.drmSystems": self.drm_system, + "cp.containerFormat": "cmaf", + "cp.supportedCodecs": "avc", + "cp.drmAuth": "true", + } + resp = self.session.get("https://videoservice.swm.digital/playback", params=params) + if resp.status_code != 200: + raise ConnectionError(f"Failed to fetch playback data: {resp.text}") + data = resp.json() + + drm = data.get("media", {}).get("stream_type_drm", False) + if drm: + source_manifest = next(( + x["src"] for x in data["media"]["sources"] + if x.get("key_systems").get("com.widevine.alpha")), + None, + ) + title.data["license_url"] = next(( + x["key_systems"][self.key_system]["license_url"] + for x in data["media"]["sources"] + if x.get("key_systems").get(self.key_system)), + None, + ) + else: + source_manifest = next(( + x["src"] for x in data["media"]["sources"] + if x.get("type") == "application/dash+xml"), + None, + ) + if not source_manifest: + raise ValueError("Failed to get manifest") + + title.data["cue_points"] = data.get("media", {}).get("cue_points") + + tracks = DASH.from_url(source_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: Movie | Episode) -> Chapters: + if not (cue_points := title.data.get("cue_points")): + return Chapters() + + cue_points = sorted(cue_points, key=lambda x: x["time"]) + + chapters = [] + for cue_point in cue_points: + if cue_point.get("time", 0) > 0: + name = "End Credits" if cue_point.get("name", "").lower() == "credits" else None + chapters.append(Chapter(name=name, timestamp=cue_point["time"] * 1000)) + + return Chapters(chapters) + + def get_widevine_service_certificate(self, **_: Any) -> str: + return None + + def get_widevine_license(self, *, challenge: bytes, title: Episode | Movie, track: Any) -> Optional[Union[bytes, str]]: + if license_url := title.data.get("license_url"): + r = self.session.post(url=license_url, data=challenge) + if r.status_code != 200: + raise ConnectionError(r.text) + return r.content + + return None + + def get_playready_license(self, *, challenge: bytes, title: Episode | Movie, track: Any) -> Optional[Union[bytes, str]]: + if license_url := title.data.get("license_url"): + r = self.session.post(url=license_url, data=challenge) + if r.status_code != 200: + raise ConnectionError(r.text) + return r.content + + return None + + # Service specific functions + + def _movie(self, content: dict) -> Movie: + title = content.get("title") + metadata = content.get("items", [{}])[0].get("videoMetadata", {}) + if not metadata: + raise ValueError("Failed to find metadata for this movie") + + return Movie( + id_=metadata.get("videoBref"), + service=self.__class__, + name=title, + year=metadata.get("productionYear"), + language="en", + data=content, + ) + + def _get_season_data(self, season_id: str, slug: str) -> List[Episode]: + params = { + "component-id": season_id, + "platform-id": self.config.get("PLATFORM_ID"), + "market-id": self.market_id, + "platform-version": self.config.get("PLATFORM_VERSION"), + "api-version": self.config.get("API_VERSION"), + "signedUp": "True", + } + + try: + r = self.session.get(f"https://component.swm.digital/component/{slug}", params=params) + r.raise_for_status() + comp = r.json() + except ConnectionError as e: + self.log.error(f"Error fetching season {season_id}: {e}") + return [] + except Exception as e: + self.log.error(f"An unexpected error occurred for season {season_id}: {e}") + return [] + + episodes = [] + for episode in comp.get("items", []): + info_panel = episode.get("infoPanelData", {}) + player_data = episode.get("playerData", {}) + card_data = episode.get("cardData", {}) + catalogue_number = episode.get("catalogueNumber", "") + + title = info_panel.get("title") + episode_name = card_data.get("image", {}).get("altTag") + card_name = card_data.get("title", "").lstrip("0123456789. ").split(" - ")[-1].strip() + + season, number, name = 0, 0, card_name + if match := re.search(r"(?:Season|Year)\s*(\d+)\s*E(?:pisode)?\s*(\d+)", episode_name, re.IGNORECASE): + season = int(match.group(1)) + number = int(match.group(2)) + + if not season and not number: + if match := re.compile(r"\w+(\d+)-(\d+)").search(catalogue_number): + season = int(match.group(1)) + number = int(match.group(2)) + + episodes.append( + Episode( + id_=player_data.get("episodePlayerId"), + service=self.__class__, + title=title, + year=card_data.get("productionYear"), + season=season, + number=number, + name=name, + language="en", + data=episode, + ) + ) + return episodes + + def _series(self, content: dict, slug: str) -> List[Episode]: + items = next((x for x in content.get("items", []) if x.get("type") == "shelfContainer"), {}) + episodes_shelf = next((x for x in items.get("items", []) if x.get("title") == "Episodes"), {}) + seasons_container = next((x for x in episodes_shelf.get("items", []) if x.get("title") in ("Season", "Year")), {}) + + season_ids = [ + item.get("items", [{}])[0].get("id") + for item in seasons_container.get("items", []) + if item.get("items") and item.get("items")[0].get("id") + ] + + if not season_ids: + return [] + + all_episodes = [] + with ThreadPoolExecutor(max_workers=len(season_ids)) as executor: + future_to_season = { + executor.submit(self._get_season_data, season_id, slug): season_id for season_id in season_ids + } + for future in future_to_season: + try: + episodes_of_season = future.result() + all_episodes.extend(episodes_of_season) + except Exception as exc: + season_id = future_to_season[future] + self.log.error(f"{season_id} generated an exception: {exc}") + + return all_episodes diff --git a/packages/envied/src/envied/services/SEVEN/config.yaml b/packages/envied/src/envied/services/SEVEN/config.yaml new file mode 100644 index 0000000..b04845f --- /dev/null +++ b/packages/envied/src/envied/services/SEVEN/config.yaml @@ -0,0 +1,7 @@ +headers: + user-agent: "7plus/5.25.1 (Linux;Android 8.1.0) ExoPlayerLib/2.11.7" + x-swm-apikey: "kGcrNnuPClrkynfnKwG8IA/NhVG6ut5nPEdWF2jscvE=" + +PLATFORM_ID: "androidtv" +PLATFORM_VERSION: "5.25.0.0" +API_VERSION: "5.9.0.0" diff --git a/packages/envied/src/envied/services/STV/__init__.py b/packages/envied/src/envied/services/STV/__init__.py index 059bf0d..6836161 100644 --- a/packages/envied/src/envied/services/STV/__init__.py +++ b/packages/envied/src/envied/services/STV/__init__.py @@ -8,12 +8,12 @@ from urllib.parse import urlparse import click from click import Context -from envied.core.manifests.dash import DASH -from envied.core.search_result import SearchResult -from envied.core.service import Service -from envied.core.titles import Episode, Movie, Movies, Series -from envied.core.tracks import Chapter, Chapters, Tracks from lxml import etree +from unshackle.core.manifests.dash import DASH +from unshackle.core.search_result import SearchResult +from unshackle.core.service import Service +from unshackle.core.titles import Episode, Movie, Movies, Series +from unshackle.core.tracks import Chapter, Chapters, Tracks class STV(Service): @@ -21,7 +21,7 @@ class STV(Service): Service code for STV Player streaming service (https://player.stv.tv/). \b - Version: 1.0.0 + Version: 1.0.1 Author: stabbedbybrick Authorization: None Robustness: @@ -108,7 +108,7 @@ class STV(Service): if episode.get("playerSeries") and re.match(r"Series \d+", episode["playerSeries"]["name"]) else 0, number=int(episode.get("number", 0)), - name=episode.get("title"), + name=episode.get("title", "").lstrip("0123456789. ").lstrip(), language="en", data=episode, ) @@ -128,10 +128,11 @@ class STV(Service): service=self.__class__, title=data["results"].get("name"), season=int(episode["playerSeries"]["name"].split(" ")[1]) - if episode.get("playerSeries") and re.match(r"Series \d+", episode["playerSeries"]["name"]) + if episode.get("playerSeries") + and re.match(r"Series \d+", episode["playerSeries"]["name"]) else 0, number=int(episode.get("number", 0)), - name=episode.get("title"), + name=episode.get("title", "").lstrip("0123456789. ").lstrip(), language="en", data=episode, ) @@ -166,19 +167,19 @@ class STV(Service): source for source in data["sources"] if source.get("type") == "application/dash+xml" - and source.get("key_systems").get("com.wienvied.alpha")), + and source.get("key_systems").get("com.widevine.alpha")), None, ) - self.license = key_systems["key_systems"]["com.wienvied.alpha"]["license_url"] if key_systems else None + self.license = key_systems["key_systems"]["com.widevine.alpha"]["license_url"] if key_systems else None manifest = self.trim_duration(source_manifest) tracks = DASH.from_text(manifest, source_manifest).to_tracks(title.language) - '''for track in tracks.audio: + 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''' + track.descriptive = True return tracks diff --git a/packages/envied/src/envied/services/TEN/__init__.py b/packages/envied/src/envied/services/TEN/__init__.py new file mode 100644 index 0000000..57bf76d --- /dev/null +++ b/packages/envied/src/envied/services/TEN/__init__.py @@ -0,0 +1,561 @@ +from __future__ import annotations + +import base64 +import concurrent.futures +import hashlib +import hmac +import json +import re +import sys +import time +import uuid +from collections.abc import Generator +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timezone +from http.cookiejar import MozillaCookieJar +from typing import Any, Optional, Union + +import click +import m3u8 +from click import Context +from langcodes import Language +from requests import Request +from unshackle.core.config import config +from unshackle.core.credential import Credential +from unshackle.core.downloaders import requests +from unshackle.core.manifests import HLS +from unshackle.core.search_result import SearchResult +from unshackle.core.service import Service +from unshackle.core.titles import Episode, Movie, Movies, Series +from unshackle.core.tracks import Chapter, Chapters, Subtitle, Tracks, Video + + +class TEN(Service): + """ + \b + Service code for 10Play streaming service (https://10.com.au/). + + \b + Version: 1.0.1 + Author: stabbedbybrick + Authorization: credentials + Geofence: AU (API and downloads) + Robustness: + AES: 1080p, AAC2.0 + + \b + Tips: + - Input should be complete URL: + SHOW: https://10.com.au/australian-survivor + EPISODE: https://10.com.au/australian-survivor/episodes/season-11-australia-v-the-world/episode-9/tpv250831fxatm + MOVIE: https://10.com.au/a-quiet-place + - Non-standard programmes (e.g. game shows/sports) have very inconsistent episode number labels. It's recommended to use episode URLs for those. + + \b + Notes: + - 10Play uses transport streams for HLS, meaning the video and audio are a part of the same stream. + As a result, only videos are listed as tracks. But the audio will be included as well. + - Since 1080p streams require some manipulation of the manifest, n_m3u8dl_re downloader is required. + + """ + + GEOFENCE = ("au",) + ALIASES = ( + "10play", + "tenplay", + ) + + @staticmethod + @click.command(name="TEN", short_help="https://10.com.au/", help=__doc__) + @click.argument("title", type=str) + @click.pass_context + def cli(ctx: Context, **kwargs: Any) -> TEN: + return TEN(ctx, **kwargs) + + def __init__(self, ctx: Context, title: str): + self.title = title + super().__init__(ctx) + + if config.downloader != "n_m3u8dl_re": + self.log.error(" - Error: n_m3u8dl_re downloader is required for this service.") + sys.exit(1) + + def search(self) -> Generator[SearchResult, None, None]: + query = self.endpoints["searchApiEndpoint"] + self.title + + results = self._request("GET", query) + + for result in results: + clean_title = self._sanitize(result.get("title")) + yield SearchResult( + id_=f"https://10.com.au/{clean_title}", + title=result.get("title"), + description=result.get("abstractShowDescription"), + label=result.get("subtitle", "").split("|")[-1].strip(), + url=f"https://10.com.au/{clean_title}", + ) + + def authenticate( + self, + cookies: Optional[MozillaCookieJar] = None, + credential: Optional[Credential] = None, + ) -> None: + super().authenticate(cookies, credential) + if not credential: + raise EnvironmentError("Service requires Credentials for Authentication.") + + self.session.headers.update(self.config["headers"]) + self.endpoints = self._request( + "GET", self.config["endpoints"]["config"], params={"SystemName": "tvos"} + ) + + cache = self.cache.get(f"tokens_{credential.sha1}") + + if cache and not cache.expired: + self.log.info(" + Using cached Tokens...") + tokens = cache.data + elif cache and cache.expired: + self.log.info(" + Refreshing expired Tokens...") + payload = { + "alternativeToken": cache.data["alternativeToken"], + "refreshToken": cache.data["refreshToken"], + } + tokens = self._request( + "POST", self.endpoints["authConfig"]["refreshToken"], json=payload + ) + cache.set(tokens, expiration=tokens["expiresIn"]) + else: + self.log.info(" + Logging in...") + headers = { + "accept": "application/json, text/plain, */*", + "content-type": "application/json", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36", + "origin": "https://10.com.au", + "referer": "https://10.com.au/", + } + login = self._request( + "POST", + self.config["endpoints"]["auth"], + headers=headers, + json={"email": credential.username, "password": credential.password}, + ) + access_token = login.get("jwt", {}).get("accessToken") + if not access_token: + raise ValueError( + "Failed to authenticate with credentials: " + login.text + ) + + identifier = str(uuid.uuid4()) + + payload = { + "deviceIdentifier": identifier, + "machine": "Hisense", + "system": "vidaa", + "systemVersion": "U6", + "platform": "vidaa", + "appVersion": "v1", + "ipAddress": "string", + } + device = self._request( + "POST", self.endpoints["authConfig"]["generateCode"], json=payload + ) + + code = device.get("code") + expiry = device.get("expiry") + if not code or not expiry: + raise ValueError("Failed to generate device code: " + device.text) + + headers = { + "accept": "application/json, text/plain, */*", + "authorization": f"Bearer {access_token}", + "content-type": "application/json", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36", + "origin": "https://10.com.au", + "referer": "https://10.com.au/activate", + } + activate = self._request( + "POST", + self.endpoints["activateApiEndpoint"], + headers=headers, + json={"code": code}, + ) + if not activate: + raise ValueError("Failed to activate device") + + payload = { + "code": code, + "deviceIdentifier": identifier, + "expiry": expiry, + } + auth = self._request( + "POST", + self.endpoints["authConfig"]["validateCode"], + json={ + "code": code, + "deviceIdentifier": identifier, + "expiry": expiry, + }, + ) + tokens = auth.get("jwt") + tokens["identifier"] = identifier + + self.log.info(" + User successfully logged in, TV device activated") + + cache.set(tokens, expiration=tokens.get("expiresIn")) + + self.access_token = tokens.get("alternativeToken") + self.session.headers.update({"authorization": f"Bearer {self.access_token}"}) + + def get_titles(self) -> Union[Movies, Series]: + url_pattern = re.compile( + r"^https://10\.com\.au/(?:[a-z0-9-]+)" + r"(?:/episodes/(?:season-)?(?P[a-z0-9-]+)/(?:episode-)?(?P[a-z0-9-]+)/(?P[a-z0-9]+))?$" + ) + + match = url_pattern.match(self.title) + if not match: + raise ValueError(f"Could not parse ID from title: {self.title}") + + matches = match.groupdict() + + if not matches.get("id"): + show_id = self._get_html(self.title) + content = self._shows(show_id) + + if "movie" in content.get("subtitle", "").lower(): + movies = self._movie(content) + return Movies(movies) + + else: + episodes = self._series(content) + return Series(episodes) + + else: + episodes = self._episode(matches.get("id")) + return Series(episodes) + + def get_tracks(self, title: Union[Movie, Episode]) -> Tracks: + playback_url = title.data.get("playbackApiEndpoint") + if not playback_url: + raise ValueError("Could not find playback URL for this title") + + params = { + "device": "Tv", + "platform": "vidaa", + "appVersion": "v1", + } + + r = self.session.get(playback_url, params=params) + if not r.ok: + raise ValueError("Failed to get playback data: " + r.text) + + + dai_auth = r.headers.get("X-DAI-AUTH") + video_id = r.headers.get("x-dai-video-id") + if dai_auth is not None: + payload = {"auth-token": dai_auth} + + playback_data = r.json() + + video_id = playback_data.get("dai", {}).get("videoId") + source_id = playback_data.get("dai", {}).get("contentSourceId", "2690006") + if not video_id or not source_id: + raise ValueError("Failed to get video ID: " + r.text) + + dai_stream = f"https://dai.google.com/ondemand/v1/hls/content/{source_id}/vid/{video_id}/stream" + + stream_data = self._request("POST", dai_stream, data=payload) + + title.data["chapters"] = stream_data.get("time_events_url") + # program_language = Language.find(stream_data["customFields"].get("program_language", "en")) + + manifest_url = stream_data.get("stream_manifest") + tracks = HLS.from_url(manifest_url, self.session).to_tracks(language="en") + + tracks = self._add_tracks(tracks) + + for track in tracks.subtitles: + track.downloader = requests + + # if caption := stream_data.get("subtitles", [])[0].get("webvtt"): + # tracks.add( + # Subtitle( + # id_=hashlib.md5(caption.encode()).hexdigest()[0:6], + # url=caption, + # codec=Subtitle.Codec.from_mime(caption[-3:]), + # language=stream_data.get("subtitles", [])[0].get("language", "en"), + # ) + # ) + + return tracks + + def get_chapters(self, title: Union[Movie, Episode]) -> Chapters: + if not title.data.get("chapters"): + return Chapters() + + events = self._request("GET", title.data["chapters"]) + cue_points = events.get("cuepoints") + if not cue_points: + return Chapters() + + chapters = [] + for cue in cue_points: + chapters.append(Chapter(timestamp=float(cue["start_float"]))) + chapters.append(Chapter(timestamp=float(cue["end_float"]))) + + return Chapters(chapters) + + # Service specific + + def _head_request(self, url: str) -> int: + try: + return self.session.head(url, timeout=10).status_code + except Exception: + return 0 + + def _check_and_add_track( + self, best_track: Video, quality_info: dict, source_bitrate: int + ) -> Video | None: + playlist_uri = best_track.data["hls"]["playlist"].uri + playlist_text = self.session.get(playlist_uri).text + + string_to_replace = f"-{source_bitrate}" + replacement_string = f"-{quality_info['bitrate']}" + + lines = [] + for line in playlist_text.splitlines(): + if "redirector.googlevideo.com" in line: + continue + + if string_to_replace in line: + line = line.replace(string_to_replace, replacement_string) + + lines.append(line) + + modified_playlist_text = "\n".join(lines) + playlist_obj = m3u8.loads(modified_playlist_text) + + if not playlist_obj.segments: + return None + + first_segment = playlist_obj.segments[0].uri + if self._head_request(first_segment) == 200: + playlist_file = config.directories.cache / "TEN" / f"playlist_{quality_info['quality']}.m3u8" + playlist_obj.dump(playlist_file) + + video = Video( + id_=f"{best_track.id}-{quality_info['quality']}", + url=best_track.url, + height=quality_info["height"], + width=quality_info["width"], + bitrate=quality_info["bitrate"], + language=best_track.language, + codec=best_track.codec, + range_=best_track.range, + fps=best_track.fps, + descriptor=best_track.descriptor, + data=best_track.data.copy(), + from_file=playlist_file, + ) + return video + return None + + def _add_tracks(self, tracks: Tracks) -> Tracks: + if not tracks.videos: + return tracks + + best_track = max(tracks.videos, key=lambda t: t.height or 0) + + source_bitrate = { + 1080: "5000000", + 720: "3000000", + 540: "1500000", + 360: "750000", + }.get(best_track.height) + + all_qualities = [ + {"quality": "540p", "bitrate": 1500000, "height": 540, "width": 960}, + {"quality": "720p", "bitrate": 3000000, "height": 720, "width": 1280}, + {"quality": "1080p", "bitrate": 5000000, "height": 1080, "width": 1920}, + ] + + qualities_to_check = [ + q for q in all_qualities if q["height"] > best_track.height + ] + + if not qualities_to_check: + return tracks + + with ThreadPoolExecutor(max_workers=len(qualities_to_check)) as executor: + future_to_track = { + executor.submit(self._check_and_add_track, best_track, quality, source_bitrate): quality + for quality in qualities_to_check + } + + for future in concurrent.futures.as_completed(future_to_track): + new_track = future.result() + if new_track: + tracks.add(new_track) + + return tracks + + + def _shows(self, show_id: str) -> dict: + show = self._request("GET", f'{self.endpoints["showsApiEndpoint"]}/{show_id}') + + return show[0] if isinstance(show, list) else show + + def _fetch_episode(self, url: str) -> list: + return self._request("GET", url) + + def _series(self, content: dict) -> Episode: + season_list = content.get("seasons") + if not season_list: + raise ValueError("Could not find a season list for this title") + + seasons = [ + season.get("menuItems", [])[0].get("apiEndpoint") + for season in season_list + if season.get("menuItems", []) + and season.get("menuItems", [])[0].get("menuTitle", "").lower() + == "episodes" + ] + + if not seasons: + raise ValueError("Could not find a season list for this title") + + with ThreadPoolExecutor(max_workers=10) as executor: + results = list(executor.map(self._fetch_episode, seasons)) + + titles = [] + for result in results: + for episode in result: + ep_number = episode.get("episode") + sea_number = episode.get("season") + titles.append( + Episode( + id_=episode.get("id"), + service=self.__class__, + name=episode.get("vodTitle", "").split(" - ")[-1], + season=int(sea_number) if sea_number and sea_number.isdigit() else 0, + number=int(ep_number) if ep_number and ep_number.isdigit() else 0, + title=episode.get("tvShow"), + data=episode, + ) + ) + + return titles + + def _movie(self, data: dict) -> Movie: + endpoint = next( + ( + season.get("menuItems", [])[0].get("apiEndpoint") + for season in data.get("seasons", []) + if season.get("menuItems", []) + ), + None, + ) + if not endpoint: + raise ValueError("Could not find an endpoint for this title") + + movie = self._request("GET", endpoint)[0] + + return [ + Movie( + id_=movie.get("id"), + service=self.__class__, + name=movie.get("title"), + year=movie.get("season"), + data=movie, + ) + ] + + def _episode(self, video_id: str) -> Episode: + data = self._request("GET", f"{self.endpoints['videosApiEndpoint']}/{video_id}") + + ep_number = data.get("episode") + sea_number = data.get("season") + return [ + Episode( + id_=data.get("id"), + service=self.__class__, + name=data.get("vodTitle", "").split(" - ")[-1], + season=int(sea_number) if sea_number and sea_number.isdigit() else 0, + number=int(ep_number) if ep_number and ep_number.isdigit() else 0, + title=data.get("tvShow"), + data=data, + ) + ] + + def _get_html(self, url: str) -> Optional[str]: + page = self.session.get(url).text + pattern = re.compile(r"const showPageData = ({.*?});", re.DOTALL) + + match = pattern.search(page) + if not match: + raise ValueError( + " - Failed to parse HTML. Page Data not found in the source code." + ) + + page_data = match.group(1) + + try: + data = json.loads(page_data) + except json.JSONDecodeError as e: + raise json.JSONDecodeError(f"Failed to parse JSON: {e}") + + show_id = data.get("video", {}).get("showUrlCode") + if not show_id: + raise ValueError(" - showUrlCode not found in the source code.") + + return show_id + + def _signature_header(self, url: str) -> str: + timestamp = int(time.time()) + message = f"{timestamp}:{url}".encode("utf-8") + api_key = bytes.fromhex(self.config["api_key"]) + signature = hmac.new(api_key, message, hashlib.sha256).hexdigest() + return f"{timestamp}_{signature}" + + def _auth_header(self) -> str: + now_utc = datetime.now(timezone.utc) + timestamp_str = now_utc.strftime("%Y%m%d%H%M%S") + encoded_bytes = base64.b64encode(timestamp_str.encode("utf-8")) + return encoded_bytes.decode("ascii") + + def _request(self, method: str, url: str, **kwargs: Any) -> Any[dict | str]: + if method == "GET": + self.session.headers.update( + { + "X-N10-SIG": self._signature_header(url), + "tp-acceptfeature": "v1/fw;v1/drm;v2/live", + "tp-platform": "UAP", + } + ) + elif method == "POST": + self.session.headers.update({"X-Network-Ten-Auth": self._auth_header()}) + + prep = self.session.prepare_request(Request(method, url, **kwargs)) + + response = self.session.send(prep) + if response.status_code not in (200, 201): + raise ConnectionError(f"{response.text}") + + try: + return json.loads(response.content) + + except json.JSONDecodeError: + return True if "true" in response.text else False + + @staticmethod + def _sanitize(title: str) -> str: + title = title.lower() + title = title.replace("&", "and") + title = re.sub(r"[:;/()]", "", title) + title = re.sub(r"[ ]", "-", title) + title = re.sub(r"[\\*!?¿,'\"<>|$#`’]", "", title) + title = re.sub(rf"[{'.'}]{{2,}}", ".", title) + title = re.sub(rf"[{'_'}]{{2,}}", "_", title) + title = re.sub(rf"[{'-'}]{{2,}}", "-", title) + title = re.sub(rf"[{' '}]{{2,}}", " ", title) + return title diff --git a/packages/envied/src/envied/services/TEN/config.yaml b/packages/envied/src/envied/services/TEN/config.yaml new file mode 100644 index 0000000..b387a2f --- /dev/null +++ b/packages/envied/src/envied/services/TEN/config.yaml @@ -0,0 +1,9 @@ +headers: + User-Agent: 10play/7.4.0.500325 Android UAP + +endpoints: + config: https://10.com.au/api/v1/config + auth: https://10.com.au/api/user/auth + query: https://vod.ten.com.au/api/videos/bcquery # androidapps-v2 + +api_key: "b918ff793563080c5821c89ee6c415c363cb36d369db1020369ac4b405a0211d" \ No newline at end of file diff --git a/packages/envied/src/envied/services/TUBI/__init__.py b/packages/envied/src/envied/services/TUBI/__init__.py index 58dc4f3..2fff7cf 100644 --- a/packages/envied/src/envied/services/TUBI/__init__.py +++ b/packages/envied/src/envied/services/TUBI/__init__.py @@ -9,13 +9,13 @@ from collections.abc import Generator from typing import Any import click -from envied.core.downloaders import aria2c, requests -from envied.core.manifests import DASH -from envied.core.search_result import SearchResult -from envied.core.service import Service -from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T -from envied.core.tracks import Chapter, Chapters, Subtitle, Track, Tracks from langcodes import Language +from unshackle.core.downloaders import aria2c, requests +from unshackle.core.manifests import DASH +from unshackle.core.search_result import SearchResult +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, Chapters, Subtitle, Track, Tracks class TUBI(Service): @@ -23,7 +23,7 @@ class TUBI(Service): Service code for TubiTV streaming service (https://tubitv.com/) \b - Version: 1.0.1 + Version: 1.0.3 Author: stabbedbybrick Authorization: None Robustness: @@ -69,7 +69,7 @@ class TUBI(Service): # r = self.session.get(self.config["endpoints"]["search"], params=params) # r.raise_for_status() # results = r.json() - # from envied.core.console import console + # from devine.core.console import console # console.print(results) # exit() @@ -214,16 +214,21 @@ class TUBI(Service): return tracks def get_chapters(self, title: Title_T) -> Chapters: - cue_points = title.data.get("credit_cuepoints") - if not cue_points: + if not (cue_points := title.data.get("credit_cuepoints")): return Chapters() chapters = [] - for title, cuepoint in cue_points.items(): - if cuepoint > 0: - chapters.append(Chapter(timestamp=float(cuepoint), name=title)) + if cue_points.get("recap_start"): + chapters.append(Chapter(name="Recap", timestamp=float(cue_points["recap_start"]))) + if cue_points.get("intro_start") and cue_points.get("intro_end"): + chapters.append(Chapter(name="Intro", timestamp=float(cue_points["intro_start"]))) + chapters.append(Chapter(timestamp=float(cue_points["intro_end"]))) + if cue_points.get("early_credits_start"): + chapters.append(Chapter(name="Early Credits", timestamp=float(cue_points["early_credits_start"]))) + if cue_points.get("postlude"): + chapters.append(Chapter(name="End Credits", timestamp=float(cue_points["postlude"]))) - return Chapters(chapters) + return sorted(chapters, key=lambda x: x.timestamp) def get_widevine_service_certificate(self, **_: Any) -> str: return None diff --git a/packages/envied/src/envied/services/TVNZ/__init__.py b/packages/envied/src/envied/services/TVNZ/__init__.py index 9c0a97a..9358278 100644 --- a/packages/envied/src/envied/services/TVNZ/__init__.py +++ b/packages/envied/src/envied/services/TVNZ/__init__.py @@ -9,15 +9,15 @@ from urllib.parse import urljoin, urlparse import click from click import Context -from envied.core.credential import Credential -from envied.core.manifests.dash import DASH -from envied.core.search_result import SearchResult -from envied.core.service import Service -from envied.core.titles import Episode, Movie, Movies, Series -from envied.core.tracks import Chapters, Tracks from lxml import etree from pywidevine.cdm import Cdm as WidevineCdm from requests import Request +from unshackle.core.credential import Credential +from unshackle.core.manifests.dash import DASH +from unshackle.core.search_result import SearchResult +from unshackle.core.service import Service +from unshackle.core.titles import Episode, Movie, Movies, Series +from unshackle.core.tracks import Chapters, Tracks class TVNZ(Service): @@ -26,7 +26,7 @@ class TVNZ(Service): Service code for TVNZ streaming service (https://www.tvnz.co.nz). \b - Version: 1.0.0 + Version: 1.0.2 Author: stabbedbybrick Authorization: Credentials Robustness: @@ -104,6 +104,9 @@ class TVNZ(Service): self.session.headers.update({"Authorization": "Bearer {}".format(tokens["access_token"])}) + # Disable SSL verification due to issues with newer versions of requests library. + self.session.verify = False + def get_titles(self) -> Union[Movies, Series]: try: path = urlparse(self.title).path @@ -172,14 +175,14 @@ class TVNZ(Service): ) self.license = next(( - x["key_systems"]["com.wienvied.alpha"]["license_url"] + x["key_systems"]["com.widevine.alpha"]["license_url"] for x in data["sources"] - if x.get("key_systems").get("com.wienvied.alpha")), + if x.get("key_systems").get("com.widevine.alpha")), None, ) source_manifest = next(( x["src"] for x in data["sources"] - if x.get("key_systems").get("com.wienvied.alpha")), + if x.get("key_systems").get("com.widevine.alpha")), None, ) diff --git a/packages/envied/src/envied/services/UKTV/__init__.py b/packages/envied/src/envied/services/UKTV/__init__.py index 18613fe..4294cea 100644 --- a/packages/envied/src/envied/services/UKTV/__init__.py +++ b/packages/envied/src/envied/services/UKTV/__init__.py @@ -7,12 +7,12 @@ from typing import Any, Union import click from click import Context -from envied.core.manifests.dash import DASH -from envied.core.search_result import SearchResult -from envied.core.service import Service -from envied.core.titles import Episode, Movie, Movies, Series -from envied.core.tracks import Chapter, Chapters, Tracks from lxml import etree +from unshackle.core.manifests.dash import DASH +from unshackle.core.search_result import SearchResult +from unshackle.core.service import Service +from unshackle.core.titles import Episode, Movie, Movies, Series +from unshackle.core.tracks import Chapter, Chapters, Tracks class UKTV(Service): @@ -20,7 +20,7 @@ class UKTV(Service): Service code for 'U' (formerly UKTV Play) streaming service (https://u.co.uk/). \b - Version: 1.0.0 + Version: 1.0.1 Author: stabbedbybrick Authorization: None Robustness: diff --git a/packages/envied/src/envied/services/iP/__init__.py b/packages/envied/src/envied/services/iP/__init__.py index 1c5bf39..119d754 100644 --- a/packages/envied/src/envied/services/iP/__init__.py +++ b/packages/envied/src/envied/services/iP/__init__.py @@ -14,13 +14,13 @@ from typing import Any, Union import click from bs4 import XMLParsedAsHTMLWarning from click import Context -from envied.core.manifests import DASH, HLS -from envied.core.search_result import SearchResult -from envied.core.service import Service -from envied.core.titles import Episode, Movie, Movies, Series -from envied.core.tracks import Audio, Chapters, Subtitle, Tracks, Video -from envied.core.utils.collections import as_list -from envied.core.utils.sslciphers import SSLCiphers +from unshackle.core.manifests import DASH, HLS +from unshackle.core.search_result import SearchResult +from unshackle.core.service import Service +from unshackle.core.titles import Episode, Movie, Movies, Series +from unshackle.core.tracks import Audio, Chapters, Subtitle, Tracks, Video +from unshackle.core.utils.collections import as_list +from unshackle.core.utils.sslciphers import SSLCiphers warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning) @@ -31,7 +31,7 @@ class iP(Service): Service code for the BBC iPlayer streaming service (https://www.bbc.co.uk/iplayer). \b - Version: 1.0.1 + Version: 1.0.2 Author: stabbedbybrick Authorization: None Security: None @@ -177,25 +177,20 @@ class iP(Service): return versions # Fallback to scraping webpage if API returns no versions - self.log.info("No versions in playlist API, falling back to webpage scrape.") + self.log.debug("No versions in playlist API, falling back to webpage scrape.") r = self.session.get(self.config["base_url"].format(type="episode", pid=pid)) r.raise_for_status() match = re.search(r"window\.__IPLAYER_REDUX_STATE__\s*=\s*(.*?);\s*", r.text) if match: redux_data = json.loads(match.group(1)) + redux_versions = redux_data.get("versions") + versions = redux_versions.values() if isinstance(redux_versions, dict) else redux_versions # Filter out audio-described versions - try: - return [ - {"pid": v.get("id")} - for v in redux_data.get("versions", {}).values() - if v.get("kind") != "audio-described" and v.get("id") - ] - except: - return [ - {"pid": v.get("id")} - for v in redux_data.get("versions", {}) - if v.get("kind") != "audio-described" and v.get("id") - ] + return [ + {"pid": v.get("id")} + for v in versions + if v.get("kind") != "audio-described" and v.get("id") + ] return []