SBB Sevices update

This commit is contained in:
VineFeeder
2025-11-11 15:32:07 +00:00
parent c518e9e4c2
commit 89f0f22cff
22 changed files with 1227 additions and 211 deletions
@@ -3,8 +3,8 @@ from __future__ import annotations
import base64 import base64
import hashlib import hashlib
import json import json
import sys
import re import re
import sys
from collections.abc import Generator from collections.abc import Generator
from datetime import datetime, timezone from datetime import datetime, timezone
from http.cookiejar import MozillaCookieJar from http.cookiejar import MozillaCookieJar
@@ -15,14 +15,12 @@ from click import Context
from Crypto.Util.Padding import unpad from Crypto.Util.Padding import unpad
from Cryptodome.Cipher import AES from Cryptodome.Cipher import AES
from pywidevine.cdm import Cdm as WidevineCdm from pywidevine.cdm import Cdm as WidevineCdm
from unshackle.core.credential import Credential
from envied.core.constants import AnyTrack from unshackle.core.manifests.dash import DASH
from envied.core.credential import Credential from unshackle.core.search_result import SearchResult
from envied.core.manifests import DASH from unshackle.core.service import Service
from envied.core.search_result import SearchResult from unshackle.core.titles import Episode, Movie, Movies, Series
from envied.core.service import Service from unshackle.core.tracks import Chapter, Subtitle, Tracks
from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
from envied.core.tracks import Chapter, Subtitle, Tracks
class ALL4(Service): class ALL4(Service):
@@ -30,7 +28,7 @@ class ALL4(Service):
Service code for Channel 4's All4 streaming service (https://channel4.com). Service code for Channel 4's All4 streaming service (https://channel4.com).
\b \b
Version: 1.0.0 Version: 1.0.1
Author: stabbedbybrick Author: stabbedbybrick
Authorization: Credentials Authorization: Credentials
Robustness: Robustness:
@@ -263,6 +261,11 @@ class ALL4(Service):
else: else:
self.log.warning("- Subtitles are either missing or empty") 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 return tracks
def get_chapters(self, title: Union[Movie, Episode]) -> list[Chapter]: def get_chapters(self, title: Union[Movie, Episode]) -> list[Chapter]:
@@ -9,15 +9,13 @@ from urllib.parse import urljoin
import click import click
from click import Context 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 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): class AUBC(Service):
@@ -26,7 +24,7 @@ class AUBC(Service):
Service code for ABC iView streaming service (https://iview.abc.net.au/). Service code for ABC iView streaming service (https://iview.abc.net.au/).
\b \b
Version: 1.0.1 Version: 1.0.2
Author: stabbedbybrick Author: stabbedbybrick
Authorization: None Authorization: None
Robustness: Robustness:
@@ -155,14 +153,14 @@ class AUBC(Service):
return tracks return tracks
def get_chapters(self, title: Union[Movie, Episode]) -> Chapters: def get_chapters(self, title: Union[Movie, Episode]) -> Chapters:
# if not title.data.get("cuePoints"): if not title.data.get("cuePoints"):
# return Chapters() return Chapters()
# credits = next((x.get("start") for x in title.data["cuePoints"] if x["type"] == "end-credits"), None) credits = next((x.get("start") for x in title.data["cuePoints"] if x["type"] == "end-credits"), None)
# if credits: if credits:
# return Chapters([Chapter(name="Credits", timestamp=credits * 1000)]) return Chapters([Chapter(name="Credits", timestamp=credits * 1000)])
return [] return Chapters()
def get_widevine_service_certificate(self, **_: Any) -> str: def get_widevine_service_certificate(self, **_: Any) -> str:
return None return None
@@ -215,11 +213,10 @@ class AUBC(Service):
def create_episode(self, episode: dict) -> Episode: def create_episode(self, episode: dict) -> Episode:
title = episode["showTitle"] title = episode["showTitle"]
season = re.search(r"Series (\d+)", episode.get("title")) series_id = episode.get("analytics", {}).get("dataLayer", {}).get("d_series_id", "")
number = re.search(r"Episode (\d+)", episode.get("title")) episode_name = episode.get("analytics", {}).get("dataLayer", {}).get("d_episode_name", "")
names_a = re.search(r"Series \d+ Episode \d+ (.+)", episode.get("title")) number = re.search(r"Episode (\d+)", episode.get("displaySubtitle", ""))
names_b = re.search(r"Series \d+ (.+)", episode.get("title")) name = re.search(r"S\d+\sEpisode\s\d+\s(.*)", episode_name)
name = names_a.group(1) if names_a else names_b.group(1) if names_b else episode.get("displaySubtitle")
language = episode.get("analytics", {}).get("dataLayer", {}).get("d_language", "en") language = episode.get("analytics", {}).get("dataLayer", {}).get("d_language", "en")
@@ -227,9 +224,9 @@ class AUBC(Service):
id_=episode["id"], id_=episode["id"],
service=self.__class__, service=self.__class__,
title=title, 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, number=int(number.group(1)) if number else 0,
name=name, name=name.group(1) if name else None,
data=episode, data=episode,
language=language, language=language,
) )
@@ -10,14 +10,14 @@ from urllib.parse import urljoin
import click import click
from click import Context 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 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): class CBC(Service):
@@ -26,7 +26,7 @@ class CBC(Service):
Service code for CBC Gem streaming service (https://gem.cbc.ca/). Service code for CBC Gem streaming service (https://gem.cbc.ca/).
\b \b
Version: 1.0.0 Version: 1.0.1
Author: stabbedbybrick Author: stabbedbybrick
Authorization: Credentials Authorization: Credentials
Robustness: Robustness:
@@ -8,15 +8,15 @@ from typing import Any, Optional, Union
from urllib.parse import urljoin from urllib.parse import urljoin
import click 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 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): class CBS(Service):
@@ -26,7 +26,7 @@ class CBS(Service):
Credit to @srpen6 for the tip on anonymous session Credit to @srpen6 for the tip on anonymous session
\b \b
Version: 1.0.0 Version: 1.0.1
Author: stabbedbybrick Author: stabbedbybrick
Authorization: None Authorization: None
Robustness: Robustness:
@@ -11,13 +11,12 @@ from typing import Any, Optional
import click import click
from pywidevine.cdm import Cdm as WidevineCdm from pywidevine.cdm import Cdm as WidevineCdm
from unshackle.core.credential import Credential
from envied.core.credential import Credential from unshackle.core.manifests import DASH
from envied.core.manifests import DASH from unshackle.core.search_result import SearchResult
from envied.core.search_result import SearchResult from unshackle.core.service import Service
from envied.core.service import Service from unshackle.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T from unshackle.core.tracks import Audio, Chapter, Subtitle, Tracks, Video
from envied.core.tracks import Chapter, Subtitle, Video, Audio, Tracks
class CTV(Service): class CTV(Service):
@@ -25,7 +24,7 @@ class CTV(Service):
Service code for CTV.ca (https://www.ctv.ca) Service code for CTV.ca (https://www.ctv.ca)
\b \b
Version: 1.0.0 Version: 1.0.1
Author: stabbedbybrick Author: stabbedbybrick
Authorization: Credentials for subscription, none for freely available titles Authorization: Credentials for subscription, none for freely available titles
Robustness: Robustness:
@@ -9,17 +9,13 @@ from urllib.parse import quote, urljoin
import click import click
from click import Context 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 lxml import etree
from requests import Request 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): class CWTV(Service):
@@ -28,7 +24,7 @@ class CWTV(Service):
Service code for CWTV streaming service (https://www.cwtv.com/). Service code for CWTV streaming service (https://www.cwtv.com/).
\b \b
Version: 1.0.0 Version: 1.0.1
Author: stabbedbybrick Author: stabbedbybrick
Authorization: None Authorization: None
Geofence: US (API and downloads) Geofence: US (API and downloads)
@@ -11,12 +11,12 @@ from typing import Any, Optional, Union
import click import click
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from click import Context from click import Context
from envied.core.credential import Credential from unshackle.core.credential import Credential
from envied.core.manifests.dash import DASH from unshackle.core.manifests.dash import DASH
from envied.core.search_result import SearchResult from unshackle.core.search_result import SearchResult
from envied.core.service import Service from unshackle.core.service import Service
from envied.core.titles import Episode, Movie, Movies, Series from unshackle.core.titles import Episode, Movie, Movies, Series
from envied.core.tracks import Chapter, Chapters, Subtitle, Tracks from unshackle.core.tracks import Chapter, Chapters, Subtitle, Tracks
class ITV(Service): class ITV(Service):
@@ -24,7 +24,7 @@ class ITV(Service):
Service code for ITVx streaming service (https://www.itv.com/). Service code for ITVx streaming service (https://www.itv.com/).
\b \b
Version: 1.0.1 Version: 1.0.2
Author: stabbedbybrick Author: stabbedbybrick
Authorization: Cookies (Optional for free content | Required for premium content) Authorization: Cookies (Optional for free content | Required for premium content)
Robustness: Robustness:
@@ -41,9 +41,9 @@ class ITV(Service):
\b \b
Examples: Examples:
- SERIES: envied dl -w s01e01 itv https://www.itv.com/watch/bay-of-fires/10a5270 - SERIES: devine 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 - EPISODE: devine 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 - FILM: devine dl itv https://www.itv.com/watch/mad-max-beyond-thunderdome/2a7095
\b \b
Notes: 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") role = track.data["dash"]["representation"].find("Role")
if role is not None and role.get("value") in ["description", "alternative", "alternate"]: if role is not None and role.get("value") in ["description", "alternative", "alternate"]:
track.descriptive = True''' track.descriptive = True
return tracks return tracks
@@ -1,9 +1,9 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import os
import re import re
import tempfile import tempfile
import os
from collections.abc import Generator from collections.abc import Generator
from typing import Any, Union from typing import Any, Union
from urllib.parse import urlparse, urlunparse from urllib.parse import urlparse, urlunparse
@@ -11,13 +11,13 @@ from urllib.parse import urlparse, urlunparse
import click import click
import requests import requests
from click import Context 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 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): class MY5(Service):
@@ -26,7 +26,7 @@ class MY5(Service):
Service code for Channel 5's My5 streaming service (https://channel5.com). Service code for Channel 5's My5 streaming service (https://channel5.com).
\b \b
Version: 1.0.0 Version: 1.0.1
Author: stabbedbybrick Author: stabbedbybrick
Authorization: None Authorization: None
Robustness: Robustness:
@@ -40,7 +40,7 @@ class MY5(Service):
\b \b
Known bugs: Known bugs:
- The progress bar is broken for certain DASH manifests - 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) 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") role = track.data["dash"]["representation"].find("Role")
if role is not None and role.get("value") in ["description", "alternative", "alternate"]: if role is not None and role.get("value") in ["description", "alternative", "alternate"]:
track.descriptive = True''' track.descriptive = True
return tracks return tracks
@@ -4,41 +4,20 @@ import json
import re import re
import uuid import uuid
from collections.abc import Generator 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 concurrent.futures import ThreadPoolExecutor
from http.cookiejar import MozillaCookieJar
from typing import Any, Optional
from urllib.parse import quote, urljoin, urlparse
import click import click
from click import Context 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 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): class PLEX(Service):
@@ -47,7 +26,7 @@ class PLEX(Service):
Service code for Plex's free streaming service (https://watch.plex.tv/). Service code for Plex's free streaming service (https://watch.plex.tv/).
\b \b
Version: 1.0.3 Version: 1.0.4
Author: stabbedbybrick Author: stabbedbybrick
Authorization: None Authorization: None
Geofence: API and downloads are locked into whatever region the user is in Geofence: API and downloads are locked into whatever region the user is in
@@ -7,13 +7,12 @@ from http.cookiejar import CookieJar
from typing import Any, Optional from typing import Any, Optional
import click import click
from unshackle.core.credential import Credential
from envied.core.credential import Credential from unshackle.core.manifests import DASH, HLS
from envied.core.manifests import DASH, HLS from unshackle.core.search_result import SearchResult
from envied.core.search_result import SearchResult from unshackle.core.service import Service
from envied.core.service import Service from unshackle.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T from unshackle.core.tracks import Chapters, Tracks
from envied.core.tracks import Chapters, Tracks
class PLUTO(Service): class PLUTO(Service):
@@ -23,7 +22,7 @@ class PLUTO(Service):
Credit to @wks_uwu for providing an alternative API, making the codebase much cleaner Credit to @wks_uwu for providing an alternative API, making the codebase much cleaner
\b \b
Version: 1.0.1 Version: 1.0.2
Author: stabbedbybrick Author: stabbedbybrick
Authorization: None Authorization: None
Robustness: Robustness:
@@ -38,7 +37,7 @@ class PLUTO(Service):
MOVIE: /movies/635c1e430888bc001ad01a9b/details MOVIE: /movies/635c1e430888bc001ad01a9b/details
- Use --lang LANG_RANGE option to request non-English tracks - Use --lang LANG_RANGE option to request non-English tracks
- Use --hls to request HLS instead of DASH: - Use --hls to request HLS instead of DASH:
envied dl pluto URL --hls devine dl pluto URL --hls
\b \b
Notes: Notes:
@@ -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)
@@ -10,13 +10,12 @@ from urllib.parse import unquote, urlparse
import click import click
import requests import requests
from unshackle.core.credential import Credential
from envied.core.credential import Credential from unshackle.core.manifests import DASH
from envied.core.manifests import DASH from unshackle.core.search_result import SearchResult
from envied.core.search_result import SearchResult from unshackle.core.service import Service
from envied.core.service import Service from unshackle.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T from unshackle.core.tracks import Chapter, Tracks
from envied.core.tracks import Chapter, Tracks
class ROKU(Service): class ROKU(Service):
@@ -24,7 +23,7 @@ class ROKU(Service):
Service code for The Roku Channel (https://therokuchannel.roku.com) Service code for The Roku Channel (https://therokuchannel.roku.com)
\b \b
Version: 1.0.1 Version: 1.0.2
Author: stabbedbybrick Author: stabbedbybrick
Authorization: Cookies Authorization: Cookies
Robustness: Robustness:
@@ -8,14 +8,14 @@ from typing import Any, Optional, Union
from urllib.parse import urljoin from urllib.parse import urljoin
import click 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 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): class RTE(Service):
@@ -24,7 +24,7 @@ class RTE(Service):
Service code for RTE Player streaming service (https://www.rte.ie/player/). Service code for RTE Player streaming service (https://www.rte.ie/player/).
\b \b
Version: 1.0.0 Version: 1.0.1
Author: stabbedbybrick Author: stabbedbybrick
Authorization: None Authorization: None
Robustness: Robustness:
@@ -45,7 +45,7 @@ class RTE(Service):
""" """
#GEOFENCE = ("ie",) # GEOFENCE = ("ie",)
@staticmethod @staticmethod
@click.command(name="RTE", short_help="https://www.rte.ie/player/", help=__doc__) @click.command(name="RTE", short_help="https://www.rte.ie/player/", help=__doc__)
@@ -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
@@ -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"
@@ -8,12 +8,12 @@ from urllib.parse import urlparse
import click import click
from click import Context 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 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): class STV(Service):
@@ -21,7 +21,7 @@ class STV(Service):
Service code for STV Player streaming service (https://player.stv.tv/). Service code for STV Player streaming service (https://player.stv.tv/).
\b \b
Version: 1.0.0 Version: 1.0.1
Author: stabbedbybrick Author: stabbedbybrick
Authorization: None Authorization: None
Robustness: Robustness:
@@ -108,7 +108,7 @@ class STV(Service):
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, else 0,
number=int(episode.get("number", 0)), number=int(episode.get("number", 0)),
name=episode.get("title"), name=episode.get("title", "").lstrip("0123456789. ").lstrip(),
language="en", language="en",
data=episode, data=episode,
) )
@@ -128,10 +128,11 @@ class STV(Service):
service=self.__class__, service=self.__class__,
title=data["results"].get("name"), title=data["results"].get("name"),
season=int(episode["playerSeries"]["name"].split(" ")[1]) 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, else 0,
number=int(episode.get("number", 0)), number=int(episode.get("number", 0)),
name=episode.get("title"), name=episode.get("title", "").lstrip("0123456789. ").lstrip(),
language="en", language="en",
data=episode, data=episode,
) )
@@ -166,19 +167,19 @@ class STV(Service):
source source
for source in data["sources"] for source in data["sources"]
if source.get("type") == "application/dash+xml" 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, 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) manifest = self.trim_duration(source_manifest)
tracks = DASH.from_text(manifest, source_manifest).to_tracks(title.language) 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") role = track.data["dash"]["representation"].find("Role")
if role is not None and role.get("value") in ["description", "alternative", "alternate"]: if role is not None and role.get("value") in ["description", "alternative", "alternate"]:
track.descriptive = True''' track.descriptive = True
return tracks return tracks
@@ -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<season>[a-z0-9-]+)/(?:episode-)?(?P<episode>[a-z0-9-]+)/(?P<id>[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
@@ -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"
@@ -9,13 +9,13 @@ from collections.abc import Generator
from typing import Any from typing import Any
import click 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 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): class TUBI(Service):
@@ -23,7 +23,7 @@ class TUBI(Service):
Service code for TubiTV streaming service (https://tubitv.com/) Service code for TubiTV streaming service (https://tubitv.com/)
\b \b
Version: 1.0.1 Version: 1.0.3
Author: stabbedbybrick Author: stabbedbybrick
Authorization: None Authorization: None
Robustness: Robustness:
@@ -69,7 +69,7 @@ class TUBI(Service):
# r = self.session.get(self.config["endpoints"]["search"], params=params) # r = self.session.get(self.config["endpoints"]["search"], params=params)
# r.raise_for_status() # r.raise_for_status()
# results = r.json() # results = r.json()
# from envied.core.console import console # from devine.core.console import console
# console.print(results) # console.print(results)
# exit() # exit()
@@ -214,16 +214,21 @@ class TUBI(Service):
return tracks return tracks
def get_chapters(self, title: Title_T) -> Chapters: def get_chapters(self, title: Title_T) -> Chapters:
cue_points = title.data.get("credit_cuepoints") if not (cue_points := title.data.get("credit_cuepoints")):
if not cue_points:
return Chapters() return Chapters()
chapters = [] chapters = []
for title, cuepoint in cue_points.items(): if cue_points.get("recap_start"):
if cuepoint > 0: chapters.append(Chapter(name="Recap", timestamp=float(cue_points["recap_start"])))
chapters.append(Chapter(timestamp=float(cuepoint), name=title)) 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: def get_widevine_service_certificate(self, **_: Any) -> str:
return None return None
@@ -9,15 +9,15 @@ from urllib.parse import urljoin, urlparse
import click import click
from click import Context 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 lxml import etree
from pywidevine.cdm import Cdm as WidevineCdm from pywidevine.cdm import Cdm as WidevineCdm
from requests import Request 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): class TVNZ(Service):
@@ -26,7 +26,7 @@ class TVNZ(Service):
Service code for TVNZ streaming service (https://www.tvnz.co.nz). Service code for TVNZ streaming service (https://www.tvnz.co.nz).
\b \b
Version: 1.0.0 Version: 1.0.2
Author: stabbedbybrick Author: stabbedbybrick
Authorization: Credentials Authorization: Credentials
Robustness: Robustness:
@@ -104,6 +104,9 @@ class TVNZ(Service):
self.session.headers.update({"Authorization": "Bearer {}".format(tokens["access_token"])}) 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]: def get_titles(self) -> Union[Movies, Series]:
try: try:
path = urlparse(self.title).path path = urlparse(self.title).path
@@ -172,14 +175,14 @@ class TVNZ(Service):
) )
self.license = next(( self.license = next((
x["key_systems"]["com.wienvied.alpha"]["license_url"] x["key_systems"]["com.widevine.alpha"]["license_url"]
for x in data["sources"] for x in data["sources"]
if x.get("key_systems").get("com.wienvied.alpha")), if x.get("key_systems").get("com.widevine.alpha")),
None, None,
) )
source_manifest = next(( source_manifest = next((
x["src"] for x in data["sources"] 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, None,
) )
@@ -7,12 +7,12 @@ from typing import Any, Union
import click import click
from click import Context 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 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): class UKTV(Service):
@@ -20,7 +20,7 @@ class UKTV(Service):
Service code for 'U' (formerly UKTV Play) streaming service (https://u.co.uk/). Service code for 'U' (formerly UKTV Play) streaming service (https://u.co.uk/).
\b \b
Version: 1.0.0 Version: 1.0.1
Author: stabbedbybrick Author: stabbedbybrick
Authorization: None Authorization: None
Robustness: Robustness:
@@ -14,13 +14,13 @@ from typing import Any, Union
import click import click
from bs4 import XMLParsedAsHTMLWarning from bs4 import XMLParsedAsHTMLWarning
from click import Context from click import Context
from envied.core.manifests import DASH, HLS from unshackle.core.manifests import DASH, HLS
from envied.core.search_result import SearchResult from unshackle.core.search_result import SearchResult
from envied.core.service import Service from unshackle.core.service import Service
from envied.core.titles import Episode, Movie, Movies, Series from unshackle.core.titles import Episode, Movie, Movies, Series
from envied.core.tracks import Audio, Chapters, Subtitle, Tracks, Video from unshackle.core.tracks import Audio, Chapters, Subtitle, Tracks, Video
from envied.core.utils.collections import as_list from unshackle.core.utils.collections import as_list
from envied.core.utils.sslciphers import SSLCiphers from unshackle.core.utils.sslciphers import SSLCiphers
warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning) 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). Service code for the BBC iPlayer streaming service (https://www.bbc.co.uk/iplayer).
\b \b
Version: 1.0.1 Version: 1.0.2
Author: stabbedbybrick Author: stabbedbybrick
Authorization: None Authorization: None
Security: None Security: None
@@ -177,23 +177,18 @@ class iP(Service):
return versions return versions
# Fallback to scraping webpage if API returns no 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 = self.session.get(self.config["base_url"].format(type="episode", pid=pid))
r.raise_for_status() r.raise_for_status()
match = re.search(r"window\.__IPLAYER_REDUX_STATE__\s*=\s*(.*?);\s*</script>", r.text) match = re.search(r"window\.__IPLAYER_REDUX_STATE__\s*=\s*(.*?);\s*</script>", r.text)
if match: if match:
redux_data = json.loads(match.group(1)) 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 # Filter out audio-described versions
try:
return [ return [
{"pid": v.get("id")} {"pid": v.get("id")}
for v in redux_data.get("versions", {}).values() for v in versions
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") if v.get("kind") != "audio-described" and v.get("id")
] ]