8 Commits

Author SHA1 Message Date
stabbedbybrick
64ec07de0a updated to v0.6.2-beta 2023-10-31 20:20:35 +01:00
stabbedbybrick
7ea4aa98a6 Fixed manifest variations 2023-10-31 14:36:47 +01:00
stabbedbybrick
a5d9bfc067 Fixed episode listing for non-playable videos 2023-10-31 14:36:31 +01:00
stabbedbybrick
8537a7b18d Fixed naming error 2023-10-31 11:18:40 +01:00
stabbedbybrick
9b6acc5802 Improved episode download by URL 2023-10-31 11:18:22 +01:00
stabbedbybrick
af2f9efa58 Corrected error message 2023-10-31 11:17:53 +01:00
stabbedbybrick
807bab8406 fixed check for HD manifest 2023-10-30 08:07:08 +01:00
stabbedbybrick
ac449d9d8c added service: ABC iview 2023-10-29 20:14:43 +01:00
11 changed files with 416 additions and 25 deletions

View File

@@ -21,6 +21,7 @@ Download the releases to get the latest stable version
ROKU: 1080p, DD5.1
CTV: 1080p, DD5.1
CBC GEM: 1080p, DD5.1
iView: 1080p, AAC2.0
ALL4: 1080p, AAC2.0
MY5: 1080p, AAC2.0
iPLAYER: 1080p, AAC2.0

306
services/abciview.py Normal file
View File

