16 Commits

Author SHA1 Message Date
rlaphoenix
3f22c969c3 Update Changelog for v1.1.0 2023-02-07 21:49:42 +00:00
rlaphoenix
71ded306b6 Bump to v1.1.0 2023-02-07 21:49:32 +00:00
rlaphoenix
44f0ab4793 Create CHANGELOG.md 2023-02-07 21:49:07 +00:00
rlaphoenix
18e2d8617e Create credentials dict prior to assignment in auth add
Fixes #17.
2023-02-07 21:32:21 +00:00
rlaphoenix
c5d6ba09f2 Check if Cookie file for profile already exists during auth add
Fixes #16.
2023-02-07 21:25:26 +00:00
rlaphoenix
6619c29fb5 Create parent dirs when adding cookies with auth add
Fixes #21.
2023-02-07 21:21:15 +00:00
rlaphoenix
0446c44a42 Only iterate over cookie dir if exists in auth list
Fixes #18.
2023-02-07 21:08:45 +00:00
rlaphoenix
794de8b516 Efficiently store data in auth list, sort the data 2023-02-07 21:07:58 +00:00
rlaphoenix
dd441bcd85 Fix crash when loading key vaults in dl command
Fixes #15.
2023-02-07 20:47:40 +00:00
rlaphoenix
1fa3ba61c8 Fix crash when using cfg on empty/new configs
Fixes #19.
2023-02-07 20:44:34 +00:00
rlaphoenix
f7683173f8 Fix startup crash if config is blank
Fixes #20.
2023-02-07 20:32:40 +00:00
rlaphoenix
76671495b4 Fix startup crash if config does not yet exist
Fixes #14.
2023-02-07 20:20:40 +00:00
rlaphoenix
5301ac2924 Add util to test decoding of the video/audio streams with FFmpeg 2023-02-07 00:58:53 +00:00
rlaphoenix
00f85f7206 Add util to change video range flag losslessly
This is useful for some services. Some times there's a random stream with the wrong video range.
2023-02-06 23:49:28 +00:00
rlaphoenix
8c66e57175 Change the icon next to the project name 2023-02-06 04:00:14 +00:00
rlaphoenix
8b9247bfc5 Fix the organization name across the project
Sadly `devine` was not available.
2023-02-06 02:55:22 +00:00
11 changed files with 205 additions and 34 deletions

36
CHANGELOG.md Normal file
View 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

View File

@@ -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

View File

@@ -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}")

View File

@@ -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)

View File

@@ -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()

View File

@@ -1 +1 @@
__version__ = "1.0.0"
__version__ = "1.1.0"

View File

@@ -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

View File

@@ -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,)

View File

@@ -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))
]

View File

@@ -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

View File

@@ -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',