Compare commits
8 Commits
v0.6.1-bet
...
v0.6.2-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64ec07de0a | ||
|
|
7ea4aa98a6 | ||
|
|
a5d9bfc067 | ||
|
|
8537a7b18d | ||
|
|
9b6acc5802 | ||
|
|
af2f9efa58 | ||
|
|
807bab8406 | ||
|
|
ac449d9d8c |
@@ -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
306
services/abciview.py
Normal 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
|
||||
@@ -152,6 +152,7 @@ class CHANNEL4(Config):
|
||||
description=episode.get("summary"),
|
||||
)
|
||||
for episode in data["brand"]["episodes"]
|
||||
if episode["showPlayLabel"] == True
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
29
services/config/abciview.yaml
Normal file
29
services/config/abciview.yaml
Normal 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
|
||||
@@ -1 +1 @@
|
||||
__version__ = "v0.6.1-beta (20231027)"
|
||||
__version__ = "v0.6.2-beta (20231031)"
|
||||
|
||||
@@ -56,5 +56,5 @@ class Config:
|
||||
"Chrome/118.0.0.0 Safari/537.36"
|
||||
),
|
||||
},
|
||||
timeout=10.0
|
||||
timeout=20.0
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user