Compare commits

..

No commits in common. "master" and "v3.0.0" have entirely different histories.

43 changed files with 1196 additions and 2890 deletions

4
.gitignore vendored
View File

@ -1,6 +1,4 @@
# devine # devine
devine.yaml
devine.yml
*.mkv *.mkv
*.mp4 *.mp4
*.exe *.exe
@ -11,8 +9,6 @@ devine.yml
*.pem *.pem
*.bin *.bin
*.db *.db
*.ttf
*.otf
device_cert device_cert
device_client_id_blob device_client_id_blob
device_private_key device_private_key

View File

@ -2,17 +2,12 @@
# See https://pre-commit.com/hooks.html for more hooks # See https://pre-commit.com/hooks.html for more hooks
repos: repos:
- repo: https://github.com/compilerla/conventional-pre-commit
rev: v3.2.0
hooks:
- id: conventional-pre-commit
stages: [commit-msg]
- repo: https://github.com/mtkennerly/pre-commit-hooks - repo: https://github.com/mtkennerly/pre-commit-hooks
rev: v0.4.0 rev: v0.3.0
hooks: hooks:
- id: poetry-ruff-check - id: poetry-ruff
- repo: https://github.com/pycqa/isort - repo: https://github.com/pycqa/isort
rev: 5.13.2 rev: 5.12.0
hooks: hooks:
- id: isort - id: isort
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks

View File