@@ -0,0 +1,306 @@
"""
ABC iVIEW
Author: stabbedbybrick
Quality: up to 1080p
"""
import subprocess
import re
import base64
from urllib.parse import urlparse
from pathlib import Path
from collections import Counter
import click
import yaml
from bs4 import BeautifulSoup
from utils.utilities import (
info,
string_cleaning,
print_info,
set_save_path,
set_filename,
add_subtitles,
)
from utils.cdm import local_cdm, remote_cdm
from utils.titles import Episode, Series, Movie, Movies
from utils.args import Options, get_args
from utils.config import Config
class ABC(Config):
def __init__(self, config, **kwargs):
super().__init__(config, **kwargs)
if self.remote:
info("Remote feature is not supported on this service")
exit(1)
if self.sub_only:
info("Subtitle downloads are not supported on this service")
exit(1)
with open(Path("services") / "config" / "abciview.yaml", "r") as f:
self.cfg = yaml.safe_load(f)
self.config.update(self.cfg)
self.lic_url = self.config["license"]
self.get_options()
def get_token(self):
return self.client.post(
self.config["jwt"],
data={"clientId": self.config["client"]},
).json()["token"]
def get_license(self, video_id: str):
jwt = self.get_token()
resp = self.client.get(
self.config["drm"].format(video_id=video_id),
headers={"bearer": jwt},
).json()
if not resp["status"] == "ok":
raise ValueError("Failed to fetch license token")
return resp["license"]
def get_data(self, url: str):
show_id = urlparse(url).path.split("/")[2]
url = self.config["series"].format(show=show_id)
return self.client.get(url).json()
def create_episode(self, 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")
)
return Episode(
id_=episode["id"],
service="iV",
title=title,
season=int(season.group(1)) if season else 0,
number=int(number.group(1)) if number else 0,
name=name,
description=episode.get("description"),
)
def get_series(self, url: str) -> Series:
data = self.get_data(url)
episodes = [
self.create_episode(episode)
for season in data
for episode in reversed(season["_embedded"]["videoEpisodes"]["items"])
]
return Series(episodes)
def get_movies(self, url: str) -> Movies:
slug = urlparse(url).path.split("/")[2]
url = self.config["film"].format(slug=slug)
data = self.client.get(url).json()
return Movies(
[
Movie(
id_=data["_embedded"]["highlightVideo"]["id"],
service="iV",
title=data["title"],
name=data["title"],
year=data.get("productionYear"),
synopsis=data.get("description"),
)
]
)
def get_pssh(self, soup: str) -> str:
try:
kid = (
soup.select_one("ContentProtection")
.attrs.get("cenc:default_KID")
.replace("-", "")
)
except:
raise AttributeError("Video unavailable outside of Australia")
array_of_bytes = bytearray(b"\x00\x00\x002pssh\x00\x00\x00\x00")
array_of_bytes.extend(bytes.fromhex("edef8ba979d64acea3c827dcd51d21ed"))
array_of_bytes.extend(b"\x00\x00\x00\x12\x12\x10")
array_of_bytes.extend(bytes.fromhex(kid.replace("-", "")))
return base64.b64encode(bytes.fromhex(array_of_bytes.hex())).decode("utf-8")
def get_mediainfo(self, manifest: str, quality: str, subtitle: str) -> str:
self.soup = BeautifulSoup(self.client.get(manifest), "xml")
pssh = self.get_pssh(self.soup)
elements = self.soup.find_all("Representation")
heights = sorted(
[int(x.attrs["height"]) for x in elements if x.attrs.get("height")],
reverse=True,
)
_base = re.sub(r"(\d+.mpd)", "", manifest)
base_urls = self.soup.find_all("BaseURL")
for base in base_urls:
base.string = _base + base.string
if quality is not None:
if int(quality) in heights:
return quality, pssh
else:
closest_match = min(heights, key=lambda x: abs(int(x) - int(quality)))
info(f"Resolution not available. Getting closest match:")
return closest_match, pssh
if subtitle is not None:
self.soup = add_subtitles(self.soup, subtitle)
with open(self.tmp / "manifest.mpd", "w") as f:
f.write(str(self.soup.prettify()))
return heights[0], pssh
def get_playlist(self, video_id: str) -> tuple:
resp = self.client.get(self.config["vod"].format(video_id=video_id)).json()
try:
playlist = resp["_embedded"]["playlist"]
except:
raise KeyError(resp["unavailableMessage"])
streams = [
x["streams"]["mpegdash"]
for x in playlist
if x["type"] == "program"
][0]
if streams.get("720"):
manifest = streams["720"].replace("720.mpd", "1080.mpd")
else:
manifest = streams["sd"]
subtitle = [
x["captions"].get("src-vtt")
for x in playlist
if x["type"] == "program"
][0]
return manifest, subtitle
def get_content(self, url: str) -> object:
if self.movie:
with self.console.status("Fetching titles..."):
content = self.get_movies(self.url)
title = string_cleaning(str(content))
info(f"{str(content)}\n")
else:
with self.console.status("Fetching titles..."):
content = self.get_series(url)
title = string_cleaning(str(content))
seasons = Counter(x.season for x in content)
num_seasons = len(seasons)
num_episodes = sum(seasons.values())
info(
f"{str(content)}: {num_seasons} Season(s), {num_episodes} Episode(s)\n"
)
return content, title
def get_episode_from_url(self, url: str):
video_id = urlparse(url).path.split("/")[2]
data = self.client.get(self.config["vod"].format(video_id=video_id)).json()
episode = self.create_episode(data)
episode = Series([episode])
title = string_cleaning(str(episode))
return [episode[0]], title
def get_options(self) -> None:
opt = Options(self)
if self.url and not any(
[self.episode, self.season, self.complete, self.movie, self.titles]
):
downloads, title = self.get_episode_from_url(self.url)
else:
content, title = self.get_content(self.url)
if self.episode:
downloads = opt.get_episode(content)
if self.season:
downloads = opt.get_season(content)
if self.complete:
downloads = opt.get_complete(content)
if self.movie:
downloads = opt.get_movie(content)
if self.titles:
opt.list_titles(content)
for download in downloads:
self.download(download, title)
def download(self, stream: object, title: str) -> None:
with self.console.status("Getting media info..."):
manifest, subtitle = self.get_playlist(stream.id)
res, pssh = self.get_mediainfo(manifest, self.quality, subtitle)
customdata = self.get_license(stream.id)
self.client.headers.update({"customdata": customdata})
with self.console.status("Getting decryption keys..."):
keys = local_cdm(pssh, self.lic_url, self.client)
with open(self.tmp / "keys.txt", "w") as file:
file.write("\n".join(keys))
if self.info:
print_info(self, stream, keys)
self.filename = set_filename(self, stream, res, audio="AAC2.0")
self.save_path = set_save_path(stream, self.config, title)
self.manifest = self.tmp / "manifest.mpd"
self.key_file = self.tmp / "keys.txt"
self.sub_path = None
info(f"{str(stream)}")
info(f"{keys[0]}")
click.echo("")
args, file_path = get_args(self, res)
if not file_path.exists():
try:
subprocess.run(args, check=True)
except:
raise ValueError("Download failed or was interrupted")
else:
info(f"{self.filename} already exist. Skipping download\n")
self.sub_path.unlink() if self.sub_path else None
pass

