Compare commits
11 Commits
v0.5.2-bet
...
v0.5.3-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
454f9b90c7 | ||
|
|
c202aff294 | ||
|
|
75da6e96d7 | ||
|
|
1fafdb2f5d | ||
|
|
0445b65028 | ||
|
|
bce9ff2db2 | ||
|
|
b431622a59 | ||
|
|
9e910eaf96 | ||
|
|
f805cf80a0 | ||
|
|
ba6ef7385f | ||
|
|
eb59aec1be |
@@ -15,6 +15,7 @@ Download videos from free streaming services
|
||||
ROKU: 1080p, DD5.1
|
||||
CTV: 1080p, DD5.1
|
||||
ALL4: 1080p, AAC2.0
|
||||
BBCiP: 1080p, AAC2.0
|
||||
MY5: 1080p, AAC2.0
|
||||
UKTV: 1080p, AAC2.0
|
||||
STV: 1080p, AAC2.0
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "v0.5.2-beta (20230927)"
|
||||
__version__ = "v0.5.3-beta (20230930)"
|
||||
|
||||
@@ -47,6 +47,7 @@ main_help = f"""
|
||||
ROKU: 1080p, DD5.1
|
||||
CTV: 1080p, DD5.1
|
||||
ALL4: 1080p, AAC2.0
|
||||
BBCiP: 1080p, AAC2.0
|
||||
MY5: 1080p, AAC2.0*
|
||||
UKTV: 1080p, AAC2.0
|
||||
STV: 1080p, AAC2.0
|
||||
|
||||
@@ -31,7 +31,9 @@ def get_service(url: str):
|
||||
parse = urlparse(url)
|
||||
netloc = parse.netloc.split(".")
|
||||
|
||||
if len(netloc) == 3 and netloc[2] == "uk":
|
||||
if len(netloc) == 4:
|
||||
domain = netloc[1]
|
||||
elif len(netloc) == 3 and netloc[2] == "uk":
|
||||
domain = netloc[0]
|
||||
elif len(netloc) == 3:
|
||||
domain = netloc[1]
|
||||
@@ -42,7 +44,7 @@ def get_service(url: str):
|
||||
|
||||
if any(
|
||||
re.search(pattern, parse.path)
|
||||
for pattern in [r"\d+-\d+$", r"s\d+e\d+$", "episode"]
|
||||
for pattern in [r"\d+-\d+$", r"s\d+e\d+$"]
|
||||
):
|
||||
error("Wrong URL format. Use series URL, not episode URL")
|
||||
sys.exit(1)
|
||||
|
||||
@@ -7,28 +7,40 @@ from helpers.utilities import string_cleaning
|
||||
|
||||
|
||||
class Episode:
|
||||
def __init__(self, **kwargs) -> None:
|
||||
self.id = kwargs.get("id_")
|
||||
self.service = kwargs.get("service")
|
||||
self.title = kwargs.get("title")
|
||||
self.season = kwargs.get("season")
|
||||
self.number = kwargs.get("number")
|
||||
self.name = kwargs.get("name")
|
||||
self.year = kwargs.get("year")
|
||||
self.data = kwargs.get("data")
|
||||
self.subtitle = kwargs.get("subtitle")
|
||||
self.lic_url = kwargs.get("lic_url")
|
||||
self.synopsis = kwargs.get("synopsis")
|
||||
self.description = kwargs.get("description")
|
||||
def __init__(
|
||||
self,
|
||||
id_=None,
|
||||
service=None,
|
||||
title=None,
|
||||
season=None,
|
||||
number=None,
|
||||
name=None,
|
||||
year=None,
|
||||
data=None,
|
||||
subtitle=None,
|
||||
lic_url=None,
|
||||
synopsis=None,
|
||||
description=None,
|
||||
) -> None:
|
||||
if name is not None:
|
||||
name = name.strip()
|
||||
if re.match(r"Episode ?#?\d+", name, re.IGNORECASE):
|
||||
name = None
|
||||
elif name.lower() == title.lower():
|
||||
name = None
|
||||
|
||||
self.title = self.title.strip()
|
||||
|
||||
if self.name is not None:
|
||||
self.name = self.name.strip()
|
||||
if re.match(r"Episode ?#?\d+", self.name, re.IGNORECASE):
|
||||
self.name = None
|
||||
elif self.name.lower() == self.title.lower():
|
||||
self.name = None
|
||||
self.id = id_
|
||||
self.service = service
|
||||
self.title = title
|
||||
self.season = season
|
||||
self.number = number
|
||||
self.name = name
|
||||
self.year = year
|
||||
self.data = data
|
||||
self.subtitle = subtitle
|
||||
self.lic_url = lic_url
|
||||
self.synopsis = synopsis
|
||||
self.description = description
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "{title} S{season:02}E{number:02} {name}".format(
|
||||
@@ -60,18 +72,31 @@ class Series(SortedKeyList, ABC):
|
||||
|
||||
|
||||
class Movie:
|
||||
def __init__(self, **kwargs) -> None:
|
||||
self.id = kwargs.get("id_")
|
||||
self.service = kwargs.get("service")
|
||||
self.title = kwargs.get("title")
|
||||
self.name = kwargs.get("name")
|
||||
self.year = kwargs.get("year")
|
||||
self.data = kwargs.get("data")
|
||||
self.subtitle = kwargs.get("subtitle")
|
||||
self.lic_url = kwargs.get("lic_url")
|
||||
self.synopsis = kwargs.get("synopsis")
|
||||
|
||||
self.name = self.name.strip()
|
||||
def __init__(
|
||||
self,
|
||||
id_=None,
|
||||
service=None,
|
||||
title=None,
|
||||
name=None,
|
||||
year=None,
|
||||
data=None,
|
||||
subtitle=None,
|
||||
lic_url=None,
|
||||
synopsis=None,
|
||||
) -> None:
|
||||
if name is not None:
|
||||
name = name.strip()
|
||||
|
||||
self.id = id_
|
||||
self.service = service
|
||||
self.title = title
|
||||
self.name = name
|
||||
self.year = year
|
||||
self.data = data
|
||||
self.subtitle = subtitle
|
||||
self.lic_url = lic_url
|
||||
self.synopsis = synopsis
|
||||
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.year:
|
||||
@@ -91,4 +116,4 @@ class Movies(SortedKeyList, ABC):
|
||||
def __str__(self) -> str:
|
||||
if not self:
|
||||
return super().__str__()
|
||||
return self[0].name + (f" ({self[0].year})" if self[0].year else "")
|
||||
return self[0].name + (f" ({self[0].year})" if self[0].year else "")
|
||||
|
||||
305
services/bbc.py
Normal file
305
services/bbc.py
Normal file
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
BBC iplayer
|
||||
Author: stabbedbybrick
|
||||
|
||||
Info:
|
||||
up to 1080p
|
||||
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import re
|
||||
|
||||
from collections import Counter
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
import click
|
||||
import requests
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from helpers.utilities import (
|
||||
info,
|
||||
string_cleaning,
|
||||
set_save_path,
|
||||
print_info,
|
||||
set_filename,
|
||||
)
|
||||
from helpers.titles import Episode, Series, Movie, Movies
|
||||
from helpers.args import Options, get_args
|
||||
from helpers.config import Config
|
||||
|
||||
|
||||
class BBC(Config):
|
||||
def __init__(self, config, srvc, **kwargs):
|
||||
super().__init__(config, srvc, **kwargs)
|
||||
|
||||
self.get_options()
|
||||
|
||||
def get_data(self, pid: str, slice_id:str) -> dict:
|
||||
|
||||
json_data = {
|
||||
'id': '9fd1636abe711717c2baf00cebb668de',
|
||||
'variables': {
|
||||
'id': pid,
|
||||
'perPage': 200,
|
||||
'page': 1,
|
||||
'sliceId': slice_id if slice_id else None,
|
||||
},
|
||||
}
|
||||
|
||||
response = self.client.post(self.srvc["bbc"]["api"], json=json_data).json()
|
||||
|
||||
return response["data"]["programme"]
|
||||
|
||||
def create_episode(self, episode):
|
||||
subtitle = episode["episode"]["subtitle"]
|
||||
title = subtitle.get("default") or subtitle.get("slice") or ""
|
||||
season_match = re.search(r"Series (\d+):", subtitle.get("default"))
|
||||
season = int(season_match.group(1)) if season_match else 0
|
||||
number_match = re.finditer(r"(\d+)\.|Episode (\d+)|Week (\d+)", title)
|
||||
number = int(next((m.group(1) or m.group(2) or m.group(3) for m in number_match), 0))
|
||||
name_match = re.search(r"\d+\. (.+)", subtitle.get("slice") or subtitle.get("default") or "")
|
||||
name = name_match.group(1) if name_match else ""
|
||||
|
||||
return Episode(
|
||||
id_=episode["episode"]["id"],
|
||||
service="iP",
|
||||
title=episode["episode"]["title"]["default"],
|
||||
season=season,
|
||||
number=number,
|
||||
name=name,
|
||||
description=episode["episode"]["synopsis"].get("small"),
|
||||
)
|
||||
|
||||
def get_series(self, pid: str, slice_id:str) -> Series:
|
||||
data = self.get_data(pid, slice_id)
|
||||
seasons = [self.get_data(pid, x["id"]) for x in data["slices"] or [{"id": None}]]
|
||||
|
||||
episodes = [
|
||||
self.create_episode(episode)
|
||||
for season in seasons
|
||||
for episode in season["entities"]["results"]
|
||||
]
|
||||
return Series(episodes)
|
||||
|
||||
def get_movies(self, pid: str, slice_id: str) -> Movies:
|
||||
data = self.get_data(pid, slice_id)
|
||||
|
||||
return Movies(
|
||||
[
|
||||
Movie(
|
||||
id_=data["id"],
|
||||
service="iP",
|
||||
title=data["title"]["default"],
|
||||
year=None, # TODO
|
||||
name=data["title"]["default"],
|
||||
synopsis=data["synopsis"].get("small"),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
def add_stream(self, soup: object, init: str) -> object:
|
||||
representation = soup.new_tag("Representation",
|
||||
id="video=12000000",
|
||||
bandwidth="8490000",
|
||||
width="1920",
|
||||
height="1080",
|
||||
frameRate="50",
|
||||
codecs="avc3.640020",
|
||||
scanType="progressive",
|
||||
)
|
||||
|
||||
template = soup.new_tag("SegmentTemplate",
|
||||
timescale="5000",
|
||||
duration="19200",
|
||||
initialization=f"{init}-$RepresentationID$.dash",
|
||||
media=f"{init}-$RepresentationID$-$Number$.m4s",
|
||||
)
|
||||
|
||||
representation.append(template)
|
||||
|
||||
soup.find("AdaptationSet", {"contentType": "video"}).append(representation)
|
||||
|
||||
return soup
|
||||
|
||||
def get_playlist(self, pid: str) -> tuple:
|
||||
resp = self.client.get(
|
||||
self.srvc["bbc"]["playlist"].format(pid=pid)).json()
|
||||
|
||||
vpid = resp["defaultAvailableVersion"]["smpConfig"]["items"][0]["vpid"]
|
||||
|
||||
media = self.client.get(
|
||||
self.srvc["bbc"]["media"].format(vpid=vpid)
|
||||
).json()
|
||||
|
||||
subtitle = None
|
||||
|
||||
for item in media["media"]:
|
||||
if item["kind"] == "video":
|
||||
videos = item["connection"]
|
||||
|
||||
for item in media["media"]:
|
||||
if item["kind"] == "captions":
|
||||
captions = item["connection"]
|
||||
|
||||
for video in videos:
|
||||
if video["supplier"] == "mf_bidi" and video["transferFormat"] == "dash": # TODO
|
||||
manifest = video["href"]
|
||||
|
||||
for caption in captions:
|
||||
if caption["supplier"] == "mf_bidi" or "mf_cloudfront":
|
||||
subtitle = caption["href"]
|
||||
|
||||
soup = BeautifulSoup(requests.get(manifest).content, "xml")
|
||||
|
||||
parse = urlparse(manifest)
|
||||
_path = parse.path.split("/")
|
||||
_path[-1] = "dash/"
|
||||
init = _path[-2].replace(".ism", "")
|
||||
|
||||
base_url = urlunparse(parse._replace(
|
||||
scheme="https",
|
||||
netloc=self.srvc["bbc"]["base"],
|
||||
path="/".join(_path),
|
||||
query=""
|
||||
)
|
||||
)
|
||||
soup.select_one("BaseURL").string = base_url
|
||||
|
||||
tag = soup.find(id="video=5070000")
|
||||
if tag:
|
||||
soup = self.add_stream(soup, init)
|
||||
|
||||
with open(self.tmp / "manifest.mpd", "w") as f:
|
||||
f.write(str(soup.prettify()))
|
||||
|
||||
self.soup = soup
|
||||
return soup, subtitle
|
||||
|
||||
def get_mediainfo(self, soup: object, quality: str) -> str:
|
||||
elements = soup.find_all("Representation")
|
||||
heights = sorted(
|
||||
[int(x.attrs["height"]) for x in elements if x.attrs.get("height")],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
if quality is not None:
|
||||
if int(quality) in heights:
|
||||
return quality
|
||||
else:
|
||||
closest_match = min(heights, key=lambda x: abs(int(x) - int(quality)))
|
||||
return closest_match
|
||||
|
||||
return heights[0]
|
||||
|
||||
def get_content(self, url: str) -> object:
|
||||
if self.movie:
|
||||
with self.console.status("Fetching titles..."):
|
||||
parse = urlparse(url)
|
||||
pid = parse.path.split("/")[3]
|
||||
slice_id = parse.query.split("=")[1] if parse.query else None
|
||||
content = self.get_movies(pid, slice_id)
|
||||
title = string_cleaning(str(content))
|
||||
|
||||
info(f"{str(content)}\n")
|
||||
|
||||
else:
|
||||
with self.console.status("Fetching titles..."):
|
||||
parse = urlparse(url)
|
||||
pid = parse.path.split("/")[3]
|
||||
slice_id = parse.query.split("=")[1] if parse.query else None
|
||||
content = self.get_series(pid, slice_id)
|
||||
|
||||
counter = 1
|
||||
for episode in content:
|
||||
episode.name = episode.get_filename()
|
||||
if "S00E00" in episode.name:
|
||||
episode.name = episode.name.replace("E00", f"E{counter:02d}")
|
||||
counter += 1
|
||||
|
||||
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_options(self) -> None:
|
||||
opt = Options(self)
|
||||
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 clean_subtitles(self, subtitle: str, filename: str):
|
||||
"""
|
||||
Temporary solution, but seems to work for the most part
|
||||
"""
|
||||
with self.console.status("Cleaning up subtitles..."):
|
||||
soup = BeautifulSoup(requests.get(subtitle).content, "xml")
|
||||
for tag in soup.find_all():
|
||||
if tag.name != "p" and tag.name != "br" and tag.name != "span":
|
||||
tag.unwrap()
|
||||
|
||||
for br in soup.find_all("br"):
|
||||
br.replace_with(" ")
|
||||
|
||||
srt = ""
|
||||
for i, tag in enumerate(soup.find_all("p")):
|
||||
start = tag["begin"]
|
||||
end = tag["end"]
|
||||
text = tag.get_text().strip()
|
||||
srt += f"{i+1}\n{start.replace('.', ',')} --> {end.replace('.', ',')}\n{text}\n\n"
|
||||
|
||||
with open(self.tmp / f"{filename}.srt", "w") as f:
|
||||
f.write(srt)
|
||||
|
||||
self.sub_path = self.tmp / f"{filename}.srt"
|
||||
|
||||
def download(self, stream: object, title: str) -> None:
|
||||
with self.console.status("Getting media info..."):
|
||||
soup, subtitle = self.get_playlist(stream.id)
|
||||
res = self.get_mediainfo(soup, self.quality)
|
||||
|
||||
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 = None # not encrypted
|
||||
self.sub_path = None
|
||||
|
||||
if subtitle is not None:
|
||||
self.clean_subtitles(subtitle, self.filename)
|
||||
|
||||
if self.info:
|
||||
print_info(self, stream, keys=None)
|
||||
|
||||
info(f"{stream.name}")
|
||||
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
|
||||
@@ -59,8 +59,8 @@ class CHANNEL5(Config):
|
||||
id_=None,
|
||||
service="MY5",
|
||||
title=episode.get("sh_title"),
|
||||
season=episode.get("sea_num") or 0,
|
||||
number=episode.get("ep_num") or 0,
|
||||
season=int(episode.get("sea_num")) or 0,
|
||||
number=int(episode.get("ep_num")) or 0,
|
||||
name=episode.get("title"),
|
||||
year=None,
|
||||
data=episode.get("id"),
|
||||
|
||||
@@ -36,4 +36,10 @@ roku:
|
||||
|
||||
pluto:
|
||||
api: "https://service-vod.clusters.pluto.tv/v4/vod"
|
||||
lic: "https://service-concierge.clusters.pluto.tv/v1/wv/alt"
|
||||
lic: "https://service-concierge.clusters.pluto.tv/v1/wv/alt"
|
||||
|
||||
bbc:
|
||||
api: "https://graph.ibl.api.bbc.co.uk/"
|
||||
media: "https://open.live.bbc.co.uk/mediaselector/6/select/version/2.0/mediaset/iptv-all/vpid/{vpid}/"
|
||||
playlist: "https://www.bbc.co.uk/programmes/{pid}/playlist.json"
|
||||
base: "b4-thdow-bbc.live.bidi.net.uk"
|
||||
Reference in New Issue
Block a user