Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9177f88d11 | ||
|
|
870c911a5c | ||
|
|
2a18d5ff90 | ||
|
|
06b334c1a2 | ||
|
|
6626f1948f | ||
|
|
767a80b86b | ||
|
|
ee4fb5d824 |
49
README.md
49
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "v1.0.0"
|
||||
__version__ = "v1.0.2"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user