View File

@@ -152,6 +152,7 @@ class CHANNEL4(Config):
description=episode.get("summary"),
)
for episode in data["brand"]["episodes"]
if episode["showPlayLabel"] == True
]
)

View File

@@ -14,6 +14,7 @@ import json
import hmac
import hashlib
import shutil
import re
from urllib.parse import urlparse, urlunparse
from collections import Counter
@@ -29,6 +30,7 @@ from Crypto.Util.Padding import unpad
from utils.utilities import (
info,
error,
string_cleaning,
set_save_path,
print_info,
@@ -139,12 +141,12 @@ class CHANNEL5(Config):
parse = urlparse(mpd_url)
_path = parse.path.split("/")
if asset["version"] == "A":
_path[-1] = f"{data['id']}.mpd"
if asset["version"] == "C":
_path[-1] = f"{data['id']}C.mpd" # SD max?
if "A-tt" in _path[-1]:
_path[-1] = f"{data['id']}A.mpd" #TODO use actual version
_path[-1] = f"{data['id']}A.mpd"
elif _path[-1].endswith("C"):
_path[-1] = f"{data['id']}C.mpd" # TODO
else:
_path[-1] = f"{data['id']}.mpd"
manifest = urlunparse(parse._replace(path="/".join(_path)))
@@ -205,32 +207,40 @@ class CHANNEL5(Config):
return content, title
def get_episode_from_url(self, url: str):
parse = urlparse(url).path.split("/")
show = parse[2] if len(parse) > 4 and parse[1] == "show" else parse[1]
season = parse[3] if len(parse) > 4 and parse[1] == "show" else parse[2]
episode = parse[4] if len(parse) > 4 and parse[1] == "show" else parse[3]
series_re = r"^(?:https?://(?:www\.)?channel5\.com/show/)?(?P<id>[a-z0-9-]+)"
episode_re = r"https?://www.channel5.com/show/+(?P<id>[^/]+)/(?P<season>[^/]+)/(?P<episode>[^/]+)"
url = self.config["single"].format(
show=show,
season=season,
episode=episode
)
series_match = re.search(series_re, url)
episode_match = re.search(episode_re, url)
if series_match:
url = self.config["content"].format(show=series_match.group("id"))
if episode_match:
url = self.config["single"].format(
show=episode_match.group("id"),
season=episode_match.group("season"),
episode=episode_match.group("episode")
)
data = self.client.get(url).json()
episodes = [data] if episode_match else data["episodes"]
episode = Series(
[
Episode(
id_=None,
service="MY5",
title=data.get("sh_title"),
season=int(data.get("sea_num")) or 0,
number=int(data.get("ep_num")) or 0,
name=data.get("title"),
title=episode.get("sh_title"),
season=int(episode.get("sea_num")) if data.get("sea_num") is not None else 0,
number=int(episode.get("ep_num")) if data.get("ep_num") is not None else 0,
name=episode.get("sh_title"),
year=None,
data=data.get("id"),
description=data.get("m_desc"),
data=episode.get("id"),
description=episode.get("m_desc"),
)
for episode in episodes
]
)

View File

@@ -0,0 +1,29 @@
## SERVICE SETTINGS
client: "1d4b5cba-42d2-403e-80e7-34565cdf772d"
license: "https://wv-keyos.licensekeyserver.com/"
jwt: "https://api.iview.abc.net.au/v3/token/jwt"
vod: "https://api.iview.abc.net.au/v3/video/{video_id}"
film: "https://api.iview.abc.net.au/v3/show/{slug}"
series: "https://api.iview.abc.net.au/v3/series/{show}"
drm: "https://api.iview.abc.net.au/v3/token/drm/{video_id}"
## SERVICE SETTINGS
# Changes made here will override the global config
# Set video options. See "N_m3u8DL-RE --morehelp select-video" for guidance. Default: best
# Using --quality will override this quality setting
video:
track: "for=best"
drop:
# Set audio options. See "N_m3u8DL-RE --morehelp select-audio" for guidance. Default: best
audio:
track: "for=best"
drop:
# Set subtitle options
subtitles:
no_mux: "false" # If "true", subtitles will be stored separately
clean: "true" # Clean and convert subtitles. If "false", subtitles remain untouched

View File

@@ -1 +1 @@
__version__ = "v0.6.1-beta (20231027)"
__version__ = "v0.6.2-beta (20231031)"

View File

@@ -56,5 +56,5 @@ class Config:
"Chrome/118.0.0.0 Safari/537.36"
),
},
timeout=10.0
timeout=20.0
)

