Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f22c969c3 | ||
|
|
71ded306b6 | ||
|
|
44f0ab4793 | ||
|
|
18e2d8617e | ||
|
|
c5d6ba09f2 | ||
|
|
6619c29fb5 | ||
|
|
0446c44a42 | ||
|
|
794de8b516 | ||
|
|
dd441bcd85 | ||
|
|
1fa3ba61c8 | ||
|
|
f7683173f8 | ||
|
|
76671495b4 | ||
|
|
5301ac2924 | ||
|
|
00f85f7206 | ||
|
|
8c66e57175 | ||
|
|
8b9247bfc5 |
36
CHANGELOG.md
Normal file
36
CHANGELOG.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.1.0] - 2023-02-07
|
||||
|
||||
### Added
|
||||
|
||||
- Added utility to change the video range flag between full(pc) and limited(tv).
|
||||
- Added utility to test decoding of video and audio streams using FFmpeg.
|
||||
- Added CHANGELOG.md
|
||||
|
||||
### Changed
|
||||
|
||||
- The services and profiles listed by `auth list` are now sorted alphabetically.
|
||||
- An explicit error is now logged when adding a Cookie to a Service under a duplicate name.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Corrected the organization name across the project from `devine` to `devine-dl` as `devine` was taken.
|
||||
- Fixed startup crash if the config was not yet created or was blank.
|
||||
- Fixed crash when using the `cfg` command to set a config option on new empty config files.
|
||||
- Fixed crash when loading key vaults during the `dl` command.
|
||||
- Fixed crash when using the `auth list` command when you do not have a `Cookies` data directory.
|
||||
- Fixed crash when adding a Cookie using `auth add` to a Service that has no directory yet.
|
||||
- Fixed crash when adding a Credential using `auth add` when it's the first ever credential, or first for the Service.
|
||||
|
||||
## [1.0.0] - 2023-02-06
|
||||
|
||||
Initial public release under the name Devine.
|
||||
|
||||
[1.1.0]: https://github.com/devine-dl/devine/releases/tag/v1.1.0
|
||||
[1.0.0]: https://github.com/devine-dl/devine/releases/tag/v1.0.0
|
||||
13
README.md
13
README.md
@@ -1,13 +1,13 @@
|
||||
<p align="center">
|
||||
<img src="https://rawcdn.githack.com/rlaphoenix/pywidevine/077a3aa6bec14777c06cbdcb47041eee9791c06e/docs/images/widevine_icon_24.png">
|
||||
<a href="https://github.com/devine/devine">Devine</a>
|
||||
<img src="https://user-images.githubusercontent.com/17136956/216880837-478f3ec7-6af6-4cca-8eef-5c98ff02104c.png">
|
||||
<a href="https://github.com/devine-dl/devine">Devine</a>
|
||||
<br/>
|
||||
<sup><em>Open-Source Movie, TV, and Music Downloading Solution</em></sup>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/devine/devine/actions/workflows/ci.yml">
|
||||
<img src="https://github.com/devine/devine/actions/workflows/ci.yml/badge.svg" alt="Build status">
|
||||
<a href="https://github.com/devine-dl/devine/actions/workflows/ci.yml">
|
||||
<img src="https://github.com/devine-dl/devine/actions/workflows/ci.yml/badge.svg" alt="Build status">
|
||||
</a>
|
||||
<a href="https://python.org">
|
||||
<img src="https://img.shields.io/badge/python-3.8.6%2B-informational" alt="Python version">
|
||||
@@ -125,7 +125,7 @@ easily rebase your fork to that commit to update.
|
||||
However, please make sure you look at changes between each version before rebasing and resolve any breaking changes and
|
||||
deprecations when rebasing to a new version.
|
||||
|
||||
1. Fork the project with `git` or GitHub [(fork)](https://github.com/devine/devine/fork).
|
||||
1. Fork the project with `git` or GitHub [(fork)](https://github.com/devine-dl/devine/fork).
|
||||
2. Head inside the root `devine` directory and create a `services` directory.
|
||||
3. Within that `services` folder you may install or create service code.
|
||||
|
||||
@@ -240,7 +240,7 @@ If you start to get sick of putting something in your CLI call, then I recommend
|
||||
The following steps are instructions on downloading, preparing, and running the code under a [Poetry] environment.
|
||||
You can skip steps 3-5 with a simple `pip install .` call instead, but you miss out on a wide array of benefits.
|
||||
|
||||
1. `git clone https://github.com/devine/devine`
|
||||
1. `git clone https://github.com/devine-dl/devine`
|
||||
2. `cd devine`
|
||||
3. (optional) `poetry config virtualenvs.in-project true`
|
||||
4. `poetry install`
|
||||
@@ -278,7 +278,6 @@ Please refrain from spam or asking for questions that infringe upon a Service's
|
||||
|
||||
## Credit
|
||||
|
||||
- Widevine Icon © Google.
|
||||
- The awesome community for their shared research and insight into the Widevine Protocol and Key Derivation.
|
||||
|
||||
## Contributors
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import logging
|
||||
import sys
|
||||
import tkinter.filedialog
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -36,27 +38,24 @@ def list_(ctx: click.Context, service: Optional[str] = None) -> None:
|
||||
log = ctx.obj
|
||||
service_f = service
|
||||
|
||||
profiles: dict[str, dict[str, list]] = {}
|
||||
for cookie_dir in config.directories.cookies.iterdir():
|
||||
service = cookie_dir.name
|
||||
profiles[service] = {}
|
||||
for cookie in cookie_dir.glob("*.txt"):
|
||||
if cookie.stem not in profiles[service]:
|
||||
profiles[service][cookie.stem] = ["Cookie"]
|
||||
auth_data: dict[str, dict[str, list]] = defaultdict(lambda: defaultdict(list))
|
||||
|
||||
if config.directories.cookies.exists():
|
||||
for cookie_dir in config.directories.cookies.iterdir():
|
||||
service = cookie_dir.name
|
||||
for cookie in cookie_dir.glob("*.txt"):
|
||||
if cookie.stem not in auth_data[service]:
|
||||
auth_data[service][cookie.stem].append("Cookie")
|
||||
|
||||
for service, credentials in config.credentials.items():
|
||||
if service not in profiles:
|
||||
profiles[service] = {}
|
||||
for profile, credential in credentials.items():
|
||||
if profile not in profiles[service]:
|
||||
profiles[service][profile] = []
|
||||
profiles[service][profile].append("Credential")
|
||||
for profile in credentials:
|
||||
auth_data[service][profile].append("Credential")
|
||||
|
||||
for service, profiles in profiles.items():
|
||||
for service, profiles in dict(sorted(auth_data.items())).items(): # type:ignore
|
||||
if service_f and service != service_f.upper():
|
||||
continue
|
||||
log.info(service)
|
||||
for profile, authorizations in profiles.items():
|
||||
for profile, authorizations in dict(sorted(profiles.items())).items():
|
||||
log.info(f' "{profile}": {", ".join(authorizations)}')
|
||||
|
||||
|
||||
@@ -239,7 +238,12 @@ def add(ctx: click.Context, profile: str, service: str, cookie: Optional[str] =
|
||||
log.info("Skipped adding a Credential...")
|
||||
|
||||
if cookie:
|
||||
cookie = cookie.rename((config.directories.cookies / service / profile).with_suffix(".txt"))
|
||||
final_path = (config.directories.cookies / service / profile).with_suffix(".txt")
|
||||
final_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if final_path.exists():
|
||||
log.error(f"A Cookie file for the Profile {profile} on {service} already exists.")
|
||||
sys.exit(1)
|
||||
cookie = cookie.rename(final_path)
|
||||
log.info(f"Moved Cookie file to: {cookie}")
|
||||
|
||||
if credential:
|
||||
@@ -247,6 +251,10 @@ def add(ctx: click.Context, profile: str, service: str, cookie: Optional[str] =
|
||||
yaml, data = YAML(), None
|
||||
yaml.default_flow_style = False
|
||||
data = yaml.load(config_path)
|
||||
if "credentials" not in data:
|
||||
data["credentials"] = {}
|
||||
if service not in data["credentials"]:
|
||||
data["credentials"][service] = {}
|
||||
data["credentials"][service][profile] = credential.dumps()
|
||||
yaml.dump(data, config_path)
|
||||
log.info(f"Added Credential: {credential}")
|
||||
|
||||
@@ -45,6 +45,10 @@ def cfg(ctx: click.Context, key: str, value: str, unset: bool, list_: bool) -> N
|
||||
|
||||
if not data:
|
||||
log.warning(f"{config_path} has no configuration data, yet")
|
||||
# yaml.load() returns `None` if the input data is blank instead of a usable object
|
||||
# force a usable object by making one and removing the only item within it
|
||||
data = yaml.load("""__TEMP__: null""")
|
||||
del data["__TEMP__"]
|
||||
|
||||
if list_:
|
||||
yaml.dump(data, sys.stdout)
|
||||
|
||||
@@ -102,3 +102,124 @@ def crop(path: Path, aspect: str, letter: bool, offset: int, preview: bool) -> N
|
||||
if ffmpeg_call.stdout:
|
||||
ffmpeg_call.stdout.close()
|
||||
ffmpeg_call.wait()
|
||||
|
||||
|
||||
@util.command(name="range")
|
||||
@click.argument("path", type=Path)
|
||||
@click.option("--full/--limited", is_flag=True,
|
||||
help="Full: 0..255, Limited: 16..235 (16..240 YUV luma)")
|
||||
@click.option("-p", "--preview", is_flag=True, default=False,
|
||||
help="Instantly preview the newly-set video range in MPV (or ffplay if mpv is unavailable).")
|
||||
def range_(path: Path, full: bool, preview: bool) -> None:
|
||||
"""
|
||||
Losslessly set the Video Range flag to full or limited at the bit-stream level.
|
||||
You may provide a path to a file, or a folder of mkv and/or mp4 files.
|
||||
|
||||
If you ever notice blacks not being quite black, and whites not being quite white,
|
||||
then you're video may have the range set to the wrong value. Flip its range to the
|
||||
opposite value and see if that fixes it.
|
||||
"""
|
||||
executable = get_binary_path("ffmpeg")
|
||||
if not executable:
|
||||
raise click.ClickException("FFmpeg executable \"ffmpeg\" not found but is required.")
|
||||
|
||||
if path.is_dir():
|
||||
paths = list(path.glob("*.mkv")) + list(path.glob("*.mp4"))
|
||||
else:
|
||||
paths = [path]
|
||||
for video_path in paths:
|
||||
try:
|
||||
video_track = next(iter(MediaInfo.parse(video_path).video_tracks or []))
|
||||
except StopIteration:
|
||||
raise click.ClickException("There's no video tracks in the provided file.")
|
||||
|
||||
metadata_key = {
|
||||
"HEVC": "hevc_metadata",
|
||||
"AVC": "h264_metadata"
|
||||
}.get(video_track.commercial_name)
|
||||
if not metadata_key:
|
||||
raise click.ClickException(f"{video_track.commercial_name} Codec not supported.")
|
||||
|
||||
if preview:
|
||||
out_path = ["-f", "mpegts", "-"] # pipe
|
||||
else:
|
||||
out_path = [str(video_path.with_stem(".".join(filter(bool, [
|
||||
video_path.stem,
|
||||
video_track.language,
|
||||
"range",
|
||||
["limited", "full"][full]
|
||||
]))).with_suffix({
|
||||
# ffmpeg's MKV muxer does not yet support HDR
|
||||
"HEVC": ".h265",
|
||||
"AVC": ".h264"
|
||||
}.get(video_track.commercial_name, ".mp4")))]
|
||||
|
||||
ffmpeg_call = subprocess.Popen([
|
||||
executable, "-y",
|
||||
"-i", str(video_path),
|
||||
"-map", "0:v:0",
|
||||
"-c", "copy",
|
||||
"-bsf:v", f"{metadata_key}=video_full_range_flag={int(full)}"
|
||||
] + out_path, stdout=subprocess.PIPE)
|
||||
try:
|
||||
if preview:
|
||||
previewer = get_binary_path("mpv", "ffplay")
|
||||
if not previewer:
|
||||
raise click.ClickException("MPV/FFplay executables weren't found but are required for previewing.")
|
||||
subprocess.Popen((previewer, "-"), stdin=ffmpeg_call.stdout)
|
||||
finally:
|
||||
if ffmpeg_call.stdout:
|
||||
ffmpeg_call.stdout.close()
|
||||
ffmpeg_call.wait()
|
||||
|
||||
|
||||
@util.command()
|
||||
@click.argument("path", type=Path)
|
||||
@click.option("-m", "--map", "map_", type=str, default="0",
|
||||
help="Test specific streams by setting FFmpeg's -map parameter.")
|
||||
def test(path: Path, map_: str) -> None:
|
||||
"""
|
||||
Decode an entire video and check for any corruptions or errors using FFmpeg.
|
||||
You may provide a path to a file, or a folder of mkv and/or mp4 files.
|
||||
|
||||
Tests all streams within the file by default. Subtitles cannot be tested.
|
||||
You may choose specific streams using the -m/--map parameter. E.g.,
|
||||
'0:v:0' to test the first video stream, or '0:a' to test all audio streams.
|
||||
"""
|
||||
executable = get_binary_path("ffmpeg")
|
||||
if not executable:
|
||||
raise click.ClickException("FFmpeg executable \"ffmpeg\" not found but is required.")
|
||||
|
||||
if path.is_dir():
|
||||
paths = list(path.glob("*.mkv")) + list(path.glob("*.mp4"))
|
||||
else:
|
||||
paths = [path]
|
||||
for video_path in paths:
|
||||
print("Starting...")
|
||||
p = subprocess.Popen([
|
||||
executable, "-hide_banner",
|
||||
"-benchmark",
|
||||
"-i", str(video_path),
|
||||
"-map", map_,
|
||||
"-sn",
|
||||
"-f", "null",
|
||||
"-"
|
||||
], stderr=subprocess.PIPE, universal_newlines=True)
|
||||
reached_output = False
|
||||
errors = 0
|
||||
for line in p.stderr:
|
||||
line = line.strip()
|
||||
if "speed=" in line:
|
||||
reached_output = True
|
||||
if not reached_output:
|
||||
continue
|
||||
if line.startswith("["): # error of some kind
|
||||
errors += 1
|
||||
stream, error = line.split("] ", maxsplit=1)
|
||||
stream = stream.split(" @ ")[0]
|
||||
line = f"{stream} ERROR: {error}"
|
||||
print(line)
|
||||
p.stderr.close()
|
||||
print(f"Finished with {errors} Errors, Cleaning up...")
|
||||
p.terminate()
|
||||
p.wait()
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.0.0"
|
||||
__version__ = "1.1.0"
|
||||
|
||||
@@ -20,7 +20,7 @@ def main(version: bool, debug: bool) -> None:
|
||||
|
||||
log.info(f"Devine version {__version__} Copyright (c) 2019-{datetime.now().year} rlaphoenix")
|
||||
log.info("Convenient Widevine-DRM Downloader and Decrypter.")
|
||||
log.info("https://github.com/devine/devine")
|
||||
log.info("https://github.com/devine-dl/devine")
|
||||
if version:
|
||||
return
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ class Config:
|
||||
setattr(self.filenames, name, filename)
|
||||
|
||||
self.headers: dict = kwargs.get("headers") or {}
|
||||
self.key_vaults: list[dict[str, Any]] = kwargs.get("key_vaults")
|
||||
self.key_vaults: list[dict[str, Any]] = kwargs.get("key_vaults", [])
|
||||
self.muxing: dict = kwargs.get("muxing") or {}
|
||||
self.nordvpn: dict = kwargs.get("nordvpn") or {}
|
||||
self.profiles: dict = kwargs.get("profiles") or {}
|
||||
@@ -70,10 +70,13 @@ class Config:
|
||||
raise FileNotFoundError(f"Config file path ({path}) was not found")
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Config file path ({path}) is not to a file.")
|
||||
return cls(**yaml.safe_load(path.read_text(encoding="utf8")))
|
||||
return cls(**yaml.safe_load(path.read_text(encoding="utf8")) or {})
|
||||
|
||||
|
||||
# noinspection PyProtectedMember
|
||||
config = Config.from_yaml(Config._Directories.user_configs / Config._Filenames.root_config)
|
||||
try:
|
||||
config = Config.from_yaml(Config._Directories.user_configs / Config._Filenames.root_config)
|
||||
except FileNotFoundError:
|
||||
config = Config()
|
||||
|
||||
__ALL__ = (config,)
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import base64
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, Optional, Union, Callable
|
||||
from typing import Any, Callable, Optional, Union
|
||||
from uuid import UUID
|
||||
|
||||
import m3u8
|
||||
@@ -199,7 +199,7 @@ class Widevine:
|
||||
for i, (kid, key) in enumerate(self.content_keys.items())
|
||||
],
|
||||
*[
|
||||
# Apple TV+ needs this as their files do not use the KID supplied in it's manifest
|
||||
# some services use a blank KID on the file, but real KID for license server
|
||||
"label={}:key_id={}:key={}".format(i, "00" * 16, key.lower())
|
||||
for i, (kid, key) in enumerate(self.content_keys.items(), len(self.content_keys))
|
||||
]
|
||||
|
||||
@@ -240,7 +240,7 @@ class Track:
|
||||
with open(save_path, "wb") as f:
|
||||
for file in sorted(segments_dir.iterdir()):
|
||||
data = file.read_bytes()
|
||||
# Apple TV+ needs this done to fix audio decryption
|
||||
# fix audio decryption
|
||||
data = re.sub(b"(tfhd\x00\x02\x00\x1a\x00\x00\x00\x01\x00\x00\x00)\x02", b"\\g<1>\x01", data)
|
||||
f.write(data)
|
||||
file.unlink() # delete, we don't need it anymore
|
||||
|
||||
@@ -4,13 +4,13 @@ build-backend = 'poetry.core.masonry.api'
|
||||
|
||||
[tool.poetry]
|
||||
name = 'devine'
|
||||
version = '1.0.0'
|
||||
version = '1.1.0'
|
||||
description = 'Open-Source Movie, TV, and Music Downloading Solution'
|
||||
license = 'GPL-3.0-only'
|
||||
authors = ['rlaphoenix <rlaphoenix@pm.me>']
|
||||
readme = 'README.md'
|
||||
homepage = 'https://github.com/devine/devine'
|
||||
repository = 'https://github.com/devine/devine'
|
||||
homepage = 'https://github.com/devine-dl/devine'
|
||||
repository = 'https://github.com/devine-dl/devine'
|
||||
keywords = ['widevine', 'drm', 'downloader']
|
||||
classifiers = [
|
||||
'Development Status :: 4 - Beta',
|
||||
|
||||
Reference in New Issue
Block a user