7 Commits

Author SHA1 Message Date
stabbedbybrick
9177f88d11 README, documentation, version 2023-11-16 14:04:00 +01:00
stabbedbybrick
870c911a5c [core] Add installation check before download (#8) 2023-11-15 19:17:01 +01:00
stabbedbybrick
2a18d5ff90 [iPlayer] Check for episode labels (#7) 2023-11-15 10:11:45 +01:00
stabbedbybrick
06b334c1a2 [abciview] Use original manifest if no captions (#6) 2023-11-15 06:57:19 +01:00
stabbedbybrick
6626f1948f [pluto] Add quality selector (#5) 2023-11-15 06:32:35 +01:00
stabbedbybrick
767a80b86b update README 2023-11-13 21:26:02 +01:00
stabbedbybrick
ee4fb5d824 Bumped to v1.0.1 2023-11-13 21:22:21 +01:00
8 changed files with 145 additions and 73 deletions

View File

@@ -1,5 +1,5 @@
<h2 align="center">Freevine</h2>
<h4 align="center">A download utility for free streaming services</h4>
<h3 align="center">Freevine<br/>
<sup>A Download Utility for Free Streaming Services</sup></h3>
## Features:
@@ -16,21 +16,20 @@
The Roku Channel: 1080p, DD5.1
CBC Gem: 1080p, DD5.1
CTV: 1080p, DD5.1
ABC iView: 1080p, AAC2.0
Channel4 All4: 1080p, AAC2.0
Channel5 My5: 1080p, AAC2.0
BBC iPlayer: 1080p, AAC2.0
UKTVPlay: 1080p, AAC2.0
STV Player: 1080p, AAC2.0
ABC iView: 1080p, AAC2.0
UKTVPlay: 1080p, AAC2.0
Crackle: 1080p, AAC2.0
Itv(x): 720p, AAC2.0
Tubi: 720p, AAC2.0
Pluto: 720p, AAC2.0
ITVx: 720p, AAC2.0
Tubi: 720p, AAC2.0
```
## Requirements:
* [Python 3.10+](https://www.python.org/)
* [Python](https://www.python.org/)
* [N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE/releases/)
@@ -42,20 +41,25 @@ Pluto: 720p, AAC2.0
* [shaka packager](https://github.com/shaka-project/shaka-packager)
* Widevine Device file (.wvd)
* Widevine Device File (.wvd)
> [!NOTE]
> Recommended Python versions are within the 3.10-11 range
>
> Support for older versions can't be guaranteed
>
> v3.12 is not yet supported
## Installation:
1. Install Python (check 'Add to PATH' if on Windows)
2. Clone or download Freevine repository
2. Clone main branch or download latest version from [Releases](https://github.com/stabbedbybrick/freevine/releases)
3. Place required tools inside Freevine folder OR add them to system PATH (recommended)
4. Create /utils/wvd/ folder and place either .wvd file or private_key and client blob inside
4. Create `/utils/wvd/` folder and place either .wvd file or private_key and client_id blob inside
5. Install necessary packages: `pip install -r requirements.txt`
> **Note**
> As of v1.0.0, the requirements have changed
>
> Make sure to re-run the installation if you're coming from the beta version
> [!TIP]
> Get the main branch for immediate fixes and updates
## Usage:
@@ -107,19 +111,8 @@ python freevine.py --select-video res=720 --season S01 URL
python freevine.py --select-audio name=English --episode S01E01 URL
```
> **Warning**
> If you encounter this error:
>
> "p = os.fspath(p)
>
> TypeError: expected str, bytes or os.PathLike object, not NoneType"
>
> It means that you haven't properly added N_m3u8DL-RE to PATH and is unable to be located
> **Note**
> Commands will override equivalent settings in config files
>
> See N_m3u8DL-RE --morehelp select-video/select-audio for possible selections
> [!TIP]
> See "N_m3u8DL-RE --morehelp select-video/audio/subtitle" for possible selection patterns
## Disclaimer

View File

@@ -11,7 +11,6 @@ import re
import base64
from urllib.parse import urlparse
from pathlib import Path
from collections import Counter
import click
@@ -43,7 +42,7 @@ class ABC(Config):
if self.sub_only:
info("Subtitle downloads are not supported on this service")
exit(1)
with open(self.srvc_api, "r") as f:
self.config.update(yaml.safe_load(f))
@@ -119,7 +118,7 @@ class ABC(Config):
if isinstance(data, dict):
data = [data]
episodes = [
self.create_episode(episode)
for season in data
@@ -179,16 +178,14 @@ class ABC(Config):
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()))
with open(self.tmp / "manifest.mpd", "w") as f:
f.write(str(self.soup.prettify()))
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
return heights[0], pssh
@@ -200,11 +197,9 @@ class ABC(Config):
playlist = resp["_embedded"]["playlist"]
except:
raise KeyError(resp["unavailableMessage"])
streams = [
x["streams"]["mpegdash"]
for x in playlist
if x["type"] == "program"
x["streams"]["mpegdash"] for x in playlist if x["type"] == "program"
][0]
if streams.get("720"):
@@ -212,11 +207,8 @@ class ABC(Config):
else:
manifest = streams["sd"]
subtitle = [
x["captions"].get("src-vtt")
for x in playlist
if x["type"] == "program"
][0]
program = [x for x in playlist if x["type"] == "program"][0]
subtitle = program.get("captions", {}).get("src-vtt")
return manifest, subtitle
@@ -285,7 +277,7 @@ class ABC(Config):
if not downloads:
error("Requested data returned empty. See --help for more information")
return
for download in downloads:
self.download(download, title)
@@ -305,7 +297,7 @@ class ABC(Config):
self.filename = set_filename(self, stream, self.res, audio="AAC2.0")
self.save_path = set_save_path(stream, self.config, title)
self.manifest = self.tmp / "manifest.mpd"
self.manifest = manifest if not subtitle else self.tmp / "manifest.mpd"
self.key_file = self.tmp / "keys.txt"
self.sub_path = None

View File

@@ -65,7 +65,8 @@ class BBC(Config):
title = episode["episode"]["title"]["default"]
subtitle = episode["episode"]["subtitle"]
fallback = subtitle.get("default").split(":")[0]
label = episode["episode"]["labels"].get("category")
labels = episode["episode"]["labels"]
cetegory = labels.get("category") if labels else None
series = re.finditer(r"Series (\d+):|(\d{4}/\d{2}): Episode \d+", subtitle.get("default"))
season_num = int(next((m.group(1) or m.group(2).replace("/", "") for m in series), 0))
@@ -78,7 +79,7 @@ class BBC(Config):
name = re.search(r"\d+\. (.+)", subtitle.get("slice") or "")
ep_name = name.group(1) if name else subtitle.get("slice") or ""
if season_special and label == "Entertainment":
if season_special and cetegory == "Entertainment":
ep_name += f" {fallback}"
if not subtitle.get("slice"):
ep_name = subtitle.get("default") or ""

View File

@@ -27,6 +27,7 @@ from pathlib import Path
import click
import yaml
import m3u8
from bs4 import BeautifulSoup
@@ -36,7 +37,7 @@ from utils.utilities import (
is_url,
string_cleaning,
set_save_path,
# print_info,
print_info,
set_filename,
)
from utils.titles import Episode, Series, Movie, Movies
@@ -53,9 +54,6 @@ class PLUTO(Config):
if self.info:
info("Info feature is not yet supported on this service")
exit(1)
if self.quality:
info("Quality option is not yet supported on this service")
exit(1)
with open(self.srvc_api, "r") as f:
self.config.update(yaml.safe_load(f))
@@ -194,14 +192,7 @@ class PLUTO(Config):
netloc="silo-hybrik.pluto.tv.s3.amazonaws.com",
).geturl()
self.client.headers.pop("Authorization")
response = self.client.get(master).text
matches = re.findall(r"hls_(\d+).m3u8", response)
hls = sorted(matches, key=int, reverse=True)[0]
manifest = master.replace("master.m3u8", f"hls_{hls}.m3u8")
return manifest
return master
def get_playlist(self, playlists: str) -> tuple:
stitched = next((x for x in playlists if x.endswith(".mpd")), None)
@@ -213,6 +204,60 @@ class PLUTO(Config):
if stitched.endswith(".m3u8"):
return self.get_hls(stitched)
if not stitched:
error("Unable to find manifest")
return
def get_dash_quality(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,
)
# 720p on Pluto is in the adaptationset rather than representation
adaptation_sets = soup.find_all("AdaptationSet")
for item in adaptation_sets:
if item.attrs.get("height"):
heights.append(int(item.attrs["height"]))
heights.sort(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_hls_quality(self, manifest: str, quality: str) -> str:
base = manifest.rstrip("master.m3u8")
self.client.headers.pop("Authorization")
r = self.client.get(manifest)
m3u8_obj = m3u8.loads(r.text)
playlists = []
if m3u8_obj.is_variant:
for playlist in m3u8_obj.playlists:
playlists.append((playlist.stream_info.resolution[1], playlist.uri))
heights = sorted([x[0] for x in playlists], reverse=True)
manifest = [base + x[1] for x in playlists if heights[0] == x[0]][0]
res = heights[0]
if quality is not None:
for playlist in playlists:
if int(quality) in playlist:
res = playlist[0]
manifest = base + playlist[1]
else:
res = min(heights, key=lambda x: abs(int(x) - int(quality)))
if res == playlist[0]:
manifest = base + playlist[1]
return res, manifest
def generate_pssh(self, kid: str):
array_of_bytes = bytearray(b"\x00\x00\x002pssh\x00\x00\x00\x00")
@@ -221,9 +266,7 @@ class PLUTO(Config):
array_of_bytes.extend(bytes.fromhex(kid.replace("-", "")))
return base64.b64encode(bytes.fromhex(array_of_bytes.hex())).decode("utf-8")
def get_pssh(self, manifest: str) -> str:
self.client.headers.pop("Authorization")
soup = BeautifulSoup(self.client.get(manifest), "xml")
def get_pssh(self, soup) -> str:
tags = soup.find_all("ContentProtection")
kids = set(
[
@@ -235,8 +278,21 @@ class PLUTO(Config):
return [self.generate_pssh(kid) for kid in kids]
def get_mediainfo(self, manifest: str) -> str:
return self.get_pssh(manifest) if manifest.endswith(".mpd") else None
def get_mediainfo(self, manifest: str, quality: str, pssh=None, hls=None) -> str:
if manifest.endswith(".mpd"):
self.client.headers.pop("Authorization")
self.soup = BeautifulSoup(self.client.get(manifest), "xml")
pssh = self.get_pssh(self.soup)
quality = self.get_dash_quality(self.soup, quality)
self.variant = True
return quality, pssh, hls
if manifest.endswith(".m3u8"):
quality, hls = self.get_hls_quality(manifest, quality)
self.variant = False
return quality, pssh, hls
def get_content(self, url: str) -> object:
if self.movie:
@@ -297,7 +353,7 @@ class PLUTO(Config):
def download(self, stream: object, title: str) -> None:
with self.console.status("Getting media info..."):
manifest = self.get_playlist(stream.data)
pssh = self.get_mediainfo(manifest)
self.res, pssh, hls = self.get_mediainfo(manifest, self.quality)
self.client.headers.update({"Authorization": f"Bearer {self.token}"})
keys = None
@@ -306,12 +362,14 @@ class PLUTO(Config):
with open(self.tmp / "keys.txt", "w") as file:
file.write("\n".join(key[0] for key in keys))
self.filename = set_filename(self, stream, res=None, audio="AAC2.0")
if self.info:
print_info(self, stream, keys)
self.filename = set_filename(self, stream, self.res, audio="AAC2.0")
self.save_path = set_save_path(stream, self.config, title)
self.manifest = manifest
self.manifest = hls if hls else manifest
self.key_file = self.tmp / "keys.txt" if pssh else None
self.sub_path = None
self.res = ""
info(f"{str(stream)}")
click.echo("")

View File

@@ -1 +1 @@
__version__ = "v1.0.0"
__version__ = "v1.0.2"

View File

@@ -3,6 +3,8 @@ import re
from pathlib import Path
from utils.utilities import info, error
def video_settings(service: object) -> tuple:
select_video = service.config["video"].get("select")
@@ -16,6 +18,10 @@ def video_settings(service: object) -> tuple:
if service.quality and service.quality != str(service.res):
select_video = re.sub(r"res=\d+", f"res={service.res}", select_video)
if hasattr(service, "variant") and not service.variant:
select_video = None
drop_video = None
return select_video, drop_video
def audio_settings(service: object) -> tuple:
@@ -27,6 +33,10 @@ def audio_settings(service: object) -> tuple:
if service.drop_audio != "False":
drop_audio = service.drop_audio
if hasattr(service, "variant") and not service.variant:
select_audio = None
drop_audio = None
return select_audio, drop_audio
def subtitle_settings(service: object) -> tuple:
@@ -47,6 +57,10 @@ def subtitle_settings(service: object) -> tuple:
if service.drop_subtitle != "False":
drop_subtitle = service.drop_subtitle
if hasattr(service, "variant") and not service.variant:
select_subtitle = None
drop_subtitle = None
return sub_no_mux, sub_fix, select_subtitle, drop_subtitle
def format_settings(service: object) -> tuple:
@@ -94,6 +108,10 @@ def get_args(service: object) -> tuple:
no_mux = service.no_mux
m3u8dl = shutil.which("N_m3u8DL-RE") or shutil.which("n-m3u8dl-re")
if not m3u8dl:
error("Unable to locate N_m3u8DL-RE on your system")
shutil.rmtree(service.tmp)
exit(1)
threads, format, muxer, packager = format_settings(service)
temp, save_path, filename = dir_settings(service)

View File

@@ -18,7 +18,7 @@ main_help = f"""
Installation:
1. Install Python (check 'Add to PATH' if on Windows)
2. Clone or download Freevine repository
3. Place N_m3u8DL-RE, ffmpeg, mkvmerge, mp4decrypt, packager, inside Freevine folder OR add to system PATH
3. Place N_m3u8DL-RE, ffmpeg, mkvmerge, mp4decrypt, packager inside Freevine folder OR add to system PATH
4. Create /utils/wvd/ folder and place either .wvd file or private_key and client blob inside
5. Install necessary packages: `pip install -r requirements.txt`
\b
@@ -79,9 +79,10 @@ main_help = f"""
python freevine.py --select-audio name=English --episode/--season URL
python freevine.py --select-audio id=Descriptive --movie URL
Request only subtitles from title(s):
python freevine.py --subtitles --episode/--movie URL
python freevine.py --sub-only --episode/--movie URL
\b
NOTES:
See "N_m3u8DL-RE --morehelp select-video/audio/subtitle" for possible selection patterns
The order of the options isn't super strict, but it's recommended to follow the examples above
Combinations of options are possible as far as common sense allows
If you request a quality that's not available, the closest match is downloaded instead
@@ -124,7 +125,6 @@ main_help = f"""
\b
Known bugs:
Programmes without clear season/episode labels might display odd names and numbers
PlutoTV does not feature the ability to request quality at the moment and defaults to best
TubiTV and PlutoTV does not work with --info at the moment
\b

View File

@@ -202,6 +202,7 @@ def print_info(service: object, stream: object, keys: list):
console = Console()
elements = service.soup.find_all("Representation")
adaptation_sets = service.soup.find_all("AdaptationSet")
video = sorted(
[
(int(x.attrs["width"]), int(x.attrs["height"]), int(x.attrs["bandwidth"]))
@@ -212,6 +213,15 @@ def print_info(service: object, stream: object, keys: list):
],
reverse=True,
)
video.extend(
[
(int(x.attrs["width"]), int(x.attrs["height"]), 2635743)
for x in adaptation_sets
if x.attrs.get("height")
and x.attrs.get("width")
]
)
video.sort(reverse=True)
audio = [
(x.attrs["bandwidth"], x.attrs["id"], x.attrs.get("codecs"))