View File

@@ -99,6 +99,7 @@ main_help = f"""
ROKU: 1080p, DD5.1
CTV: 1080p, DD5.1
CBC GEM: 1080p, DD5.1
iView: 1080p, AAC2.0
ALL4: 1080p, AAC2.0 *
MY5: 1080p, AAC2.0
iPLAYER: 1080p, AAC2.0

View File

@@ -164,6 +164,25 @@ def _dict(keywords: str):
},
"method": "GET",
},
{
"name": "ABC iView",
"alias": ["ABC", "IVIEW", "IV"],
"url": (
"https://y63q32nvdl-1.algolianet.com/1/indexes/*/queries?x-algolia-agent=Algolia"
"%20for%20JavaScript%20(4.9.1)%3B%20Browser%20(lite)%3B%20react%20(17.0.2)%3B%20"
"react-instantsearch%20(6.30.2)%3B%20JS%20Helper%20(3.10.0)&x-"
"algolia-api-key=bcdf11ba901b780dc3c0a3ca677fbefc&x-algolia-application-id=Y63Q32NVDL"
),
"payload": {
"requests": [
{
"indexName": "ABC_production_iview_web",
"params": f"query={keywords}&tagFilters=&userToken=anonymous-74be3cf1-1dc7-4fa1-9cff-19592162db1c",
}
],
},
"method": "POST",
},
]
@@ -414,4 +433,23 @@ def _parse(query: dict, service: dict, client=None):
)
)
if service["name"] == "ABC iView":
link = "https://iview.abc.net.au/show/{slug}"
if query:
hits = [
x for x in query["results"][0]["hits"]
if x["docType"] == "Program"
]
for field in hits:
results.append(
template.format(
service=service["name"],
title=field["title"],
synopsis=field.get("synopsis"),
type=field.get("subType"),
url=link.format(slug=field["slug"]),
)
)
return results

View File

@@ -13,6 +13,11 @@ def _services():
services = Path("services")
supported_services = {
"iview.abc.net.au": {
"name": "ABC",
"alias": "ABC iView",
"path": services / "abciview.py",
},
"www.bbc.co.uk": {
"name": "BBC",
"alias": "BBC iPlayer",

View File

@@ -25,10 +25,10 @@ class Episode:
) -> None:
if name is not None:
name = name.strip()
# if re.match(r"Episode ?#?\d+", name, re.IGNORECASE):
# name = None
if name.lower() == title.lower():
name = None
name = ""
if re.match(r"Episode ?#?\d+", name, re.IGNORECASE):
name = ""
self.id = id_
self.service = service