mirror of
https://github.com/vinefeeder/TwinVine.git
synced 2026-07-15 18:10:04 +02:00
SBB Sevices update
This commit is contained in:
@@ -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]:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 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:
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,23 +177,18 @@ 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*</script>", 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", {})
|
||||
for v in versions
|
||||
if v.get("kind") != "audio-described" and v.get("id")
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user