@ -2,179 +2,8 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 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).
Versions [3.0.0] and older use a format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
but versions thereafter use a custom changelog format using [git-cliff](https://git-cliff.org).
## [3.3.3] - 2024-05-07
### Bug Fixes
- *dl*: Automatically convert TTML Subs to WebVTT for MKV support
- *Subtitle*: Correct timestamps when merging fragmented WebVTT
### Changes
- *env*: List all directories as table in info
- *env*: List possible config path locations when not found
- *binaries*: Move all binary definitions to core/binaries file
- *curl-impersonate*: Remove manual fix for curl proxy SSL
- *curl-impersonate*: Update the default browser to chrome124
- *Config*: Move possible config paths out of func to constant
- *utilities*: Remove get_binary_path, use binaries.find instead
- *dl*: Improve readability of download worker errors
- *env*: Shorten paths on Windows with env vars
## [3.3.2] - 2024-04-16
### Bug Fixes
- *Video*: Ensure track is supported in change_color_range()
- *Video*: Optionalise constructor args, add doc-string & checks
- *Audio*: Optionalise constructor args, add doc-string & checks
- *Subtitle*: Optionalise constructor args, add doc-string & checks
- *HLS*: Ensure playlist.stream_info.codecs exists before use
- *HLS*: Ensure playlist.stream_info.resolution exists before use
- *env*: List used config path, otherwise the default path
- *cfg*: Use loaded config path instead of hardcoded default
- *Basic*: Return None not Exception if no proxy configured
### Changes
- *Video*: Do not print "?"/"Unknown" values in str()
- *Audio*: Do not print "?"/"Unknown" values in str()
- *Subtitle*: Do not print "?"/"Unknown" values in str()
- *Audio*: List lang after codec for consistency with other Tracks
- *Video*: Return None if no m3u RANGE, not SDR
- *env*: Use -- to indicate no config found/loaded
### New Contributors
- [retouching](https://github.com/retouching)
## [3.3.1] - 2024-04-05
### Features
- *dl*: Add *new* --workers to set download threads/workers
### Bug Fixes
- *Chapter*: Cast values to int prior to formatting
- *requests*: Fix multithreaded downloads
- *Events*: Dereference subscription store from ephemeral store
### Changes
- *dl*: Change --workers to --downloads
### New Contributors
- [knowhere01](https://github.com/knowhere01)
## [3.3.0] - 2024-04-02
### Features
- Add support for MKV Attachments via Attachment class
- *dl*: Automatically attach fonts used within SSAv4 subs
- *dl*: Try find SSAv4 fonts in System OS fonts folder
- *Basic*: Allow single string URIs for countries
- *Basic*: Allow proxy selection by index (one-indexed)
- *Events*: Add new global Event Observer API
### Bug Fixes
- *curl-impersonate*: Set Cert-Authority Bundle for HTTPS Proxies
- *Basic*: Make query case-insensitive
- *WVD*: Ensure WVDs dir exists before moving WVD file
- *WVD*: Fix empty path to WVDs folder check
- *WVD*: Move log out of loop to save performance
- *WVD*: Move log with path before Device load
- *WVD*: Add exists/empty checks to WVD folder dumps
- *Basic*: Fix variable typo regression
### Changes
- *Basic*: Improve proxy format checks
- *WVD*: Print error if path to parse doesn't exist
- *WVD*: Seperate logs in loop for visual clarity
- *Track*: Move from OnXyz callables to Event observer
## [3.2.0] - 2024-03-25
### Features
- *ClearKey*: Pass session not proxy str in from_m3u_key method
- *Track*: Allow Track to choose downloader to use
- *search*: New Search command, Service method, SearchResult Class
### Bug Fixes
- *dl*: Include chapters when muxing
- *aria2c*: Support aria2(c) 1.37.0 by handling upstream regression
- *MultipleChoice*: Simplify super() call and value types
- *dl*: Add single mux job if there's no video tracks
- *Track*: Compute Track ID from the `this` variable, not `self`
- *DASH/HLS*: Don't merge folders, skip final merge if only 1 segment
- *dl*: Use click.command() instead of click.group()
- *HLS*: Remove save dir even if final merge wasn't needed
- *Track*: Fix order of operation mistake in get_track_name
- *requests*: Set HTTP pool connections/maxsize to max workers
- *Video*: Delete original file after using change_color_range()
- *Video*: Delete original file after using remove_eia_cc()
- *requests*: Manually compute default max_workers or pool size is None
- *requests*: Block until connection freed if too many connections
- *HLS*: Delete subtitle segments as they are merged
- *HLS*: Delete video/audio segments after FFmpeg merge
### Changes
- *ClearKey*: Only use User-Agent if none set in from_m3u_key
- *Track*: Remove TERRITORY_MAP constant, trim SAR China manually
- *Track*: Default the track name to it's lang's script/territory
- *Service*: Go back to the default pool_maxsize in Session
## [3.1.0] - 2024-03-05
### Features
- *cli*: Implement MultipleChoice click param based on Choice param
- *dl*: Skip video lang filter if --v-lang unused & only 1 video lang
- *dl*: Change --vcodec default to None, use any codec
- *dl*: Support multiple -r/--range and mux ranges separately
- *Subtitle*: Convert from fTTML->TTML & fVTT->WebVTT post-download
- *Track*: Make ID optional, Automatically compute one if not provided
- *Track*: Add a name property to use for the Track Name
### Bug Fixes
- *dl*: Have --sub-format default to None to keep original sub format
- *HLS*: Use filtered out segment key info
- *Track*: Don't modify lang when getting name
- *Track*: Don't use fallback values "Zzzz"/"ZZ" for track name
- *version*: The `__version__` variable forgot to be updated
### Changes
- Move dl command's download_track() to Track.download()
- *dl*: Remove unused `get_profiles()` method
- *DASH*: Move data values from track url to track data property
- *DASH*: Change how Video FPS is gotten to remove FutureWarning log
- *Track*: Add type checks, improve typing
- *Track*: Remove swap() method and it's uses
- *Track*: Remove unused DRM enum
- *Track*: Rename Descriptor's M3U & MPD to HLS & DASH
- *Track*: Remove unnecessary bool casting
- *Track*: Move the path class instance variable with the rest
- *Track*: Return new path on move(), raise exceptions on errors
- *Track*: Move delete and move methods near start of Class
- *Track*: Rename extra to data, enforce type as dict
### Builds
- Explicitly use marisa-trie==1.1.0 for Python 3.12 wheels
## [3.0.0] - 2024-03-01 ## [3.0.0] - 2024-03-01
@ -814,12 +643,6 @@ This release brings a huge change to the fundamentals of Devine's logging, UI, a
Initial public release under the name Devine. Initial public release under the name Devine.
[3.3.3]: https://github.com/devine-dl/devine/releases/tag/v3.3.3
[3.3.2]: https://github.com/devine-dl/devine/releases/tag/v3.3.2
[3.3.1]: https://github.com/devine-dl/devine/releases/tag/v3.3.1
[3.3.0]: https://github.com/devine-dl/devine/releases/tag/v3.3.0
[3.2.0]: https://github.com/devine-dl/devine/releases/tag/v3.2.0
[3.1.0]: https://github.com/devine-dl/devine/releases/tag/v3.1.0
[3.0.0]: https://github.com/devine-dl/devine/releases/tag/v3.0.0 [3.0.0]: https://github.com/devine-dl/devine/releases/tag/v3.0.0
[2.2.0]: https://github.com/devine-dl/devine/releases/tag/v2.2.0 [2.2.0]: https://github.com/devine-dl/devine/releases/tag/v2.2.0
[2.1.0]: https://github.com/devine-dl/devine/releases/tag/v2.1.0 [2.1.0]: https://github.com/devine-dl/devine/releases/tag/v2.1.0

View File

@ -122,7 +122,6 @@ The following directories are available and may be overridden,
- `commands` - CLI Command Classes. - `commands` - CLI Command Classes.
- `services` - Service Classes. - `services` - Service Classes.
- `vaults` - Vault Classes. - `vaults` - Vault Classes.
- `fonts` - Font files (ttf or otf).
- `downloads` - Downloads. - `downloads` - Downloads.
- `temp` - Temporary files or conversions during download. - `temp` - Temporary files or conversions during download.
- `cache` - Expiring data like Authorization tokens, or other misc data. - `cache` - Expiring data like Authorization tokens, or other misc data.
@ -153,13 +152,6 @@ For example to set the default primary language to download to German,
lang: de lang: de
``` ```
to set how many tracks to download concurrently to 4 and download threads to 16,
```yaml
downloads: 4
workers: 16
```
to set `--bitrate=CVBR` for the AMZN service, to set `--bitrate=CVBR` for the AMZN service,
```yaml ```yaml
@ -168,7 +160,7 @@ AMZN:
bitrate: CVBR bitrate: CVBR
``` ```
or to change the output subtitle format from the default (original format) to WebVTT, or to change the output subtitle format from the default (SubRip SRT) to WebVTT,
```yaml ```yaml
sub_format: vtt sub_format: vtt
@ -289,30 +281,27 @@ together.
## proxy_providers (dict) ## proxy_providers (dict)
Enable external proxy provider services. These proxies will be used automatically where needed as defined by the Enable external proxy provider services.
Service's GEOFENCE class property, but can also be explicitly used with `--proxy`. You can specify which provider
to use by prefixing it with the provider key name, e.g., `--proxy basic:de` or `--proxy nordvpn:de`. Some providers
support specific query formats for selecting a country/server.
### basic (dict[str, str|list]) ### basic (list\[dict])
Define a mapping of country to proxy to use where required. Define a mapping of country to proxy to use where required.
The keys are region Alpha 2 Country Codes. Alpha 2 Country Codes are `[a-z]{2}` codes, e.g., `us`, `gb`, and `jp`. The keys are region Alpha 2 Country Codes. Alpha 2 Country Codes are `[a-z]{2}` codes, e.g., `us`, `gb`, and `jp`.
Don't get this mixed up with language codes like `en` vs. `gb`, or `ja` vs. `jp`. Don't get this mixed up with language codes like `en` vs. `gb`, or `ja` vs. `jp`.
Do note that each key's value can be a list of strings, or a string. For example, Do note that each key's value is not a string but a list or sequence.
It will randomly choose which entry to use.
For example,
```yaml ```yaml
us: us:
- "http://john%40email.tld:password123@proxy-us.domain.tld:8080" - "http://john%40email.tld:password123@proxy-us.domain.tld:8080"
- "http://jane%40email.tld:password456@proxy-us.domain2.tld:8080" - "http://jane%40email.tld:password456@proxy-us.domain2.tld:8080"
de: "https://127.0.0.1:8080" de:
- "http://127.0.0.1:8888"
``` ```
Note that if multiple proxies are defined for a region, then by default one will be randomly chosen.
You can choose a specific one by specifying it's number, e.g., `--proxy basic:us2` will choose the
second proxy of the US list.
### nordvpn (dict) ### nordvpn (dict)
Set your NordVPN Service credentials with `username` and `password` keys to automate the use of NordVPN as a Proxy Set your NordVPN Service credentials with `username` and `password` keys to automate the use of NordVPN as a Proxy

View File

@ -341,10 +341,6 @@ Please refrain from spam or asking for questions that infringe upon a Service's
<a href="https://github.com/varyg1001"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/88599103?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="varyg1001"/></a> <a href="https://github.com/varyg1001"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/88599103?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="varyg1001"/></a>
<a href="https://github.com/Hollander-1908"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/93162595?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="Hollander-1908"/></a> <a href="https://github.com/Hollander-1908"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/93162595?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="Hollander-1908"/></a>
<a href="https://github.com/Shivelight"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20620780?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="Shivelight"/></a> <a href="https://github.com/Shivelight"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20620780?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="Shivelight"/></a>
<a href="https://github.com/knowhere01"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/113712042?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="knowhere01"/></a>
<a href="https://github.com/retouching"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/33735357?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="retouching"/></a>
<a href="https://github.com/pandamoon21"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/33972938?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="pandamoon21"/></a>
<a href="https://github.com/adbbbb"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/56319336?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="adbbbb"/></a>
## Licensing ## Licensing

View File

@ -1,71 +0,0 @@
# git-cliff ~ default configuration file
# https://git-cliff.org/docs/configuration
[changelog]
header = """
# Changelog\n
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Versions [3.0.0] and older use a format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
but versions thereafter use a custom changelog format using [git-cliff](https://git-cliff.org).\n
"""
body = """
{% if version -%}
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else -%}
## [Unreleased]
{% endif -%}
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}*{{ commit.scope }}*: {% endif %}\
{% if commit.breaking %}[**breaking**] {% endif %}\
{{ commit.message | upper_first }}\
{% endfor %}
{% endfor %}\n
"""
footer = """
{% for release in releases -%}
{% if release.version -%}
{% if release.previous.version -%}
[{{ release.version | trim_start_matches(pat="v") }}]: \
https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}\
/compare/{{ release.previous.version }}..{{ release.version }}
{% endif -%}
{% else -%}
[unreleased]: https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}\
/compare/{{ release.previous.version }}..HEAD
{% endif -%}
{% endfor %}
"""
trim = true
postprocessors = [
# { pattern = '<REPO>', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL
]
[git]
conventional_commits = true
filter_unconventional = true
split_commits = false
commit_preprocessors = []
commit_parsers = [
{ message = "^feat", group = "<!-- 0 -->Features" },
{ message = "^fix|revert", group = "<!-- 1 -->Bug Fixes" },
{ message = "^docs", group = "<!-- 2 -->Documentation" },
{ message = "^style", skip = true },
{ message = "^refactor", group = "<!-- 3 -->Changes" },
{ message = "^perf", group = "<!-- 4 -->Performance Improvements" },
{ message = "^test", skip = true },
{ message = "^build", group = "<!-- 5 -->Builds" },
{ message = "^ci", skip = true },
{ message = "^chore", skip = true },
]
protect_breaking_commits = false
filter_commits = false
# tag_pattern = "v[0-9].*"
# skip_tags = ""
# ignore_tags = ""
topo_order = false
sort_commits = "oldest"

View File

@ -5,7 +5,7 @@ import sys
import click import click
from ruamel.yaml import YAML from ruamel.yaml import YAML
from devine.core.config import config, get_config_path from devine.core.config import config
from devine.core.constants import context_settings from devine.core.constants import context_settings
@ -36,15 +36,15 @@ def cfg(ctx: click.Context, key: str, value: str, unset: bool, list_: bool) -> N
log = logging.getLogger("cfg") log = logging.getLogger("cfg")
config_path = config.directories.user_configs / config.filenames.root_config
yaml, data = YAML(), None yaml, data = YAML(), None
yaml.default_flow_style = False yaml.default_flow_style = False
if config_path.is_file():
config_path = get_config_path() or config.directories.user_configs / config.filenames.root_config
if config_path.exists():
data = yaml.load(config_path) data = yaml.load(config_path)
if not data: if not data:
log.warning("No config file was found or it has no data, yet") 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 # 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 # force a usable object by making one and removing the only item within it
data = yaml.load("""__TEMP__: null""") data = yaml.load("""__TEMP__: null""")

View File

@ -14,7 +14,7 @@ from concurrent.futures import ThreadPoolExecutor
from copy import deepcopy from copy import deepcopy
from functools import partial from functools import partial
from http.cookiejar import CookieJar, MozillaCookieJar from http.cookiejar import CookieJar, MozillaCookieJar
from itertools import product from itertools import zip_longest
from pathlib import Path from pathlib import Path
from threading import Lock from threading import Lock
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
@ -32,35 +32,34 @@ from rich.console import Group
from rich.live import Live from rich.live import Live
from rich.padding import Padding from rich.padding import Padding
from rich.panel import Panel from rich.panel import Panel
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskID, TextColumn, TimeRemainingColumn from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeRemainingColumn
from rich.rule import Rule from rich.rule import Rule
from rich.table import Table from rich.table import Table
from rich.text import Text from rich.text import Text
from rich.tree import Tree from rich.tree import Tree
from devine.core import binaries
from devine.core.config import config from devine.core.config import config
from devine.core.console import console from devine.core.console import console
from devine.core.constants import DOWNLOAD_LICENCE_ONLY, AnyTrack, context_settings from devine.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack, context_settings
from devine.core.credential import Credential from devine.core.credential import Credential
from devine.core.downloaders import downloader
from devine.core.drm import DRM_T, Widevine from devine.core.drm import DRM_T, Widevine
from devine.core.events import events from devine.core.manifests import DASH, HLS
from devine.core.proxies import Basic, Hola, NordVPN from devine.core.proxies import Basic, Hola, NordVPN
from devine.core.service import Service from devine.core.service import Service
from devine.core.services import Services from devine.core.services import Services
from devine.core.titles import Movie, Song, Title_T from devine.core.titles import Movie, Song, Title_T
from devine.core.titles.episode import Episode from devine.core.titles.episode import Episode
from devine.core.tracks import Audio, Subtitle, Tracks, Video from devine.core.tracks import Audio, Subtitle, Tracks, Video
from devine.core.tracks.attachment import Attachment from devine.core.utilities import get_binary_path, is_close_match, time_elapsed_since, try_ensure_utf8
from devine.core.utilities import get_system_fonts, is_close_match, time_elapsed_since from devine.core.utils.click_types import LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE, ContextData
from devine.core.utils.click_types import LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE, ContextData, MultipleChoice
from devine.core.utils.collections import merge_dict from devine.core.utils.collections import merge_dict
from devine.core.utils.subprocess import ffprobe from devine.core.utils.subprocess import ffprobe
from devine.core.vaults import Vaults from devine.core.vaults import Vaults
class dl: class dl:
@click.command( @click.group(
short_help="Download, Decrypt, and Mux tracks for titles from a Service.", short_help="Download, Decrypt, and Mux tracks for titles from a Service.",
cls=Services, cls=Services,
context_settings=dict( context_settings=dict(
@ -73,8 +72,8 @@ class dl:
@click.option("-q", "--quality", type=QUALITY_LIST, default=[], @click.option("-q", "--quality", type=QUALITY_LIST, default=[],
help="Download Resolution(s), defaults to the best available resolution.") help="Download Resolution(s), defaults to the best available resolution.")
@click.option("-v", "--vcodec", type=click.Choice(Video.Codec, case_sensitive=False), @click.option("-v", "--vcodec", type=click.Choice(Video.Codec, case_sensitive=False),
default=None, default=Video.Codec.AVC,
help="Video Codec to download, defaults to any codec.") help="Video Codec to download, defaults to H.264.")
@click.option("-a", "--acodec", type=click.Choice(Audio.Codec, case_sensitive=False), @click.option("-a", "--acodec", type=click.Choice(Audio.Codec, case_sensitive=False),
default=None, default=None,
help="Audio Codec to download, defaults to any codec.") help="Audio Codec to download, defaults to any codec.")
@ -84,9 +83,9 @@ class dl:
@click.option("-ab", "--abitrate", type=int, @click.option("-ab", "--abitrate", type=int,
default=None, default=None,
help="Audio Bitrate to download (in kbps), defaults to highest available.") help="Audio Bitrate to download (in kbps), defaults to highest available.")
@click.option("-r", "--range", "range_", type=MultipleChoice(Video.Range, case_sensitive=False), @click.option("-r", "--range", "range_", type=click.Choice(Video.Range, case_sensitive=False),
default=[Video.Range.SDR], default=Video.Range.SDR,
help="Video Color Range(s) to download, defaults to SDR.") help="Video Color Range, defaults to SDR.")
@click.option("-c", "--channels", type=float, @click.option("-c", "--channels", type=float,
default=None, default=None,
help="Audio Channel(s) to download. Matches sub-channel layouts like 5.1 with 6.0 implicitly.") help="Audio Channel(s) to download. Matches sub-channel layouts like 5.1 with 6.0 implicitly.")
@ -103,7 +102,7 @@ class dl:
@click.option("--tag", type=str, default=None, @click.option("--tag", type=str, default=None,
help="Set the Group Tag to be used, overriding the one in config if any.") help="Set the Group Tag to be used, overriding the one in config if any.")
@click.option("--sub-format", type=click.Choice(Subtitle.Codec, case_sensitive=False), @click.option("--sub-format", type=click.Choice(Subtitle.Codec, case_sensitive=False),
default=None, default=Subtitle.Codec.SubRip,
help="Set Output Subtitle Format, only converting if necessary.") help="Set Output Subtitle Format, only converting if necessary.")
@click.option("-V", "--video-only", is_flag=True, default=False, @click.option("-V", "--video-only", is_flag=True, default=False,
help="Only download video tracks.") help="Only download video tracks.")
@ -132,10 +131,8 @@ class dl:
help="Disable folder creation for TV Shows.") help="Disable folder creation for TV Shows.")
@click.option("--no-source", is_flag=True, default=False, @click.option("--no-source", is_flag=True, default=False,
help="Disable the source tag from the output file name and path.") help="Disable the source tag from the output file name and path.")
@click.option("--workers", type=int, default=None, @click.option("--workers", type=int, default=1,
help="Max workers/threads to download with per-track. Default depends on the downloader.") help="Max concurrent workers to use throughout the code, particularly downloads.")
@click.option("--downloads", type=int, default=1,
help="Amount of tracks to download concurrently.")
@click.pass_context @click.pass_context
def cli(ctx: click.Context, **kwargs: Any) -> dl: def cli(ctx: click.Context, **kwargs: Any) -> dl:
return dl(ctx, **kwargs) return dl(ctx, **kwargs)
@ -178,10 +175,9 @@ class dl:
except ValueError as e: except ValueError as e:
self.log.error(f"Failed to load Widevine CDM, {e}") self.log.error(f"Failed to load Widevine CDM, {e}")
sys.exit(1) sys.exit(1)
if self.cdm: self.log.info(
self.log.info( f"Loaded {self.cdm.__class__.__name__} Widevine CDM: {self.cdm.system_id} (L{self.cdm.security_level})"
f"Loaded {self.cdm.__class__.__name__} Widevine CDM: {self.cdm.system_id} (L{self.cdm.security_level})" )
)
with console.status("Loading Key Vaults...", spinner="dots"): with console.status("Loading Key Vaults...", spinner="dots"):
self.vaults = Vaults(self.service) self.vaults = Vaults(self.service)
@ -200,7 +196,7 @@ class dl:
self.proxy_providers.append(Basic(**config.proxy_providers["basic"])) self.proxy_providers.append(Basic(**config.proxy_providers["basic"]))
if config.proxy_providers.get("nordvpn"): if config.proxy_providers.get("nordvpn"):
self.proxy_providers.append(NordVPN(**config.proxy_providers["nordvpn"])) self.proxy_providers.append(NordVPN(**config.proxy_providers["nordvpn"]))
if binaries.HolaProxy: if get_binary_path("hola-proxy"):
self.proxy_providers.append(Hola()) self.proxy_providers.append(Hola())
for proxy_provider in self.proxy_providers: for proxy_provider in self.proxy_providers:
self.log.info(f"Loaded {proxy_provider.__class__.__name__}: {proxy_provider}") self.log.info(f"Loaded {proxy_provider.__class__.__name__}: {proxy_provider}")
@ -256,17 +252,17 @@ class dl:
self, self,
service: Service, service: Service,
quality: list[int], quality: list[int],
vcodec: Optional[Video.Codec], vcodec: Video.Codec,
acodec: Optional[Audio.Codec], acodec: Optional[Audio.Codec],
vbitrate: int, vbitrate: int,
abitrate: int, abitrate: int,
range_: list[Video.Range], range_: Video.Range,
channels: float, channels: float,
wanted: list[str], wanted: list[str],
lang: list[str], lang: list[str],
v_lang: list[str], v_lang: list[str],
s_lang: list[str], s_lang: list[str],
sub_format: Optional[Subtitle.Codec], sub_format: Subtitle.Codec,
video_only: bool, video_only: bool,
audio_only: bool, audio_only: bool,
subs_only: bool, subs_only: bool,
@ -279,8 +275,7 @@ class dl:
no_proxy: bool, no_proxy: bool,
no_folder: bool, no_folder: bool,
no_source: bool, no_source: bool,
workers: Optional[int], workers: int,
downloads: int,
*_: Any, *_: Any,
**__: Any **__: Any
) -> None: ) -> None:
@ -330,14 +325,6 @@ class dl:
with console.status(f"Delaying by {delay} seconds..."): with console.status(f"Delaying by {delay} seconds..."):
time.sleep(delay) time.sleep(delay)
with console.status("Subscribing to events...", spinner="dots"):
events.reset()
events.subscribe(events.Types.SEGMENT_DOWNLOADED, service.on_segment_downloaded)
events.subscribe(events.Types.TRACK_DOWNLOADED, service.on_track_downloaded)
events.subscribe(events.Types.TRACK_DECRYPTED, service.on_track_decrypted)
events.subscribe(events.Types.TRACK_REPACKED, service.on_track_repacked)
events.subscribe(events.Types.TRACK_MULTIPLEX, service.on_track_multiplex)
with console.status("Getting tracks...", spinner="dots"): with console.status("Getting tracks...", spinner="dots"):
title.tracks.add(service.get_tracks(title), warn_only=True) title.tracks.add(service.get_tracks(title), warn_only=True)
title.tracks.chapters = service.get_chapters(title) title.tracks.chapters = service.get_chapters(title)
@ -353,13 +340,8 @@ class dl:
non_sdh_sub = deepcopy(subtitle) non_sdh_sub = deepcopy(subtitle)
non_sdh_sub.id += "_stripped" non_sdh_sub.id += "_stripped"
non_sdh_sub.sdh = False non_sdh_sub.sdh = False
non_sdh_sub.OnMultiplex = lambda: non_sdh_sub.strip_hearing_impaired()
title.tracks.add(non_sdh_sub) title.tracks.add(non_sdh_sub)
events.subscribe(
events.Types.TRACK_MULTIPLEX,
lambda track: (
track.strip_hearing_impaired()
) if track.id == non_sdh_sub.id else None
)
with console.status("Sorting tracks by language and bitrate...", spinner="dots"): with console.status("Sorting tracks by language and bitrate...", spinner="dots"):
title.tracks.sort_videos(by_language=v_lang or lang) title.tracks.sort_videos(by_language=v_lang or lang)
@ -377,18 +359,15 @@ class dl:
with console.status("Selecting tracks...", spinner="dots"): with console.status("Selecting tracks...", spinner="dots"):
if isinstance(title, (Movie, Episode)): if isinstance(title, (Movie, Episode)):
# filter video tracks # filter video tracks
if vcodec: title.tracks.select_video(lambda x: x.codec == vcodec)
title.tracks.select_video(lambda x: x.codec == vcodec) if not title.tracks.videos:
if not title.tracks.videos: self.log.error(f"There's no {vcodec.name} Video Track...")
self.log.error(f"There's no {vcodec.name} Video Track...") sys.exit(1)
sys.exit(1)
if range_: title.tracks.select_video(lambda x: x.range == range_)
title.tracks.select_video(lambda x: x.range in range_) if not title.tracks.videos:
for color_range in range_: self.log.error(f"There's no {range_.name} Video Track...")
if not any(x.range == color_range for x in title.tracks.videos): sys.exit(1)
self.log.error(f"There's no {color_range.name} Video Tracks...")
sys.exit(1)
if vbitrate: if vbitrate:
title.tracks.select_video(lambda x: x.bitrate and x.bitrate // 1000 == vbitrate) title.tracks.select_video(lambda x: x.bitrate and x.bitrate // 1000 == vbitrate)
@ -404,7 +383,7 @@ class dl:
sys.exit(1) sys.exit(1)
if quality: if quality:
title.tracks.by_resolutions(quality) title.tracks.by_resolutions(quality, per_resolution=1)
missing_resolutions = [] missing_resolutions = []
for resolution in quality: for resolution in quality:
if any(video.height == resolution for video in title.tracks.videos): if any(video.height == resolution for video in title.tracks.videos):
@ -420,27 +399,8 @@ class dl:
plural = "s" if len(missing_resolutions) > 1 else "" plural = "s" if len(missing_resolutions) > 1 else ""
self.log.error(f"There's no {res_list} Video Track{plural}...") self.log.error(f"There's no {res_list} Video Track{plural}...")
sys.exit(1) sys.exit(1)
else:
# choose best track by range and quality title.tracks.videos = [title.tracks.videos[0]]
title.tracks.videos = [
track
for resolution, color_range in product(
quality or [None],
range_ or [None]
)
for track in [next(
t
for t in title.tracks.videos
if (not resolution and not color_range) or
(
(not resolution or (
(t.height == resolution) or
(int(t.width * (9 / 16)) == resolution)
))
and (not color_range or t.range == color_range)
)
)]
]
# filter subtitle tracks # filter subtitle tracks
if s_lang and "all" not in s_lang: if s_lang and "all" not in s_lang:
@ -507,11 +467,12 @@ class dl:
console=console, console=console,
refresh_per_second=5 refresh_per_second=5
): ):
with ThreadPoolExecutor(downloads) as pool: with ThreadPoolExecutor(workers) as pool:
for download in futures.as_completed(( for download in futures.as_completed((
pool.submit( pool.submit(
track.download, self.download_track,
session=service.session, service=service,
track=track,
prepare_drm=partial( prepare_drm=partial(
partial( partial(
self.prepare_drm, self.prepare_drm,
@ -533,7 +494,6 @@ class dl:
vaults_only=vaults_only, vaults_only=vaults_only,
export=export export=export
), ),
max_workers=workers,
progress=tracks_progress_callables[i] progress=tracks_progress_callables[i]
) )
for i, track in enumerate(title.tracks) for i, track in enumerate(title.tracks)
@ -548,17 +508,14 @@ class dl:
except Exception as e: # noqa except Exception as e: # noqa
error_messages = [ error_messages = [
":x: Download Failed...", ":x: Download Failed...",
" One of the download workers had an error!",
" See the error trace above for more information."
] ]
if isinstance(e, EnvironmentError): if isinstance(e, subprocess.CalledProcessError):
error_messages.append(f" {e}") # ignore process exceptions as proper error logs are already shown
error_messages.append(f" Process exit code: {e.returncode}")
else: else:
error_messages.append(" An unexpected error occurred in one of the download workers.",) console.print_exception()
if hasattr(e, "returncode"):
error_messages.append(f" Binary call failed, Process exit code: {e.returncode}")
error_messages.append(" See the error trace above for more information.")
if isinstance(e, subprocess.CalledProcessError):
# CalledProcessError already lists the exception trace
console.print_exception()
console.print(Padding( console.print(Padding(
Group(*error_messages), Group(*error_messages),
(1, 5) (1, 5)
@ -615,46 +572,10 @@ class dl:
break break
video_track_n += 1 video_track_n += 1
with console.status("Converting Subtitles..."): with console.status(f"Converting Subtitles to {sub_format.name}..."):
for subtitle in title.tracks.subtitles: for subtitle in title.tracks.subtitles:
if sub_format: if subtitle.codec != sub_format:
if subtitle.codec != sub_format: subtitle.convert(sub_format)
subtitle.convert(sub_format)
elif subtitle.codec == Subtitle.Codec.TimedTextMarkupLang:
# MKV does not support TTML, VTT is the next best option
subtitle.convert(Subtitle.Codec.WebVTT)
with console.status("Checking Subtitles for Fonts..."):
font_names = []
for subtitle in title.tracks.subtitles:
if subtitle.codec == Subtitle.Codec.SubStationAlphav4:
for line in subtitle.path.read_text("utf8").splitlines():
if line.startswith("Style: "):
font_names.append(line.removesuffix("Style: ").split(",")[1])
font_count = 0
system_fonts = get_system_fonts()
for font_name in set(font_names):
family_dir = Path(config.directories.fonts, font_name)
fonts_from_system = [
file
for name, file in system_fonts.items()
if name.startswith(font_name)
]
if family_dir.exists():
fonts = family_dir.glob("*.*tf")
for font in fonts:
title.tracks.add(Attachment(font, f"{font_name} ({font.stem})"))
font_count += 1
elif fonts_from_system:
for font in fonts_from_system:
title.tracks.add(Attachment(font, f"{font_name} ({font.stem})"))
font_count += 1
else:
self.log.warning(f"Subtitle uses font [text2]{font_name}[/] but it could not be found...")
if font_count:
self.log.info(f"Attached {font_count} fonts for the Subtitles")
with console.status("Repackaging tracks with FFMPEG..."): with console.status("Repackaging tracks with FFMPEG..."):
has_repacked = False has_repacked = False
@ -662,7 +583,8 @@ class dl:
if track.needs_repack: if track.needs_repack:
track.repackage() track.repackage()
has_repacked = True has_repacked = True
events.emit(events.Types.TRACK_REPACKED, track=track) if callable(track.OnRepacked):
track.OnRepacked()
if has_repacked: if has_repacked:
# we don't want to fill up the log with "Repacked x track" # we don't want to fill up the log with "Repacked x track"
self.log.info("Repacked one or more tracks with FFMPEG") self.log.info("Repacked one or more tracks with FFMPEG")
@ -678,48 +600,35 @@ class dl:
TimeRemainingColumn(compact=True, elapsed_when_finished=True), TimeRemainingColumn(compact=True, elapsed_when_finished=True),
console=console console=console
) )
multi_jobs = len(title.tracks.videos) > 1
multiplex_tasks: list[tuple[TaskID, Tracks]] = [] tasks = [
for video_track in title.tracks.videos or [None]: progress.add_task(
task_description = "Multiplexing" f"Multiplexing{f' {x.height}p' if multi_jobs else ''}...",
if video_track: total=None,
if len(quality) > 1: start=False
task_description += f" {video_track.height}p" )
if len(range_) > 1: for x in title.tracks.videos or [None]
task_description += f" {video_track.range.name}" ]
task_id = progress.add_task(f"{task_description}...", total=None, start=False)
task_tracks = Tracks(title.tracks) + title.tracks.chapters + title.tracks.attachments
if video_track:
task_tracks.videos = [video_track]
multiplex_tasks.append((task_id, task_tracks))
with Live( with Live(
Padding(progress, (0, 5, 1, 5)), Padding(progress, (0, 5, 1, 5)),
console=console console=console
): ):
for task_id, task_tracks in multiplex_tasks: for task, video_track in zip_longest(tasks, title.tracks.videos, fillvalue=None):
progress.start_task(task_id) # TODO: Needed? if video_track:
muxed_path, return_code, errors = task_tracks.mux( title.tracks.videos = [video_track]
progress.start_task(task) # TODO: Needed?
muxed_path, return_code = title.tracks.mux(
str(title), str(title),
progress=partial(progress.update, task_id=task_id), progress=partial(progress.update, task_id=task),
delete=False delete=False
) )
muxed_paths.append(muxed_path) muxed_paths.append(muxed_path)
if return_code >= 2: if return_code == 1:
self.log.error(f"Failed to Mux video to Matroska file ({return_code}):") self.log.warning("mkvmerge had at least one warning, will continue anyway...")
elif return_code == 1 or errors: elif return_code >= 2:
self.log.warning("mkvmerge had at least one warning or error, continuing anyway...") self.log.error(f"Failed to Mux video to Matroska file ({return_code})")
for line in errors:
if line.startswith("#GUI#error"):
self.log.error(line)
else:
self.log.warning(line)
if return_code >= 2:
sys.exit(1) sys.exit(1)
for video_track in task_tracks.videos: if video_track:
video_track.delete() video_track.delete()
for track in title.tracks: for track in title.tracks:
track.delete() track.delete()
@ -879,6 +788,168 @@ class dl:
keys[str(title)][str(track)].update(drm.content_keys) keys[str(title)][str(track)].update(drm.content_keys)
export.write_text(jsonpickle.dumps(keys, indent=4), encoding="utf8") export.write_text(jsonpickle.dumps(keys, indent=4), encoding="utf8")
def download_track(
self,
service: Service,
track: AnyTrack,
prepare_drm: Callable,
progress: partial
):
if DOWNLOAD_LICENCE_ONLY.is_set():
progress(downloaded="[yellow]SKIPPING")
if DOWNLOAD_CANCELLED.is_set():
progress(downloaded="[yellow]CANCELLED")
return
proxy = next(iter(service.session.proxies.values()), None)
save_path = config.directories.temp / f"{track.__class__.__name__}_{track.id}.mp4"
if isinstance(track, Subtitle):
save_path = save_path.with_suffix(f".{track.codec.extension}")
if track.descriptor != track.Descriptor.URL:
save_dir = save_path.with_name(save_path.name + "_segments")
else:
save_dir = save_path.parent
def cleanup():
# track file (e.g., "foo.mp4")
save_path.unlink(missing_ok=True)
# aria2c control file (e.g., "foo.mp4.aria2")
save_path.with_suffix(f"{save_path.suffix}.aria2").unlink(missing_ok=True)
if save_dir.exists() and save_dir.name.endswith("_segments"):
shutil.rmtree(save_dir)
if not DOWNLOAD_LICENCE_ONLY.is_set():
if config.directories.temp.is_file():
self.log.error(f"Temp Directory '{config.directories.temp}' must be a Directory, not a file")
sys.exit(1)
config.directories.temp.mkdir(parents=True, exist_ok=True)
# Delete any pre-existing temp files matching this track.
# We can't re-use or continue downloading these tracks as they do not use a
# lock file. Or at least the majority don't. Even if they did I've encountered
# corruptions caused by sudden interruptions to the lock file.
cleanup()
try:
if track.descriptor == track.Descriptor.M3U:
HLS.download_track(
track=track,
save_path=save_path,
save_dir=save_dir,
progress=progress,
session=service.session,
proxy=proxy,
license_widevine=prepare_drm
)
elif track.descriptor == track.Descriptor.MPD:
DASH.download_track(
track=track,
save_path=save_path,
save_dir=save_dir,
progress=progress,
session=service.session,
proxy=proxy,
license_widevine=prepare_drm
)
elif track.descriptor == track.Descriptor.URL:
try:
if not track.drm and isinstance(track, (Video, Audio)):
# the service might not have explicitly defined the `drm` property
# try find widevine DRM information from the init data of URL
try:
track.drm = [Widevine.from_track(track, service.session)]
except Widevine.Exceptions.PSSHNotFound:
# it might not have Widevine DRM, or might not have found the PSSH
self.log.warning("No Widevine PSSH was found for this track, is it DRM free?")
if track.drm:
track_kid = track.get_key_id(session=service.session)
drm = track.drm[0] # just use the first supported DRM system for now
if isinstance(drm, Widevine):
# license and grab content keys
if not prepare_drm:
raise ValueError("prepare_drm func must be supplied to use Widevine DRM")
progress(downloaded="LICENSING")
prepare_drm(drm, track_kid=track_kid)
progress(downloaded="[yellow]LICENSED")
else:
drm = None
if DOWNLOAD_LICENCE_ONLY.is_set():
progress(downloaded="[yellow]SKIPPED")
else:
for status_update in downloader(
urls=track.url,
output_dir=save_path.parent,
filename=save_path.name,
headers=service.session.headers,
cookies=service.session.cookies,
proxy=proxy
):
file_downloaded = status_update.get("file_downloaded")
if not file_downloaded:
progress(**status_update)
track.path = save_path
if callable(track.OnDownloaded):
track.OnDownloaded()
if drm:
progress(downloaded="Decrypting", completed=0, total=100)
drm.decrypt(save_path)
track.drm = None
if callable(track.OnDecrypted):
track.OnDecrypted(drm)
progress(downloaded="Decrypted", completed=100)
if isinstance(track, Subtitle) and \
track.codec not in (Subtitle.Codec.fVTT, Subtitle.Codec.fTTML):
track_data = track.path.read_bytes()
track_data = try_ensure_utf8(track_data)
track_data = track_data.decode("utf8"). \
replace("&lrm;", html.unescape("&lrm;")). \
replace("&rlm;", html.unescape("&rlm;")). \
encode("utf8")
track.path.write_bytes(track_data)
progress(downloaded="Downloaded")
except KeyboardInterrupt:
DOWNLOAD_CANCELLED.set()
progress(downloaded="[yellow]CANCELLED")
raise
except Exception:
DOWNLOAD_CANCELLED.set()
progress(downloaded="[red]FAILED")
raise
except (Exception, KeyboardInterrupt):
if not DOWNLOAD_LICENCE_ONLY.is_set():
cleanup()
raise
if DOWNLOAD_CANCELLED.is_set():
# we stopped during the download, let's exit
return
if not DOWNLOAD_LICENCE_ONLY.is_set():
if track.path.stat().st_size <= 3: # Empty UTF-8 BOM == 3 bytes
raise IOError("Download failed, the downloaded file is empty.")
@staticmethod
def get_profile(service: str) -> Optional[str]:
"""Get profile for Service from config."""
profile = config.profiles.get(service)
if profile is False:
return None # auth-less service if `false` in config
if not profile:
profile = config.profiles.get("default")
if not profile:
raise ValueError(f"No profile has been defined for '{service}' in the config.")
return profile
@staticmethod @staticmethod
def get_cookie_path(service: str, profile: Optional[str]) -> Optional[Path]: def get_cookie_path(service: str, profile: Optional[str]) -> Optional[Path]:
"""Get Service Cookie File Path for Profile.""" """Get Service Cookie File Path for Profile."""
@ -937,21 +1008,21 @@ class dl:
return Credential.loads(credentials) # type: ignore return Credential.loads(credentials) # type: ignore
@staticmethod @staticmethod
def get_cdm(service: str, profile: Optional[str] = None) -> Optional[WidevineCdm]: def get_cdm(service: str, profile: Optional[str] = None) -> WidevineCdm:
""" """
Get CDM for a specified service (either Local or Remote CDM). Get CDM for a specified service (either Local or Remote CDM).
Raises a ValueError if there's a problem getting a CDM. Raises a ValueError if there's a problem getting a CDM.
""" """
cdm_name = config.cdm.get(service) or config.cdm.get("default") cdm_name = config.cdm.get(service) or config.cdm.get("default")
if not cdm_name: if not cdm_name:
return None raise ValueError("A CDM to use wasn't listed in the config")
if isinstance(cdm_name, dict): if isinstance(cdm_name, dict):
if not profile: if not profile:
return None raise ValueError("CDM config is mapped for profiles, but no profile was chosen")
cdm_name = cdm_name.get(profile) or config.cdm.get("default") cdm_name = cdm_name.get(profile) or config.cdm.get("default")
if not cdm_name: if not cdm_name:
return None raise ValueError(f"A CDM to use was not mapped for the profile {profile}")
cdm_api = next(iter(x for x in config.remote_cdm if x["name"] == cdm_name), None) cdm_api = next(iter(x for x in config.remote_cdm if x["name"] == cdm_name), None)
if cdm_api: if cdm_api:

View File

@ -1,17 +1,10 @@
import logging import logging
import os
import shutil import shutil
import sys
from pathlib import Path
from typing import Optional from typing import Optional
import click import click
from rich.padding import Padding
from rich.table import Table
from rich.tree import Tree
from devine.core.config import POSSIBLE_CONFIG_PATHS, config, config_path from devine.core.config import config
from devine.core.console import console
from devine.core.constants import context_settings from devine.core.constants import context_settings
from devine.core.services import Services from devine.core.services import Services
@ -25,42 +18,13 @@ def env() -> None:
def info() -> None: def info() -> None:
"""Displays information about the current environment.""" """Displays information about the current environment."""
log = logging.getLogger("env") log = logging.getLogger("env")
log.info(f"[Root Config] : {config.directories.user_configs / config.filenames.root_config}")
if config_path: log.info(f"[Cookies] : {config.directories.cookies}")
log.info(f"Config loaded from {config_path}") log.info(f"[WVDs] : {config.directories.wvds}")
else: log.info(f"[Cache] : {config.directories.cache}")
tree = Tree("No config file found, you can use any of the following locations:") log.info(f"[Logs] : {config.directories.logs}")
for i, path in enumerate(POSSIBLE_CONFIG_PATHS, start=1): log.info(f"[Temp Files] : {config.directories.temp}")
tree.add(f"[repr.number]{i}.[/] [text2]{path.resolve()}[/]") log.info(f"[Downloads] : {config.directories.downloads}")
console.print(Padding(
tree,
(0, 5)
))
table = Table(title="Directories", expand=True)
table.add_column("Name", no_wrap=True)
table.add_column("Path")
path_vars = {
x: Path(os.getenv(x))
for x in ("TEMP", "APPDATA", "LOCALAPPDATA", "USERPROFILE")
if sys.platform == "win32" and os.getenv(x)
}
for name in sorted(dir(config.directories)):
if name.startswith("__") or name == "app_dirs":
continue
path = getattr(config.directories, name).resolve()
for var, var_path in path_vars.items():
if path.is_relative_to(var_path):
path = rf"%{var}%\{path.relative_to(var_path)}"
break
table.add_row(name.title(), str(path))
console.print(Padding(
table,
(1, 5)
))
@env.group(name="clear", short_help="Clear an environment directory.", context_settings=context_settings) @env.group(name="clear", short_help="Clear an environment directory.", context_settings=context_settings)

View File

@ -1,166 +0,0 @@
from __future__ import annotations
import logging
import re
import sys
from typing import Any, Optional
import click
import yaml
from rich.padding import Padding
from rich.rule import Rule
from rich.tree import Tree
from devine.commands.dl import dl
from devine.core import binaries
from devine.core.config import config
from devine.core.console import console
from devine.core.constants import context_settings
from devine.core.proxies import Basic, Hola, NordVPN
from devine.core.service import Service
from devine.core.services import Services
from devine.core.utils.click_types import ContextData
from devine.core.utils.collections import merge_dict
@click.command(
short_help="Search for titles from a Service.",
cls=Services,
context_settings=dict(
**context_settings,
token_normalize_func=Services.get_tag
))
@click.option("-p", "--profile", type=str, default=None,
help="Profile to use for Credentials and Cookies (if available).")
@click.option("--proxy", type=str, default=None,
help="Proxy URI to use. If a 2-letter country is provided, it will try get a proxy from the config.")
@click.option("--no-proxy", is_flag=True, default=False,
help="Force disable all proxy use.")
@click.pass_context
def search(
ctx: click.Context,
no_proxy: bool,
profile: Optional[str] = None,
proxy: Optional[str] = None
):
if not ctx.invoked_subcommand:
raise ValueError("A subcommand to invoke was not specified, the main code cannot continue.")
log = logging.getLogger("search")
service = Services.get_tag(ctx.invoked_subcommand)
profile = profile
if profile:
log.info(f"Using profile: '{profile}'")
with console.status("Loading Service Config...", spinner="dots"):
service_config_path = Services.get_path(service) / config.filenames.config
if service_config_path.exists():
service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8"))
log.info("Service Config loaded")
else:
service_config = {}
merge_dict(config.services.get(service), service_config)
proxy_providers = []
if no_proxy:
ctx.params["proxy"] = None
else:
with console.status("Loading Proxy Providers...", spinner="dots"):
if config.proxy_providers.get("basic"):
proxy_providers.append(Basic(**config.proxy_providers["basic"]))
if config.proxy_providers.get("nordvpn"):
proxy_providers.append(NordVPN(**config.proxy_providers["nordvpn"]))
if binaries.HolaProxy:
proxy_providers.append(Hola())
for proxy_provider in proxy_providers:
log.info(f"Loaded {proxy_provider.__class__.__name__}: {proxy_provider}")
if proxy:
requested_provider = None
if re.match(r"^[a-z]+:.+$", proxy, re.IGNORECASE):
# requesting proxy from a specific proxy provider
requested_provider, proxy = proxy.split(":", maxsplit=1)
if re.match(r"^[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE):
proxy = proxy.lower()
with console.status(f"Getting a Proxy to {proxy}...", spinner="dots"):
if requested_provider:
proxy_provider = next((
x
for x in proxy_providers
if x.__class__.__name__.lower() == requested_provider
), None)
if not proxy_provider:
log.error(f"The proxy provider '{requested_provider}' was not recognised.")
sys.exit(1)
proxy_uri = proxy_provider.get_proxy(proxy)
if not proxy_uri:
log.error(f"The proxy provider {requested_provider} had no proxy for {proxy}")
sys.exit(1)
proxy = ctx.params["proxy"] = proxy_uri
log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
else:
for proxy_provider in proxy_providers:
proxy_uri = proxy_provider.get_proxy(proxy)
if proxy_uri:
proxy = ctx.params["proxy"] = proxy_uri
log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
break
else:
log.info(f"Using explicit Proxy: {proxy}")
ctx.obj = ContextData(
config=service_config,
cdm=None,
proxy_providers=proxy_providers,
profile=profile
)
@search.result_callback()
def result(service: Service, profile: Optional[str] = None, **_: Any) -> None:
log = logging.getLogger("search")
service_tag = service.__class__.__name__
with console.status("Authenticating with Service...", spinner="dots"):
cookies = dl.get_cookie_jar(service_tag, profile)
credential = dl.get_credentials(service_tag, profile)
service.authenticate(cookies, credential)
if cookies or credential:
log.info("Authenticated with Service")
search_results = Tree("Search Results", hide_root=True)
with console.status("Searching...", spinner="dots"):
for result in service.search():
result_text = f"[bold text]{result.title}[/]"
if result.url:
result_text = f"[link={result.url}]{result_text}[/link]"
if result.label:
result_text += f" [pink]{result.label}[/]"
if result.description:
result_text += f"\n[text2]{result.description}[/]"
result_text += f"\n[bright_black]id: {result.id}[/]"
search_results.add(result_text + "\n")
# update cookies
cookie_file = dl.get_cookie_path(service_tag, profile)
if cookie_file:
dl.save_cookies(cookie_file, service.session.cookies)
console.print(Padding(
Rule(f"[rule.text]{len(search_results.children)} Search Results"),
(1, 2)
))
if search_results.children:
console.print(Padding(
search_results,
(0, 5)
))
else:
console.print(Padding(
"[bold text]No matches[/]\n[bright_black]Please check spelling and search again....[/]",
(0, 5)
))

View File

@ -2,9 +2,9 @@ import subprocess
import click import click
from devine.core import binaries
from devine.core.config import config from devine.core.config import config
from devine.core.constants import context_settings from devine.core.constants import context_settings
from devine.core.utilities import get_binary_path
@click.command( @click.command(
@ -29,10 +29,11 @@ def serve(host: str, port: int, caddy: bool) -> None:
from pywidevine import serve from pywidevine import serve
if caddy: if caddy:
if not binaries.Caddy: executable = get_binary_path("caddy")
if not executable:
raise click.ClickException("Caddy executable \"caddy\" not found but is required for --caddy.") raise click.ClickException("Caddy executable \"caddy\" not found but is required for --caddy.")
caddy_p = subprocess.Popen([ caddy_p = subprocess.Popen([
binaries.Caddy, executable,
"run", "run",
"--config", str(config.directories.user_configs / "Caddyfile") "--config", str(config.directories.user_configs / "Caddyfile")
]) ])

View File

@ -4,8 +4,8 @@ from pathlib import Path
import click import click
from pymediainfo import MediaInfo from pymediainfo import MediaInfo
from devine.core import binaries
from devine.core.constants import context_settings from devine.core.constants import context_settings
from devine.core.utilities import get_binary_path
@click.group(short_help="Various helper scripts and programs.", context_settings=context_settings) @click.group(short_help="Various helper scripts and programs.", context_settings=context_settings)
@ -38,7 +38,8 @@ def crop(path: Path, aspect: str, letter: bool, offset: int, preview: bool) -> N
as it may go from being 2px away from a perfect crop, to 20px over-cropping as it may go from being 2px away from a perfect crop, to 20px over-cropping
again due to sub-sampled chroma. again due to sub-sampled chroma.
""" """
if not binaries.FFMPEG: executable = get_binary_path("ffmpeg")
if not executable:
raise click.ClickException("FFmpeg executable \"ffmpeg\" not found but is required.") raise click.ClickException("FFmpeg executable \"ffmpeg\" not found but is required.")
if path.is_dir(): if path.is_dir():
@ -86,7 +87,7 @@ def crop(path: Path, aspect: str, letter: bool, offset: int, preview: bool) -> N
]))))] ]))))]
ffmpeg_call = subprocess.Popen([ ffmpeg_call = subprocess.Popen([
binaries.FFMPEG, "-y", executable, "-y",
"-i", str(video_path), "-i", str(video_path),
"-map", "0:v:0", "-map", "0:v:0",
"-c", "copy", "-c", "copy",
@ -94,7 +95,7 @@ def crop(path: Path, aspect: str, letter: bool, offset: int, preview: bool) -> N
] + out_path, stdout=subprocess.PIPE) ] + out_path, stdout=subprocess.PIPE)
try: try:
if preview: if preview:
previewer = binaries.MPV or binaries.FFPlay previewer = get_binary_path("mpv", "ffplay")
if not previewer: if not previewer:
raise click.ClickException("MPV/FFplay executables weren't found but are required for previewing.") raise click.ClickException("MPV/FFplay executables weren't found but are required for previewing.")
subprocess.Popen((previewer, "-"), stdin=ffmpeg_call.stdout) subprocess.Popen((previewer, "-"), stdin=ffmpeg_call.stdout)
@ -119,7 +120,8 @@ def range_(path: Path, full: bool, preview: bool) -> None:
then you're video may have the range set to the wrong value. Flip its range to the 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. opposite value and see if that fixes it.
""" """
if not binaries.FFMPEG: executable = get_binary_path("ffmpeg")
if not executable:
raise click.ClickException("FFmpeg executable \"ffmpeg\" not found but is required.") raise click.ClickException("FFmpeg executable \"ffmpeg\" not found but is required.")
if path.is_dir(): if path.is_dir():
@ -155,7 +157,7 @@ def range_(path: Path, full: bool, preview: bool) -> None:
]))))] ]))))]
ffmpeg_call = subprocess.Popen([ ffmpeg_call = subprocess.Popen([
binaries.FFMPEG, "-y", executable, "-y",
"-i", str(video_path), "-i", str(video_path),
"-map", "0:v:0", "-map", "0:v:0",
"-c", "copy", "-c", "copy",
@ -163,7 +165,7 @@ def range_(path: Path, full: bool, preview: bool) -> None:
] + out_path, stdout=subprocess.PIPE) ] + out_path, stdout=subprocess.PIPE)
try: try:
if preview: if preview:
previewer = binaries.MPV or binaries.FFPlay previewer = get_binary_path("mpv", "ffplay")
if not previewer: if not previewer:
raise click.ClickException("MPV/FFplay executables weren't found but are required for previewing.") raise click.ClickException("MPV/FFplay executables weren't found but are required for previewing.")
subprocess.Popen((previewer, "-"), stdin=ffmpeg_call.stdout) subprocess.Popen((previewer, "-"), stdin=ffmpeg_call.stdout)
@ -186,7 +188,8 @@ def test(path: Path, map_: str) -> None:
You may choose specific streams using the -m/--map parameter. E.g., 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. '0:v:0' to test the first video stream, or '0:a' to test all audio streams.
""" """
if not binaries.FFMPEG: executable = get_binary_path("ffmpeg")
if not executable:
raise click.ClickException("FFmpeg executable \"ffmpeg\" not found but is required.") raise click.ClickException("FFmpeg executable \"ffmpeg\" not found but is required.")
if path.is_dir(): if path.is_dir():
@ -196,7 +199,7 @@ def test(path: Path, map_: str) -> None:
for video_path in paths: for video_path in paths:
print("Starting...") print("Starting...")
p = subprocess.Popen([ p = subprocess.Popen([
binaries.FFMPEG, "-hide_banner", executable, "-hide_banner",
"-benchmark", "-benchmark",
"-i", str(video_path), "-i", str(video_path),
"-map", map_, "-map", map_,

View File

@ -38,7 +38,6 @@ def add(paths: list[Path]) -> None:
else: else:
# TODO: Check for and log errors # TODO: Check for and log errors
_ = Device.load(path) # test if WVD is valid _ = Device.load(path) # test if WVD is valid
dst_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(path, dst_path) shutil.move(path, dst_path)
log.info(f"Added {path.stem}") log.info(f"Added {path.stem}")
@ -84,10 +83,6 @@ def parse(path: Path) -> None:
log = logging.getLogger("wvd") log = logging.getLogger("wvd")
if not path.exists():
console.log(f"[bright_blue]{path.absolute()}[/] does not exist...")
return
device = Device.load(path) device = Device.load(path)
log.info(f"System ID: {device.system_id}") log.info(f"System ID: {device.system_id}")
@ -120,23 +115,9 @@ def dump(wvd_paths: list[Path], out_dir: Path) -> None:
If the path is relative, with no file extension, it will dump the WVD in the WVDs If the path is relative, with no file extension, it will dump the WVD in the WVDs
directory. directory.
""" """
log = logging.getLogger("wvd") if wvd_paths == (Path(""),):
wvd_paths = list(config.directories.wvds.iterdir())
if wvd_paths == (): for wvd_path, out_path in zip(wvd_paths, (out_dir / x.stem for x in wvd_paths)):
if not config.directories.wvds.exists():
console.log(f"[bright_blue]{config.directories.wvds.absolute()}[/] does not exist...")
wvd_paths = list(
x
for x in config.directories.wvds.iterdir()
if x.is_file() and x.suffix.lower() == ".wvd"
)
if not wvd_paths:
console.log(f"[bright_blue]{config.directories.wvds.absolute()}[/] is empty...")
for i, (wvd_path, out_path) in enumerate(zip(wvd_paths, (out_dir / x.stem for x in wvd_paths))):
if i > 0:
log.info("")
try: try:
named = not wvd_path.suffix and wvd_path.relative_to(Path("")) named = not wvd_path.suffix and wvd_path.relative_to(Path(""))
except ValueError: except ValueError:
@ -145,9 +126,10 @@ def dump(wvd_paths: list[Path], out_dir: Path) -> None:
wvd_path = config.directories.wvds / f"{wvd_path.stem}.wvd" wvd_path = config.directories.wvds / f"{wvd_path.stem}.wvd"
out_path.mkdir(parents=True, exist_ok=True) out_path.mkdir(parents=True, exist_ok=True)
log.info(f"Dumping: {wvd_path}")
device = Device.load(wvd_path) device = Device.load(wvd_path)
log = logging.getLogger("wvd")
log.info(f"Dumping: {wvd_path}")
log.info(f"L{device.security_level} {device.system_id} {device.type.name}") log.info(f"L{device.security_level} {device.system_id} {device.type.name}")
log.info(f"Saving to: {out_path}") log.info(f"Saving to: {out_path}")

View File

@ -1 +1 @@
__version__ = "3.3.3" __version__ = "2.1.0"

View File

@ -1,46 +0,0 @@
import shutil
import sys
from pathlib import Path
from typing import Optional
__shaka_platform = {
"win32": "win",
"darwin": "osx"
}.get(sys.platform, sys.platform)
def find(*names: str) -> Optional[Path]:
"""Find the path of the first found binary name."""
for name in names:
path = shutil.which(name)
if path:
return Path(path)
return None
FFMPEG = find("ffmpeg")
FFProbe = find("ffprobe")
FFPlay = find("ffplay")
SubtitleEdit = find("SubtitleEdit")
ShakaPackager = find(
"shaka-packager",
"packager",
f"packager-{__shaka_platform}",
f"packager-{__shaka_platform}-arm64",
f"packager-{__shaka_platform}-x64"
)
Aria2 = find("aria2c", "aria2")
CCExtractor = find(
"ccextractor",
"ccextractorwin",
"ccextractorwinfull"
)
HolaProxy = find("hola-proxy")
MPV = find("mpv")
Caddy = find("caddy")
__all__ = (
"FFMPEG", "FFProbe", "FFPlay", "SubtitleEdit", "ShakaPackager",
"Aria2", "CCExtractor", "HolaProxy", "MPV", "Caddy", "find"
)

View File

@ -17,7 +17,6 @@ class Config:
commands = namespace_dir / "commands" commands = namespace_dir / "commands"
services = namespace_dir / "services" services = namespace_dir / "services"
vaults = namespace_dir / "vaults" vaults = namespace_dir / "vaults"
fonts = namespace_dir / "fonts"
user_configs = Path(app_dirs.user_config_dir) user_configs = Path(app_dirs.user_config_dir)
data = Path(app_dirs.user_data_dir) data = Path(app_dirs.user_data_dir)
downloads = Path.home() / "Downloads" / "devine" downloads = Path.home() / "Downloads" / "devine"
@ -77,27 +76,29 @@ class Config:
return cls(**yaml.safe_load(path.read_text(encoding="utf8")) or {}) return cls(**yaml.safe_load(path.read_text(encoding="utf8")) or {})
# noinspection PyProtectedMember
POSSIBLE_CONFIG_PATHS = (
# The Devine Namespace Folder (e.g., %appdata%/Python/Python311/site-packages/devine)
Config._Directories.namespace_dir / Config._Filenames.root_config,
# The Parent Folder to the Devine Namespace Folder (e.g., %appdata%/Python/Python311/site-packages)
Config._Directories.namespace_dir.parent / Config._Filenames.root_config,
# The AppDirs User Config Folder (e.g., %localappdata%/devine)
Config._Directories.user_configs / Config._Filenames.root_config
)
def get_config_path() -> Optional[Path]: def get_config_path() -> Optional[Path]:
""" """
Get Path to Config from any one of the possible locations. Get Path to Config from various locations.
Looks for a config file in the following folders in order:
1. The Devine Namespace Folder (e.g., %appdata%/Python/Python311/site-packages/devine)
2. The Parent Folder to the Devine Namespace Folder (e.g., %appdata%/Python/Python311/site-packages)
3. The AppDirs User Config Folder (e.g., %localappdata%/devine)
Returns None if no config file could be found. Returns None if no config file could be found.
""" """
for path in POSSIBLE_CONFIG_PATHS: # noinspection PyProtectedMember
if path.exists(): path = Config._Directories.namespace_dir / Config._Filenames.root_config
return path if not path.exists():
return None # noinspection PyProtectedMember
path = Config._Directories.namespace_dir.parent / Config._Filenames.root_config
if not path.exists():
# noinspection PyProtectedMember
path = Config._Directories.user_configs / Config._Filenames.root_config
if not path.exists():
path = None
return path
config_path = get_config_path() config_path = get_config_path()

View File

@ -5,6 +5,9 @@ DOWNLOAD_CANCELLED = Event()
DOWNLOAD_LICENCE_ONLY = Event() DOWNLOAD_LICENCE_ONLY = Event()
DRM_SORT_MAP = ["ClearKey", "Widevine"] DRM_SORT_MAP = ["ClearKey", "Widevine"]
TERRITORY_MAP = {
"Hong Kong SAR China": "Hong Kong"
}
LANGUAGE_MAX_DISTANCE = 5 # this is max to be considered "same", e.g., en, en-US, en-AU LANGUAGE_MAX_DISTANCE = 5 # this is max to be considered "same", e.g., en, en-US, en-AU
VIDEO_CODEC_MAP = { VIDEO_CODEC_MAP = {
"AVC": "H.264", "AVC": "H.264",

View File

@ -1,5 +1,13 @@
from ..config import config
from .aria2c import aria2c from .aria2c import aria2c
from .curl_impersonate import curl_impersonate from .curl_impersonate import curl_impersonate
from .requests import requests from .requests import requests
__all__ = ("aria2c", "curl_impersonate", "requests") downloader = {
"aria2c": aria2c,
"curl_impersonate": curl_impersonate,
"requests": requests
}[config.downloader]
__all__ = ("downloader", "aria2c", "curl_impersonate", "requests")

View File

@ -15,11 +15,10 @@ from requests.cookies import cookiejar_from_dict, get_cookie_header
from rich import filesize from rich import filesize
from rich.text import Text from rich.text import Text
from devine.core import binaries
from devine.core.config import config from devine.core.config import config
from devine.core.console import console from devine.core.console import console
from devine.core.constants import DOWNLOAD_CANCELLED from devine.core.constants import DOWNLOAD_CANCELLED
from devine.core.utilities import get_extension, get_free_port from devine.core.utilities import get_binary_path, get_extension, get_free_port
def rpc(caller: Callable, secret: str, method: str, params: Optional[list[Any]] = None) -> Any: def rpc(caller: Callable, secret: str, method: str, params: Optional[list[Any]] = None) -> Any:
@ -88,7 +87,8 @@ def download(
if not isinstance(urls, list): if not isinstance(urls, list):
urls = [urls] urls = [urls]
if not binaries.Aria2: executable = get_binary_path("aria2c", "aria2")
if not executable:
raise EnvironmentError("Aria2c executable not found...") raise EnvironmentError("Aria2c executable not found...")
if proxy and not proxy.lower().startswith("http://"): if proxy and not proxy.lower().startswith("http://"):
@ -186,7 +186,7 @@ def download(
try: try:
p = subprocess.Popen( p = subprocess.Popen(
[ [
binaries.Aria2, executable,
*arguments *arguments
], ],
stdin=subprocess.PIPE, stdin=subprocess.PIPE,

View File

@ -17,13 +17,13 @@ MAX_ATTEMPTS = 5
RETRY_WAIT = 2 RETRY_WAIT = 2
CHUNK_SIZE = 1024 CHUNK_SIZE = 1024
PROGRESS_WINDOW = 5 PROGRESS_WINDOW = 5
BROWSER = config.curl_impersonate.get("browser", "chrome124") BROWSER = config.curl_impersonate.get("browser", "chrome120")
def download( def download(
url: str, url: str,
save_path: Path, save_path: Path,
session: Session, session: Optional[Session] = None,
**kwargs: Any **kwargs: Any
) -> Generator[dict[str, Any], None, None]: ) -> Generator[dict[str, Any], None, None]:
""" """
@ -52,6 +52,9 @@ def download(
for one-time request changes like a header, cookie, or proxy. For example, for one-time request changes like a header, cookie, or proxy. For example,
to request Byte-ranges use e.g., `headers={"Range": "bytes=0-128"}`. to request Byte-ranges use e.g., `headers={"Range": "bytes=0-128"}`.
""" """
if not session:
session = Session(impersonate=BROWSER)
save_dir = save_path.parent save_dir = save_path.parent
control_file = save_path.with_name(f"{save_path.name}.!dev") control_file = save_path.with_name(f"{save_path.name}.!dev")
@ -221,7 +224,10 @@ def curl_impersonate(
if cookies: if cookies:
session.cookies.update(cookies) session.cookies.update(cookies)
if proxy: if proxy:
session.proxies.update({"all": proxy}) session.proxies.update({
"http": proxy.replace("https://", "http://"),
"https": proxy.replace("https://", "http://")
})
yield dict(total=len(urls)) yield dict(total=len(urls))

View File

@ -1,14 +1,12 @@
import math import math
import os
import time import time
from concurrent.futures import as_completed from concurrent import futures
from concurrent.futures.thread import ThreadPoolExecutor from concurrent.futures.thread import ThreadPoolExecutor
from http.cookiejar import CookieJar from http.cookiejar import CookieJar
from pathlib import Path from pathlib import Path
from typing import Any, Generator, MutableMapping, Optional, Union from typing import Any, Generator, MutableMapping, Optional, Union
from requests import Session from requests import Session
from requests.adapters import HTTPAdapter
from rich import filesize from rich import filesize
from devine.core.constants import DOWNLOAD_CANCELLED from devine.core.constants import DOWNLOAD_CANCELLED
@ -19,14 +17,11 @@ RETRY_WAIT = 2
CHUNK_SIZE = 1024 CHUNK_SIZE = 1024
PROGRESS_WINDOW = 5 PROGRESS_WINDOW = 5
DOWNLOAD_SIZES = []
LAST_SPEED_REFRESH = time.time()
def download( def download(
url: str, url: str,
save_path: Path, save_path: Path,
session: Optional[Session] = None, session: Optional[Session] = None,
segmented: bool = False,
**kwargs: Any **kwargs: Any
) -> Generator[dict[str, Any], None, None]: ) -> Generator[dict[str, Any], None, None]:
""" """
@ -51,13 +46,10 @@ def download(
session: The Requests Session to make HTTP requests with. Useful to set Header, session: The Requests Session to make HTTP requests with. Useful to set Header,
Cookie, and Proxy data. Connections are saved and re-used with the session Cookie, and Proxy data. Connections are saved and re-used with the session
so long as the server keeps the connection alive. so long as the server keeps the connection alive.
segmented: If downloads are segments or parts of one bigger file.
kwargs: Any extra keyword arguments to pass to the session.get() call. Use this kwargs: Any extra keyword arguments to pass to the session.get() call. Use this
for one-time request changes like a header, cookie, or proxy. For example, for one-time request changes like a header, cookie, or proxy. For example,
to request Byte-ranges use e.g., `headers={"Range": "bytes=0-128"}`. to request Byte-ranges use e.g., `headers={"Range": "bytes=0-128"}`.
""" """
global LAST_SPEED_REFRESH
session = session or Session() session = session or Session()
save_dir = save_path.parent save_dir = save_path.parent
@ -75,7 +67,6 @@ def download(
file_downloaded=save_path, file_downloaded=save_path,
written=save_path.stat().st_size written=save_path.stat().st_size
) )
# TODO: This should return, potential recovery bug
# TODO: Design a control file format so we know how much of the file is missing # TODO: Design a control file format so we know how much of the file is missing
control_file.write_bytes(b"") control_file.write_bytes(b"")
@ -84,8 +75,6 @@ def download(
try: try:
while True: while True:
written = 0 written = 0
# these are for single-url speed calcs only
download_sizes = [] download_sizes = []
last_speed_refresh = time.time() last_speed_refresh = time.time()
@ -93,17 +82,16 @@ def download(
stream = session.get(url, stream=True, **kwargs) stream = session.get(url, stream=True, **kwargs)
stream.raise_for_status() stream.raise_for_status()
if not segmented: try:
try: content_length = int(stream.headers.get("Content-Length", "0"))
content_length = int(stream.headers.get("Content-Length", "0")) except ValueError:
except ValueError: content_length = 0
content_length = 0
if content_length > 0: if content_length > 0:
yield dict(total=math.ceil(content_length / CHUNK_SIZE)) yield dict(total=math.ceil(content_length / CHUNK_SIZE))
else: else:
# we have no data to calculate total chunks # we have no data to calculate total chunks
yield dict(total=None) # indeterminate mode yield dict(total=None) # indeterminate mode
with open(save_path, "wb") as f: with open(save_path, "wb") as f:
for chunk in stream.iter_content(chunk_size=CHUNK_SIZE): for chunk in stream.iter_content(chunk_size=CHUNK_SIZE):
@ -111,32 +99,23 @@ def download(
f.write(chunk) f.write(chunk)
written += download_size written += download_size
if not segmented: yield dict(advance=1)
yield dict(advance=1)
now = time.time()
time_since = now - last_speed_refresh
download_sizes.append(download_size)
if time_since > PROGRESS_WINDOW or download_size < CHUNK_SIZE:
data_size = sum(download_sizes)
download_speed = math.ceil(data_size / (time_since or 1))
yield dict(downloaded=f"{filesize.decimal(download_speed)}/s")
last_speed_refresh = now
download_sizes.clear()
yield dict(file_downloaded=save_path, written=written) now = time.time()
time_since = now - last_speed_refresh
if segmented: download_sizes.append(download_size)
yield dict(advance=1) if time_since > PROGRESS_WINDOW or download_size < CHUNK_SIZE:
now = time.time() data_size = sum(download_sizes)
time_since = now - LAST_SPEED_REFRESH download_speed = math.ceil(data_size / (time_since or 1))
if written: # no size == skipped dl yield dict(downloaded=f"{filesize.decimal(download_speed)}/s")
DOWNLOAD_SIZES.append(written) last_speed_refresh = now
if DOWNLOAD_SIZES and time_since > PROGRESS_WINDOW: download_sizes.clear()
data_size = sum(DOWNLOAD_SIZES)
download_speed = math.ceil(data_size / (time_since or 1)) yield dict(
yield dict(downloaded=f"{filesize.decimal(download_speed)}/s") file_downloaded=save_path,
LAST_SPEED_REFRESH = now written=written
DOWNLOAD_SIZES.clear() )
break break
except Exception as e: except Exception as e:
save_path.unlink(missing_ok=True) save_path.unlink(missing_ok=True)
@ -216,9 +195,6 @@ def requests(
if not isinstance(urls, list): if not isinstance(urls, list):
urls = [urls] urls = [urls]
if not max_workers:
max_workers = min(32, (os.cpu_count() or 1) + 4)
urls = [ urls = [
dict( dict(
save_path=save_path, save_path=save_path,
@ -235,13 +211,6 @@ def requests(
] ]
session = Session() session = Session()
session.mount("https://", HTTPAdapter(
pool_connections=max_workers,
pool_maxsize=max_workers,
pool_block=True
))
session.mount("http://", session.adapters["https://"])
if headers: if headers:
headers = { headers = {
k: v k: v
@ -256,37 +225,59 @@ def requests(
yield dict(total=len(urls)) yield dict(total=len(urls))
try: download_sizes = []
with ThreadPoolExecutor(max_workers=max_workers) as pool: last_speed_refresh = time.time()
for future in as_completed(
pool.submit( with ThreadPoolExecutor(max_workers=max_workers) as pool:
download, for i, future in enumerate(futures.as_completed((
session=session, pool.submit(
segmented=True, download,
**url session=session,
) **url
for url in urls )
): for url in urls
try: ))):
yield from future.result() file_path, download_size = None, None
except KeyboardInterrupt: try:
DOWNLOAD_CANCELLED.set() # skip pending track downloads for status_update in future.result():
yield dict(downloaded="[yellow]CANCELLING") if status_update.get("file_downloaded") and status_update.get("written"):
pool.shutdown(wait=True, cancel_futures=True) file_path = status_update["file_downloaded"]
yield dict(downloaded="[yellow]CANCELLED") download_size = status_update["written"]
# tell dl that it was cancelled elif len(urls) == 1:
# the pool is already shut down, so exiting loop is fine # these are per-chunk updates, only useful if it's one big file
raise yield status_update
except Exception: except KeyboardInterrupt:
DOWNLOAD_CANCELLED.set() # skip pending track downloads DOWNLOAD_CANCELLED.set() # skip pending track downloads
yield dict(downloaded="[red]FAILING") yield dict(downloaded="[yellow]CANCELLING")
pool.shutdown(wait=True, cancel_futures=True) pool.shutdown(wait=True, cancel_futures=True)
yield dict(downloaded="[red]FAILED") yield dict(downloaded="[yellow]CANCELLED")
# tell dl that it failed # tell dl that it was cancelled
# the pool is already shut down, so exiting loop is fine # the pool is already shut down, so exiting loop is fine
raise raise
finally: except Exception:
DOWNLOAD_SIZES.clear() DOWNLOAD_CANCELLED.set() # skip pending track downloads
yield dict(downloaded="[red]FAILING")
pool.shutdown(wait=True, cancel_futures=True)
yield dict(downloaded="[red]FAILED")
# tell dl that it failed
# the pool is already shut down, so exiting loop is fine
raise
else:
yield dict(file_downloaded=file_path, written=download_size)
yield dict(advance=1)
now = time.time()
time_since = now - last_speed_refresh
if download_size: # no size == skipped dl
download_sizes.append(download_size)
if download_sizes and (time_since > PROGRESS_WINDOW or i == len(urls)):
data_size = sum(download_sizes)
download_speed = math.ceil(data_size / (time_since or 1))
yield dict(downloaded=f"{filesize.decimal(download_speed)}/s")
last_speed_refresh = now
download_sizes.clear()
__all__ = ("requests",) __all__ = ("requests",)

View File

@ -6,10 +6,10 @@ from pathlib import Path
from typing import Optional, Union from typing import Optional, Union
from urllib.parse import urljoin from urllib.parse import urljoin
import requests
from Cryptodome.Cipher import AES from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import unpad from Cryptodome.Util.Padding import pad, unpad
from m3u8.model import Key from m3u8.model import Key
from requests import Session
class ClearKey: class ClearKey:
@ -43,7 +43,7 @@ class ClearKey:
decrypted = AES. \ decrypted = AES. \
new(self.key, AES.MODE_CBC, self.iv). \ new(self.key, AES.MODE_CBC, self.iv). \
decrypt(path.read_bytes()) decrypt(pad(path.read_bytes(), AES.block_size))
try: try:
decrypted = unpad(decrypted, AES.block_size) decrypted = unpad(decrypted, AES.block_size)
@ -58,33 +58,14 @@ class ClearKey:
shutil.move(decrypted_path, path) shutil.move(decrypted_path, path)
@classmethod @classmethod
def from_m3u_key(cls, m3u_key: Key, session: Optional[Session] = None) -> ClearKey: def from_m3u_key(cls, m3u_key: Key, proxy: Optional[str] = None) -> ClearKey:
"""
Load a ClearKey from an M3U(8) Playlist's EXT-X-KEY.
Parameters:
m3u_key: A Key object parsed from a m3u(8) playlist using
the `m3u8` library.
session: Optional session used to request external URIs with.
Useful to set headers, proxies, cookies, and so forth.
"""
if not isinstance(m3u_key, Key): if not isinstance(m3u_key, Key):
raise ValueError(f"Provided M3U Key is in an unexpected type {m3u_key!r}") raise ValueError(f"Provided M3U Key is in an unexpected type {m3u_key!r}")
if not isinstance(session, (Session, type(None))):
raise TypeError(f"Expected session to be a {Session}, not a {type(session)}")
if not m3u_key.method.startswith("AES"): if not m3u_key.method.startswith("AES"):
raise ValueError(f"Provided M3U Key is not an AES Clear Key, {m3u_key.method}") raise ValueError(f"Provided M3U Key is not an AES Clear Key, {m3u_key.method}")
if not m3u_key.uri: if not m3u_key.uri:
raise ValueError("No URI in M3U Key, unable to get Key.") raise ValueError("No URI in M3U Key, unable to get Key.")
if not session:
session = Session()
if not session.headers.get("User-Agent"):
# commonly needed default for HLS playlists
session.headers["User-Agent"] = "smartexoplayer/1.1.0 (Linux;Android 8.0.0) ExoPlayerLib/2.13.3"
if m3u_key.uri.startswith("data:"): if m3u_key.uri.startswith("data:"):
media_types, data = m3u_key.uri[5:].split(",") media_types, data = m3u_key.uri[5:].split(",")
media_types = media_types.split(";") media_types = media_types.split(";")
@ -93,7 +74,13 @@ class ClearKey:
key = data key = data
else: else:
url = urljoin(m3u_key.base_uri, m3u_key.uri) url = urljoin(m3u_key.base_uri, m3u_key.uri)
res = session.get(url) res = requests.get(
url=url,
headers={
"User-Agent": "smartexoplayer/1.1.0 (Linux;Android 8.0.0) ExoPlayerLib/2.13.3"
},
proxies={"all": proxy} if proxy else None
)
res.raise_for_status() res.raise_for_status()
if not res.content: if not res.content:
raise EOFError("Unexpected Empty Response by M3U Key URI.") raise EOFError("Unexpected Empty Response by M3U Key URI.")

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import base64 import base64
import shutil import shutil
import subprocess import subprocess
import sys
import textwrap import textwrap
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Optional, Union from typing import Any, Callable, Optional, Union
@ -16,11 +17,10 @@ from pywidevine.pssh import PSSH
from requests import Session from requests import Session
from rich.text import Text from rich.text import Text
from devine.core import binaries
from devine.core.config import config from devine.core.config import config
from devine.core.console import console from devine.core.console import console
from devine.core.constants import AnyTrack from devine.core.constants import AnyTrack
from devine.core.utilities import get_boxes from devine.core.utilities import get_binary_path, get_boxes
from devine.core.utils.subprocess import ffprobe from devine.core.utils.subprocess import ffprobe
@ -78,7 +78,7 @@ class Widevine:
pssh_boxes: list[Container] = [] pssh_boxes: list[Container] = []
tenc_boxes: list[Container] = [] tenc_boxes: list[Container] = []
if track.descriptor == track.Descriptor.HLS: if track.descriptor == track.Descriptor.M3U:
m3u_url = track.url m3u_url = track.url
master = m3u8.loads(session.get(m3u_url).text, uri=m3u_url) master = m3u8.loads(session.get(m3u_url).text, uri=m3u_url)
pssh_boxes.extend( pssh_boxes.extend(
@ -223,7 +223,9 @@ class Widevine:
if not self.content_keys: if not self.content_keys:
raise ValueError("Cannot decrypt a Track without any Content Keys...") raise ValueError("Cannot decrypt a Track without any Content Keys...")
if not binaries.ShakaPackager: platform = {"win32": "win", "darwin": "osx"}.get(sys.platform, sys.platform)
executable = get_binary_path("shaka-packager", "packager", f"packager-{platform}", f"packager-{platform}-x64")
if not executable:
raise EnvironmentError("Shaka Packager executable not found but is required.") raise EnvironmentError("Shaka Packager executable not found but is required.")
if not path or not path.exists(): if not path or not path.exists():
raise ValueError("Tried to decrypt a file that does not exist.") raise ValueError("Tried to decrypt a file that does not exist.")
@ -250,7 +252,7 @@ class Widevine:
] ]
p = subprocess.Popen( p = subprocess.Popen(
[binaries.ShakaPackager, *arguments], [executable, *arguments],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
universal_newlines=True universal_newlines=True

View File

@ -1,79 +0,0 @@
from __future__ import annotations
from copy import deepcopy
from enum import Enum
from typing import Any, Callable
class Events:
class Types(Enum):
_reserved = 0
# A Track's segment has finished downloading
SEGMENT_DOWNLOADED = 1
# Track has finished downloading
TRACK_DOWNLOADED = 2
# Track has finished decrypting
TRACK_DECRYPTED = 3
# Track has finished repacking
TRACK_REPACKED = 4
# Track is about to be Multiplexed into a Container
TRACK_MULTIPLEX = 5
def __init__(self):
self.__subscriptions: dict[Events.Types, list[Callable]] = {}
self.__ephemeral: dict[Events.Types, list[Callable]] = {}
self.reset()
def reset(self):
"""Reset Event Observer clearing all Subscriptions."""
self.__subscriptions = {
k: []
for k in Events.Types.__members__.values()
}
self.__ephemeral = deepcopy(self.__subscriptions)
def subscribe(self, event_type: Events.Types, callback: Callable, ephemeral: bool = False) -> None:
"""
Subscribe to an Event with a Callback.
Parameters:
event_type: The Events.Type to subscribe to.
callback: The function or lambda to call on event emit.
ephemeral: Unsubscribe the callback from the event on first emit.
Note that this is not thread-safe and may be called multiple
times at roughly the same time.
"""
[self.__subscriptions, self.__ephemeral][ephemeral][event_type].append(callback)
def unsubscribe(self, event_type: Events.Types, callback: Callable) -> None:
"""
Unsubscribe a Callback from an Event.
Parameters:
event_type: The Events.Type to unsubscribe from.
callback: The function or lambda to remove from event emit.
"""
if callback in self.__subscriptions[event_type]:
self.__subscriptions[event_type].remove(callback)
if callback in self.__ephemeral[event_type]:
self.__ephemeral[event_type].remove(callback)
def emit(self, event_type: Events.Types, *args: Any, **kwargs: Any) -> None:
"""
Emit an Event, executing all subscribed Callbacks.
Parameters:
event_type: The Events.Type to emit.
args: Positional arguments to pass to callbacks.
kwargs: Keyword arguments to pass to callbacks.
"""
if event_type not in self.__subscriptions:
raise ValueError(f"Event type \"{event_type}\" is invalid")
for callback in self.__subscriptions[event_type] + self.__ephemeral[event_type]:
callback(*args, **kwargs)
self.__ephemeral[event_type].clear()
events = Events()

View File

@ -16,15 +16,15 @@ from zlib import crc32
import requests import requests
from langcodes import Language, tag_is_valid from langcodes import Language, tag_is_valid
from lxml.etree import Element, ElementTree from lxml.etree import Element
from pywidevine.cdm import Cdm as WidevineCdm from pywidevine.cdm import Cdm as WidevineCdm
from pywidevine.pssh import PSSH from pywidevine.pssh import PSSH
from requests import Session from requests import Session
from devine.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack from devine.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack
from devine.core.downloaders import downloader
from devine.core.downloaders import requests as requests_downloader from devine.core.downloaders import requests as requests_downloader
from devine.core.drm import Widevine from devine.core.drm import Widevine
from devine.core.events import events
from devine.core.tracks import Audio, Subtitle, Tracks, Video from devine.core.tracks import Audio, Subtitle, Tracks, Video
from devine.core.utilities import is_close_match, try_ensure_utf8 from devine.core.utilities import is_close_match, try_ensure_utf8
from devine.core.utils.xml import load_xml from devine.core.utils.xml import load_xml
@ -114,7 +114,6 @@ class DASH:
for rep in adaptation_set.findall("Representation"): for rep in adaptation_set.findall("Representation"):
get = partial(self._get, adaptation_set=adaptation_set, representation=rep) get = partial(self._get, adaptation_set=adaptation_set, representation=rep)
findall = partial(self._findall, adaptation_set=adaptation_set, representation=rep, both=True) findall = partial(self._findall, adaptation_set=adaptation_set, representation=rep, both=True)
segment_base = rep.find("SegmentBase")
codecs = get("codecs") codecs = get("codecs")
content_type = get("contentType") content_type = get("contentType")
@ -142,10 +141,6 @@ class DASH:
if content_type == "video": if content_type == "video":
track_type = Video track_type = Video
track_codec = Video.Codec.from_codecs(codecs) track_codec = Video.Codec.from_codecs(codecs)
track_fps = get("frameRate")
if not track_fps and segment_base is not None:
track_fps = segment_base.get("timescale")
track_args = dict( track_args = dict(
range_=self.get_video_range( range_=self.get_video_range(
codecs, codecs,
@ -155,7 +150,7 @@ class DASH:
bitrate=get("bandwidth") or None, bitrate=get("bandwidth") or None,
width=get("width") or 0, width=get("width") or 0,
height=get("height") or 0, height=get("height") or 0,
fps=track_fps or None fps=get("frameRate") or (rep.find("SegmentBase") or {}).get("timescale") or None
) )
elif content_type == "audio": elif content_type == "audio":
track_type = Audio track_type = Audio
@ -207,19 +202,12 @@ class DASH:
tracks.add(track_type( tracks.add(track_type(
id_=track_id, id_=track_id,
url=self.url, url=(self.url, self.manifest, rep, adaptation_set, period),
codec=track_codec, codec=track_codec,
language=track_lang, language=track_lang,
is_original_lang=language and is_close_match(track_lang, [language]), is_original_lang=language and is_close_match(track_lang, [language]),
descriptor=Video.Descriptor.DASH, descriptor=Video.Descriptor.MPD,
data={ extra=(rep, adaptation_set),
"dash": {
"manifest": self.manifest,
"period": period,
"adaptation_set": adaptation_set,
"representation": rep
}
},
**track_args **track_args
)) ))
@ -236,7 +224,6 @@ class DASH:
progress: partial, progress: partial,
session: Optional[Session] = None, session: Optional[Session] = None,
proxy: Optional[str] = None, proxy: Optional[str] = None,
max_workers: Optional[int] = None,
license_widevine: Optional[Callable] = None license_widevine: Optional[Callable] = None
): ):
if not session: if not session:
@ -251,21 +238,20 @@ class DASH:
log = logging.getLogger("DASH") log = logging.getLogger("DASH")
manifest: ElementTree = track.data["dash"]["manifest"] manifest_url, manifest, representation, adaptation_set, period = track.url
period: Element = track.data["dash"]["period"]
adaptation_set: Element = track.data["dash"]["adaptation_set"]
representation: Element = track.data["dash"]["representation"]
track.drm = DASH.get_drm( track.drm = DASH.get_drm(
representation.findall("ContentProtection") + representation.findall("ContentProtection") +
adaptation_set.findall("ContentProtection") adaptation_set.findall("ContentProtection")
) )
manifest_url_query = urlparse(manifest_url).query
manifest_base_url = manifest.findtext("BaseURL") manifest_base_url = manifest.findtext("BaseURL")
if not manifest_base_url: if not manifest_base_url:
manifest_base_url = track.url manifest_base_url = manifest_url
elif not re.match("^https?://", manifest_base_url, re.IGNORECASE): elif not re.match("^https?://", manifest_base_url, re.IGNORECASE):
manifest_base_url = urljoin(track.url, f"./{manifest_base_url}") manifest_base_url = urljoin(manifest_url, f"./{manifest_base_url}")
period_base_url = urljoin(manifest_base_url, period.findtext("BaseURL")) period_base_url = urljoin(manifest_base_url, period.findtext("BaseURL"))
rep_base_url = urljoin(period_base_url, representation.findtext("BaseURL")) rep_base_url = urljoin(period_base_url, representation.findtext("BaseURL"))
@ -285,16 +271,12 @@ class DASH:
segment_base = adaptation_set.find("SegmentBase") segment_base = adaptation_set.find("SegmentBase")
segments: list[tuple[str, Optional[str]]] = [] segments: list[tuple[str, Optional[str]]] = []
segment_timescale: float = 0
segment_durations: list[int] = []
track_kid: Optional[UUID] = None track_kid: Optional[UUID] = None
if segment_template is not None: if segment_template is not None:
segment_template = copy(segment_template) segment_template = copy(segment_template)
start_number = int(segment_template.get("startNumber") or 1) start_number = int(segment_template.get("startNumber") or 1)
end_number = int(segment_template.get("endNumber") or 0) or None
segment_timeline = segment_template.find("SegmentTimeline") segment_timeline = segment_template.find("SegmentTimeline")
segment_timescale = float(segment_template.get("timescale") or 1)
for item in ("initialization", "media"): for item in ("initialization", "media"):
value = segment_template.get(item) value = segment_template.get(item)
@ -304,10 +286,8 @@ class DASH:
if not rep_base_url: if not rep_base_url:
raise ValueError("Resolved Segment URL is not absolute, and no Base URL is available.") raise ValueError("Resolved Segment URL is not absolute, and no Base URL is available.")
value = urljoin(rep_base_url, value) value = urljoin(rep_base_url, value)
if not urlparse(value).query: if not urlparse(value).query and manifest_url_query:
manifest_url_query = urlparse(track.url).query value += f"?{manifest_url_query}"
if manifest_url_query:
value += f"?{manifest_url_query}"
segment_template.set(item, value) segment_template.set(item, value)
init_url = segment_template.get("initialization") init_url = segment_template.get("initialization")
@ -322,18 +302,17 @@ class DASH:
track_kid = track.get_key_id(init_data) track_kid = track.get_key_id(init_data)
if segment_timeline is not None: if segment_timeline is not None:
seg_time_list = []
current_time = 0 current_time = 0
for s in segment_timeline.findall("S"): for s in segment_timeline.findall("S"):
if s.get("t"): if s.get("t"):
current_time = int(s.get("t")) current_time = int(s.get("t"))
for _ in range(1 + (int(s.get("r") or 0))): for _ in range(1 + (int(s.get("r") or 0))):
segment_durations.append(current_time) seg_time_list.append(current_time)
current_time += int(s.get("d")) current_time += int(s.get("d"))
seg_num_list = list(range(start_number, len(seg_time_list) + start_number))
if not end_number: for t, n in zip(seg_time_list, seg_num_list):
end_number = len(segment_durations)
for t, n in zip(segment_durations, range(start_number, end_number + 1)):
segments.append(( segments.append((
DASH.replace_fields( DASH.replace_fields(
segment_template.get("media"), segment_template.get("media"),
@ -347,12 +326,11 @@ class DASH:
if not period_duration: if not period_duration:
raise ValueError("Duration of the Period was unable to be determined.") raise ValueError("Duration of the Period was unable to be determined.")
period_duration = DASH.pt_to_sec(period_duration) period_duration = DASH.pt_to_sec(period_duration)
segment_duration = float(segment_template.get("duration")) or 1 segment_duration = float(segment_template.get("duration"))
segment_timescale = float(segment_template.get("timescale") or 1)
total_segments = math.ceil(period_duration / (segment_duration / segment_timescale))
if not end_number: for s in range(start_number, start_number + total_segments):
end_number = math.ceil(period_duration / (segment_duration / segment_timescale))
for s in range(start_number, end_number + 1):
segments.append(( segments.append((
DASH.replace_fields( DASH.replace_fields(
segment_template.get("media"), segment_template.get("media"),
@ -362,11 +340,7 @@ class DASH:
Time=s Time=s
), None ), None
)) ))
# TODO: Should we floor/ceil/round, or is int() ok?
segment_durations.append(int(segment_duration))
elif segment_list is not None: elif segment_list is not None:
segment_timescale = float(segment_list.get("timescale") or 1)
init_data = None init_data = None
initialization = segment_list.find("Initialization") initialization = segment_list.find("Initialization")
if initialization is not None: if initialization is not None:
@ -398,7 +372,6 @@ class DASH:
media_url, media_url,
segment_url.get("mediaRange") segment_url.get("mediaRange")
)) ))
segment_durations.append(int(segment_url.get("duration") or 1))
elif segment_base is not None: elif segment_base is not None:
media_range = None media_range = None
init_data = None init_data = None
@ -428,13 +401,9 @@ class DASH:
)) ))
else: else:
log.error("Could not find a way to get segments from this MPD manifest.") log.error("Could not find a way to get segments from this MPD manifest.")
log.debug(track.url) log.debug(manifest_url)
sys.exit(1) sys.exit(1)
# TODO: Should we floor/ceil/round, or is int() ok?
track.data["dash"]["timescale"] = int(segment_timescale)
track.data["dash"]["segment_durations"] = segment_durations
if not track.drm and isinstance(track, (Video, Audio)): if not track.drm and isinstance(track, (Video, Audio)):
try: try:
track.drm = [Widevine.from_init_data(init_data)] track.drm = [Widevine.from_init_data(init_data)]
@ -468,13 +437,12 @@ class DASH:
progress(total=len(segments)) progress(total=len(segments))
downloader = track.downloader downloader_ = downloader
if downloader.__name__ == "aria2c" and any(bytes_range is not None for url, bytes_range in segments): if downloader.__name__ == "aria2c" and any(bytes_range is not None for url, bytes_range in segments):
# aria2(c) is shit and doesn't support the Range header, fallback to the requests downloader # aria2(c) is shit and doesn't support the Range header, fallback to the requests downloader
downloader = requests_downloader downloader_ = requests_downloader
log.warning("Falling back to the requests downloader as aria2(c) doesn't support the Range header")
for status_update in downloader( for status_update in downloader_(
urls=[ urls=[
{ {
"url": url, "url": url,
@ -489,31 +457,23 @@ class DASH:
headers=session.headers, headers=session.headers,
cookies=session.cookies, cookies=session.cookies,
proxy=proxy, proxy=proxy,
max_workers=max_workers max_workers=16
): ):
file_downloaded = status_update.get("file_downloaded") file_downloaded = status_update.get("file_downloaded")
if file_downloaded: if file_downloaded and callable(track.OnSegmentDownloaded):
events.emit(events.Types.SEGMENT_DOWNLOADED, track=track, segment=file_downloaded) track.OnSegmentDownloaded(file_downloaded)
else: else:
downloaded = status_update.get("downloaded") downloaded = status_update.get("downloaded")
if downloaded and downloaded.endswith("/s"): if downloaded and downloaded.endswith("/s"):
status_update["downloaded"] = f"DASH {downloaded}" status_update["downloaded"] = f"DASH {downloaded}"
progress(**status_update) progress(**status_update)
# see https://github.com/devine-dl/devine/issues/71 segments_to_merge = sorted(save_dir.iterdir())
for control_file in save_dir.glob("*.aria2__temp"): progress(downloaded="Merging", completed=0, total=len(segments_to_merge))
control_file.unlink()
segments_to_merge = [
x
for x in sorted(save_dir.iterdir())
if x.is_file()
]
with open(save_path, "wb") as f: with open(save_path, "wb") as f:
if init_data: if init_data:
f.write(init_data) f.write(init_data)
if len(segments_to_merge) > 1:
progress(downloaded="Merging", completed=0, total=len(segments_to_merge))
for segment_file in segments_to_merge: for segment_file in segments_to_merge:
segment_data = segment_file.read_bytes() segment_data = segment_file.read_bytes()
# TODO: fix encoding after decryption? # TODO: fix encoding after decryption?
@ -532,18 +492,15 @@ class DASH:
progress(advance=1) progress(advance=1)
track.path = save_path track.path = save_path
events.emit(events.Types.TRACK_DOWNLOADED, track=track) if callable(track.OnDownloaded):
track.OnDownloaded()
if drm: if drm:
progress(downloaded="Decrypting", completed=0, total=100) progress(downloaded="Decrypting", completed=0, total=100)
drm.decrypt(save_path) drm.decrypt(save_path)
track.drm = None track.drm = None
events.emit( if callable(track.OnDecrypted):
events.Types.TRACK_DECRYPTED, track.OnDecrypted(drm)
track=track,
drm=drm,
segment=None
)
progress(downloaded="Decrypting", advance=100) progress(downloaded="Decrypting", advance=100)
save_dir.rmdir() save_dir.rmdir()

View File

@ -19,13 +19,12 @@ from pywidevine.cdm import Cdm as WidevineCdm
from pywidevine.pssh import PSSH from pywidevine.pssh import PSSH
from requests import Session from requests import Session
from devine.core import binaries
from devine.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack from devine.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack
from devine.core.downloaders import downloader
from devine.core.downloaders import requests as requests_downloader from devine.core.downloaders import requests as requests_downloader
from devine.core.drm import DRM_T, ClearKey, Widevine from devine.core.drm import DRM_T, ClearKey, Widevine
from devine.core.events import events
from devine.core.tracks import Audio, Subtitle, Tracks, Video from devine.core.tracks import Audio, Subtitle, Tracks, Video
from devine.core.utilities import get_extension, is_close_match, try_ensure_utf8 from devine.core.utilities import get_binary_path, get_extension, is_close_match, try_ensure_utf8
class HLS: class HLS:
@ -102,8 +101,7 @@ class HLS:
try: try:
# TODO: Any better way to figure out the primary track type? # TODO: Any better way to figure out the primary track type?
if playlist.stream_info.codecs: Video.Codec.from_codecs(playlist.stream_info.codecs)
Video.Codec.from_codecs(playlist.stream_info.codecs)
except ValueError: except ValueError:
primary_track_type = Audio primary_track_type = Audio
else: else:
@ -112,28 +110,21 @@ class HLS:
tracks.add(primary_track_type( tracks.add(primary_track_type(
id_=hex(crc32(str(playlist).encode()))[2:], id_=hex(crc32(str(playlist).encode()))[2:],
url=urljoin(playlist.base_uri, playlist.uri), url=urljoin(playlist.base_uri, playlist.uri),
codec=( codec=primary_track_type.Codec.from_codecs(playlist.stream_info.codecs),
primary_track_type.Codec.from_codecs(playlist.stream_info.codecs)
if playlist.stream_info.codecs else None
),
language=language, # HLS manifests do not seem to have language info language=language, # HLS manifests do not seem to have language info
is_original_lang=True, # TODO: All we can do is assume Yes is_original_lang=True, # TODO: All we can do is assume Yes
bitrate=playlist.stream_info.average_bandwidth or playlist.stream_info.bandwidth, bitrate=playlist.stream_info.average_bandwidth or playlist.stream_info.bandwidth,
descriptor=Video.Descriptor.HLS, descriptor=Video.Descriptor.M3U,
drm=session_drm, drm=session_drm,
data={ extra=playlist,
"hls": {
"playlist": playlist
}
},
# video track args # video track args
**(dict( **(dict(
range_=Video.Range.DV if any( range_=Video.Range.DV if any(
codec.split(".")[0] in ("dva1", "dvav", "dvhe", "dvh1") codec.split(".")[0] in ("dva1", "dvav", "dvhe", "dvh1")
for codec in (playlist.stream_info.codecs or "").lower().split(",") for codec in playlist.stream_info.codecs.lower().split(",")
) else Video.Range.from_m3u_range_tag(playlist.stream_info.video_range), ) else Video.Range.from_m3u_range_tag(playlist.stream_info.video_range),
width=playlist.stream_info.resolution[0] if playlist.stream_info.resolution else None, width=playlist.stream_info.resolution[0],
height=playlist.stream_info.resolution[1] if playlist.stream_info.resolution else None, height=playlist.stream_info.resolution[1],
fps=playlist.stream_info.frame_rate fps=playlist.stream_info.frame_rate
) if primary_track_type is Video else {}) ) if primary_track_type is Video else {})
)) ))
@ -173,13 +164,9 @@ class HLS:
codec=codec, codec=codec,
language=track_lang, # HLS media may not have language info, fallback if needed language=track_lang, # HLS media may not have language info, fallback if needed
is_original_lang=language and is_close_match(track_lang, [language]), is_original_lang=language and is_close_match(track_lang, [language]),
descriptor=Audio.Descriptor.HLS, descriptor=Audio.Descriptor.M3U,
drm=session_drm if media.type == "AUDIO" else None, drm=session_drm if media.type == "AUDIO" else None,
data={ extra=media,
"hls": {
"media": media
}
},
# audio track args # audio track args
**(dict( **(dict(
bitrate=0, # TODO: M3U doesn't seem to state bitrate? bitrate=0, # TODO: M3U doesn't seem to state bitrate?
@ -202,7 +189,6 @@ class HLS:
progress: partial, progress: partial,
session: Optional[Session] = None, session: Optional[Session] = None,
proxy: Optional[str] = None, proxy: Optional[str] = None,
max_workers: Optional[int] = None,
license_widevine: Optional[Callable] = None license_widevine: Optional[Callable] = None
) -> None: ) -> None:
if not session: if not session:
@ -245,38 +231,27 @@ class HLS:
else: else:
session_drm = None session_drm = None
unwanted_segments = [ segments = [
segment for segment in master.segments segment for segment in master.segments
if callable(track.OnSegmentFilter) and track.OnSegmentFilter(segment) if not callable(track.OnSegmentFilter) or not track.OnSegmentFilter(segment)
] ]
total_segments = len(master.segments) - len(unwanted_segments) total_segments = len(segments)
progress(total=total_segments) progress(total=total_segments)
downloader = track.downloader downloader_ = downloader
if (
downloader.__name__ == "aria2c" and
any(x.byterange for x in master.segments if x not in unwanted_segments)
):
downloader = requests_downloader
log.warning("Falling back to the requests downloader as aria2(c) doesn't support the Range header")
urls: list[dict[str, Any]] = [] urls: list[dict[str, Any]] = []
segment_durations: list[int] = []
range_offset = 0 range_offset = 0
for segment in master.segments: for segment in segments:
if segment in unwanted_segments:
continue
segment_durations.append(int(segment.duration))
if segment.byterange: if segment.byterange:
if downloader_.__name__ == "aria2c":
# aria2(c) is shit and doesn't support the Range header, fallback to the requests downloader
downloader_ = requests_downloader
byte_range = HLS.calculate_byte_range(segment.byterange, range_offset) byte_range = HLS.calculate_byte_range(segment.byterange, range_offset)
range_offset = byte_range.split("-")[0] range_offset = byte_range.split("-")[0]
else: else:
byte_range = None byte_range = None
urls.append({ urls.append({
"url": urljoin(segment.base_uri, segment.uri), "url": urljoin(segment.base_uri, segment.uri),
"headers": { "headers": {
@ -284,49 +259,41 @@ class HLS:
} if byte_range else {} } if byte_range else {}
}) })
track.data["hls"]["segment_durations"] = segment_durations
segment_save_dir = save_dir / "segments" segment_save_dir = save_dir / "segments"
for status_update in downloader( for status_update in downloader_(
urls=urls, urls=urls,
output_dir=segment_save_dir, output_dir=segment_save_dir,
filename="{i:0%d}{ext}" % len(str(len(urls))), filename="{i:0%d}{ext}" % len(str(len(segments))),
headers=session.headers, headers=session.headers,
cookies=session.cookies, cookies=session.cookies,
proxy=proxy, proxy=proxy,
max_workers=max_workers max_workers=16
): ):
file_downloaded = status_update.get("file_downloaded") file_downloaded = status_update.get("file_downloaded")
if file_downloaded: if file_downloaded and callable(track.OnSegmentDownloaded):
events.emit(events.Types.SEGMENT_DOWNLOADED, track=track, segment=file_downloaded) track.OnSegmentDownloaded(file_downloaded)
else: else:
downloaded = status_update.get("downloaded") downloaded = status_update.get("downloaded")
if downloaded and downloaded.endswith("/s"): if downloaded and downloaded.endswith("/s"):
status_update["downloaded"] = f"HLS {downloaded}" status_update["downloaded"] = f"HLS {downloaded}"
progress(**status_update) progress(**status_update)
# see https://github.com/devine-dl/devine/issues/71
for control_file in segment_save_dir.glob("*.aria2__temp"):
control_file.unlink()
progress(total=total_segments, completed=0, downloaded="Merging") progress(total=total_segments, completed=0, downloaded="Merging")
name_len = len(str(total_segments))
discon_i = 0 discon_i = 0
range_offset = 0 range_offset = 0
map_data: Optional[tuple[m3u8.model.InitializationSection, bytes]] = None map_data: Optional[tuple[m3u8.model.InitializationSection, bytes]] = None
if session_drm: if session_drm:
encryption_data: Optional[tuple[Optional[m3u8.Key], DRM_T]] = (None, session_drm) encryption_data: Optional[tuple[int, Optional[m3u8.Key], DRM_T]] = (0, None, session_drm)
else: else:
encryption_data: Optional[tuple[Optional[m3u8.Key], DRM_T]] = None encryption_data: Optional[tuple[int, Optional[m3u8.Key], DRM_T]] = None
i = -1 for i, segment in enumerate(segments):
for real_i, segment in enumerate(master.segments): is_last_segment = (i + 1) == total_segments
if segment not in unwanted_segments: name_len = len(str(total_segments))
i += 1 segment_file_ext = get_extension(segment.uri)
segment_file_path = segment_save_dir / f"{str(i).zfill(name_len)}{segment_file_ext}"
is_last_segment = (real_i + 1) == len(master.segments)
def merge(to: Path, via: list[Path], delete: bool = False, include_map_data: bool = False): def merge(to: Path, via: list[Path], delete: bool = False, include_map_data: bool = False):
""" """
@ -364,17 +331,13 @@ class HLS:
Returns the decrypted path. Returns the decrypted path.
""" """
drm = encryption_data[1] drm = encryption_data[2]
first_segment_i = next( first_segment_i = encryption_data[0]
int(file.stem)
for file in sorted(segment_save_dir.iterdir())
if file.stem.isdigit()
)
last_segment_i = max(0, i - int(not include_this_segment)) last_segment_i = max(0, i - int(not include_this_segment))
range_len = (last_segment_i - first_segment_i) + 1 range_len = (last_segment_i - first_segment_i) + 1
segment_range = f"{str(first_segment_i).zfill(name_len)}-{str(last_segment_i).zfill(name_len)}" segment_range = f"{str(first_segment_i).zfill(name_len)}-{str(last_segment_i).zfill(name_len)}"
merged_path = segment_save_dir / f"{segment_range}{get_extension(master.segments[last_segment_i].uri)}" merged_path = segment_save_dir / f"{segment_range}{get_extension(segments[last_segment_i].uri)}"
decrypted_path = segment_save_dir / f"{merged_path.stem}_decrypted{merged_path.suffix}" decrypted_path = segment_save_dir / f"{merged_path.stem}_decrypted{merged_path.suffix}"
files = [ files = [
@ -387,35 +350,19 @@ class HLS:
elif len(files) != range_len: elif len(files) != range_len:
raise ValueError(f"Missing {range_len - len(files)} segment files for {segment_range}...") raise ValueError(f"Missing {range_len - len(files)} segment files for {segment_range}...")
if isinstance(drm, Widevine): merge(
# with widevine we can merge all segments and decrypt once to=merged_path,
merge( via=files,
to=merged_path, delete=True,
via=files, include_map_data=True
delete=True,
include_map_data=True
)
drm.decrypt(merged_path)
merged_path.rename(decrypted_path)
else:
# with other drm we must decrypt separately and then merge them
# for aes this is because each segment likely has 16-byte padding
for file in files:
drm.decrypt(file)
merge(
to=merged_path,
via=files,
delete=True,
include_map_data=True
)
events.emit(
events.Types.TRACK_DECRYPTED,
track=track,
drm=drm,
segment=decrypted_path
) )
drm.decrypt(merged_path)
merged_path.rename(decrypted_path)
if callable(track.OnDecrypted):
track.OnDecrypted(drm, decrypted_path)
return decrypted_path return decrypted_path
def merge_discontinuity(include_this_segment: bool, include_map_data: bool = True): def merge_discontinuity(include_this_segment: bool, include_map_data: bool = True):
@ -450,61 +397,58 @@ class HLS:
include_map_data=include_map_data include_map_data=include_map_data
) )
if segment not in unwanted_segments: if isinstance(track, Subtitle):
if isinstance(track, Subtitle): segment_data = try_ensure_utf8(segment_file_path.read_bytes())
segment_file_ext = get_extension(segment.uri) if track.codec not in (Subtitle.Codec.fVTT, Subtitle.Codec.fTTML):
segment_file_path = segment_save_dir / f"{str(i).zfill(name_len)}{segment_file_ext}" segment_data = segment_data.decode("utf8"). \
segment_data = try_ensure_utf8(segment_file_path.read_bytes()) replace("&lrm;", html.unescape("&lrm;")). \
if track.codec not in (Subtitle.Codec.fVTT, Subtitle.Codec.fTTML): replace("&rlm;", html.unescape("&rlm;")). \
segment_data = segment_data.decode("utf8"). \ encode("utf8")
replace("&lrm;", html.unescape("&lrm;")). \ segment_file_path.write_bytes(segment_data)
replace("&rlm;", html.unescape("&rlm;")). \
encode("utf8")
segment_file_path.write_bytes(segment_data)
if segment.discontinuity and i != 0: if segment.discontinuity and i != 0:
if encryption_data: if encryption_data:
decrypt(include_this_segment=False) decrypt(include_this_segment=False)
merge_discontinuity( merge_discontinuity(
include_this_segment=False, include_this_segment=False,
include_map_data=not encryption_data or not encryption_data[1] include_map_data=not encryption_data or not encryption_data[2]
)
discon_i += 1
range_offset = 0 # TODO: Should this be reset or not?
map_data = None
if encryption_data:
encryption_data = (i, encryption_data[1], encryption_data[2])
if segment.init_section and (not map_data or segment.init_section != map_data[0]):
if segment.init_section.byterange:
init_byte_range = HLS.calculate_byte_range(
segment.init_section.byterange,
range_offset
) )
range_offset = init_byte_range.split("-")[0]
init_range_header = {
"Range": f"bytes={init_byte_range}"
}
else:
init_range_header = {}
discon_i += 1 res = session.get(
range_offset = 0 # TODO: Should this be reset or not? url=urljoin(segment.init_section.base_uri, segment.init_section.uri),
map_data = None headers=init_range_header
if encryption_data: )
encryption_data = (encryption_data[0], encryption_data[1]) res.raise_for_status()
map_data = (segment.init_section, res.content)
if segment.init_section and (not map_data or segment.init_section != map_data[0]):
if segment.init_section.byterange:
init_byte_range = HLS.calculate_byte_range(
segment.init_section.byterange,
range_offset
)
range_offset = init_byte_range.split("-")[0]
init_range_header = {
"Range": f"bytes={init_byte_range}"
}
else:
init_range_header = {}
res = session.get(
url=urljoin(segment.init_section.base_uri, segment.init_section.uri),
headers=init_range_header
)
res.raise_for_status()
map_data = (segment.init_section, res.content)
if segment.keys: if segment.keys:
key = HLS.get_supported_key(segment.keys) key = HLS.get_supported_key(segment.keys)
if encryption_data and encryption_data[0] != key and i != 0 and segment not in unwanted_segments: if encryption_data and encryption_data[1] != key and i != 0:
decrypt(include_this_segment=False) decrypt(include_this_segment=False)
if key is None: if key is None:
encryption_data = None encryption_data = None
elif not encryption_data or encryption_data[0] != key: elif not encryption_data or encryption_data[1] != key:
drm = HLS.get_drm(key, session) drm = HLS.get_drm(key, proxy)
if isinstance(drm, Widevine): if isinstance(drm, Widevine):
try: try:
if map_data: if map_data:
@ -518,7 +462,7 @@ class HLS:
DOWNLOAD_CANCELLED.set() # skip pending track downloads DOWNLOAD_CANCELLED.set() # skip pending track downloads
progress(downloaded="[red]FAILED") progress(downloaded="[red]FAILED")
raise raise
encryption_data = (key, drm) encryption_data = (i, key, drm)
# TODO: This wont work as we already downloaded # TODO: This wont work as we already downloaded
if DOWNLOAD_LICENCE_ONLY.is_set(): if DOWNLOAD_LICENCE_ONLY.is_set():
@ -530,7 +474,7 @@ class HLS:
decrypt(include_this_segment=True) decrypt(include_this_segment=True)
merge_discontinuity( merge_discontinuity(
include_this_segment=True, include_this_segment=True,
include_map_data=not encryption_data or not encryption_data[1] include_map_data=not encryption_data or not encryption_data[2]
) )
progress(advance=1) progress(advance=1)
@ -539,37 +483,29 @@ class HLS:
if DOWNLOAD_LICENCE_ONLY.is_set(): if DOWNLOAD_LICENCE_ONLY.is_set():
return return
segment_save_dir.rmdir()
# finally merge all the discontinuity save files together to the final path # finally merge all the discontinuity save files together to the final path
segments_to_merge = [ progress(downloaded="Merging")
x if isinstance(track, (Video, Audio)):
for x in sorted(save_dir.iterdir()) HLS.merge_segments(
if x.is_file() segments=sorted(list(save_dir.iterdir())),
] save_path=save_path
if len(segments_to_merge) == 1: )
shutil.move(segments_to_merge[0], save_path) shutil.rmtree(save_dir)
else: else:
progress(downloaded="Merging") with open(save_path, "wb") as f:
if isinstance(track, (Video, Audio)): for discontinuity_file in sorted(save_dir.iterdir()):
HLS.merge_segments( if discontinuity_file.is_dir():
segments=segments_to_merge, continue
save_path=save_path discontinuity_data = discontinuity_file.read_bytes()
) f.write(discontinuity_data)
else: f.flush()
with open(save_path, "wb") as f: shutil.rmtree(save_dir)
for discontinuity_file in segments_to_merge:
discontinuity_data = discontinuity_file.read_bytes()
f.write(discontinuity_data)
f.flush()
discontinuity_file.unlink()
save_dir.rmdir()
progress(downloaded="Downloaded") progress(downloaded="Downloaded")
track.path = save_path track.path = save_path
events.emit(events.Types.TRACK_DOWNLOADED, track=track) if callable(track.OnDownloaded):
track.OnDownloaded()
@staticmethod @staticmethod
def merge_segments(segments: list[Path], save_path: Path) -> int: def merge_segments(segments: list[Path], save_path: Path) -> int:
@ -578,7 +514,8 @@ class HLS:
Returns the file size of the merged file. Returns the file size of the merged file.
""" """
if not binaries.FFMPEG: ffmpeg = get_binary_path("ffmpeg")
if not ffmpeg:
raise EnvironmentError("FFmpeg executable was not found but is required to merge HLS segments.") raise EnvironmentError("FFmpeg executable was not found but is required to merge HLS segments.")
demuxer_file = segments[0].parent / "ffmpeg_concat_demuxer.txt" demuxer_file = segments[0].parent / "ffmpeg_concat_demuxer.txt"
@ -588,7 +525,7 @@ class HLS:
])) ]))
subprocess.check_call([ subprocess.check_call([
binaries.FFMPEG, "-hide_banner", ffmpeg, "-hide_banner",
"-loglevel", "panic", "-loglevel", "panic",
"-f", "concat", "-f", "concat",
"-safe", "0", "-safe", "0",
@ -599,9 +536,6 @@ class HLS:
]) ])
demuxer_file.unlink() demuxer_file.unlink()
for segment in segments:
segment.unlink()
return save_path.stat().st_size return save_path.stat().st_size
@staticmethod @staticmethod
@ -626,12 +560,30 @@ class HLS:
continue continue
# TODO: Add a way to specify which supported key system to use # TODO: Add a way to specify which supported key system to use
# TODO: Add support for 'SAMPLE-AES', 'AES-CTR', 'AES-CBC', 'ClearKey' # TODO: Add support for 'SAMPLE-AES', 'AES-CTR', 'AES-CBC', 'ClearKey'
# if encryption_data and encryption_data[0] == key:
# # no need to re-obtain the exact same encryption data
# break
elif key.method == "AES-128": elif key.method == "AES-128":
return key return key
# # TODO: Use a session instead of creating a new connection within
# encryption_data = (key, ClearKey.from_m3u_key(key, proxy))
# break
elif key.method == "ISO-23001-7": elif key.method == "ISO-23001-7":
return key return key
# encryption_data = (key, Widevine(
# pssh=PSSH.new(
# key_ids=[key.uri.split(",")[-1]],
# system_id=PSSH.SystemId.Widevine
# )
# ))
# break
elif key.keyformat and key.keyformat.lower() == WidevineCdm.urn: elif key.keyformat and key.keyformat.lower() == WidevineCdm.urn:
return key return key
# encryption_data = (key, Widevine(
# pssh=PSSH(key.uri.split(",")[-1]),
# **key._extra_params # noqa
# ))
# break
else: else:
unsupported_systems.append(key.method + (f" ({key.keyformat})" if key.keyformat else "")) unsupported_systems.append(key.method + (f" ({key.keyformat})" if key.keyformat else ""))
else: else:
@ -640,26 +592,21 @@ class HLS:
@staticmethod @staticmethod
def get_drm( def get_drm(
key: Union[m3u8.model.SessionKey, m3u8.model.Key], key: Union[m3u8.model.SessionKey, m3u8.model.Key],
session: Optional[requests.Session] = None proxy: Optional[str] = None
) -> DRM_T: ) -> DRM_T:
""" """
Convert HLS EXT-X-KEY data to an initialized DRM object. Convert HLS EXT-X-KEY data to an initialized DRM object.
Parameters: Parameters:
key: m3u8 key system (EXT-X-KEY) object. key: m3u8 key system (EXT-X-KEY) object.
session: Optional session used to request AES-128 URIs. proxy: Optional proxy string used for requesting AES-128 URIs.
Useful to set headers, proxies, cookies, and so forth.
Raises a NotImplementedError if the key system is not supported. Raises a NotImplementedError if the key system is not supported.
""" """
if not isinstance(session, (Session, type(None))):
raise TypeError(f"Expected session to be a {Session}, not {type(session)}")
if not session:
session = Session()
# TODO: Add support for 'SAMPLE-AES', 'AES-CTR', 'AES-CBC', 'ClearKey' # TODO: Add support for 'SAMPLE-AES', 'AES-CTR', 'AES-CBC', 'ClearKey'
if key.method == "AES-128": if key.method == "AES-128":
drm = ClearKey.from_m3u_key(key, session) # TODO: Use a session instead of creating a new connection within
drm = ClearKey.from_m3u_key(key, proxy)
elif key.method == "ISO-23001-7": elif key.method == "ISO-23001-7":
drm = Widevine( drm = Widevine(
pssh=PSSH.new( pssh=PSSH.new(

View File

@ -1,20 +1,13 @@
import random import random
import re from typing import Optional
from typing import Optional, Union
from requests.utils import prepend_scheme_if_needed
from urllib3.util import parse_url
from devine.core.proxies.proxy import Proxy from devine.core.proxies.proxy import Proxy
class Basic(Proxy): class Basic(Proxy):
def __init__(self, **countries: dict[str, Union[str, list[str]]]): def __init__(self, **countries):
"""Basic Proxy Service using Proxies specified in the config.""" """Basic Proxy Service using Proxies specified in the config."""
self.countries = { self.countries = countries
k.lower(): v
for k, v in countries.items()
}
def __repr__(self) -> str: def __repr__(self) -> str:
countries = len(self.countries) countries = len(self.countries)
@ -24,35 +17,14 @@ class Basic(Proxy):
def get_proxy(self, query: str) -> Optional[str]: def get_proxy(self, query: str) -> Optional[str]:
"""Get a proxy URI from the config.""" """Get a proxy URI from the config."""
query = query.lower() servers = self.countries.get(query)
match = re.match(r"^([a-z]{2})(\d+)?$", query, re.IGNORECASE)
if not match:
raise ValueError(f"The query \"{query}\" was not recognized...")
country_code = match.group(1)
entry = match.group(2)
servers: Optional[Union[str, list[str]]] = self.countries.get(country_code)
if not servers: if not servers:
return None return
if isinstance(servers, str): proxy = random.choice(servers)
proxy = servers
elif entry:
try:
proxy = servers[int(entry) - 1]
except IndexError:
raise ValueError(
f"There's only {len(servers)} prox{'y' if len(servers) == 1 else 'ies'} "
f"for \"{country_code}\"..."
)
else:
proxy = random.choice(servers)
proxy = prepend_scheme_if_needed(proxy, "http") if "://" not in proxy:
parsed_proxy = parse_url(proxy) # TODO: Improve the test for a valid URI
if not parsed_proxy.host:
raise ValueError(f"The proxy '{proxy}' is not a valid proxy URI supported by Python-Requests.") raise ValueError(f"The proxy '{proxy}' is not a valid proxy URI supported by Python-Requests.")
return proxy return proxy

View File

@ -3,8 +3,8 @@ import re
import subprocess import subprocess
from typing import Optional from typing import Optional
from devine.core import binaries
from devine.core.proxies.proxy import Proxy from devine.core.proxies.proxy import Proxy
from devine.core.utilities import get_binary_path
class Hola(Proxy): class Hola(Proxy):
@ -13,7 +13,7 @@ class Hola(Proxy):
Proxy Service using Hola's direct connections via the hola-proxy project. Proxy Service using Hola's direct connections via the hola-proxy project.
https://github.com/Snawoot/hola-proxy https://github.com/Snawoot/hola-proxy
""" """
self.binary = binaries.HolaProxy self.binary = get_binary_path("hola-proxy")
if not self.binary: if not self.binary:
raise EnvironmentError("hola-proxy executable not found but is required for the Hola proxy provider.") raise EnvironmentError("hola-proxy executable not found but is required for the Hola proxy provider.")

View File

@ -1,44 +0,0 @@
from typing import Optional, Union
class SearchResult:
def __init__(
self,
id_: Union[str, int],
title: str,
description: Optional[str] = None,
label: Optional[str] = None,
url: Optional[str] = None
):
"""
A Search Result for any support Title Type.
Parameters:
id_: The search result's Title ID.
title: The primary display text, e.g., the Title's Name.
description: The secondary display text, e.g., the Title's Description or
further title information.
label: The tertiary display text. This will typically be used to display
an informative label or tag to the result. E.g., "unavailable", the
title's price tag, region, etc.
url: A hyperlink to the search result or title's page.
"""
if not isinstance(id_, (str, int)):
raise TypeError(f"Expected id_ to be a {str} or {int}, not {type(id_)}")
if not isinstance(title, str):
raise TypeError(f"Expected title to be a {str}, not {type(title)}")
if not isinstance(description, (str, type(None))):
raise TypeError(f"Expected description to be a {str}, not {type(description)}")
if not isinstance(label, (str, type(None))):
raise TypeError(f"Expected label to be a {str}, not {type(label)}")
if not isinstance(url, (str, type(None))):
raise TypeError(f"Expected url to be a {str}, not {type(url)}")
self.id = id_
self.title = title
self.description = description
self.label = label
self.url = url
__all__ = ("SearchResult",)

View File

@ -1,14 +1,11 @@
import base64 import base64
import logging import logging
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
from collections.abc import Generator
from http.cookiejar import CookieJar from http.cookiejar import CookieJar
from pathlib import Path
from typing import Optional, Union from typing import Optional, Union
from urllib.parse import urlparse from urllib.parse import urlparse
import click import click
import m3u8
import requests import requests
from requests.adapters import HTTPAdapter, Retry from requests.adapters import HTTPAdapter, Retry
from rich.padding import Padding from rich.padding import Padding
@ -19,8 +16,6 @@ from devine.core.config import config
from devine.core.console import console from devine.core.console import console
from devine.core.constants import AnyTrack from devine.core.constants import AnyTrack
from devine.core.credential import Credential from devine.core.credential import Credential
from devine.core.drm import DRM_T
from devine.core.search_result import SearchResult
from devine.core.titles import Title_T, Titles_T from devine.core.titles import Title_T, Titles_T
from devine.core.tracks import Chapters, Tracks from devine.core.tracks import Chapters, Tracks
from devine.core.utilities import get_ip_info from devine.core.utilities import get_ip_info
@ -101,6 +96,9 @@ class Service(metaclass=ABCMeta):
backoff_factor=0.2, backoff_factor=0.2,
status_forcelist=[429, 500, 502, 503, 504] status_forcelist=[429, 500, 502, 503, 504]
), ),
# 16 connections is used for byte-ranged downloads
# double it to allow for 16 non-related connections
pool_maxsize=16 * 2,
pool_block=True pool_block=True
)) ))
session.mount("http://", session.adapters["https://"]) session.mount("http://", session.adapters["https://"])
@ -125,17 +123,6 @@ class Service(metaclass=ABCMeta):
raise TypeError(f"Expected cookies to be a {CookieJar}, not {cookies!r}.") raise TypeError(f"Expected cookies to be a {CookieJar}, not {cookies!r}.")
self.session.cookies.update(cookies) self.session.cookies.update(cookies)
def search(self) -> Generator[SearchResult, None, None]:
"""
Search by query for titles from the Service.
The query must be taken as a CLI argument by the Service class.
Ideally just re-use the title ID argument (i.e. self.title).
Search results will be displayed in the order yielded.
"""
raise NotImplementedError(f"Search functionality has not been implemented by {self.__class__.__name__}")
def get_widevine_service_certificate(self, *, challenge: bytes, title: Title_T, track: AnyTrack) \ def get_widevine_service_certificate(self, *, challenge: bytes, title: Title_T, track: AnyTrack) \
-> Union[bytes, str]: -> Union[bytes, str]:
""" """
@ -238,53 +225,5 @@ class Service(metaclass=ABCMeta):
option `chapter_fallback_name`. For example, `"Chapter {i:02}"` for "Chapter 01". option `chapter_fallback_name`. For example, `"Chapter {i:02}"` for "Chapter 01".
""" """
# Optional Event methods
def on_segment_downloaded(self, track: AnyTrack, segment: Path) -> None:
"""
Called when one of a Track's Segments has finished downloading.
Parameters:
track: The Track object that had a Segment downloaded.
segment: The Path to the Segment that was downloaded.
"""
def on_track_downloaded(self, track: AnyTrack) -> None:
"""
Called when a Track has finished downloading.
Parameters:
track: The Track object that was downloaded.
"""
def on_track_decrypted(self, track: AnyTrack, drm: DRM_T, segment: Optional[m3u8.Segment] = None) -> None:
"""
Called when a Track has finished decrypting.
Parameters:
track: The Track object that was decrypted.
drm: The DRM object it decrypted with.
segment: The HLS segment information that was decrypted.
"""
def on_track_repacked(self, track: AnyTrack) -> None:
"""
Called when a Track has finished repacking.
Parameters:
track: The Track object that was repacked.
"""
def on_track_multiplex(self, track: AnyTrack) -> None:
"""
Called when a Track is about to be Multiplexed into a Container.
Note: Right now only MKV containers are multiplexed but in the future
this may also be called when multiplexing to other containers like
MP4 via ffmpeg/mp4box.
Parameters:
track: The Track object that was repacked.
"""
__all__ = ("Service",) __all__ = ("Service",)

View File

@ -1,70 +0,0 @@
from __future__ import annotations
import mimetypes
from pathlib import Path
from typing import Optional, Union
from zlib import crc32
class Attachment:
def __init__(
self,
path: Union[Path, str],
name: Optional[str] = None,
mime_type: Optional[str] = None,
description: Optional[str] = None
):
"""
Create a new Attachment.
If name is not provided it will use the file name (without extension).
If mime_type is not provided, it will try to guess it.
"""
if not isinstance(path, (str, Path)):
raise ValueError("The attachment path must be provided.")
if not isinstance(name, (str, type(None))):
raise ValueError("The attachment name must be provided.")
path = Path(path)
if not path.exists():
raise ValueError("The attachment file does not exist.")
name = (name or path.stem).strip()
mime_type = (mime_type or "").strip() or None
description = (description or "").strip() or None
if not mime_type:
mime_type = {
".ttf": "application/x-truetype-font",
".otf": "application/vnd.ms-opentype"
}.get(path.suffix.lower(), mimetypes.guess_type(path)[0])
if not mime_type:
raise ValueError("The attachment mime-type could not be automatically detected.")
self.path = path
self.name = name
self.mime_type = mime_type
self.description = description
def __repr__(self) -> str:
return "{name}({items})".format(
name=self.__class__.__name__,
items=", ".join([f"{k}={repr(v)}" for k, v in self.__dict__.items()])
)
def __str__(self) -> str:
return " | ".join(filter(bool, [
"ATT",
self.name,
self.mime_type,
self.description
]))
@property
def id(self) -> str:
"""Compute an ID from the attachment data."""
checksum = crc32(self.path.read_bytes())
return hex(checksum)
__all__ = ("Attachment",)

View File

@ -64,80 +64,18 @@ class Audio(Track):
return Audio.Codec.OGG return Audio.Codec.OGG
raise ValueError(f"The Content Profile '{profile}' is not a supported Audio Codec") raise ValueError(f"The Content Profile '{profile}' is not a supported Audio Codec")
def __init__( def __init__(self, *args: Any, codec: Audio.Codec, bitrate: Union[str, int, float],
self, channels: Optional[Union[str, int, float]] = None, joc: int = 0, descriptive: bool = False,
*args: Any, **kwargs: Any):
codec: Optional[Audio.Codec] = None,
bitrate: Optional[Union[str, int, float]] = None,
channels: Optional[Union[str, int, float]] = None,
joc: Optional[int] = None,
descriptive: Union[bool, int] = False,
**kwargs: Any
):
"""
Create a new Audio track object.
Parameters:
codec: An Audio.Codec enum representing the audio codec.
If not specified, MediaInfo will be used to retrieve the codec
once the track has been downloaded.
bitrate: A number or float representing the average bandwidth in bytes/s.
Float values are rounded up to the nearest integer.
channels: A number, float, or string representing the number of audio channels.
Strings may represent numbers or floats. Expanded layouts like 7.1.1 is
not supported. All numbers and strings will be cast to float.
joc: The number of Joint-Object-Coding Channels/Objects in the audio stream.
descriptive: Mark this audio as being descriptive audio for the blind.
Note: If codec, bitrate, channels, or joc is not specified some checks may be
skipped or assume a value. Specifying as much information as possible is highly
recommended.
"""
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# required
if not isinstance(codec, (Audio.Codec, type(None))):
raise TypeError(f"Expected codec to be a {Audio.Codec}, not {codec!r}")
if not isinstance(bitrate, (str, int, float, type(None))):
raise TypeError(f"Expected bitrate to be a {str}, {int}, or {float}, not {bitrate!r}")
if not isinstance(channels, (str, int, float, type(None))):
raise TypeError(f"Expected channels to be a {str}, {int}, or {float}, not {channels!r}")
if not isinstance(joc, (int, type(None))):
raise TypeError(f"Expected joc to be a {int}, not {joc!r}")
if (
not isinstance(descriptive, (bool, int)) or
(isinstance(descriptive, int) and descriptive not in (0, 1))
):
raise TypeError(f"Expected descriptive to be a {bool} or bool-like {int}, not {descriptive!r}")
self.codec = codec self.codec = codec
self.bitrate = int(math.ceil(float(bitrate))) if bitrate else None
try: self.channels = self.parse_channels(channels) if channels else None
self.bitrate = int(math.ceil(float(bitrate))) if bitrate else None # optional
except (ValueError, TypeError) as e:
raise ValueError(f"Expected bitrate to be a number or float, {e}")
try:
self.channels = self.parse_channels(channels) if channels else None
except (ValueError, NotImplementedError) as e:
raise ValueError(f"Expected channels to be a number, float, or a string, {e}")
self.joc = joc self.joc = joc
self.descriptive = bool(descriptive) self.descriptive = bool(descriptive)
def __str__(self) -> str:
return " | ".join(filter(bool, [
"AUD",
f"[{self.codec.value}]" if self.codec else None,
str(self.language),
", ".join(filter(bool, [
str(self.channels) if self.channels else None,
f"JOC {self.joc}" if self.joc else None,
])),
f"{self.bitrate // 1000} kb/s" if self.bitrate else None,
self.get_track_name(),
self.edition
]))
@staticmethod @staticmethod
def parse_channels(channels: Union[str, int, float]) -> float: def parse_channels(channels: Union[str, int, float]) -> float:
""" """
@ -171,5 +109,16 @@ class Audio(Track):
track_name += flag track_name += flag
return track_name or None return track_name or None
def __str__(self) -> str:
return " | ".join(filter(bool, [
"AUD",
f"[{self.codec.value}]",
str(self.channels or "?") + (f" (JOC {self.joc})" if self.joc else ""),
f"{self.bitrate // 1000 if self.bitrate else '?'} kb/s",
str(self.language),
self.get_track_name(),
self.edition
]))
__all__ = ("Audio",) __all__ = ("Audio",)

View File

@ -41,7 +41,7 @@ class Chapter:
seconds, ms = divmod(int(remainder * 1000), 1000) seconds, ms = divmod(int(remainder * 1000), 1000)
else: else:
raise TypeError raise TypeError
timestamp = f"{int(hours):02}:{int(minutes):02}:{int(seconds):02}.{str(ms).zfill(3)[:3]}" timestamp = f"{hours:02}:{minutes:02}:{seconds:02}.{str(ms).zfill(3)[:3]}"
timestamp_m = TIMESTAMP_FORMAT.match(timestamp) timestamp_m = TIMESTAMP_FORMAT.match(timestamp)
if not timestamp_m: if not timestamp_m:

View File

@ -4,23 +4,19 @@ import re
import subprocess import subprocess
from collections import defaultdict from collections import defaultdict
from enum import Enum from enum import Enum
from functools import partial
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Iterable, Optional, Union from typing import Any, Callable, Iterable, Optional
import pycaption import pycaption
import requests
from construct import Container from construct import Container
from pycaption import Caption, CaptionList, CaptionNode, WebVTTReader from pycaption import Caption, CaptionList, CaptionNode, WebVTTReader
from pycaption.geometry import Layout from pycaption.geometry import Layout
from pymp4.parser import MP4 from pymp4.parser import MP4
from subtitle_filter import Subtitles from subtitle_filter import Subtitles
from devine.core import binaries
from devine.core.tracks.track import Track from devine.core.tracks.track import Track
from devine.core.utilities import try_ensure_utf8 from devine.core.utilities import get_binary_path, try_ensure_utf8
from devine.core.utils.webvtt import merge_segmented_webvtt
class Subtitle(Track): class Subtitle(Track):
@ -76,22 +72,22 @@ class Subtitle(Track):
return Subtitle.Codec.TimedTextMarkupLang return Subtitle.Codec.TimedTextMarkupLang
raise ValueError(f"The Content Profile '{profile}' is not a supported Subtitle Codec") raise ValueError(f"The Content Profile '{profile}' is not a supported Subtitle Codec")
def __init__( def __init__(self, *args: Any, codec: Subtitle.Codec, cc: bool = False, sdh: bool = False, forced: bool = False,
self, **kwargs: Any):
*args: Any,
codec: Optional[Subtitle.Codec] = None,
cc: bool = False,
sdh: bool = False,
forced: bool = False,
**kwargs: Any
):
""" """
Create a new Subtitle track object. Information on Subtitle Types:
https://bit.ly/2Oe4fLC (3PlayMedia Blog on SUB vs CC vs SDH).
However, I wouldn't pay much attention to the claims about SDH needing to
be in the original source language. It's logically not true.
CC == Closed Captions. Source: Basically every site.
SDH = Subtitles for the Deaf or Hard-of-Hearing. Source: Basically every site.
HOH = Exact same as SDH. Is a term used in the UK. Source: https://bit.ly/2PGJatz (ICO UK)
More in-depth information, examples, and stuff to look for can be found in the Parameter
explanation list below.
Parameters: Parameters:
codec: A Subtitle.Codec enum representing the subtitle format.
If not specified, MediaInfo will be used to retrieve the format
once the track has been downloaded.
cc: Closed Caption. cc: Closed Caption.
- Intended as if you couldn't hear the audio at all. - Intended as if you couldn't hear the audio at all.
- Can have Sound as well as Dialogue, but doesn't have to. - Can have Sound as well as Dialogue, but doesn't have to.
@ -127,57 +123,20 @@ class Subtitle(Track):
no other way to reliably work with Forced subtitles where multiple no other way to reliably work with Forced subtitles where multiple
forced subtitles may be in the output file. Just know what to expect forced subtitles may be in the output file. Just know what to expect
with "forced" subtitles. with "forced" subtitles.
Note: If codec is not specified some checks may be skipped or assume a value.
Specifying as much information as possible is highly recommended.
Information on Subtitle Types:
https://bit.ly/2Oe4fLC (3PlayMedia Blog on SUB vs CC vs SDH).
However, I wouldn't pay much attention to the claims about SDH needing to
be in the original source language. It's logically not true.
CC == Closed Captions. Source: Basically every site.
SDH = Subtitles for the Deaf or Hard-of-Hearing. Source: Basically every site.
HOH = Exact same as SDH. Is a term used in the UK. Source: https://bit.ly/2PGJatz (ICO UK)
More in-depth information, examples, and stuff to look for can be found in the Parameter
explanation list above.
""" """
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if not isinstance(codec, (Subtitle.Codec, type(None))):
raise TypeError(f"Expected codec to be a {Subtitle.Codec}, not {codec!r}")
if not isinstance(cc, (bool, int)) or (isinstance(cc, int) and cc not in (0, 1)):
raise TypeError(f"Expected cc to be a {bool} or bool-like {int}, not {cc!r}")
if not isinstance(sdh, (bool, int)) or (isinstance(sdh, int) and sdh not in (0, 1)):
raise TypeError(f"Expected sdh to be a {bool} or bool-like {int}, not {sdh!r}")
if not isinstance(forced, (bool, int)) or (isinstance(forced, int) and forced not in (0, 1)):
raise TypeError(f"Expected forced to be a {bool} or bool-like {int}, not {forced!r}")
self.codec = codec self.codec = codec
self.cc = bool(cc) self.cc = bool(cc)
self.sdh = bool(sdh) self.sdh = bool(sdh)
self.forced = bool(forced)
if self.cc and self.sdh: if self.cc and self.sdh:
raise ValueError("A text track cannot be both CC and SDH.") raise ValueError("A text track cannot be both CC and SDH.")
self.forced = bool(forced)
if self.forced and (self.cc or self.sdh): if (self.cc or self.sdh) and self.forced:
raise ValueError("A text track cannot be CC/SDH as well as Forced.") raise ValueError("A text track cannot be CC/SDH as well as Forced.")
# TODO: Migrate to new event observer system
# Called after Track has been converted to another format # Called after Track has been converted to another format
self.OnConverted: Optional[Callable[[Subtitle.Codec], None]] = None self.OnConverted: Optional[Callable[[Subtitle.Codec], None]] = None
def __str__(self) -> str:
return " | ".join(filter(bool, [
"SUB",
f"[{self.codec.value}]" if self.codec else None,
str(self.language),
self.get_track_name()
]))
def get_track_name(self) -> Optional[str]: def get_track_name(self) -> Optional[str]:
"""Return the base Track Name.""" """Return the base Track Name."""
track_name = super().get_track_name() or "" track_name = super().get_track_name() or ""
@ -188,42 +147,6 @@ class Subtitle(Track):
track_name += flag track_name += flag
return track_name or None return track_name or None
def download(
self,
session: requests.Session,
prepare_drm: partial,
max_workers: Optional[int] = None,
progress: Optional[partial] = None
):
super().download(session, prepare_drm, max_workers, progress)
if not self.path:
return
if self.codec == Subtitle.Codec.fTTML:
self.convert(Subtitle.Codec.TimedTextMarkupLang)
elif self.codec == Subtitle.Codec.fVTT:
self.convert(Subtitle.Codec.WebVTT)
elif self.codec == Subtitle.Codec.WebVTT:
text = self.path.read_text("utf8")
if self.descriptor == Track.Descriptor.DASH:
if len(self.data["dash"]["segment_durations"]) > 1:
text = merge_segmented_webvtt(
text,
segment_durations=self.data["dash"]["segment_durations"],
timescale=self.data["dash"]["timescale"]
)
elif self.descriptor == Track.Descriptor.HLS:
if len(self.data["hls"]["segment_durations"]) > 1:
text = merge_segmented_webvtt(
text,
segment_durations=self.data["hls"]["segment_durations"],
timescale=1 # ?
)
caption_set = pycaption.WebVTTReader().read(text)
Subtitle.merge_same_cues(caption_set)
subtitle_text = pycaption.WebVTTWriter().write(caption_set)
self.path.write_text(subtitle_text, encoding="utf8")
def convert(self, codec: Subtitle.Codec) -> Path: def convert(self, codec: Subtitle.Codec) -> Path:
""" """
Convert this Subtitle to another Format. Convert this Subtitle to another Format.
@ -255,13 +178,14 @@ class Subtitle(Track):
output_path = self.path.with_suffix(f".{codec.value.lower()}") output_path = self.path.with_suffix(f".{codec.value.lower()}")
if binaries.SubtitleEdit and self.codec not in (Subtitle.Codec.fTTML, Subtitle.Codec.fVTT): sub_edit_executable = get_binary_path("SubtitleEdit")
if sub_edit_executable and self.codec not in (Subtitle.Codec.fTTML, Subtitle.Codec.fVTT):
sub_edit_format = { sub_edit_format = {
Subtitle.Codec.SubStationAlphav4: "AdvancedSubStationAlpha", Subtitle.Codec.SubStationAlphav4: "AdvancedSubStationAlpha",
Subtitle.Codec.TimedTextMarkupLang: "TimedText1.0" Subtitle.Codec.TimedTextMarkupLang: "TimedText1.0"
}.get(codec, codec.name) }.get(codec, codec.name)
sub_edit_args = [ sub_edit_args = [
binaries.SubtitleEdit, sub_edit_executable,
"/Convert", self.path, sub_edit_format, "/Convert", self.path, sub_edit_format,
f"/outputfilename:{output_path.name}", f"/outputfilename:{output_path.name}",
"/encoding:utf8" "/encoding:utf8"
@ -290,7 +214,7 @@ class Subtitle(Track):
output_path.write_text(subtitle_text, encoding="utf8") output_path.write_text(subtitle_text, encoding="utf8")
self.path = output_path self.swap(output_path)
self.codec = codec self.codec = codec
if callable(self.OnConverted): if callable(self.OnConverted):
@ -329,7 +253,14 @@ class Subtitle(Track):
caption_lists[language] = caption_list caption_lists[language] = caption_list
caption_set: pycaption.CaptionSet = pycaption.CaptionSet(caption_lists) caption_set: pycaption.CaptionSet = pycaption.CaptionSet(caption_lists)
elif codec == Subtitle.Codec.WebVTT: elif codec == Subtitle.Codec.WebVTT:
text = Subtitle.space_webvtt_headers(data) text = try_ensure_utf8(data).decode("utf8")
# Segmented VTT when merged may have the WEBVTT headers part of the next caption
# if they are not separated far enough from the previous caption, hence the \n\n
text = text. \
replace("WEBVTT", "\n\nWEBVTT"). \
replace("\r", ""). \
replace("\n\n\n", "\n \n\n"). \
replace("\n\n<", "\n<")
caption_set = pycaption.WebVTTReader().read(text) caption_set = pycaption.WebVTTReader().read(text)
else: else:
raise ValueError(f"Unknown Subtitle format \"{codec}\"...") raise ValueError(f"Unknown Subtitle format \"{codec}\"...")
@ -346,27 +277,6 @@ class Subtitle(Track):
return caption_set return caption_set
@staticmethod
def space_webvtt_headers(data: Union[str, bytes]):
"""
Space out the WEBVTT Headers from Captions.
Segmented VTT when merged may have the WEBVTT headers part of the next caption
as they were not separated far enough from the previous caption and ended up
being considered as caption text rather than the header for the next segment.
"""
if isinstance(data, bytes):
data = try_ensure_utf8(data).decode("utf8")
elif not isinstance(data, str):
raise ValueError(f"Expecting data to be a str, not {data!r}")
text = data.replace("WEBVTT", "\n\nWEBVTT").\
replace("\r", "").\
replace("\n\n\n", "\n \n\n").\
replace("\n\n<", "\n<")
return text
@staticmethod @staticmethod
def merge_same_cues(caption_set: pycaption.CaptionSet): def merge_same_cues(caption_set: pycaption.CaptionSet):
"""Merge captions with the same timecodes and text as one in-place.""" """Merge captions with the same timecodes and text as one in-place."""
@ -535,7 +445,8 @@ class Subtitle(Track):
if not self.path or not self.path.exists(): if not self.path or not self.path.exists():
raise ValueError("You must download the subtitle track first.") raise ValueError("You must download the subtitle track first.")
if binaries.SubtitleEdit: executable = get_binary_path("SubtitleEdit")
if executable:
if self.codec == Subtitle.Codec.SubStationAlphav4: if self.codec == Subtitle.Codec.SubStationAlphav4:
output_format = "AdvancedSubStationAlpha" output_format = "AdvancedSubStationAlpha"
elif self.codec == Subtitle.Codec.TimedTextMarkupLang: elif self.codec == Subtitle.Codec.TimedTextMarkupLang:
@ -544,7 +455,7 @@ class Subtitle(Track):
output_format = self.codec.name output_format = self.codec.name
subprocess.run( subprocess.run(
[ [
binaries.SubtitleEdit, executable,
"/Convert", self.path, output_format, "/Convert", self.path, output_format,
"/encoding:utf8", "/encoding:utf8",
"/overwrite", "/overwrite",
@ -573,7 +484,8 @@ class Subtitle(Track):
if not self.path or not self.path.exists(): if not self.path or not self.path.exists():
raise ValueError("You must download the subtitle track first.") raise ValueError("You must download the subtitle track first.")
if not binaries.SubtitleEdit: executable = get_binary_path("SubtitleEdit")
if not executable:
raise EnvironmentError("SubtitleEdit executable not found...") raise EnvironmentError("SubtitleEdit executable not found...")
if self.codec == Subtitle.Codec.SubStationAlphav4: if self.codec == Subtitle.Codec.SubStationAlphav4:
@ -585,7 +497,7 @@ class Subtitle(Track):
subprocess.run( subprocess.run(
[ [
binaries.SubtitleEdit, executable,
"/Convert", self.path, output_format, "/Convert", self.path, output_format,
"/ReverseRtlStartEnd", "/ReverseRtlStartEnd",
"/encoding:utf8", "/encoding:utf8",
@ -595,5 +507,13 @@ class Subtitle(Track):
stdout=subprocess.DEVNULL stdout=subprocess.DEVNULL
) )
def __str__(self) -> str:
return " | ".join(filter(bool, [
"SUB",
f"[{self.codec.value}]",
str(self.language),
self.get_track_name()
]))
__all__ = ("Subtitle",) __all__ = ("Subtitle",)

View File

@ -1,380 +1,97 @@
import base64 import base64
import html
import logging
import re import re
import shutil import shutil
import subprocess import subprocess
from collections import defaultdict
from copy import copy
from enum import Enum from enum import Enum
from functools import partial
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Iterable, Optional, Union from typing import Any, Callable, Iterable, Optional, Union
from uuid import UUID from uuid import UUID
from zlib import crc32
import m3u8
import requests
from langcodes import Language from langcodes import Language
from requests import Session
from devine.core import binaries from devine.core.constants import TERRITORY_MAP
from devine.core.config import config from devine.core.drm import DRM_T
from devine.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY from devine.core.utilities import get_binary_path, get_boxes
from devine.core.downloaders import aria2c, curl_impersonate, requests
from devine.core.drm import DRM_T, Widevine
from devine.core.events import events
from devine.core.utilities import get_boxes, try_ensure_utf8
from devine.core.utils.subprocess import ffprobe from devine.core.utils.subprocess import ffprobe
class Track: class Track:
class DRM(Enum):
pass
class Descriptor(Enum): class Descriptor(Enum):
URL = 1 # Direct URL, nothing fancy URL = 1 # Direct URL, nothing fancy
HLS = 2 # https://en.wikipedia.org/wiki/HTTP_Live_Streaming M3U = 2 # https://en.wikipedia.org/wiki/M3U (and M3U8)
DASH = 3 # https://en.wikipedia.org/wiki/Dynamic_Adaptive_Streaming_over_HTTP MPD = 3 # https://en.wikipedia.org/wiki/Dynamic_Adaptive_Streaming_over_HTTP
def __init__( def __init__(
self, self,
id_: str,
url: Union[str, list[str]], url: Union[str, list[str]],
language: Union[Language, str], language: Union[Language, str],
is_original_lang: bool = False, is_original_lang: bool = False,
descriptor: Descriptor = Descriptor.URL, descriptor: Descriptor = Descriptor.URL,
needs_repack: bool = False, needs_repack: bool = False,
name: Optional[str] = None,
drm: Optional[Iterable[DRM_T]] = None, drm: Optional[Iterable[DRM_T]] = None,
edition: Optional[str] = None, edition: Optional[str] = None,
downloader: Optional[Callable] = None, extra: Optional[Any] = None
data: Optional[Union[dict, defaultdict]] = None,
id_: Optional[str] = None,
) -> None: ) -> None:
if not isinstance(url, (str, list)):
raise TypeError(f"Expected url to be a {str}, or list of {str}, not {type(url)}")
if not isinstance(language, (Language, str)):
raise TypeError(f"Expected language to be a {Language} or {str}, not {type(language)}")
if not isinstance(is_original_lang, bool):
raise TypeError(f"Expected is_original_lang to be a {bool}, not {type(is_original_lang)}")
if not isinstance(descriptor, Track.Descriptor):
raise TypeError(f"Expected descriptor to be a {Track.Descriptor}, not {type(descriptor)}")
if not isinstance(needs_repack, bool):
raise TypeError(f"Expected needs_repack to be a {bool}, not {type(needs_repack)}")
if not isinstance(name, (str, type(None))):
raise TypeError(f"Expected name to be a {str}, not {type(name)}")
if not isinstance(id_, (str, type(None))):
raise TypeError(f"Expected id_ to be a {str}, not {type(id_)}")
if not isinstance(edition, (str, type(None))):
raise TypeError(f"Expected edition to be a {str}, not {type(edition)}")
if not isinstance(downloader, (Callable, type(None))):
raise TypeError(f"Expected downloader to be a {Callable}, not {type(downloader)}")
if not isinstance(data, (dict, defaultdict, type(None))):
raise TypeError(f"Expected data to be a {dict} or {defaultdict}, not {type(data)}")
invalid_urls = ", ".join(set(type(x) for x in url if not isinstance(x, str)))
if invalid_urls:
raise TypeError(f"Expected all items in url to be a {str}, but found {invalid_urls}")
if drm is not None:
try:
iter(drm)
except TypeError:
raise TypeError(f"Expected drm to be an iterable, not {type(drm)}")
if downloader is None:
downloader = {
"aria2c": aria2c,
"curl_impersonate": curl_impersonate,
"requests": requests
}[config.downloader]
self.path: Optional[Path] = None
self.url = url
self.language = Language.get(language)
self.is_original_lang = is_original_lang
self.descriptor = descriptor
self.needs_repack = needs_repack
self.name = name
self.drm = drm
self.edition: str = edition
self.downloader = downloader
self._data: defaultdict[Any, Any] = defaultdict(dict)
self.data = data or {}
if self.name is None:
lang = Language.get(self.language)
if (lang.language or "").lower() == (lang.territory or "").lower():
lang.territory = None # e.g. en-en, de-DE
reduced = lang.simplify_script()
extra_parts = []
if reduced.script is not None:
script = reduced.script_name(max_distance=25)
if script and script != "Zzzz":
extra_parts.append(script)
if reduced.territory is not None:
territory = reduced.territory_name(max_distance=25)
if territory and territory != "ZZ":
territory = territory.removesuffix(" SAR China")
extra_parts.append(territory)
self.name = ", ".join(extra_parts) or None
if not id_:
this = copy(self)
this.url = self.url.rsplit("?", maxsplit=1)[0]
checksum = crc32(repr(this).encode("utf8"))
id_ = hex(checksum)[2:]
self.id = id_ self.id = id_
self.url = url
# required basic metadata
self.language = Language.get(language)
self.is_original_lang = bool(is_original_lang)
# optional io metadata
self.descriptor = descriptor
self.needs_repack = bool(needs_repack)
# drm
self.drm = drm
# extra data
self.edition: str = edition
self.extra: Any = extra or {} # allow anything for extra, but default to a dict
# TODO: Currently using OnFoo event naming, change to just segment_filter # TODO: Currently using OnFoo event naming, change to just segment_filter
self.OnSegmentFilter: Optional[Callable] = None self.OnSegmentFilter: Optional[Callable] = None
# Called after one of the Track's segments have downloaded
self.OnSegmentDownloaded: Optional[Callable[[Path], None]] = None
# Called after the Track has downloaded
self.OnDownloaded: Optional[Callable] = None
# Called after the Track or one of its segments have been decrypted
self.OnDecrypted: Optional[Callable[[DRM_T, Optional[m3u8.Segment]], None]] = None
# Called after the Track has been repackaged
self.OnRepacked: Optional[Callable] = None
# Called before the Track is multiplexed
self.OnMultiplex: Optional[Callable] = None
# should only be set internally
self.path: Optional[Path] = None
def __repr__(self) -> str: def __repr__(self) -> str:
return "{name}({items})".format( return "{name}({items})".format(
name=self.__class__.__name__, name=self.__class__.__name__,
items=", ".join([f"{k}={repr(v)}" for k, v in self.__dict__.items()]) items=", ".join([f"{k}={repr(v)}" for k, v in self.__dict__.items()])
) )
def __eq__(self, other: Any) -> bool: def __eq__(self, other: object) -> bool:
return isinstance(other, Track) and self.id == other.id return isinstance(other, Track) and self.id == other.id
@property
def data(self) -> defaultdict[Any, Any]:
"""
Arbitrary track data dictionary.
A defaultdict is used with a dict as the factory for easier
nested saving and safer exists-checks.
Reserved keys:
- "hls" used by the HLS class.
- playlist: m3u8.model.Playlist - The primary track information.
- media: m3u8.model.Media - The audio/subtitle track information.
- segment_durations: list[int] - A list of each segment's duration.
- "dash" used by the DASH class.
- manifest: lxml.ElementTree - DASH MPD manifest.
- period: lxml.Element - The period of this track.
- adaptation_set: lxml.Element - The adaptation set of this track.
- representation: lxml.Element - The representation of this track.
- timescale: int - The timescale of the track's segments.
- segment_durations: list[int] - A list of each segment's duration.
You should not add, change, or remove any data within reserved keys.
You may use their data but do note that the values of them may change
or be removed at any point.
"""
return self._data
@data.setter
def data(self, value: Union[dict, defaultdict]) -> None:
if not isinstance(value, (dict, defaultdict)):
raise TypeError(f"Expected data to be a {dict} or {defaultdict}, not {type(value)}")
if isinstance(value, dict):
value = defaultdict(dict, **value)
self._data = value
def download(
self,
session: Session,
prepare_drm: partial,
max_workers: Optional[int] = None,
progress: Optional[partial] = None
):
"""Download and optionally Decrypt this Track."""
from devine.core.manifests import DASH, HLS
if DOWNLOAD_LICENCE_ONLY.is_set():
progress(downloaded="[yellow]SKIPPING")
if DOWNLOAD_CANCELLED.is_set():
progress(downloaded="[yellow]SKIPPED")
return
log = logging.getLogger("track")
proxy = next(iter(session.proxies.values()), None)
track_type = self.__class__.__name__
save_path = config.directories.temp / f"{track_type}_{self.id}.mp4"
if track_type == "Subtitle":
save_path = save_path.with_suffix(f".{self.codec.extension}")
if self.descriptor != self.Descriptor.URL:
save_dir = save_path.with_name(save_path.name + "_segments")
else:
save_dir = save_path.parent
def cleanup():
# track file (e.g., "foo.mp4")
save_path.unlink(missing_ok=True)
# aria2c control file (e.g., "foo.mp4.aria2" or "foo.mp4.aria2__temp")
save_path.with_suffix(f"{save_path.suffix}.aria2").unlink(missing_ok=True)
save_path.with_suffix(f"{save_path.suffix}.aria2__temp").unlink(missing_ok=True)
if save_dir.exists() and save_dir.name.endswith("_segments"):
shutil.rmtree(save_dir)
if not DOWNLOAD_LICENCE_ONLY.is_set():
if config.directories.temp.is_file():
raise ValueError(f"Temp Directory '{config.directories.temp}' must be a Directory, not a file")
config.directories.temp.mkdir(parents=True, exist_ok=True)
# Delete any pre-existing temp files matching this track.
# We can't re-use or continue downloading these tracks as they do not use a
# lock file. Or at least the majority don't. Even if they did I've encountered
# corruptions caused by sudden interruptions to the lock file.
cleanup()
try:
if self.descriptor == self.Descriptor.HLS:
HLS.download_track(
track=self,
save_path=save_path,
save_dir=save_dir,
progress=progress,
session=session,
proxy=proxy,
max_workers=max_workers,
license_widevine=prepare_drm
)
elif self.descriptor == self.Descriptor.DASH:
DASH.download_track(
track=self,
save_path=save_path,
save_dir=save_dir,
progress=progress,
session=session,
proxy=proxy,
max_workers=max_workers,
license_widevine=prepare_drm
)
elif self.descriptor == self.Descriptor.URL:
try:
if not self.drm and track_type in ("Video", "Audio"):
# the service might not have explicitly defined the `drm` property
# try find widevine DRM information from the init data of URL
try:
self.drm = [Widevine.from_track(self, session)]
except Widevine.Exceptions.PSSHNotFound:
# it might not have Widevine DRM, or might not have found the PSSH
log.warning("No Widevine PSSH was found for this track, is it DRM free?")
if self.drm:
track_kid = self.get_key_id(session=session)
drm = self.drm[0] # just use the first supported DRM system for now
if isinstance(drm, Widevine):
# license and grab content keys
if not prepare_drm:
raise ValueError("prepare_drm func must be supplied to use Widevine DRM")
progress(downloaded="LICENSING")
prepare_drm(drm, track_kid=track_kid)
progress(downloaded="[yellow]LICENSED")
else:
drm = None
if DOWNLOAD_LICENCE_ONLY.is_set():
progress(downloaded="[yellow]SKIPPED")
else:
for status_update in self.downloader(
urls=self.url,
output_dir=save_path.parent,
filename=save_path.name,
headers=session.headers,
cookies=session.cookies,
proxy=proxy,
max_workers=max_workers
):
file_downloaded = status_update.get("file_downloaded")
if not file_downloaded:
progress(**status_update)
# see https://github.com/devine-dl/devine/issues/71
save_path.with_suffix(f"{save_path.suffix}.aria2__temp").unlink(missing_ok=True)
self.path = save_path
events.emit(events.Types.TRACK_DOWNLOADED, track=self)
if drm:
progress(downloaded="Decrypting", completed=0, total=100)
drm.decrypt(save_path)
self.drm = None
events.emit(
events.Types.TRACK_DECRYPTED,
track=self,
drm=drm,
segment=None
)
progress(downloaded="Decrypted", completed=100)
if track_type == "Subtitle" and self.codec.name not in ("fVTT", "fTTML"):
track_data = self.path.read_bytes()
track_data = try_ensure_utf8(track_data)
track_data = track_data.decode("utf8"). \
replace("&lrm;", html.unescape("&lrm;")). \
replace("&rlm;", html.unescape("&rlm;")). \
encode("utf8")
self.path.write_bytes(track_data)
progress(downloaded="Downloaded")
except KeyboardInterrupt:
DOWNLOAD_CANCELLED.set()
progress(downloaded="[yellow]CANCELLED")
raise
except Exception:
DOWNLOAD_CANCELLED.set()
progress(downloaded="[red]FAILED")
raise
except (Exception, KeyboardInterrupt):
if not DOWNLOAD_LICENCE_ONLY.is_set():
cleanup()
raise
if DOWNLOAD_CANCELLED.is_set():
# we stopped during the download, let's exit
return
if not DOWNLOAD_LICENCE_ONLY.is_set():
if self.path.stat().st_size <= 3: # Empty UTF-8 BOM == 3 bytes
raise IOError("Download failed, the downloaded file is empty.")
events.emit(events.Types.TRACK_DOWNLOADED, track=self)
def delete(self) -> None:
if self.path:
self.path.unlink()
self.path = None
def move(self, target: Union[Path, str]) -> Path:
"""
Move the Track's file from current location, to target location.
This will overwrite anything at the target path.
Raises:
TypeError: If the target argument is not the expected type.
ValueError: If track has no file to move, or the target does not exist.
OSError: If the file somehow failed to move.
Returns the new location of the track.
"""
if not isinstance(target, (str, Path)):
raise TypeError(f"Expected {target} to be a {Path} or {str}, not {type(target)}")
if not self.path:
raise ValueError("Track has no file to move")
if not isinstance(target, Path):
target = Path(target)
if not target.exists():
raise ValueError(f"Target file {repr(target)} does not exist")
moved_to = Path(shutil.move(self.path, target))
if moved_to.resolve() != target.resolve():
raise OSError(f"Failed to move {self.path} to {target}")
self.path = target
return target
def get_track_name(self) -> Optional[str]: def get_track_name(self) -> Optional[str]:
"""Get the Track Name.""" """Return the base Track Name. This may be enhanced in sub-classes."""
return self.name if (self.language.language or "").lower() == (self.language.territory or "").lower():
self.language.territory = None # e.g. en-en, de-DE
if self.language.territory == "US":
self.language.territory = None
reduced = self.language.simplify_script()
extra_parts = []
if reduced.script is not None:
extra_parts.append(reduced.script_name(max_distance=25))
if reduced.territory is not None:
territory = reduced.territory_name(max_distance=25)
extra_parts.append(TERRITORY_MAP.get(territory, territory))
return ", ".join(extra_parts) or None
def get_key_id(self, init_data: Optional[bytes] = None, *args, **kwargs) -> Optional[UUID]: def get_key_id(self, init_data: Optional[bytes] = None, *args, **kwargs) -> Optional[UUID]:
""" """
@ -400,6 +117,7 @@ class Track:
if not isinstance(init_data, bytes): if not isinstance(init_data, bytes):
raise TypeError(f"Expected init_data to be bytes, not {init_data!r}") raise TypeError(f"Expected init_data to be bytes, not {init_data!r}")
# try get via ffprobe, needed for non mp4 data e.g. WEBM from Google Play
probe = ffprobe(init_data) probe = ffprobe(init_data)
if probe: if probe:
for stream in probe.get("streams") or []: for stream in probe.get("streams") or []:
@ -407,12 +125,14 @@ class Track:
if enc_key_id: if enc_key_id:
return UUID(bytes=base64.b64decode(enc_key_id)) return UUID(bytes=base64.b64decode(enc_key_id))
# look for track encryption mp4 boxes
for tenc in get_boxes(init_data, b"tenc"): for tenc in get_boxes(init_data, b"tenc"):
if tenc.key_ID.int != 0: if tenc.key_ID.int != 0:
return tenc.key_ID return tenc.key_ID
# look for UUID mp4 boxes holding track encryption mp4 boxes
for uuid_box in get_boxes(init_data, b"uuid"): for uuid_box in get_boxes(init_data, b"uuid"):
if uuid_box.extended_type == UUID("8974dbce-7be7-4c51-84f9-7148f9882554"): # tenc if uuid_box.extended_type == UUID("8974dbce-7be7-4c51-84f9-7148f9882554"):
tenc = uuid_box.data tenc = uuid_box.data
if tenc.key_ID.int != 0: if tenc.key_ID.int != 0:
return tenc.key_ID return tenc.key_ID
@ -422,7 +142,7 @@ class Track:
maximum_size: int = 20000, maximum_size: int = 20000,
url: Optional[str] = None, url: Optional[str] = None,
byte_range: Optional[str] = None, byte_range: Optional[str] = None,
session: Optional[Session] = None session: Optional[requests.Session] = None
) -> bytes: ) -> bytes:
""" """
Get the Track's Initial Segment Data Stream. Get the Track's Initial Segment Data Stream.
@ -446,24 +166,20 @@ class Track:
byte_range: Range of bytes to download from the explicit or implicit URL. byte_range: Range of bytes to download from the explicit or implicit URL.
session: Session context, e.g., authorization and headers. session: Session context, e.g., authorization and headers.
""" """
if not isinstance(maximum_size, int):
raise TypeError(f"Expected maximum_size to be an {int}, not {type(maximum_size)}")
if not isinstance(url, (str, type(None))):
raise TypeError(f"Expected url to be a {str}, not {type(url)}")
if not isinstance(byte_range, (str, type(None))):
raise TypeError(f"Expected byte_range to be a {str}, not {type(byte_range)}")
if not isinstance(session, (Session, type(None))):
raise TypeError(f"Expected session to be a {Session}, not {type(session)}")
if not url:
if self.descriptor != self.Descriptor.URL:
raise ValueError(f"An explicit URL must be provided for {self.descriptor.name} tracks")
if not self.url:
raise ValueError("An explicit URL must be provided as the track has no URL")
url = self.url
if not session: if not session:
session = Session() session = requests.Session()
if self.descriptor != self.Descriptor.URL and not url:
# We cannot know which init map from the HLS or DASH playlist is actually used.
# For DASH this could be from any adaptation set, any period, e.t.c.
# For HLS we could make some assumptions, but it's best that it is explicitly provided.
raise ValueError(
f"An explicit URL to an init map or file must be provided for {self.descriptor.name} tracks."
)
url = url or self.url
if not url:
raise ValueError("The track must have an URL to point towards it's data.")
content_length = maximum_size content_length = maximum_size
@ -480,6 +196,7 @@ class Track:
if "Content-Length" in size_test.headers: if "Content-Length" in size_test.headers:
content_length_header = int(size_test.headers["Content-Length"]) content_length_header = int(size_test.headers["Content-Length"])
if content_length_header > 0: if content_length_header > 0:
# use whichever is smaller in case this is a large file
content_length = min(content_length_header, maximum_size) content_length = min(content_length_header, maximum_size)
range_test = session.head(url, headers={"Range": "bytes=0-1"}) range_test = session.head(url, headers={"Range": "bytes=0-1"})
if range_test.status_code == 206: if range_test.status_code == 206:
@ -495,6 +212,8 @@ class Track:
res.raise_for_status() res.raise_for_status()
init_data = res.content init_data = res.content
else: else:
# Take advantage of streaming support to take just the first n bytes
# This is a hacky alternative to HTTP's Range on unsupported servers
init_data = None init_data = None
with session.get(url, stream=True) as s: with session.get(url, stream=True) as s:
for chunk in s.iter_content(content_length): for chunk in s.iter_content(content_length):
@ -505,11 +224,17 @@ class Track:
return init_data return init_data
def delete(self) -> None:
if self.path:
self.path.unlink()
self.path = None
def repackage(self) -> None: def repackage(self) -> None:
if not self.path or not self.path.exists(): if not self.path or not self.path.exists():
raise ValueError("Cannot repackage a Track that has not been downloaded.") raise ValueError("Cannot repackage a Track that has not been downloaded.")
if not binaries.FFMPEG: executable = get_binary_path("ffmpeg")
if not executable:
raise EnvironmentError("FFmpeg executable \"ffmpeg\" was not found but is required for this call.") raise EnvironmentError("FFmpeg executable \"ffmpeg\" was not found but is required for this call.")
original_path = self.path original_path = self.path
@ -518,7 +243,7 @@ class Track:
def _ffmpeg(extra_args: list[str] = None): def _ffmpeg(extra_args: list[str] = None):
subprocess.run( subprocess.run(
[ [
binaries.FFMPEG, "-hide_banner", executable, "-hide_banner",
"-loglevel", "error", "-loglevel", "error",
"-i", original_path, "-i", original_path,
*(extra_args or []), *(extra_args or []),
@ -542,8 +267,36 @@ class Track:
else: else:
raise raise
original_path.unlink() self.swap(output_path)
self.path = output_path self.move(original_path)
def move(self, target: Union[str, Path]) -> bool:
"""
Move the Track's file from current location, to target location.
This will overwrite anything at the target path.
"""
if not self.path:
return False
target = Path(target)
ok = Path(shutil.move(self.path, target)).resolve() == target.resolve()
if ok:
self.path = target
return ok
def swap(self, target: Union[str, Path]) -> bool:
"""
Swaps the Track's file with the Target file. The current Track's file is deleted.
Returns False if the Track is not yet downloaded, or the target path does not exist.
"""
target = Path(target)
if not target.exists() or not self.path:
return False
self.path.unlink()
ok = Path(shutil.move(target, self.path)).resolve() == self.path.resolve()
if not ok:
return False
return self.move(target)
__all__ = ("Track",) __all__ = ("Track",)

View File

@ -14,8 +14,6 @@ from rich.tree import Tree
from devine.core.config import config from devine.core.config import config
from devine.core.console import console from devine.core.console import console
from devine.core.constants import LANGUAGE_MAX_DISTANCE, AnyTrack, TrackT from devine.core.constants import LANGUAGE_MAX_DISTANCE, AnyTrack, TrackT
from devine.core.events import events
from devine.core.tracks.attachment import Attachment
from devine.core.tracks.audio import Audio from devine.core.tracks.audio import Audio
from devine.core.tracks.chapters import Chapter, Chapters from devine.core.tracks.chapters import Chapter, Chapters
from devine.core.tracks.subtitle import Subtitle from devine.core.tracks.subtitle import Subtitle
@ -27,7 +25,7 @@ from devine.core.utils.collections import as_list, flatten
class Tracks: class Tracks:
""" """
Video, Audio, Subtitle, Chapter, and Attachment Track Store. Video, Audio, Subtitle, and Chapter Track Store.
It provides convenience functions for listing, sorting, and selecting tracks. It provides convenience functions for listing, sorting, and selecting tracks.
""" """
@ -35,23 +33,14 @@ class Tracks:
Video: 0, Video: 0,
Audio: 1, Audio: 1,
Subtitle: 2, Subtitle: 2,
Chapter: 3, Chapter: 3
Attachment: 4
} }
def __init__(self, *args: Union[ def __init__(self, *args: Union[Tracks, list[Track], Track]):
Tracks,
Sequence[Union[AnyTrack, Chapter, Chapters, Attachment]],
Track,
Chapter,
Chapters,
Attachment
]):
self.videos: list[Video] = [] self.videos: list[Video] = []
self.audio: list[Audio] = [] self.audio: list[Audio] = []
self.subtitles: list[Subtitle] = [] self.subtitles: list[Subtitle] = []
self.chapters = Chapters() self.chapters = Chapters()
self.attachments: list[Attachment] = []
if args: if args:
self.add(args) self.add(args)
@ -64,14 +53,7 @@ class Tracks:
def __add__( def __add__(
self, self,
other: Union[ other: Union[Tracks, Sequence[Union[AnyTrack, Chapter, Chapters]], Track, Chapter, Chapters]
Tracks,
Sequence[Union[AnyTrack, Chapter, Chapters, Attachment]],
Track,
Chapter,
Chapters,
Attachment
]
) -> Tracks: ) -> Tracks:
self.add(other) self.add(other)
return self return self
@ -87,8 +69,7 @@ class Tracks:
Video: [], Video: [],
Audio: [], Audio: [],
Subtitle: [], Subtitle: [],
Chapter: [], Chapter: []
Attachment: []
} }
tracks = [*list(self), *self.chapters] tracks = [*list(self), *self.chapters]
@ -117,7 +98,7 @@ class Tracks:
return rep return rep
def tree(self, add_progress: bool = False) -> tuple[Tree, list[partial]]: def tree(self, add_progress: bool = False) -> tuple[Tree, list[partial]]:
all_tracks = [*list(self), *self.chapters, *self.attachments] all_tracks = [*list(self), *self.chapters]
progress_callables = [] progress_callables = []
@ -130,7 +111,7 @@ class Tracks:
track_type_plural = track_type.__name__ + ("s" if track_type != Audio and num_tracks != 1 else "") track_type_plural = track_type.__name__ + ("s" if track_type != Audio and num_tracks != 1 else "")
tracks_tree = tree.add(f"[repr.number]{num_tracks}[/] {track_type_plural}") tracks_tree = tree.add(f"[repr.number]{num_tracks}[/] {track_type_plural}")
for track in tracks: for track in tracks:
if add_progress and track_type not in (Chapter, Attachment): if add_progress and track_type != Chapter:
progress = Progress( progress = Progress(
SpinnerColumn(finished_text=""), SpinnerColumn(finished_text=""),
BarColumn(), BarColumn(),
@ -162,19 +143,12 @@ class Tracks:
def add( def add(
self, self,
tracks: Union[ tracks: Union[Tracks, Sequence[Union[AnyTrack, Chapter, Chapters]], Track, Chapter, Chapters],
Tracks,
Sequence[Union[AnyTrack, Chapter, Chapters, Attachment]],
Track,
Chapter,
Chapters,
Attachment
],
warn_only: bool = False warn_only: bool = False
) -> None: ) -> None:
"""Add a provided track to its appropriate array and ensuring it's not a duplicate.""" """Add a provided track to its appropriate array and ensuring it's not a duplicate."""
if isinstance(tracks, Tracks): if isinstance(tracks, Tracks):
tracks = [*list(tracks), *tracks.chapters, *tracks.attachments] tracks = [*list(tracks), *tracks.chapters]
duplicates = 0 duplicates = 0
for track in flatten(tracks): for track in flatten(tracks):
@ -199,8 +173,6 @@ class Tracks:
self.subtitles.append(track) self.subtitles.append(track)
elif isinstance(track, Chapter): elif isinstance(track, Chapter):
self.chapters.add(track) self.chapters.add(track)
elif isinstance(track, Attachment):
self.attachments.append(track)
else: else:
raise ValueError("Track type was not set or is invalid.") raise ValueError("Track type was not set or is invalid.")
@ -316,7 +288,7 @@ class Tracks:
][:per_language or None]) ][:per_language or None])
return selected return selected
def mux(self, title: str, delete: bool = True, progress: Optional[partial] = None) -> tuple[Path, int, list[str]]: def mux(self, title: str, delete: bool = True, progress: Optional[partial] = None) -> tuple[Path, int]:
""" """
Multiplex all the Tracks into a Matroska Container file. Multiplex all the Tracks into a Matroska Container file.
@ -338,7 +310,8 @@ class Tracks:
for i, vt in enumerate(self.videos): for i, vt in enumerate(self.videos):
if not vt.path or not vt.path.exists(): if not vt.path or not vt.path.exists():
raise ValueError("Video Track must be downloaded before muxing...") raise ValueError("Video Track must be downloaded before muxing...")
events.emit(events.Types.TRACK_MULTIPLEX, track=vt) if callable(vt.OnMultiplex):
vt.OnMultiplex()
cl.extend([ cl.extend([
"--language", f"0:{vt.language}", "--language", f"0:{vt.language}",
"--default-track", f"0:{i == 0}", "--default-track", f"0:{i == 0}",
@ -350,7 +323,8 @@ class Tracks:
for i, at in enumerate(self.audio): for i, at in enumerate(self.audio):
if not at.path or not at.path.exists(): if not at.path or not at.path.exists():
raise ValueError("Audio Track must be downloaded before muxing...") raise ValueError("Audio Track must be downloaded before muxing...")
events.emit(events.Types.TRACK_MULTIPLEX, track=at) if callable(at.OnMultiplex):
at.OnMultiplex()
cl.extend([ cl.extend([
"--track-name", f"0:{at.get_track_name() or ''}", "--track-name", f"0:{at.get_track_name() or ''}",
"--language", f"0:{at.language}", "--language", f"0:{at.language}",
@ -364,7 +338,8 @@ class Tracks:
for st in self.subtitles: for st in self.subtitles:
if not st.path or not st.path.exists(): if not st.path or not st.path.exists():
raise ValueError("Text Track must be downloaded before muxing...") raise ValueError("Text Track must be downloaded before muxing...")
events.emit(events.Types.TRACK_MULTIPLEX, track=st) if callable(st.OnMultiplex):
st.OnMultiplex()
default = bool(self.audio and is_close_match(st.language, [self.audio[0].language]) and st.forced) default = bool(self.audio and is_close_match(st.language, [self.audio[0].language]) and st.forced)
cl.extend([ cl.extend([
"--track-name", f"0:{st.get_track_name() or ''}", "--track-name", f"0:{st.get_track_name() or ''}",
@ -388,16 +363,6 @@ class Tracks:
else: else:
chapters_path = None chapters_path = None
for attachment in self.attachments:
if not attachment.path or not attachment.path.exists():
raise ValueError("Attachment File was not found...")
cl.extend([
"--attachment-description", attachment.description or "",
"--attachment-mime-type", attachment.mime_type,
"--attachment-name", attachment.name,
"--attach-file", str(attachment.path.resolve())
])
output_path = ( output_path = (
self.videos[0].path.with_suffix(".muxed.mkv") if self.videos else self.videos[0].path.with_suffix(".muxed.mkv") if self.videos else
self.audio[0].path.with_suffix(".muxed.mka") if self.audio else self.audio[0].path.with_suffix(".muxed.mka") if self.audio else
@ -410,18 +375,15 @@ class Tracks:
# let potential failures go to caller, caller should handle # let potential failures go to caller, caller should handle
try: try:
errors = []
p = subprocess.Popen([ p = subprocess.Popen([
*cl, *cl,
"--output", str(output_path), "--output", str(output_path),
"--gui-mode" "--gui-mode"
], text=True, stdout=subprocess.PIPE) ], text=True, stdout=subprocess.PIPE)
for line in iter(p.stdout.readline, ""): for line in iter(p.stdout.readline, ""):
if line.startswith("#GUI#error") or line.startswith("#GUI#warning"):
errors.append(line)
if "progress" in line: if "progress" in line:
progress(total=100, completed=int(line.strip()[14:-1])) progress(total=100, completed=int(line.strip()[14:-1]))
return output_path, p.wait(), errors return output_path, p.wait()
finally: finally:
if chapters_path: if chapters_path:
# regardless of delete param, we delete as it's a file we made during muxing # regardless of delete param, we delete as it's a file we made during muxing

View File

@ -10,11 +10,10 @@ from typing import Any, Optional, Union
from langcodes import Language from langcodes import Language
from devine.core import binaries
from devine.core.config import config from devine.core.config import config
from devine.core.tracks.subtitle import Subtitle from devine.core.tracks.subtitle import Subtitle
from devine.core.tracks.track import Track from devine.core.tracks.track import Track
from devine.core.utilities import FPS, get_boxes from devine.core.utilities import FPS, get_binary_path, get_boxes
class Video(Track): class Video(Track):
@ -142,11 +141,9 @@ class Video(Track):
return Video.Range.SDR return Video.Range.SDR
@staticmethod @staticmethod
def from_m3u_range_tag(tag: str) -> Optional[Video.Range]: def from_m3u_range_tag(tag: str) -> Video.Range:
tag = (tag or "").upper().replace('"', '').strip() tag = (tag or "").upper().replace('"', '').strip()
if not tag: if not tag or tag == "SDR":
return None
if tag == "SDR":
return Video.Range.SDR return Video.Range.SDR
elif tag == "PQ": elif tag == "PQ":
return Video.Range.HDR10 # technically could be any PQ-transfer range return Video.Range.HDR10 # technically could be any PQ-transfer range
@ -155,110 +152,35 @@ class Video(Track):
# for some reason there's no Dolby Vision info tag # for some reason there's no Dolby Vision info tag
raise ValueError(f"The M3U Range Tag '{tag}' is not a supported Video Range") raise ValueError(f"The M3U Range Tag '{tag}' is not a supported Video Range")
def __init__( def __init__(self, *args: Any, codec: Video.Codec, range_: Video.Range, bitrate: Union[str, int, float],
self, width: int, height: int, fps: Optional[Union[str, int, float]] = None, **kwargs: Any) -> None:
*args: Any,
codec: Optional[Video.Codec] = None,
range_: Optional[Video.Range] = None,
bitrate: Optional[Union[str, int, float]] = None,
width: Optional[int] = None,
height: Optional[int] = None,
fps: Optional[Union[str, int, float]] = None,
**kwargs: Any
) -> None:
"""
Create a new Video track object.
Parameters:
codec: A Video.Codec enum representing the video codec.
If not specified, MediaInfo will be used to retrieve the codec
once the track has been downloaded.
range_: A Video.Range enum representing the video color range.
Defaults to SDR if not specified.
bitrate: A number or float representing the average bandwidth in bytes/s.
Float values are rounded up to the nearest integer.
width: The horizontal resolution of the video.
height: The vertical resolution of the video.
fps: A number, float, or string representing the frames/s of the video.
Strings may represent numbers, floats, or a fraction (num/den).
All strings will be cast to either a number or float.
Note: If codec, bitrate, width, height, or fps is not specified some checks
may be skipped or assume a value. Specifying as much information as possible
is highly recommended.
"""
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# required
if not isinstance(codec, (Video.Codec, type(None))):
raise TypeError(f"Expected codec to be a {Video.Codec}, not {codec!r}")
if not isinstance(range_, (Video.Range, type(None))):
raise TypeError(f"Expected range_ to be a {Video.Range}, not {range_!r}")
if not isinstance(bitrate, (str, int, float, type(None))):
raise TypeError(f"Expected bitrate to be a {str}, {int}, or {float}, not {bitrate!r}")
if not isinstance(width, (int, str, type(None))):
raise TypeError(f"Expected width to be a {int}, not {width!r}")
if not isinstance(height, (int, str, type(None))):
raise TypeError(f"Expected height to be a {int}, not {height!r}")
if not isinstance(fps, (str, int, float, type(None))):
raise TypeError(f"Expected fps to be a {str}, {int}, or {float}, not {fps!r}")
self.codec = codec self.codec = codec
self.range = range_ or Video.Range.SDR self.range = range_ or Video.Range.SDR
self.bitrate = int(math.ceil(float(bitrate))) if bitrate else None
try: self.width = int(width)
self.bitrate = int(math.ceil(float(bitrate))) if bitrate else None self.height = int(height)
except (ValueError, TypeError) as e: # optional
raise ValueError(f"Expected bitrate to be a number or float, {e}") self.fps = FPS.parse(str(fps)) if fps else None
try:
self.width = int(width or 0) or None
except ValueError as e:
raise ValueError(f"Expected width to be a number, not {width!r}, {e}")
try:
self.height = int(height or 0) or None
except ValueError as e:
raise ValueError(f"Expected height to be a number, not {height!r}, {e}")
try:
self.fps = (FPS.parse(str(fps)) or None) if fps else None
except Exception as e:
raise ValueError(
"Expected fps to be a number, float, or a string as numerator/denominator form, " +
str(e)
)
def __str__(self) -> str: def __str__(self) -> str:
fps = f"{self.fps:.3f}" if self.fps else "Unknown"
return " | ".join(filter(bool, [ return " | ".join(filter(bool, [
"VID", "VID",
"[" + (", ".join(filter(bool, [ f"[{self.codec.value}, {self.range.name}]",
self.codec.value if self.codec else None,
self.range.name
]))) + "]",
str(self.language), str(self.language),
", ".join(filter(bool, [ f"{self.width}x{self.height} @ {self.bitrate // 1000 if self.bitrate else '?'} kb/s, {fps} FPS",
" @ ".join(filter(bool, [
f"{self.width}x{self.height}" if self.width and self.height else None,
f"{self.bitrate // 1000} kb/s" if self.bitrate else None
])),
f"{self.fps:.3f} FPS" if self.fps else None
])),
self.edition self.edition
])) ]))
def change_color_range(self, range_: int) -> None: def change_color_range(self, range_: int) -> None:
"""Change the Video's Color Range to Limited (0) or Full (1).""" """Change the Video's Color Range to Limited (0) or Full (1)."""
if not self.path or not self.path.exists(): if not self.path or not self.path.exists():
raise ValueError("Cannot change the color range flag on a Video that has not been downloaded.") raise ValueError("Cannot repackage a Track that has not been downloaded.")
if not self.codec:
raise ValueError("Cannot change the color range flag on a Video that has no codec specified.")
if self.codec not in (Video.Codec.AVC, Video.Codec.HEVC):
raise NotImplementedError(
"Cannot change the color range flag on this Video as "
f"it's codec, {self.codec.value}, is not yet supported."
)
if not binaries.FFMPEG: executable = get_binary_path("ffmpeg")
if not executable:
raise EnvironmentError("FFmpeg executable \"ffmpeg\" was not found but is required for this call.") raise EnvironmentError("FFmpeg executable \"ffmpeg\" was not found but is required for this call.")
filter_key = { filter_key = {
@ -270,7 +192,7 @@ class Video(Track):
output_path = original_path.with_stem(f"{original_path.stem}_{['limited', 'full'][range_]}_range") output_path = original_path.with_stem(f"{original_path.stem}_{['limited', 'full'][range_]}_range")
subprocess.run([ subprocess.run([
binaries.FFMPEG, "-hide_banner", executable, "-hide_banner",
"-loglevel", "panic", "-loglevel", "panic",
"-i", original_path, "-i", original_path,
"-codec", "copy", "-codec", "copy",
@ -278,8 +200,8 @@ class Video(Track):
str(output_path) str(output_path)
], check=True) ], check=True)
self.path = output_path self.swap(output_path)
original_path.unlink() self.move(original_path)
def ccextractor( def ccextractor(
self, track_id: Any, out_path: Union[Path, str], language: Language, original: bool = False self, track_id: Any, out_path: Union[Path, str], language: Language, original: bool = False
@ -288,7 +210,8 @@ class Video(Track):
if not self.path: if not self.path:
raise ValueError("You must download the track first.") raise ValueError("You must download the track first.")
if not binaries.CCExtractor: executable = get_binary_path("ccextractor", "ccextractorwin", "ccextractorwinfull")
if not executable:
raise EnvironmentError("ccextractor executable was not found.") raise EnvironmentError("ccextractor executable was not found.")
# ccextractor often fails in weird ways unless we repack # ccextractor often fails in weird ways unless we repack
@ -298,7 +221,7 @@ class Video(Track):
try: try:
subprocess.run([ subprocess.run([
binaries.CCExtractor, executable,
"-trim", "-trim",
"-nobom", "-nobom",
"-noru", "-ru1", "-noru", "-ru1",
@ -379,7 +302,8 @@ class Video(Track):
if not self.path or not self.path.exists(): if not self.path or not self.path.exists():
raise ValueError("Cannot clean a Track that has not been downloaded.") raise ValueError("Cannot clean a Track that has not been downloaded.")
if not binaries.FFMPEG: executable = get_binary_path("ffmpeg")
if not executable:
raise EnvironmentError("FFmpeg executable \"ffmpeg\" was not found but is required for this call.") raise EnvironmentError("FFmpeg executable \"ffmpeg\" was not found but is required for this call.")
log = logging.getLogger("x264-clean") log = logging.getLogger("x264-clean")
@ -397,12 +321,11 @@ class Video(Track):
i = file.index(b"x264") i = file.index(b"x264")
encoding_settings = file[i: i + file[i:].index(b"\x00")].replace(b":", br"\\:").replace(b",", br"\,").decode() encoding_settings = file[i: i + file[i:].index(b"\x00")].replace(b":", br"\\:").replace(b",", br"\,").decode()
original_path = self.path cleaned_path = self.path.with_suffix(f".cleaned{self.path.suffix}")
cleaned_path = original_path.with_suffix(f".cleaned{original_path.suffix}")
subprocess.run([ subprocess.run([
binaries.FFMPEG, "-hide_banner", executable, "-hide_banner",
"-loglevel", "panic", "-loglevel", "panic",
"-i", original_path, "-i", self.path,
"-map_metadata", "-1", "-map_metadata", "-1",
"-fflags", "bitexact", "-fflags", "bitexact",
"-bsf:v", f"filter_units=remove_types=6,h264_metadata=sei_user_data={uuid}+{encoding_settings}", "-bsf:v", f"filter_units=remove_types=6,h264_metadata=sei_user_data={uuid}+{encoding_settings}",
@ -412,8 +335,7 @@ class Video(Track):
log.info(" + Removed") log.info(" + Removed")
self.path = cleaned_path self.swap(cleaned_path)
original_path.unlink()
return True return True

View File

@ -3,6 +3,7 @@ import contextlib
import importlib.util import importlib.util
import os import os
import re import re
import shutil
import socket import socket
import sys import sys
import time import time
@ -86,6 +87,15 @@ def import_module_by_path(path: Path) -> ModuleType:
return module return module
def get_binary_path(*names: str) -> Optional[Path]:
"""Find the path of the first found binary name."""
for name in names:
path = shutil.which(name)
if path:
return Path(path)
return None
def sanitize_filename(filename: str, spacer: str = ".") -> str: def sanitize_filename(filename: str, spacer: str = ".") -> str:
""" """
Sanitize a string to be filename safe. Sanitize a string to be filename safe.
@ -123,18 +133,18 @@ def get_boxes(data: bytes, box_type: bytes, as_bytes: bool = False) -> Box:
# since it doesn't care what child box the wanted box is from, this works fine. # since it doesn't care what child box the wanted box is from, this works fine.
if not isinstance(data, (bytes, bytearray)): if not isinstance(data, (bytes, bytearray)):
raise ValueError("data must be bytes") raise ValueError("data must be bytes")
offset = 0
while True: while True:
try: try:
index = data[offset:].index(box_type) index = data.index(box_type)
except ValueError: except ValueError:
break break
if index < 0: if index < 0:
break break
index -= 4 # size is before box type and is 4 bytes long if index > 4:
index -= 4 # size is before box type and is 4 bytes long
data = data[index:]
try: try:
box = Box.parse(data[offset:][index:]) box = Box.parse(data)
except IOError: except IOError:
# since get_init_segment might cut off unexpectedly, pymp4 may be unable to read # since get_init_segment might cut off unexpectedly, pymp4 may be unable to read
# the expected amounts of data and complain, so let's just end the function here # the expected amounts of data and complain, so let's just end the function here
@ -147,7 +157,6 @@ def get_boxes(data: bytes, box_type: bytes, as_bytes: bool = False) -> Box:
raise e raise e
if as_bytes: if as_bytes:
box = Box.build(box) box = Box.build(box)
offset += index + len(Box.build(box))
yield box yield box
@ -268,27 +277,6 @@ def get_extension(value: Union[str, Path, ParseResult]) -> Optional[str]:
return ext return ext
def get_system_fonts() -> dict[str, Path]:
if sys.platform == "win32":
import winreg
with winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE) as reg:
key = winreg.OpenKey(
reg,
r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts",
0,
winreg.KEY_READ
)
total_fonts = winreg.QueryInfoKey(key)[1]
return {
name.replace(" (TrueType)", ""): Path(r"C:\Windows\Fonts", filename)
for n in range(0, total_fonts)
for name, filename, _ in [winreg.EnumValue(key, n)]
}
else:
# TODO: Get System Fonts for Linux and mac OS
return {}
class FPS(ast.NodeVisitor): class FPS(ast.NodeVisitor):
def visit_BinOp(self, node: ast.BinOp) -> float: def visit_BinOp(self, node: ast.BinOp) -> float:
if isinstance(node.op, ast.Div): if isinstance(node.op, ast.Div):

View File

@ -1,8 +1,7 @@
import re import re
from typing import Any, Optional, Union from typing import Optional, Union
import click import click
from click.shell_completion import CompletionItem
from pywidevine.cdm import Cdm as WidevineCdm from pywidevine.cdm import Cdm as WidevineCdm
@ -123,62 +122,6 @@ class QualityList(click.ParamType):
return sorted(resolutions, reverse=True) return sorted(resolutions, reverse=True)
class MultipleChoice(click.Choice):
"""
The multiple choice type allows multiple values to be checked against
a fixed set of supported values.
It internally uses and is based off of click.Choice.
"""
name = "multiple_choice"
def __repr__(self) -> str:
return f"MultipleChoice({list(self.choices)})"
def convert(
self,
value: Any,
param: Optional[click.Parameter] = None,
ctx: Optional[click.Context] = None
) -> list[Any]:
if not value:
return []
if isinstance(value, str):
values = value.split(",")
elif isinstance(value, list):
values = value
else:
self.fail(
f"{value!r} is not a supported value.",
param,
ctx
)
chosen_values: list[Any] = []
for value in values:
chosen_values.append(super().convert(value, param, ctx))
return chosen_values
def shell_complete(
self,
ctx: click.Context,
param: click.Parameter,
incomplete: str
) -> list[CompletionItem]:
"""
Complete choices that start with the incomplete value.
Parameters:
ctx: Invocation context for this command.
param: The parameter that is requesting completion.
incomplete: Value being completed. May be empty.
"""
incomplete = incomplete.rsplit(",")[-1]
return super(self).shell_complete(ctx, param, incomplete)
SEASON_RANGE = SeasonRange() SEASON_RANGE = SeasonRange()
LANGUAGE_RANGE = LanguageRange() LANGUAGE_RANGE = LanguageRange()
QUALITY_LIST = QualityList() QUALITY_LIST = QualityList()

View File

@ -3,16 +3,11 @@ import subprocess
from pathlib import Path from pathlib import Path
from typing import Union from typing import Union
from devine.core import binaries
def ffprobe(uri: Union[bytes, Path]) -> dict: def ffprobe(uri: Union[bytes, Path]) -> dict:
"""Use ffprobe on the provided data to get stream information.""" """Use ffprobe on the provided data to get stream information."""
if not binaries.FFProbe:
raise EnvironmentError("FFProbe executable \"ffprobe\" not found but is required.")
args = [ args = [
binaries.FFProbe, "ffprobe",
"-v", "quiet", "-v", "quiet",
"-of", "json", "-of", "json",
"-show_streams" "-show_streams"

View File

@ -1,191 +0,0 @@
import re
import sys
import typing
from typing import Optional
from pycaption import Caption, CaptionList, CaptionNode, CaptionReadError, WebVTTReader, WebVTTWriter
class CaptionListExt(CaptionList):
@typing.no_type_check
def __init__(self, iterable=None, layout_info=None):
self.first_segment_mpegts = 0
super().__init__(iterable, layout_info)
class CaptionExt(Caption):
@typing.no_type_check
def __init__(self, start, end, nodes, style=None, layout_info=None, segment_index=0, mpegts=0, cue_time=0.0):
style = style or {}
self.segment_index: int = segment_index
self.mpegts: float = mpegts
self.cue_time: float = cue_time
super().__init__(start, end, nodes, style, layout_info)
class WebVTTReaderExt(WebVTTReader):
# HLS extension support <https://datatracker.ietf.org/doc/html/rfc8216#section-3.5>
RE_TIMESTAMP_MAP = re.compile(r"X-TIMESTAMP-MAP.*")
RE_MPEGTS = re.compile(r"MPEGTS:(\d+)")
RE_LOCAL = re.compile(r"LOCAL:((?:(\d{1,}):)?(\d{2}):(\d{2})\.(\d{3}))")
def _parse(self, lines: list[str]) -> CaptionList:
captions = CaptionListExt()
start = None
end = None
nodes: list[CaptionNode] = []
layout_info = None
found_timing = False
segment_index = -1
mpegts = 0
cue_time = 0.0
# The first segment MPEGTS is needed to calculate the rest. It is possible that
# the first segment contains no cue and is ignored by pycaption, this acts as a fallback.
captions.first_segment_mpegts = 0
for i, line in enumerate(lines):
if "-->" in line:
found_timing = True
timing_line = i
last_start_time = captions[-1].start if captions else 0
try:
start, end, layout_info = self._parse_timing_line(line, last_start_time)
except CaptionReadError as e:
new_msg = f"{e.args[0]} (line {timing_line})"
tb = sys.exc_info()[2]
raise type(e)(new_msg).with_traceback(tb) from None
elif "" == line:
if found_timing and nodes:
found_timing = False
caption = CaptionExt(
start,
end,
nodes,
layout_info=layout_info,
segment_index=segment_index,
mpegts=mpegts,
cue_time=cue_time,
)
captions.append(caption)
nodes = []
elif "WEBVTT" in line:
# Merged segmented VTT doesn't have index information, track manually.
segment_index += 1
mpegts = 0
cue_time = 0.0
elif m := self.RE_TIMESTAMP_MAP.match(line):
if r := self.RE_MPEGTS.search(m.group()):
mpegts = int(r.group(1))
cue_time = self._parse_local(m.group())
# Early assignment in case the first segment contains no cue.
if segment_index == 0:
captions.first_segment_mpegts = mpegts
else:
if found_timing:
if nodes:
nodes.append(CaptionNode.create_break())
nodes.append(CaptionNode.create_text(self._decode(line)))
else:
# it's a comment or some metadata; ignore it
pass
# Add a last caption if there are remaining nodes
if nodes:
caption = CaptionExt(start, end, nodes, layout_info=layout_info, segment_index=segment_index, mpegts=mpegts)
captions.append(caption)
return captions
@staticmethod
def _parse_local(string: str) -> float:
"""
Parse WebVTT LOCAL time and convert it to seconds.
"""
m = WebVTTReaderExt.RE_LOCAL.search(string)
if not m:
return 0
parsed = m.groups()
if not parsed:
return 0
hours = int(parsed[1])
minutes = int(parsed[2])
seconds = int(parsed[3])
milliseconds = int(parsed[4])
return (milliseconds / 1000) + seconds + (minutes * 60) + (hours * 3600)
def merge_segmented_webvtt(vtt_raw: str, segment_durations: Optional[list[int]] = None, timescale: int = 1) -> str:
"""
Merge Segmented WebVTT data.
Parameters:
vtt_raw: The concatenated WebVTT files to merge. All WebVTT headers must be
appropriately spaced apart, or it may produce unwanted effects like
considering headers as captions, timestamp lines, etc.
segment_durations: A list of each segment's duration. If not provided it will try
to get it from the X-TIMESTAMP-MAP headers, specifically the MPEGTS number.
timescale: The number of time units per second.
This parses the X-TIMESTAMP-MAP data to compute new absolute timestamps, replacing
the old start and end timestamp values. All X-TIMESTAMP-MAP header information will
be removed from the output as they are no longer of concern. Consider this function
the opposite of a WebVTT Segmenter, a WebVTT Joiner of sorts.
Algorithm borrowed from N_m3u8DL-RE and shaka-player.
"""
MPEG_TIMESCALE = 90_000
vtt = WebVTTReaderExt().read(vtt_raw)
for lang in vtt.get_languages():
prev_caption = None
duplicate_index: list[int] = []
captions = vtt.get_captions(lang)
if captions[0].segment_index == 0:
first_segment_mpegts = captions[0].mpegts
else:
first_segment_mpegts = segment_durations[0] if segment_durations else captions.first_segment_mpegts
caption: CaptionExt
for i, caption in enumerate(captions):
# DASH WebVTT doesn't have MPEGTS timestamp like HLS. Instead,
# calculate the timestamp from SegmentTemplate/SegmentList duration.
likely_dash = first_segment_mpegts == 0 and caption.mpegts == 0
if likely_dash and segment_durations:
duration = segment_durations[caption.segment_index]
caption.mpegts = MPEG_TIMESCALE * (duration / timescale)
if caption.mpegts == 0:
continue
seconds = (caption.mpegts - first_segment_mpegts) / MPEG_TIMESCALE - caption.cue_time
offset = seconds * 1_000_000 # pycaption use microseconds
if caption.start < offset:
caption.start += offset
caption.end += offset
# If the difference between current and previous captions is <=1ms
# and the payload is equal then splice.
if (
prev_caption
and not caption.is_empty()
and (caption.start - prev_caption.end) <= 1000 # 1ms in microseconds
and caption.get_text() == prev_caption.get_text()
):
prev_caption.end = caption.end
duplicate_index.append(i)
prev_caption = caption
# Remove duplicate
captions[:] = [c for c_index, c in enumerate(captions) if c_index not in set(duplicate_index)]
return WebVTTWriter().write(vtt)

780
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
[tool.poetry] [tool.poetry]
name = "devine" name = "devine"
version = "3.3.3" version = "3.0.0"
description = "Modular Movie, TV, and Music Archival Software." description = "Modular Movie, TV, and Music Archival Software."
license = "GPL-3.0-only" license = "GPL-3.0-only"
authors = ["rlaphoenix <rlaphoenix@pm.me>"] authors = ["rlaphoenix <rlaphoenix@pm.me>"]
@ -39,12 +39,12 @@ Brotli = "^1.1.0"
click = "^8.1.7" click = "^8.1.7"
construct = "^2.8.8" construct = "^2.8.8"
crccheck = "^1.3.0" crccheck = "^1.3.0"
jsonpickle = "^3.0.4" jsonpickle = "^3.0.3"
langcodes = { extras = ["data"], version = "^3.4.0" } langcodes = { extras = ["data"], version = "^3.3.0" }
lxml = "^5.2.1" lxml = "^5.1.0"
pproxy = "^2.7.9" pproxy = "^2.7.9"
protobuf = "^4.25.3" protobuf = "^4.25.3"
pycaption = "^2.2.6" pycaption = "^2.2.4"
pycryptodomex = "^3.20.0" pycryptodomex = "^3.20.0"
pyjwt = "^2.8.0" pyjwt = "^2.8.0"
pymediainfo = "^6.1.0" pymediainfo = "^6.1.0"
@ -57,21 +57,21 @@ rich = "^13.7.1"
"rlaphoenix.m3u8" = "^3.4.0" "rlaphoenix.m3u8" = "^3.4.0"
"ruamel.yaml" = "^0.18.6" "ruamel.yaml" = "^0.18.6"
sortedcontainers = "^2.4.0" sortedcontainers = "^2.4.0"
subtitle-filter = "^1.4.9" subtitle-filter = "^1.4.8"
Unidecode = "^1.3.8" Unidecode = "^1.3.8"
urllib3 = "^2.2.1" urllib3 = "^2.2.1"
chardet = "^5.2.0" chardet = "^5.2.0"
curl-cffi = "^0.7.0b4" curl-cffi = "^0.6.1"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pre-commit = "^3.7.0" pre-commit = "^3.6.2"
mypy = "^1.9.0" mypy = "^1.8.0"
mypy-protobuf = "^3.6.0" mypy-protobuf = "^3.5.0"
types-protobuf = "^4.24.0.20240408" types-protobuf = "^4.24.0.20240129"
types-PyMySQL = "^1.1.0.1" types-PyMySQL = "^1.1.0.1"
types-requests = "^2.31.0.20240406" types-requests = "^2.31.0.20240218"
isort = "^5.13.2" isort = "^5.13.2"
ruff = "~0.3.7" ruff = "~0.3.0"
[tool.poetry.scripts] [tool.poetry.scripts]
devine = "devine.core.__main__:main" devine = "devine.core.__main__:main"